feat: 实现 OIDC 服务端会话与请求刷新

使用 AES-256-GCM 保存认证中心令牌,并以随机 Cookie 哈希关联 PostgreSQL 会话。加入闲置与绝对期限、Refresh Token 轮换以及多实例刷新锁。
This commit is contained in:
2026-07-13 19:09:10 +08:00
parent a81a7b5200
commit d345c070ae
16 changed files with 1804 additions and 58 deletions
+41 -1
View File
@@ -21,6 +21,7 @@ import (
)
const maxOIDCResponseBytes = 1 << 20
const defaultOIDCHTTPTimeout = 10 * time.Second
type OIDCConfig struct {
Issuer string
@@ -82,7 +83,7 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
}
client := config.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
client = &http.Client{Timeout: defaultOIDCHTTPTimeout, CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}}
}
@@ -151,6 +152,45 @@ 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)