refactor: 使用 go-oidc 验证 ID Token

This commit is contained in:
chengcheng 2026-07-14 10:23:53 +08:00
parent 053bc260c7
commit 85d72a1c8c
7 changed files with 91 additions and 69 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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)