1
0
镜像自地址 https://github.com/therootcompany/golib.git 已同步 2026-04-24 04:38:02 +00:00
golib/auth/ajwt/fetcher.go
AJ ONeal ac25aa2ee5
ajwt: fix Audience type, digestFor panic, validator bugs, fetcher staleness
- Add Audience type (RFC 7519 §4.1.3): unmarshals string or []string,
  marshals to string for single value and array for multiple
- Fix digestFor panic: return ([]byte, error) instead of panicking on
  unsupported hash; plumb error through Sign and verifyWith callers
- Fix headerJSON marshal error: propagate instead of discarding in
  NewJWSFromClaims and JWS.Sign (all three key-type branches)
- Fix MaxAge/IgnoreAuthTime interaction: IgnoreAuthTime: true now
  correctly skips auth_time checks even when MaxAge > 0
- Fix "unchecked" warnings for Jti/Nonce/Azp: invert to opt-in —
  these fields are only validated when the Validator has them set
- Fix MultiValidator.Aud for Audience type: checks if any token
  audience value is in the allowed list (set intersection)
- Fix stale now in JWKsFetcher slow path: recapture time.Now() after
  acquiring the mutex so stale-window checks use a current timestamp
- Remove RespectHeaders no-op field from JWKsFetcher
- Simplify RSA exponent decode: use big.Int.IsInt64() instead of
  platform-dependent int size check
2026-03-13 12:04:15 -06:00

123 行
3.9 KiB
Go

// Copyright 2025 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
//
// SPDX-License-Identifier: MPL-2.0
package ajwt
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
)
// cachedIssuer bundles an [*Issuer] with its freshness window.
// Stored atomically in [JWKsFetcher]; immutable after creation.
type cachedIssuer struct {
iss *Issuer
fetchedAt time.Time
expiresAt time.Time // fetchedAt + MaxAge; fresh until this point
}
// JWKsFetcher lazily fetches and caches JWKS keys from a remote URL,
// returning a fresh [*Issuer] on demand.
//
// Each call to [JWKsFetcher.Issuer] checks freshness and either returns the
// cached Issuer immediately or fetches a new one. There is no background
// goroutine — refresh only happens when a caller requests an Issuer.
//
// Fields must be set before the first call to [JWKsFetcher.Issuer]; do not
// modify them concurrently.
//
// Typical usage:
//
// fetcher := &ajwt.JWKsFetcher{
// URL: "https://accounts.example.com/.well-known/jwks.json",
// MaxAge: time.Hour,
// StaleAge: 30 * time.Minute,
// KeepOnError: true,
// }
// iss, err := fetcher.Issuer(ctx)
type JWKsFetcher struct {
// URL is the JWKS endpoint to fetch keys from.
URL string
// MaxAge is how long fetched keys are considered fresh. After MaxAge,
// the next call to Issuer triggers a refresh. Defaults to 1 hour.
MaxAge time.Duration
// StaleAge is additional time beyond MaxAge during which the old Issuer
// may be returned when a refresh fails. For example, MaxAge=1h and
// StaleAge=30m means keys will be served up to 90 minutes after the last
// successful fetch, if KeepOnError is true and fetches keep failing.
// Defaults to 0 (no stale window).
StaleAge time.Duration
// KeepOnError causes the previous Issuer to be returned (with an error)
// when a refresh fails, as long as the result is within the stale window
// (expiresAt + StaleAge). If false, any fetch error after MaxAge returns
// (nil, err).
KeepOnError bool
mu sync.Mutex
cached atomic.Pointer[cachedIssuer]
}
// Issuer returns a current [*Issuer] for verifying tokens.
//
// If the cached Issuer is still fresh (within MaxAge), it is returned without
// a network call. If it has expired, a new fetch is performed. On fetch
// failure with KeepOnError=true and within StaleAge, the old Issuer is
// returned alongside a non-nil error; callers may choose to accept it.
func (f *JWKsFetcher) Issuer(ctx context.Context) (*Issuer, error) {
now := time.Now()
// Fast path: check cached value without locking.
if ci := f.cached.Load(); ci != nil && now.Before(ci.expiresAt) {
return ci.iss, nil
}
// Slow path: refresh needed. Serialize to avoid stampeding.
f.mu.Lock()
defer f.mu.Unlock()
// Recapture time after acquiring lock — the fast-path timestamp may be stale
// if there was contention and another goroutine held the lock for a while.
now = time.Now()
// Re-check after acquiring lock — another goroutine may have refreshed.
if ci := f.cached.Load(); ci != nil && now.Before(ci.expiresAt) {
return ci.iss, nil
}
keys, err := FetchJWKsURL(ctx, f.URL)
if err != nil {
// On error, serve stale keys within the stale window.
if ci := f.cached.Load(); ci != nil && f.KeepOnError {
staleDeadline := ci.expiresAt.Add(f.StaleAge)
if now.Before(staleDeadline) {
return ci.iss, fmt.Errorf("JWKS refresh failed (serving cached keys): %w", err)
}
}
return nil, fmt.Errorf("fetch JWKS from %s: %w", f.URL, err)
}
maxAge := f.MaxAge
if maxAge <= 0 {
maxAge = time.Hour
}
ci := &cachedIssuer{
iss: New(keys),
fetchedAt: now,
expiresAt: now.Add(maxAge),
}
f.cached.Store(ci)
return ci.iss, nil
}