1.AI 协作文档体系重构(objections/worklines/contracts+matrix.md) 2.coord 仲裁文档(final-decisions/cross-review/final-rulings/orchestration) 3.各服务 01/02 文档补全 4.共享包初始化(shared-ts/shared-go/hooks/ui-components/ui-tokens) 5.Proto 契约补全 6.004 架构影响地图更新 7.端口分配表 8.设计规格文档
184 lines
4.9 KiB
Go
184 lines
4.9 KiB
Go
// Package jwks fetches JSON Web Key Sets and validates RS256 JWTs against them.
|
|
//
|
|
// A Fetcher caches parsed RSA public keys in memory for 5 minutes per kid so
|
|
// repeated token validation does not re-hit the issuer's JWKS endpoint. It is
|
|
// safe for concurrent use.
|
|
package jwks
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rsa"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// cacheTTL is how long a cached public key is considered fresh before the
|
|
// Fetcher re-fetches the JWKS document.
|
|
const cacheTTL = 5 * time.Minute
|
|
|
|
// Claims is the set of assertions carried by an Edu access token. The
|
|
// embedded RegisteredClaims provides exp, iss, aud and the other standard
|
|
// JWT claims.
|
|
type Claims struct {
|
|
UserID string `json:"user_id"`
|
|
Role string `json:"role"`
|
|
DataScope string `json:"data_scope"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// cachedKey holds a parsed public key together with the moment it expires.
|
|
type cachedKey struct {
|
|
key *rsa.PublicKey
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// Fetcher downloads and caches a JWKS document and validates tokens against
|
|
// the cached keys.
|
|
type Fetcher struct {
|
|
jwksURL string
|
|
httpClient *http.Client
|
|
cache sync.Map
|
|
}
|
|
|
|
// NewFetcher returns a Fetcher that retrieves keys from jwksURL.
|
|
func NewFetcher(jwksURL string) *Fetcher {
|
|
return &Fetcher{
|
|
jwksURL: jwksURL,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
}
|
|
|
|
// GetPublicKey returns the RSA public key identified by kid. The key is served
|
|
// from the in-memory cache when fresh; otherwise the JWKS document is
|
|
// re-fetched and the cache repopulated.
|
|
func (f *Fetcher) GetPublicKey(kid string) (*rsa.PublicKey, error) {
|
|
if v, ok := f.cache.Load(kid); ok {
|
|
if ck, ok := v.(*cachedKey); ok && time.Now().Before(ck.expiresAt) {
|
|
return ck.key, nil
|
|
}
|
|
}
|
|
|
|
if err := f.refreshCache(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
v, ok := f.cache.Load(kid)
|
|
if !ok {
|
|
return nil, fmt.Errorf("jwks: kid %q not found in JWKS", kid)
|
|
}
|
|
ck, ok := v.(*cachedKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("jwks: cached entry for kid %q has unexpected type", kid)
|
|
}
|
|
return ck.key, nil
|
|
}
|
|
|
|
// refreshCache downloads the JWKS document and replaces every cached key.
|
|
func (f *Fetcher) refreshCache() error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.jwksURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("jwks: building request: %w", err)
|
|
}
|
|
|
|
resp, err := f.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("jwks: fetching JWKS: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("jwks: unexpected status %d from %s", resp.StatusCode, f.jwksURL)
|
|
}
|
|
|
|
var body struct {
|
|
Keys []struct {
|
|
Kid string `json:"kid"`
|
|
Kty string `json:"kty"`
|
|
N string `json:"n"`
|
|
E string `json:"e"`
|
|
} `json:"keys"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
return fmt.Errorf("jwks: decoding response: %w", err)
|
|
}
|
|
|
|
expiresAt := time.Now().Add(cacheTTL)
|
|
for _, k := range body.Keys {
|
|
if k.Kty != "RSA" {
|
|
continue
|
|
}
|
|
pk, err := buildRSAPublicKey(k.N, k.E)
|
|
if err != nil {
|
|
return fmt.Errorf("jwks: parsing key %q: %w", k.Kid, err)
|
|
}
|
|
f.cache.Store(k.Kid, &cachedKey{key: pk, expiresAt: expiresAt})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildRSAPublicKey reconstructs an rsa.PublicKey from the base64url-encoded
|
|
// modulus (n) and exponent (e) fields of a JWK.
|
|
func buildRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
|
|
nBytes, err := base64.RawURLEncoding.DecodeString(nStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decoding modulus: %w", err)
|
|
}
|
|
eBytes, err := base64.RawURLEncoding.DecodeString(eStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decoding exponent: %w", err)
|
|
}
|
|
|
|
var e int
|
|
for _, b := range eBytes {
|
|
e = e<<8 + int(b)
|
|
}
|
|
if e == 0 {
|
|
return nil, fmt.Errorf("empty exponent")
|
|
}
|
|
|
|
return &rsa.PublicKey{
|
|
N: new(big.Int).SetBytes(nBytes),
|
|
E: e,
|
|
}, nil
|
|
}
|
|
|
|
// ValidateToken parses and validates an RS256 JWT using the cached JWKS keys.
|
|
// The kid in the token header selects the verification key. On success the
|
|
// populated Claims (including user_id, role, data_scope and the standard
|
|
// registered claims) are returned.
|
|
func (f *Fetcher) ValidateToken(token string) (*Claims, error) {
|
|
parsed, err := jwt.ParseWithClaims(token, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
|
return nil, fmt.Errorf("jwks: unexpected signing method %v", t.Header["alg"])
|
|
}
|
|
kid, ok := t.Header["kid"].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("jwks: token header missing kid")
|
|
}
|
|
pk, err := f.GetPublicKey(kid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return pk, nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jwks: parsing token: %w", err)
|
|
}
|
|
|
|
claims, ok := parsed.Claims.(*Claims)
|
|
if !ok || !parsed.Valid {
|
|
return nil, fmt.Errorf("jwks: invalid token claims")
|
|
}
|
|
return claims, nil
|
|
}
|