refactor: 使用 go-oidc 验证 ID Token
This commit is contained in:
parent
053bc260c7
commit
85d72a1c8c
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user