From 85d72a1c8c3a0b7754761af785f54d98aa872b84 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Tue, 14 Jul 2026 10:23:53 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BD=BF=E7=94=A8=20go-oidc=20?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=20ID=20Token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/internal/auth/oidc.go | 39 --------------- apps/api/internal/auth/oidc_client.go | 37 ++++++++++++++ apps/api/internal/auth/oidc_client_test.go | 49 +++++++++++++++++++ apps/api/internal/auth/oidc_test.go | 29 ----------- apps/api/internal/httpapi/oidc_session.go | 2 +- .../api/internal/httpapi/oidc_session_test.go | 3 ++ apps/api/internal/httpapi/server.go | 1 + 7 files changed, 91 insertions(+), 69 deletions(-) diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go index 5ece0e0..b767221 100644 --- a/apps/api/internal/auth/oidc.go +++ b/apps/api/internal/auth/oidc.go @@ -152,45 +152,6 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { }, nil } -func (v *OIDCVerifier) VerifyIDToken(ctx context.Context, raw, clientID, expectedNonce string) (string, error) { - clientID = strings.TrimSpace(clientID) - expectedNonce = strings.TrimSpace(expectedNonce) - if clientID == "" || expectedNonce == "" { - return "", oidcUnauthorized("ID token validation context is invalid", nil) - } - parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "ES256"})) - unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{}) - if err != nil || unverified == nil { - return "", oidcUnauthorized("ID token envelope is invalid", err) - } - kid, _ := unverified.Header["kid"].(string) - if kid == "" { - return "", oidcUnauthorized("ID token kid is missing", nil) - } - key, err := v.key(ctx, kid) - if err != nil { - return "", oidcUnauthorized("ID token signing key lookup failed", err) - } - token, err := jwt.Parse(raw, func(token *jwt.Token) (any, error) { - if token.Header["kid"] != kid { - return nil, ErrUnauthorized - } - return key, nil - }, jwt.WithValidMethods([]string{"RS256", "ES256"}), jwt.WithIssuer(v.config.Issuer), - jwt.WithAudience(clientID), jwt.WithExpirationRequired(), jwt.WithLeeway(30*time.Second)) - if err != nil || !token.Valid { - return "", oidcUnauthorized("ID token signature or registered claims are invalid", err) - } - claims, ok := token.Claims.(jwt.MapClaims) - if !ok || stringClaim(claims, "sub") == "" || stringClaim(claims, "nonce") != expectedNonce { - return "", oidcUnauthorized("ID token subject or nonce is invalid", nil) - } - if _, ok := numericDateClaim(claims["nbf"]); !ok { - return "", oidcUnauthorized("ID token nbf is missing", nil) - } - return stringClaim(claims, "sub"), nil -} - func oidcUnauthorized(reason string, cause error) error { if cause != nil { return fmt.Errorf("%w: %s: %v", ErrUnauthorized, reason, cause) diff --git a/apps/api/internal/auth/oidc_client.go b/apps/api/internal/auth/oidc_client.go index 149c135..cba4453 100644 --- a/apps/api/internal/auth/oidc_client.go +++ b/apps/api/internal/auth/oidc_client.go @@ -43,6 +43,7 @@ type OIDCPublicClient struct { mu sync.Mutex oauth2Config *oauth2.Config metadata oidcClientDiscovery + idVerifier *oidc.IDTokenVerifier } type oidcClientDiscovery struct { @@ -112,6 +113,37 @@ func (c *OIDCPublicClient) Refresh(ctx context.Context, refreshToken string) (OI return oidcTokenResponse(token) } +func (c *OIDCPublicClient) VerifyIDToken(ctx context.Context, raw, expectedNonce string) (string, error) { + expectedNonce = strings.TrimSpace(expectedNonce) + if strings.TrimSpace(raw) == "" || expectedNonce == "" { + return "", oidcUnauthorized("ID token validation context is invalid", nil) + } + if _, _, err := c.configuration(ctx); err != nil { + return "", oidcUnauthorized("ID token provider discovery failed", err) + } + c.mu.Lock() + verifier := c.idVerifier + c.mu.Unlock() + if verifier == nil { + return "", oidcUnauthorized("ID token verifier is unavailable", nil) + } + token, err := verifier.Verify(c.requestContext(ctx), raw) + if err != nil { + return "", oidcUnauthorized("ID token signature or registered claims are invalid", nil) + } + if token.Subject == "" || token.Nonce != expectedNonce { + return "", oidcUnauthorized("ID token subject or nonce is invalid", nil) + } + var claims map[string]any + if err := token.Claims(&claims); err != nil { + return "", oidcUnauthorized("ID token claims are invalid", nil) + } + if _, ok := numericDateClaim(claims["nbf"]); !ok { + return "", oidcUnauthorized("ID token nbf is missing", nil) + } + return token.Subject, nil +} + func (c *OIDCPublicClient) RevokeRefreshToken(ctx context.Context, refreshToken string) error { if strings.TrimSpace(refreshToken) == "" { return nil @@ -197,11 +229,16 @@ func (c *OIDCPublicClient) configuration(ctx context.Context) (*oauth2.Config, o ClientID: c.config.ClientID, RedirectURL: c.config.RedirectURI, Endpoint: endpoint, Scopes: append([]string(nil), c.config.Scopes...), } + verifierContext := oidc.ClientContext(context.Background(), c.client) + idVerifier := provider.VerifierContext(verifierContext, &oidc.Config{ + ClientID: c.config.ClientID, SupportedSigningAlgs: []string{oidc.RS256, oidc.ES256}, + }) c.mu.Lock() defer c.mu.Unlock() if c.oauth2Config == nil { c.oauth2Config = config c.metadata = metadata + c.idVerifier = idVerifier } return c.oauth2Config, c.metadata, nil } diff --git a/apps/api/internal/auth/oidc_client_test.go b/apps/api/internal/auth/oidc_client_test.go index 9b0d564..d5393be 100644 --- a/apps/api/internal/auth/oidc_client_test.go +++ b/apps/api/internal/auth/oidc_client_test.go @@ -2,6 +2,9 @@ package auth import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" @@ -11,6 +14,8 @@ import ( "net/url" "strings" "testing" + + "github.com/golang-jwt/jwt/v5" ) func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T) { @@ -85,6 +90,50 @@ func TestOIDCPublicClientRejectsOfflineAccess(t *testing.T) { } } +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{ + 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") + } +} + func TestOIDCPublicClientRefreshAndRevokeNeverSendSecret(t *testing.T) { var issuer string requests := 0 diff --git a/apps/api/internal/auth/oidc_test.go b/apps/api/internal/auth/oidc_test.go index 94c2370..ad625bf 100644 --- a/apps/api/internal/auth/oidc_test.go +++ b/apps/api/internal/auth/oidc_test.go @@ -104,35 +104,6 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) { } } -func TestOIDCVerifierValidatesIDTokenNonceAndPublicClientAudience(t *testing.T) { - key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - var issuer string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { - if request.URL.Path == "/.well-known/openid-configuration" { - _ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/jwks"}) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{ecJWK("ec-key", &key.PublicKey)}}) - })) - defer server.Close() - issuer = server.URL - verifier, _ := NewOIDCVerifier(OIDCConfig{ - Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.", - RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(), - }) - idToken := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) { - claims["aud"] = "gateway-public-client" - claims["nonce"] = "expected-nonce" - }) - subject, err := verifier.VerifyIDToken(context.Background(), idToken, "gateway-public-client", "expected-nonce") - if err != nil || subject != "platform-subject" { - t.Fatalf("VerifyIDToken() subject=%q err=%v", subject, err) - } - if _, err := verifier.VerifyIDToken(context.Background(), idToken, "gateway-public-client", "wrong-nonce"); err == nil { - t.Fatal("ID token with mismatched nonce was accepted") - } -} - func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing.T) { key, _ := rsa.GenerateKey(rand.Reader, 2048) active := true diff --git a/apps/api/internal/httpapi/oidc_session.go b/apps/api/internal/httpapi/oidc_session.go index c777be8..037cb84 100644 --- a/apps/api/internal/httpapi/oidc_session.go +++ b/apps/api/internal/httpapi/oidc_session.go @@ -117,7 +117,7 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed) return } - idSubject, err := s.auth.OIDCVerifier.VerifyIDToken(r.Context(), tokens.IDToken, s.cfg.OIDCClientID, transaction.Nonce) + idSubject, err := s.oidcClient.VerifyIDToken(r.Context(), tokens.IDToken, transaction.Nonce) if err != nil || idSubject != identity.ID { s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed) return diff --git a/apps/api/internal/httpapi/oidc_session_test.go b/apps/api/internal/httpapi/oidc_session_test.go index 02e5731..0e6f351 100644 --- a/apps/api/internal/httpapi/oidc_session_test.go +++ b/apps/api/internal/httpapi/oidc_session_test.go @@ -204,6 +204,9 @@ func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, chall func (f *fakeOIDCClient) ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error) { return auth.OIDCTokenResponse{}, nil } +func (f *fakeOIDCClient) VerifyIDToken(context.Context, string, string) (string, error) { + return "", nil +} func (f *fakeOIDCClient) Refresh(context.Context, string) (auth.OIDCTokenResponse, error) { return auth.OIDCTokenResponse{}, nil } diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 6ebb207..85c6cd4 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -33,6 +33,7 @@ type Server struct { type oidcPublicClient interface { AuthorizationURL(context.Context, string, string, string) (string, error) ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error) + VerifyIDToken(context.Context, string, string) (string, error) Refresh(context.Context, string) (auth.OIDCTokenResponse, error) RevokeRefreshToken(context.Context, string) error EndSessionURL(context.Context, string) (string, error)