feat(observability): 增加 OIDC 失败关联诊断

将 OIDC Access Token 与 ID Token 校验失败映射为固定安全分类,并生成 diagnosticId 关联浏览器错误与服务端日志。日志不记录授权码、Token 或底层敏感输入,且保留 errors.Is(ErrUnauthorized) 兼容语义。

验证:go test ./... -count=1(apps/api)
This commit is contained in:
2026-07-28 06:19:24 +08:00
parent b51ecc5eed
commit 4a464cacca
5 changed files with 180 additions and 35 deletions
+87 -18
View File
@@ -132,15 +132,28 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "ES256"}))
unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
if err != nil || unverified == nil {
return nil, oidcUnauthorized("token envelope is invalid", err)
return nil, oidcUnauthorized("TOKEN_ENVELOPE_INVALID", "token envelope is invalid", err)
}
unverifiedClaims, ok := unverified.Claims.(jwt.MapClaims)
if !ok {
return nil, oidcUnauthorized("TOKEN_ENVELOPE_INVALID", "token envelope is invalid", nil)
}
if stringClaim(unverifiedClaims, "iss") == "" {
return nil, oidcUnauthorized("ISSUER_MISSING", "issuer is missing", nil)
}
if len(stringSliceClaim(unverifiedClaims, "aud")) == 0 {
return nil, oidcUnauthorized("AUDIENCE_MISSING", "audience is missing", nil)
}
if _, exists := unverifiedClaims["exp"]; !exists {
return nil, oidcUnauthorized("EXP_MISSING", "exp is missing", nil)
}
kid, _ := unverified.Header["kid"].(string)
if kid == "" {
return nil, oidcUnauthorized("kid is missing", nil)
return nil, oidcUnauthorized("KID_MISSING", "kid is missing", nil)
}
key, err := v.key(ctx, kid)
if err != nil {
return nil, oidcUnauthorized("signing key lookup failed", err)
return nil, oidcUnauthorized("SIGNING_KEY_LOOKUP_FAILED", "signing key lookup failed", err)
}
token, err := jwt.Parse(raw, func(token *jwt.Token) (any, error) {
if token.Header["kid"] != kid {
@@ -150,30 +163,30 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
}, jwt.WithValidMethods([]string{"RS256", "ES256"}), jwt.WithIssuer(v.config.Issuer),
jwt.WithAudience(v.config.Audience), jwt.WithExpirationRequired(), jwt.WithLeeway(30*time.Second))
if err != nil || !token.Valid {
return nil, oidcUnauthorized("signature or registered claims are invalid", err)
return nil, oidcUnauthorized(registeredClaimsValidationCategory(err), "signature or registered claims are invalid", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || stringClaim(claims, "sub") == "" || stringClaim(claims, "tid") != v.config.TenantID {
return nil, oidcUnauthorized("stable identity claims are invalid", nil)
return nil, oidcUnauthorized("STABLE_IDENTITY_CLAIMS_INVALID", "stable identity claims are invalid", nil)
}
expiresAt, ok := numericDateClaim(claims["exp"])
if !ok {
return nil, oidcUnauthorized("exp is invalid", nil)
return nil, oidcUnauthorized("EXP_INVALID", "exp is invalid", nil)
}
if _, ok := numericDateClaim(claims["nbf"]); !ok {
return nil, oidcUnauthorized("nbf is missing", nil)
return nil, oidcUnauthorized("NBF_MISSING", "nbf is missing", nil)
}
issuedAt, hasIssuedAt := numericDateClaim(claims["iat"])
scopes := scopeClaims(claims)
if !containsAll(scopes, v.config.RequiredScopes) {
return nil, oidcUnauthorized("required scope is missing", nil)
return nil, oidcUnauthorized("REQUIRED_SCOPE_MISSING", "required scope is missing", nil)
}
roles := gatewayRoles(stringSliceClaim(claims, "roles"), v.config.RolePrefix)
if len(roles) == 0 {
roles = gatewayRoles(stringSliceClaim(claims, "role"), v.config.RolePrefix)
}
if len(roles) == 0 {
return nil, oidcUnauthorized("mapped role is missing", nil)
return nil, oidcUnauthorized("MAPPED_ROLE_MISSING", "mapped role is missing", nil)
}
if v.config.SecurityEventEvaluator != nil {
evaluation, evaluateErr := v.config.SecurityEventEvaluator(ctx, OIDCSecurityEventIdentity{
@@ -183,10 +196,10 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
}
if evaluation.Enabled && !hasIssuedAt {
return nil, oidcUnauthorized("iat is required when security events are enabled", nil)
return nil, oidcUnauthorized("SECURITY_EVENT_IAT_MISSING", "iat is required when security events are enabled", nil)
}
if evaluation.Revoked {
return nil, oidcUnauthorized("token was issued before the revocation watermark", nil)
return nil, oidcUnauthorized("TOKEN_REVOKED", "token was issued before the revocation watermark", nil)
}
if evaluation.RequireIntrospection {
active, introspectionErr := v.introspect(ctx, raw)
@@ -194,18 +207,18 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_INTROSPECTION_UNAVAILABLE", "认证中心内省暂时不可用")
}
if !active {
return nil, oidcUnauthorized("token is inactive", nil)
return nil, oidcUnauthorized("TOKEN_INACTIVE", "token is inactive", nil)
}
} else if !evaluation.Enabled && v.config.IntrospectionEnabled {
active, introspectionErr := v.introspect(ctx, raw)
if introspectionErr != nil || !active {
return nil, oidcUnauthorized("token is inactive", introspectionErr)
return nil, oidcUnauthorized("TOKEN_INACTIVE", "token is inactive", introspectionErr)
}
}
} else if v.config.IntrospectionEnabled {
active, err := v.introspect(ctx, raw)
if err != nil || !active {
return nil, oidcUnauthorized("token is inactive", err)
return nil, oidcUnauthorized("TOKEN_INACTIVE", "token is inactive", err)
}
}
username := stringClaim(claims, "preferred_username")
@@ -218,11 +231,67 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
}, nil
}
func oidcUnauthorized(reason string, cause error) error {
if cause != nil {
return fmt.Errorf("%w: %s: %v", ErrUnauthorized, reason, cause)
type oidcValidationError struct {
category string
reason string
cause error
}
func (err *oidcValidationError) Error() string {
if err.cause != nil {
return fmt.Sprintf("%v: %s: %v", ErrUnauthorized, err.reason, err.cause)
}
return fmt.Sprintf("%v: %s", ErrUnauthorized, err.reason)
}
func (err *oidcValidationError) Unwrap() []error {
if err.cause == nil {
return []error{ErrUnauthorized}
}
return []error{ErrUnauthorized, err.cause}
}
func OIDCValidationCategory(err error) string {
var validationError *oidcValidationError
if errors.As(err, &validationError) {
return validationError.category
}
return "UNCLASSIFIED"
}
func registeredClaimsValidationCategory(err error) string {
switch {
case errors.Is(err, jwt.ErrTokenRequiredClaimMissing):
return "REQUIRED_CLAIM_MISSING"
case errors.Is(err, jwt.ErrTokenInvalidAudience):
return "AUDIENCE_INVALID"
case errors.Is(err, jwt.ErrTokenInvalidIssuer):
return "ISSUER_INVALID"
case errors.Is(err, jwt.ErrTokenExpired):
return "TOKEN_EXPIRED"
case errors.Is(err, jwt.ErrTokenNotValidYet):
return "TOKEN_NOT_VALID_YET"
case errors.Is(err, jwt.ErrTokenUsedBeforeIssued):
return "TOKEN_USED_BEFORE_ISSUED"
case errors.Is(err, jwt.ErrTokenSignatureInvalid):
return "SIGNATURE_INVALID"
case errors.Is(err, jwt.ErrTokenMalformed):
return "TOKEN_MALFORMED"
case errors.Is(err, jwt.ErrTokenUnverifiable):
return "TOKEN_UNVERIFIABLE"
case errors.Is(err, jwt.ErrTokenInvalidClaims):
return "CLAIMS_INVALID"
default:
return "REGISTERED_CLAIMS_INVALID"
}
}
func oidcUnauthorized(category, reason string, cause error) error {
return &oidcValidationError{
category: category,
reason: reason,
cause: cause,
}
return fmt.Errorf("%w: %s", ErrUnauthorized, reason)
}
func (v *OIDCVerifier) key(ctx context.Context, kid string) (any, error) {