easyai-ai-gateway/apps/api/internal/auth/oidc_client_test.go
chengcheng a312ad880d fix(identity): 完善统一认证配对恢复与安全退役
修复 credentials_saved 状态无法恢复、配对与激活并发冲突,以及 SSF 和身份 Secret 生命周期不完整的问题。新增持久化协调器、取消与清理状态机、事务级并发门禁、受控 SSF 凭据交接、禁用后的延迟 Secret 清理,并对生产环境统一认证及 Discovery 端点强制 HTTPS。

验证:go test ./...;go test -race ./internal/auth ./internal/identity ./internal/identityruntime ./internal/securityevents ./internal/httpapi ./internal/store -count=1;go vet ./...;真实 PostgreSQL 并发及清理成功/冲突回滚测试;pnpm openapi。
2026-07-17 18:31:12 +08:00

293 lines
11 KiB
Go

package auth
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/golang-jwt/jwt/v5"
)
func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T) {
const pkceVerifier = "test-pkce-verifier-with-at-least-43-characters-1234"
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
"revocation_endpoint": issuer + "/revoke",
"end_session_endpoint": issuer + "/logout",
})
case "/token":
body, _ := io.ReadAll(r.Body)
values, _ := url.ParseQuery(string(body))
if values.Get("client_secret") != "" || strings.Contains(r.Header.Get("Authorization"), "Basic") {
t.Error("public client token request must not contain client credentials")
http.Error(w, "invalid client authentication", http.StatusBadRequest)
return
}
if values.Get("grant_type") != "authorization_code" || values.Get("client_id") != "gateway-public" || values.Get("code_verifier") != pkceVerifier {
t.Error("token request omitted authorization code, public client id, or PKCE verifier")
http.Error(w, "invalid token request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-token", "refresh_token": "refresh-token",
"id_token": "id-token", "expires_in": 300, "token_type": "Bearer",
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "test",
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/gateway-api/api/v1/auth/oidc/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", Scopes: []string{"openid", "profile", "gateway.access"}, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if err := client.ValidateConfiguration(context.Background()); err != nil {
t.Fatalf("ValidateConfiguration() error = %v", err)
}
authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier)
if err != nil {
t.Fatal(err)
}
parsed, _ := url.Parse(authorizationURL)
query := parsed.Query()
digest := sha256.Sum256([]byte(pkceVerifier))
expectedChallenge := base64.RawURLEncoding.EncodeToString(digest[:])
if query.Get("response_type") != "code" || query.Get("code_challenge_method") != "S256" || query.Get("code_challenge") != expectedChallenge {
t.Fatalf("authorization request is not PKCE S256: %v", query)
}
if _, err := client.ExchangeCode(context.Background(), "authorization-code", pkceVerifier); err != nil {
t.Fatal(err)
}
}
func TestOIDCPublicClientRejectsOfflineAccess(t *testing.T) {
_, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "production",
Issuer: "https://auth.example.com", ClientID: "gateway-public",
RedirectURI: "https://gateway.example.com/api/v1/auth/oidc/callback",
PostLogoutRedirectURI: "https://gateway.example.com/",
Scopes: []string{"openid", "gateway.access", "offline_access"},
})
if err == nil || !strings.Contains(err.Error(), "offline_access") {
t.Fatalf("NewOIDCPublicClient() error = %v, want offline_access rejection", err)
}
}
func TestOIDCPublicClientVerifiesIDTokenNonceAndAudience(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
"id_token_signing_alg_values_supported": []string{"RS256", "ES256"},
})
case "/jwks":
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{ecJWK("ec-key", &key.PublicKey)}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "test",
Issuer: issuer, ClientID: "gateway-public-client", RedirectURI: "https://gateway.example.com/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
idToken := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) {
claims["aud"] = "gateway-public-client"
claims["nonce"] = "expected-nonce"
})
subject, err := client.VerifyIDToken(context.Background(), idToken, "expected-nonce")
if err != nil || subject != "platform-subject" {
t.Fatalf("VerifyIDToken() subject=%q err=%v", subject, err)
}
if _, err := client.VerifyIDToken(context.Background(), idToken, "wrong-nonce"); err == nil {
t.Fatal("ID token with mismatched nonce was accepted")
}
for _, test := range []struct {
name string
mutate func(jwt.MapClaims)
}{
{name: "wrong audience", mutate: func(claims jwt.MapClaims) { claims["aud"] = "other-client" }},
{name: "missing nbf", mutate: func(claims jwt.MapClaims) { delete(claims, "nbf") }},
} {
t.Run(test.name, func(t *testing.T) {
invalid := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) {
claims["aud"] = "gateway-public-client"
claims["nonce"] = "expected-nonce"
test.mutate(claims)
})
if _, err := client.VerifyIDToken(context.Background(), invalid, "expected-nonce"); err == nil {
t.Fatal("invalid ID token was accepted")
}
})
}
}
func TestOIDCPublicClientRefreshAndRevokeNeverSendSecret(t *testing.T) {
var issuer string
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
"revocation_endpoint": issuer + "/revoke",
"end_session_endpoint": issuer + "/logout",
})
case "/token", "/revoke":
requests++
body, _ := io.ReadAll(r.Body)
values, _ := url.ParseQuery(string(body))
if values.Get("client_secret") != "" || r.Header.Get("Authorization") != "" {
t.Error("public client request contained client authentication")
http.Error(w, "invalid client authentication", http.StatusBadRequest)
return
}
if r.URL.Path == "/token" {
if values.Get("grant_type") != "refresh_token" || values.Get("refresh_token") != "old-refresh" {
t.Error("refresh request omitted grant type or refresh token")
http.Error(w, "invalid refresh request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "new-access", "refresh_token": "new-refresh", "expires_in": 300})
return
}
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "test",
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if _, err := client.Refresh(context.Background(), "old-refresh"); err != nil {
t.Fatal(err)
}
if err := client.RevokeRefreshToken(context.Background(), "new-refresh"); err != nil {
t.Fatal(err)
}
if requests != 2 {
t.Fatalf("requests = %d, want 2", requests)
}
}
func TestOIDCPublicClientMapsInvalidGrantWithoutLeakingProviderResponse(t *testing.T) {
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
})
case "/token":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"sensitive-provider-detail"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "test",
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
_, err = client.Refresh(context.Background(), "redacted-refresh-token")
if !errors.Is(err, ErrOIDCInvalidGrant) || strings.Contains(err.Error(), "sensitive-provider-detail") {
t.Fatalf("invalid_grant mapping was not stable and redacted: %v", err)
}
}
func TestOIDCPublicClientRejectsLoopbackHTTPDiscoveryEndpointsInProduction(t *testing.T) {
var issuer, insecureField string
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/.well-known/openid-configuration" {
http.NotFound(w, r)
return
}
metadata := map[string]any{
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
"revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout",
"id_token_signing_alg_values_supported": []string{"RS256", "ES256"},
}
metadata[insecureField] = "http://127.0.0.1:1/oidc-endpoint"
_ = json.NewEncoder(w).Encode(metadata)
}))
defer server.Close()
issuer = server.URL
for _, field := range []string{
"authorization_endpoint", "token_endpoint", "jwks_uri", "revocation_endpoint", "end_session_endpoint",
} {
t.Run(field, func(t *testing.T) {
insecureField = field
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
AppEnv: "production", Issuer: issuer, ClientID: "gateway-public",
RedirectURI: "https://gateway.example.com/callback", PostLogoutRedirectURI: "https://gateway.example.com/",
HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if err := client.ValidateConfiguration(context.Background()); err == nil {
t.Fatalf("production accepted loopback HTTP %s", field)
}
})
}
}