refactor: 使用 go-oidc 验证 ID Token
This commit is contained in:
@@ -152,45 +152,6 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
|||||||
}, nil
|
}, 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 {
|
func oidcUnauthorized(reason string, cause error) error {
|
||||||
if cause != nil {
|
if cause != nil {
|
||||||
return fmt.Errorf("%w: %s: %v", ErrUnauthorized, reason, cause)
|
return fmt.Errorf("%w: %s: %v", ErrUnauthorized, reason, cause)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type OIDCPublicClient struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
oauth2Config *oauth2.Config
|
oauth2Config *oauth2.Config
|
||||||
metadata oidcClientDiscovery
|
metadata oidcClientDiscovery
|
||||||
|
idVerifier *oidc.IDTokenVerifier
|
||||||
}
|
}
|
||||||
|
|
||||||
type oidcClientDiscovery struct {
|
type oidcClientDiscovery struct {
|
||||||
@@ -112,6 +113,37 @@ func (c *OIDCPublicClient) Refresh(ctx context.Context, refreshToken string) (OI
|
|||||||
return oidcTokenResponse(token)
|
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 {
|
func (c *OIDCPublicClient) RevokeRefreshToken(ctx context.Context, refreshToken string) error {
|
||||||
if strings.TrimSpace(refreshToken) == "" {
|
if strings.TrimSpace(refreshToken) == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -197,11 +229,16 @@ func (c *OIDCPublicClient) configuration(ctx context.Context) (*oauth2.Config, o
|
|||||||
ClientID: c.config.ClientID, RedirectURL: c.config.RedirectURI,
|
ClientID: c.config.ClientID, RedirectURL: c.config.RedirectURI,
|
||||||
Endpoint: endpoint, Scopes: append([]string(nil), c.config.Scopes...),
|
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()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if c.oauth2Config == nil {
|
if c.oauth2Config == nil {
|
||||||
c.oauth2Config = config
|
c.oauth2Config = config
|
||||||
c.metadata = metadata
|
c.metadata = metadata
|
||||||
|
c.idVerifier = idVerifier
|
||||||
}
|
}
|
||||||
return c.oauth2Config, c.metadata, nil
|
return c.oauth2Config, c.metadata, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -11,6 +14,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T) {
|
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) {
|
func TestOIDCPublicClientRefreshAndRevokeNeverSendSecret(t *testing.T) {
|
||||||
var issuer string
|
var issuer string
|
||||||
requests := 0
|
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) {
|
func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing.T) {
|
||||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
active := true
|
active := true
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
||||||
return
|
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 {
|
if err != nil || idSubject != identity.ID {
|
||||||
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
||||||
return
|
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) {
|
func (f *fakeOIDCClient) ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error) {
|
||||||
return auth.OIDCTokenResponse{}, nil
|
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) {
|
func (f *fakeOIDCClient) Refresh(context.Context, string) (auth.OIDCTokenResponse, error) {
|
||||||
return auth.OIDCTokenResponse{}, nil
|
return auth.OIDCTokenResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Server struct {
|
|||||||
type oidcPublicClient interface {
|
type oidcPublicClient interface {
|
||||||
AuthorizationURL(context.Context, string, string, string) (string, error)
|
AuthorizationURL(context.Context, string, string, string) (string, error)
|
||||||
ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error)
|
ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error)
|
||||||
|
VerifyIDToken(context.Context, string, string) (string, error)
|
||||||
Refresh(context.Context, string) (auth.OIDCTokenResponse, error)
|
Refresh(context.Context, string) (auth.OIDCTokenResponse, error)
|
||||||
RevokeRefreshToken(context.Context, string) error
|
RevokeRefreshToken(context.Context, string) error
|
||||||
EndSessionURL(context.Context, string) (string, error)
|
EndSessionURL(context.Context, string) (string, error)
|
||||||
|
|||||||
Reference in New Issue
Block a user