镜像自地址
https://github.com/therootcompany/golib.git
已同步 2026-04-24 04:38:02 +00:00
- 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
123 行
3.9 KiB
Go
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
|
|
}
|