Files
seaweedfs/weed/iam/oidc/oidc_provider.go
T
Chris LuandDevin b65832bdc9 iam/oidc: reject reused jti claims with a bounded replay cache (#11936)
* iam/oidc: reject reused jti claims with a bounded replay cache

* iam/sts: pass jti replay settings through convertToOIDCConfig

* iam/oidc: keep consumed jtis and stop the cleanup worker across re-init

Re-initializing a provider reset the JTI store, so an already-used token
could be presented again after a config refresh; the store is now
allocated once and survives Initialize. The cleanup worker is stopped on
every re-init (previously only when protection stayed enabled), is
started only after the rest of Initialize has succeeded, and Shutdown no
longer consumes a permanent sync.Once, so a re-initialized worker can be
stopped again.

* iam/oidc: enforce the jti replay cache bound for every token

Tokens without a usable jti claim skipped the cache entirely and stayed
reusable; they are now keyed by the token's own fingerprint so a repeat
presentation is still a replay. Tokens without exp previously failed as
expired on first use; their entry now gets a bounded retention. The
replay test-and-set now runs before any capacity work so replayed tokens
cannot each trigger a full-cache scan, the entry count is incremented as
part of the insert so concurrent validations cannot overshoot the bound,
and a guaranteed serialized sweep runs before a capacity rejection while
opportunistic sweeps are rate limited and confined to one at a time. Raw
token identifiers are no longer written to logs or errors, a stable
fingerprint is logged instead.

* iam/sts: shut down providers dropped by the managed-provider swap

Each managed OIDC provider runs a background JTI cleanup worker, but
SetIAMManagedOIDCProviders replaced the provider map without stopping
the old providers, so every refresh leaked goroutines. Providers absent
from the new map are now shut down; providers that stay keep running.

* iam/integration: reuse providers across refreshes for unchanged records

Every refresh rebuilt providers from their records, so a refreshed
provider forgot the JTIs it had already consumed and an already-used
token could be presented again. Records are now fingerprinted and an
unchanged record reuses its provider, keeping the consumed-token cache;
changed records are rebuilt, removed records are dropped, and the swap
in STS releases the workers of whatever is no longer referenced.

* iam/oidc: consume replay entries only on the single-use exchange path

Bearer authentication validates the same token on every request, so
consuming it in shared ValidateToken/Authenticate made every token
single-use and rejected all but the first request. The replay check now
runs only for the STS exchange via AuthenticateSingleUse; bearer paths
validate without consuming. A token without exp also no longer gets a
bounded replay entry that ages out while the token stays valid: its
entry is kept until the capacity bound evicts it, so it can never become
usable again.

* iam/oidc: keep the mock provider on the mock path for single-use calls

* iam/oidc: require exp on single-use tokens

A cache slot frees only by expiry, so an expiry-less entry would hold it
forever; a burst of distinct tokens could exhaust the bound permanently
and block every later exchange.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3: give each web-identity test token a unique jti

Subtests minted tokens with identical claims inside one second, so the
exchange saw the same token fingerprint twice and the second call read
as a replay. Each exchange now carries its own jti.

* iam/oidc: carry the consumed-token replay store across provider rebuilds

A record change rebuilds the managed provider with a fresh JTI store, so a token consumed before the rebuild could be presented again until it expired. The new provider adopts the replaced provider's replay store before it is published, and the replaced provider is shut down so only one cleanup worker sweeps the shared store.

* iam/oidc: stop the cleanup worker of providers dropped from the store

A refresh removed the provider object from the managed cache but left its JTI cleanup goroutine running for the life of the process.

* iam/oidc: merge a provider's consumed replay entries into another's store

MergeReplayStateFrom folds one provider's consumed-token entries into
another's, skipping already-expired ones. Unlike AdoptReplayStateFrom it
swaps no pointers, so it is safe on a provider still serving validators.

* iam/integration: share one provider and replay store across identical issuer records

Records for the same issuer URL with identical validation settings now
reuse a single provider, so a token consumed through one account's
record is rejected through the other's — previously each record owned a
separate jti store and a spent token could be replayed cross-account.

A superseded provider's consumed entries merge into the shared store so
no rejection is lost, and a removed or rebuilt provider is only shut
down once no remaining record references it.

* iam/oidc: hand the superseded provider's live replay store to the shared one

* iam/oidc: carry merged replay entries without charging the capacity bound

* iam/oidc: key the jti-less replay fingerprint on the signed input

Hashing the whole token lets ECDSA signature malleability (s -> N-s)
mint a second cache key for identical claims, so a consumed token could
be replayed for another exchange. Key the fallback on header.payload
instead.

* iam/oidc: publish the jti cache bound before the enabled flag

Initialize stored jtiEnabled ahead of jtiMaxSize, so a provider flipped
from disabled to enabled exposed enabled=true with a zero bound for a
window; a single-use validation landing there would compute
newCount > maxSize and reject with "JTI cache capacity exceeded". Store
the bound first so the flag is the last thing published.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 00:46:14 -07:00

1639 lines
53 KiB
Go

package oidc
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/sha1"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
"github.com/seaweedfs/seaweedfs/weed/security"
)
const (
// clockSkewTolerance matches the typical JWT library clock skew allowance (RFC 7519)
clockSkewTolerance = 5 * time.Minute
// defaultJTICacheSize is the default maximum number of JTI entries to cache
defaultJTICacheSize = 10000
// defaultCleanupInterval is how often to scan for expired JTI entries
defaultCleanupInterval = 5 * time.Minute
// eagerCleanupMinInterval rate limits opportunistic sweeps near capacity
eagerCleanupMinInterval = 5 * time.Second
// tokenFingerprintPrefix marks replay-cache keys derived from the token's
// own fingerprint, used when the token carries no usable jti claim
tokenFingerprintPrefix = "fp:"
)
// jtiEntry represents a cached JTI with metadata
type jtiEntry struct {
subject string
expiresAt time.Time
// uncounted marks an entry merged in past the capacity bound: it keeps
// rejecting replays but does not charge jtiCount, so merged excess can
// neither reject new exchanges nor be double-counted when swept.
uncounted bool
}
// OIDCProvider implements OpenID Connect authentication with JTI replay protection.
//
// Replay Protection:
// The provider validates the 'jti' (JWT ID) claim to prevent token replay attacks.
// Each JTI is stored in memory until the token expires. If a token with a duplicate
// JTI is presented, it is rejected with ErrProviderTokenReplayed.
//
// The JTI cache is bounded by JTICacheMaxSize and uses automatic TTL-based expiration.
// A token without a usable jti claim is tracked by the token's own fingerprint
// instead, so a repeated presentation is still detected as a replay.
//
// Thread Safety:
// All methods are safe for concurrent use. The JTI cache uses atomic LoadOrStore
// operations to prevent TOCTOU race conditions.
type OIDCProvider struct {
name string
config *OIDCConfig
initialized bool
httpClient *http.Client
jwksTTL time.Duration
// mu guards the lazily-mutated cache fields below: jwksCache, jwksFetchedAt,
// resolvedJWKSUri, and discoveryFailed are all populated on the first
// validate-token call and refreshed when the cache expires. Multiple S3
// requests can land here in parallel, so they need synchronization.
mu sync.RWMutex
jwksCache *JWKS
jwksFetchedAt time.Time
resolvedJWKSUri string
discoveryFailed bool
// JTI replay protection fields. jtiStore and jtiCount are allocated once
// in NewOIDCProvider and survive re-initialization, so a config refresh
// cannot reopen already-consumed tokens for replay.
jtiEnabled atomic.Bool
jtiStore *sync.Map // map[string]*jtiEntry - atomic storage for consumed token keys
jtiCount *atomic.Int64 // entries in jtiStore; shared with the store
jtiMaxSize atomic.Int64
jtiLastSweep atomic.Int64 // unix nano of the last sweep; rate limits eager cleanups
// jtiStoreMu guards the jtiStore/jtiCount pair: replay-state handoffs
// (AdoptReplayStateFrom, MergeReplayStateFrom) repoint them under the
// write lock while the consume path holds a read lock, so a consume can
// never land in a store the handoff already drained and abandoned.
// It is always acquired before jtiSweepMu, never while holding it.
jtiStoreMu sync.RWMutex
// jtiMu guards the lifecycle of the cleanup worker: jtiCleanupCancel and
// jtiCleanupDone. The worker itself only touches jtiStore/jtiCount, so
// holding jtiMu while waiting for it is safe.
jtiMu sync.Mutex
jtiCleanupCancel context.CancelFunc
jtiCleanupDone chan struct{} // Closed when cleanup goroutine exits
// jtiSweepMu serializes cache sweeps so at most one scan runs at a time.
jtiSweepMu sync.Mutex
}
// OIDCConfig holds OIDC provider configuration
type OIDCConfig struct {
// Issuer is the OIDC issuer URL
Issuer string `json:"issuer"`
// ClientID is the OAuth2 client ID. Either ClientID or ClientIDs is
// required; when both are present, ClientID is appended to the audience
// allowlist.
ClientID string `json:"clientId,omitempty"`
// ClientIDs is the AWS-compatible audience allowlist. Tokens are accepted
// when any of `aud` or `azp` matches any entry. Mutually compatible with
// ClientID for backward compatibility.
ClientIDs []string `json:"clientIds,omitempty"`
// ClientSecret is the OAuth2 client secret (optional for public clients)
ClientSecret string `json:"clientSecret,omitempty"`
// JWKSUri is the JSON Web Key Set URI
JWKSUri string `json:"jwksUri,omitempty"`
// UserInfoUri is the UserInfo endpoint URI
UserInfoUri string `json:"userInfoUri,omitempty"`
// Scopes are the OAuth2 scopes to request
Scopes []string `json:"scopes,omitempty"`
// RoleMapping defines how to map OIDC claims to roles
RoleMapping *providers.RoleMapping `json:"roleMapping,omitempty"`
// ClaimsMapping defines how to map OIDC claims to identity attributes
ClaimsMapping map[string]string `json:"claimsMapping,omitempty"`
// JWKSCacheTTLSeconds sets how long to cache JWKS before refresh (default 3600 seconds)
JWKSCacheTTLSeconds int `json:"jwksCacheTTLSeconds,omitempty"`
// Thumbprints, when non-empty, pins the issuer's TLS certificate against
// this allowlist of SHA-1 hex digests. Matches the AWS IAM
// CreateOpenIDConnectProvider semantics. Empty means "trust the system
// root store" (or whatever TLSCACert configures).
Thumbprints []string `json:"thumbprints,omitempty"`
// AllowedPrincipalTagKeys filters the keys read from the AWS principal
// session tags claim. Empty means "no tags surfaced". Provide an explicit
// allowlist (e.g. ["team", "env"]) to opt specific keys in.
AllowedPrincipalTagKeys []string `json:"allowedPrincipalTagKeys,omitempty"`
// PolicyClaim names a JWT claim whose value carries the effective policy
// list for the session. When non-empty and the assume request opts into
// claim-based policy mode via the ClaimBasedPolicyRoleArn sentinel, the
// policies are pulled from this claim rather than from a server-side
// role mapping. Accepted shapes: string (single policy), comma-separated
// string, or string array.
PolicyClaim string `json:"policyClaim,omitempty"`
// TLSCACert is the path to the CA certificate file for custom/self-signed certificates
TLSCACert string `json:"tlsCaCert,omitempty"`
// TLSInsecureSkipVerify controls whether to skip TLS verification.
// WARNING: Should only be used in development/testing environments. Never use in production.
TLSInsecureSkipVerify bool `json:"tlsInsecureSkipVerify,omitempty"`
// JTIReplayProtectionEnabled enables token replay protection via JTI tracking (default: true)
// Use pointer to distinguish between "not set" (nil = default true) and "explicitly false"
JTIReplayProtectionEnabled *bool `json:"jtiReplayProtectionEnabled,omitempty"`
// JTICacheMaxSize sets maximum number of JTI entries to cache (default: 10000)
JTICacheMaxSize int `json:"jtiCacheMaxSize,omitempty"`
}
// PrincipalTagsClaim is the AWS-defined namespace claim that carries
// principal session tags. Tokens that include this claim must encode it as
// an object whose top-level keys are tag names. AWS uses the same string;
// see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html.
const PrincipalTagsClaim = "https://aws.amazon.com/tags/principal_tags"
// extractClaimPolicies reads policy names from the configured JWT claim.
// Accepts three shapes: a single string ("readonly"), a comma-separated
// string ("readonly,billing"), or a string array. Returns nil when the
// provider isn't in claim-based mode or the claim is absent/empty.
func extractClaimPolicies(claims map[string]interface{}, claimName string) []string {
if claimName == "" {
return nil
}
raw, ok := claims[claimName]
if !ok {
return nil
}
switch v := raw.(type) {
case string:
return splitPolicyClaimString(v)
case []interface{}:
out := make([]string, 0, len(v))
for _, e := range v {
s, ok := e.(string)
if !ok {
continue
}
s = strings.TrimSpace(s)
if s == "" {
continue
}
out = append(out, s)
}
if len(out) == 0 {
return nil
}
return out
}
return nil
}
func splitPolicyClaimString(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out = append(out, p)
}
if len(out) == 0 {
return nil
}
return out
}
// filterPrincipalTags drops keys that are not on `allowed`. An empty
// allowlist means "deny all" — security-conservative default that forces
// operators to explicitly opt tags in. Returns nil when the result is empty.
//
// Comparison is case-insensitive on the key, matching AWS IAM's session-tag
// rules (the AWS docs explicitly state tag keys are case-insensitive even
// though the original casing is preserved on the value side). Without this
// an IDP whose claim casing drifts from the operator's allowlist string
// would fail in surprising ways.
func filterPrincipalTags(tags map[string]string, allowed []string) map[string]string {
if len(tags) == 0 {
return nil
}
if len(allowed) == 0 {
return nil
}
allowSet := make(map[string]struct{}, len(allowed))
for _, k := range allowed {
allowSet[strings.ToLower(k)] = struct{}{}
}
out := make(map[string]string, len(tags))
for k, v := range tags {
if _, ok := allowSet[strings.ToLower(k)]; ok {
out[k] = v
}
}
if len(out) == 0 {
return nil
}
return out
}
// extractPrincipalTags pulls the principal-tags namespace claim out of the
// JWT claim map. Only string values survive — everything else is dropped to
// avoid surfacing structured data into a flat policy condition key. Returns
// nil when the claim is absent or empty.
func extractPrincipalTags(claims map[string]interface{}) map[string]string {
raw, ok := claims[PrincipalTagsClaim]
if !ok {
return nil
}
obj, ok := raw.(map[string]interface{})
if !ok || len(obj) == 0 {
return nil
}
out := make(map[string]string, len(obj))
for k, v := range obj {
switch s := v.(type) {
case string:
out[k] = s
case []interface{}:
// Multi-value tag: AWS condition keys carry only a single value, so
// take the first stringy element. Multi-value matching can land
// later if a real customer needs it.
for _, e := range s {
if str, ok := e.(string); ok {
out[k] = str
break
}
}
}
}
if len(out) == 0 {
return nil
}
return out
}
// normalizeThumbprints lowercases and de-duplicates the configured allowlist.
// Returns a set keyed by lowercase hex for O(1) lookup during TLS verification.
func normalizeThumbprints(in []string) map[string]struct{} {
out := make(map[string]struct{}, len(in))
for _, t := range in {
t = strings.ToLower(strings.TrimSpace(t))
if t == "" {
continue
}
out[t] = struct{}{}
}
return out
}
// verifyThumbprintMatch checks that some certificate in the negotiated chain
// hashes to a thumbprint in `expected`. AWS pins the certificate immediately
// below the root in the chain; we accept any chain certificate to also cover
// self-signed deployments and skip-verify test setups. When the chain has not
// been built (InsecureSkipVerify), we fall back to PeerCertificates.
func verifyThumbprintMatch(cs tls.ConnectionState, expected map[string]struct{}) error {
candidates := collectThumbprintCandidates(cs)
for _, c := range candidates {
sum := sha1.Sum(c.Raw)
hexSum := hex.EncodeToString(sum[:])
if _, ok := expected[hexSum]; ok {
return nil
}
}
return fmt.Errorf("OIDC TLS thumbprint did not match any configured allowlist entry")
}
// collectThumbprintCandidates flattens the verified chains and peer cert list
// into a single slice of unique certificates worth checking.
func collectThumbprintCandidates(cs tls.ConnectionState) []*x509.Certificate {
var out []*x509.Certificate
seen := map[string]struct{}{}
add := func(c *x509.Certificate) {
if c == nil {
return
}
k := string(c.Raw)
if _, ok := seen[k]; ok {
return
}
seen[k] = struct{}{}
out = append(out, c)
}
for _, chain := range cs.VerifiedChains {
for _, c := range chain {
add(c)
}
}
for _, c := range cs.PeerCertificates {
add(c)
}
return out
}
// JWKS represents JSON Web Key Set
type JWKS struct {
Keys []JWK `json:"keys"`
}
// JWK represents a JSON Web Key
type JWK struct {
Kty string `json:"kty"` // Key Type (RSA, EC, etc.)
Kid string `json:"kid"` // Key ID
Use string `json:"use"` // Usage (sig for signature)
Alg string `json:"alg"` // Algorithm (RS256, etc.)
N string `json:"n"` // RSA public key modulus
E string `json:"e"` // RSA public key exponent
X string `json:"x"` // EC public key x coordinate
Y string `json:"y"` // EC public key y coordinate
Crv string `json:"crv"` // EC curve
}
// NewOIDCProvider creates a new OIDC provider
func NewOIDCProvider(name string) *OIDCProvider {
return &OIDCProvider{
name: name,
httpClient: &http.Client{Timeout: 30 * time.Second, CheckRedirect: refuseDowngradeRedirect},
jtiStore: &sync.Map{},
jtiCount: &atomic.Int64{},
}
}
// refuseDowngradeRedirect is the redirect policy of the client that fetches
// discovery documents and signing keys: a request that began over https may
// not be redirected to plain http, where anyone on the network path could
// substitute the keys and mint tokens STS accepts. It otherwise keeps
// net/http's default limit of 10 redirects.
func refuseDowngradeRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
if via[0].URL.Scheme == "https" && req.URL.Scheme != "https" {
return fmt.Errorf("refusing redirect from %s to non-https %s", via[0].URL.Redacted(), req.URL.Redacted())
}
return nil
}
// Name returns the provider name
func (p *OIDCProvider) Name() string {
return p.name
}
// GetIssuer returns the configured issuer URL for efficient provider lookup
func (p *OIDCProvider) GetIssuer() string {
if p.config == nil {
return ""
}
return p.config.Issuer
}
// Initialize initializes the OIDC provider with configuration
func (p *OIDCProvider) Initialize(config interface{}) error {
if config == nil {
return fmt.Errorf("config cannot be nil")
}
oidcConfig, ok := config.(*OIDCConfig)
if !ok {
return fmt.Errorf("invalid config type for OIDC provider")
}
if err := p.validateConfig(oidcConfig); err != nil {
return fmt.Errorf("invalid OIDC configuration: %w", err)
}
p.config = oidcConfig
p.initialized = true
// Configure JWKS cache TTL
if oidcConfig.JWKSCacheTTLSeconds > 0 {
p.jwksTTL = time.Duration(oidcConfig.JWKSCacheTTLSeconds) * time.Second
} else {
p.jwksTTL = time.Hour
}
// Configure HTTP client with TLS settings
tlsConfig := &tls.Config{
InsecureSkipVerify: oidcConfig.TLSInsecureSkipVerify,
MinVersion: tls.VersionTLS12, // Prevent TLS downgrade attacks
}
if oidcConfig.TLSInsecureSkipVerify {
glog.Warningf("OIDC provider %q is configured to skip TLS verification. This is insecure and should not be used in production.", p.name)
}
// Thumbprint pinning: when configured, every TLS handshake to the IDP must
// present a chain whose terminal certificate (the cert just below the root,
// matching AWS IAM semantics) hashes to one of the listed SHA-1 digests.
// VerifyConnection runs after the chain build, so cs.VerifiedChains is
// populated when InsecureSkipVerify is false; we additionally pin against
// PeerCertificates so self-signed test setups work too.
if len(oidcConfig.Thumbprints) > 0 {
expected := normalizeThumbprints(oidcConfig.Thumbprints)
tlsConfig.VerifyConnection = func(cs tls.ConnectionState) error {
return verifyThumbprintMatch(cs, expected)
}
glog.V(2).Infof("OIDC provider %q: TLS thumbprint pinning enabled (%d allowed)", p.name, len(expected))
}
if oidcConfig.TLSCACert != "" {
// Validate that the CA cert path is absolute to prevent reading unintended files
if !filepath.IsAbs(oidcConfig.TLSCACert) {
return fmt.Errorf("TLSCACert must be an absolute path, got: %s", oidcConfig.TLSCACert)
}
caCert, err := os.ReadFile(oidcConfig.TLSCACert)
if err != nil {
return fmt.Errorf("failed to read CA cert file: %w", err)
}
// Start with the system cert pool to trust public CAs, then add the custom one.
rootCAs, _ := x509.SystemCertPool()
if rootCAs == nil {
rootCAs = x509.NewCertPool()
}
if !rootCAs.AppendCertsFromPEM(caCert) {
return fmt.Errorf("failed to append CA cert from file: %s", oidcConfig.TLSCACert)
}
tlsConfig.RootCAs = rootCAs
}
transport := &http.Transport{
TLSClientConfig: tlsConfig,
}
p.httpClient = &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
CheckRedirect: refuseDowngradeRedirect,
}
// Configure JTI replay protection last, so a config failure above cannot
// leave a running worker behind. A previous worker is always stopped,
// whether the new configuration enables protection or disables it.
// The consumed-token store survives re-initialization: resetting it would
// let already-seen tokens be replayed against the refreshed provider.
jtiEnabled := true
if oidcConfig.JTIReplayProtectionEnabled != nil {
jtiEnabled = *oidcConfig.JTIReplayProtectionEnabled
}
p.jtiMu.Lock()
p.stopJTICleanupWorkerLocked()
if jtiEnabled {
maxSize := int64(oidcConfig.JTICacheMaxSize)
if maxSize <= 0 {
maxSize = defaultJTICacheSize
}
// the bound must land before the flag: a consume that sees enabled
// with a zero bound would reject the first token it registers
p.jtiMaxSize.Store(maxSize)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
p.jtiCleanupCancel = cancel
p.jtiCleanupDone = done
p.jtiEnabled.Store(true)
p.jtiMu.Unlock()
go p.cleanupExpiredJTIs(ctx, done, defaultCleanupInterval)
glog.V(2).Infof("OIDC provider %q: JTI replay protection enabled (max size: %d)", p.name, maxSize)
} else {
p.jtiEnabled.Store(false)
p.jtiMu.Unlock()
glog.Warningf("OIDC provider %q: JTI replay protection is DISABLED", p.name)
}
// For testing, we'll skip the actual OIDC client initialization
return nil
}
// stopJTICleanupWorkerLocked cancels the current cleanup goroutine and waits
// briefly for it to exit. Caller must hold jtiMu.
func (p *OIDCProvider) stopJTICleanupWorkerLocked() {
if p.jtiCleanupCancel == nil {
return
}
p.jtiCleanupCancel()
select {
case <-p.jtiCleanupDone:
glog.V(3).Infof("OIDC provider %q: JTI cleanup goroutine exited", p.name)
case <-time.After(2 * time.Second):
glog.Warningf("OIDC provider %q: Timeout waiting for JTI cleanup goroutine", p.name)
}
p.jtiCleanupCancel = nil
p.jtiCleanupDone = nil
}
// validateConfig validates the OIDC configuration
func (p *OIDCProvider) validateConfig(config *OIDCConfig) error {
if config.Issuer == "" {
return fmt.Errorf("issuer is required")
}
if config.ClientID == "" && len(config.ClientIDs) == 0 {
return fmt.Errorf("client ID is required")
}
// Basic URL validation for issuer
if config.Issuer != "" && config.Issuer != "https://accounts.google.com" && config.Issuer[0:4] != "http" {
return fmt.Errorf("invalid issuer URL format")
}
return nil
}
// allowedAudiences returns the merged list of acceptable audiences for this
// provider. Both the singular ClientID and the plural ClientIDs are honoured;
// duplicates collapse silently.
func (p *OIDCProvider) allowedAudiences() []string {
if p.config == nil {
return nil
}
seen := map[string]struct{}{}
var out []string
add := func(s string) {
if s == "" {
return
}
if _, ok := seen[s]; ok {
return
}
seen[s] = struct{}{}
out = append(out, s)
}
add(p.config.ClientID)
for _, c := range p.config.ClientIDs {
add(c)
}
return out
}
// Authenticate authenticates a user with an OIDC token
func (p *OIDCProvider) Authenticate(ctx context.Context, token string) (*providers.ExternalIdentity, error) {
return p.authenticate(ctx, token, false)
}
// AuthenticateSingleUse authenticates and consumes the token so a second
// presentation is rejected as a replay. The web-identity exchange uses it,
// where a token buys a session exactly once; callers that validate the same
// token on every request use Authenticate.
func (p *OIDCProvider) AuthenticateSingleUse(ctx context.Context, token string) (*providers.ExternalIdentity, error) {
return p.authenticate(ctx, token, true)
}
func (p *OIDCProvider) authenticate(ctx context.Context, token string, singleUse bool) (*providers.ExternalIdentity, error) {
if !p.initialized {
return nil, fmt.Errorf("provider not initialized")
}
if token == "" {
return nil, fmt.Errorf("token cannot be empty")
}
// Validate token and get claims
claims, err := p.validateToken(ctx, token, singleUse)
if err != nil {
return nil, err
}
// Map claims to external identity
email, _ := claims.GetClaimString("email")
displayName, _ := claims.GetClaimString("name")
groups, _ := claims.GetClaimStringSlice("groups")
// The raw `roles` claim (e.g. from a Keycloak realm-role mapper). Surfaced into
// the STS request context below (see ExternalIdentity.Roles) so resource policies
// can gate on jwt:roles. Distinct from the provider-configured role mapping below.
rawRoles, _ := claims.GetClaimStringSlice("roles")
// Debug: Log available claims
glog.V(3).Infof("Available claims: %+v", claims.Claims)
if rolesFromClaims, exists := claims.GetClaimStringSlice("roles"); exists {
glog.V(3).Infof("Roles claim found as string slice: %v", rolesFromClaims)
} else if roleFromClaims, exists := claims.GetClaimString("roles"); exists {
glog.V(3).Infof("Roles claim found as string: %s", roleFromClaims)
} else {
glog.V(3).Infof("No roles claim found in token")
}
// Map claims to roles using configured role mapping
roles := p.mapClaimsToRolesWithConfig(claims)
// Create attributes map and add roles
attributes := make(map[string]string)
if len(roles) > 0 {
// Store roles as a comma-separated string in attributes
attributes["roles"] = strings.Join(roles, ",")
}
// Store all additional claims as attributes
processedClaims := map[string]struct{}{
// user / business claims already handled elsewhere
"sub": {},
"email": {},
"name": {},
"groups": {},
"roles": {},
// standard structural OIDC/JWT claims that should not be exposed as attributes
"iss": {},
"aud": {},
"exp": {},
"iat": {},
"nbf": {},
"jti": {},
}
for key, value := range claims.Claims {
if _, isProcessed := processedClaims[key]; !isProcessed {
if strValue, ok := value.(string); ok {
attributes[key] = strValue
} else if jsonValue, err := json.Marshal(value); err == nil {
attributes[key] = string(jsonValue)
} else {
glog.Warningf("failed to marshal claim %q to JSON for OIDC attributes: %v", key, err)
}
}
}
identity := &providers.ExternalIdentity{
UserID: claims.Subject,
Email: email,
DisplayName: displayName,
Groups: groups,
Roles: rawRoles,
Attributes: attributes,
Provider: p.name,
Issuer: claims.Issuer,
PrincipalTags: filterPrincipalTags(extractPrincipalTags(claims.Claims), p.config.AllowedPrincipalTagKeys),
ClaimPolicies: extractClaimPolicies(claims.Claims, p.config.PolicyClaim),
}
// Pass the token expiration to limit session duration
// This ensures the STS session doesn't exceed the source token's validity
if !claims.ExpiresAt.IsZero() {
identity.TokenExpiration = &claims.ExpiresAt
}
return identity, nil
}
// GetUserInfo retrieves user information from the UserInfo endpoint
func (p *OIDCProvider) GetUserInfo(ctx context.Context, userID string) (*providers.ExternalIdentity, error) {
if !p.initialized {
return nil, fmt.Errorf("provider not initialized")
}
if userID == "" {
return nil, fmt.Errorf("user ID cannot be empty")
}
// For now, we'll use a token-based approach since OIDC UserInfo typically requires a token
// In a real implementation, this would need an access token from the authentication flow
return p.getUserInfoWithToken(ctx, userID, "")
}
// GetUserInfoWithToken retrieves user information using an access token
func (p *OIDCProvider) GetUserInfoWithToken(ctx context.Context, accessToken string) (*providers.ExternalIdentity, error) {
if !p.initialized {
return nil, fmt.Errorf("provider not initialized")
}
if accessToken == "" {
return nil, fmt.Errorf("access token cannot be empty")
}
return p.getUserInfoWithToken(ctx, "", accessToken)
}
// getUserInfoWithToken is the internal implementation for UserInfo endpoint calls
func (p *OIDCProvider) getUserInfoWithToken(ctx context.Context, userID, accessToken string) (*providers.ExternalIdentity, error) {
// Determine UserInfo endpoint URL
userInfoUri := p.config.UserInfoUri
if userInfoUri == "" {
// Use standard OIDC discovery endpoint convention
userInfoUri = strings.TrimSuffix(p.config.Issuer, "/") + "/userinfo"
}
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, "GET", userInfoUri, nil)
if err != nil {
return nil, fmt.Errorf("failed to create UserInfo request: %v", err)
}
// Set authorization header if access token is provided
if accessToken != "" {
req.Header.Set("Authorization", security.BearerPrefix+accessToken)
}
req.Header.Set("Accept", "application/json")
// Make HTTP request
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call UserInfo endpoint: %v", err)
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("UserInfo endpoint returned status %d", resp.StatusCode)
}
// Parse JSON response
var userInfo map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
return nil, fmt.Errorf("failed to decode UserInfo response: %v", err)
}
glog.V(4).Infof("Received UserInfo response: %+v", userInfo)
// Map UserInfo claims to ExternalIdentity
identity := p.mapUserInfoToIdentity(userInfo)
// If userID was provided but not found in claims, use it
if userID != "" && identity.UserID == "" {
identity.UserID = userID
}
glog.V(3).Infof("Retrieved user info from OIDC provider: %s", identity.UserID)
return identity, nil
}
// ValidateToken validates an OIDC JWT token
func (p *OIDCProvider) ValidateToken(ctx context.Context, token string) (*providers.TokenClaims, error) {
return p.validateToken(ctx, token, false)
}
// ValidateTokenSingleUse validates and consumes the token: a second
// presentation of it is rejected as a replay. See AuthenticateSingleUse.
func (p *OIDCProvider) ValidateTokenSingleUse(ctx context.Context, token string) (*providers.TokenClaims, error) {
return p.validateToken(ctx, token, true)
}
func (p *OIDCProvider) validateToken(ctx context.Context, token string, singleUse bool) (*providers.TokenClaims, error) {
if !p.initialized {
return nil, fmt.Errorf("provider not initialized")
}
if token == "" {
return nil, fmt.Errorf("token cannot be empty")
}
// Parse token without verification first to get header info
parsedToken, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return nil, fmt.Errorf("failed to parse JWT token: %v", err)
}
// Get key ID from header
kid, ok := parsedToken.Header["kid"].(string)
if !ok {
return nil, fmt.Errorf("missing key ID in JWT header")
}
// Get signing key from JWKS
publicKey, err := p.getPublicKey(ctx, kid)
if err != nil {
return nil, fmt.Errorf("failed to get public key: %v", err)
}
// Parse and validate token with proper signature verification
claims := jwt.MapClaims{}
validatedToken, err := jwt.ParseWithClaims(token, claims, func(token *jwt.Token) (interface{}, error) {
// Verify signing method
switch token.Method.(type) {
case *jwt.SigningMethodRSA, *jwt.SigningMethodECDSA:
return publicKey, nil
default:
return nil, fmt.Errorf("unsupported signing method: %v", token.Header["alg"])
}
})
if err != nil {
// Use JWT library's typed errors for robust error checking
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, fmt.Errorf("%w: %v", providers.ErrProviderTokenExpired, err)
}
return nil, fmt.Errorf("%w: %v", providers.ErrProviderInvalidToken, err)
}
if !validatedToken.Valid {
return nil, fmt.Errorf("%w: token validation failed", providers.ErrProviderInvalidToken)
}
// Validate required claims
issuer, ok := claims["iss"].(string)
if !ok || issuer != p.config.Issuer {
return nil, fmt.Errorf("%w: expected %s, got %s", providers.ErrProviderInvalidIssuer, p.config.Issuer, issuer)
}
// Check audience claim (aud) or authorized party (azp) — Keycloak uses azp.
// Per RFC 7519, aud can be either a string or an array of strings.
// Multiple client IDs are supported per AWS IAM CreateOpenIDConnectProvider
// semantics: any one match in the allowlist accepts the token.
allowed := p.allowedAudiences()
allowedSet := make(map[string]struct{}, len(allowed))
for _, a := range allowed {
allowedSet[a] = struct{}{}
}
var audienceMatched bool
if audClaim, ok := claims["aud"]; ok {
switch aud := audClaim.(type) {
case string:
if _, ok := allowedSet[aud]; ok {
audienceMatched = true
}
case []interface{}:
for _, a := range aud {
if str, ok := a.(string); ok {
if _, ok := allowedSet[str]; ok {
audienceMatched = true
break
}
}
}
}
}
if !audienceMatched {
if azp, ok := claims["azp"].(string); ok {
if _, ok := allowedSet[azp]; ok {
audienceMatched = true
}
}
}
if !audienceMatched {
return nil, fmt.Errorf("%w: token audience matches none of the configured client IDs", providers.ErrProviderInvalidAudience)
}
subject, ok := claims["sub"].(string)
if !ok {
return nil, fmt.Errorf("%w: missing subject claim", providers.ErrProviderMissingClaims)
}
// Convert to our TokenClaims structure
tokenClaims := &providers.TokenClaims{
Subject: subject,
Issuer: issuer,
Claims: make(map[string]interface{}),
}
// Extract time-based claims (exp, iat, nbf)
for key, target := range map[string]*time.Time{
"exp": &tokenClaims.ExpiresAt,
"iat": &tokenClaims.IssuedAt,
"nbf": &tokenClaims.NotBefore,
} {
if val, ok := claims[key]; ok {
switch v := val.(type) {
case float64:
*target = time.Unix(int64(v), 0)
case json.Number:
if intVal, err := v.Int64(); err == nil {
*target = time.Unix(intVal, 0)
}
}
}
}
// Copy all claims
for key, value := range claims {
tokenClaims.Claims[key] = value
}
// Extract and validate JTI claim for replay protection. The check runs
// only for single-use callers: a bearer token is meant to be presented on
// every request, so validating it must not consume it.
if singleUse && p.jtiEnabled.Load() {
jti, _ := claims["jti"].(string)
key := jti
if key == "" {
// Providers that issue no jti cannot be tracked by claim, so the
// signed input keys the entry: a repeated presentation of the same
// claims is a replay even when the signature bytes differ (e.g.
// ECDSA s/N-s malleability).
signingInput := token
if i := strings.LastIndexByte(token, '.'); i > 0 {
signingInput = token[:i]
}
sum := sha256.Sum256([]byte(signingInput))
key = tokenFingerprintPrefix + hex.EncodeToString(sum[:])
}
expiresAt := tokenClaims.ExpiresAt
if expiresAt.IsZero() {
// a cache slot is freed only by expiry, so a token without exp
// would hold it forever and eventually exhaust the bound
return nil, fmt.Errorf("%w: missing exp claim", providers.ErrProviderMissingClaims)
}
expiresAt = expiresAt.Add(clockSkewTolerance)
if !time.Now().Before(expiresAt) {
return nil, fmt.Errorf("%w: token expired", providers.ErrProviderTokenExpired)
}
entry := &jtiEntry{
subject: subject,
expiresAt: expiresAt,
}
// The read lock pins the store/count pair for the whole test-and-set
// so a replay-state handoff cannot repoint them mid-check and strand
// this consume in an abandoned store.
p.jtiStoreMu.RLock()
store, count := p.jtiStore, p.jtiCount
// Atomic test-and-set detects replays before any capacity work runs,
// so a replayed token cannot trigger cleanup scans.
if _, loaded := store.LoadOrStore(key, entry); loaded {
p.jtiStoreMu.RUnlock()
glog.Warningf("OIDC provider %q: Token replay detected (jti: %s)",
p.name, jtiLogKey(key))
return nil, fmt.Errorf("%w: jti %s has already been used",
providers.ErrProviderTokenReplayed, jtiLogKey(key))
}
// The count is incremented as part of the insert, so concurrent
// validations cannot all slip under the limit: whoever pushes the
// count over evicts their own entry and is rejected.
newCount := count.Add(1)
maxSize := p.jtiMaxSize.Load()
if newCount > maxSize {
// Over the bound: expired entries may still occupy slots, so
// reclaim them with a synchronized sweep before rejecting.
p.sweepJTIStore(store, count)
if count.Load() > maxSize {
if _, loaded := store.LoadAndDelete(key); loaded {
count.Add(-1)
}
p.jtiStoreMu.RUnlock()
glog.Errorf("OIDC provider %q: JTI cache FULL (%d entries) - rejecting token. "+
"This may indicate a DoS attack or insufficient cache size. Consider increasing JTICacheMaxSize.",
p.name, maxSize)
return nil, fmt.Errorf("JTI cache capacity exceeded")
}
} else if newCount >= maxSize*9/10 {
glog.Warningf("OIDC provider %q: JTI cache at %d%% capacity (%d/%d), triggering eager cleanup",
p.name, (newCount*100)/maxSize, newCount, maxSize)
go p.performEagerCleanup()
}
p.jtiStoreMu.RUnlock()
glog.V(4).Infof("OIDC provider %q: Registered JTI %s (expires at %v, cache size: %d)",
p.name, jtiLogKey(key), expiresAt, newCount)
}
return tokenClaims, nil
}
// mapClaimsToRoles maps token claims to SeaweedFS roles (legacy method)
func (p *OIDCProvider) mapClaimsToRoles(claims *providers.TokenClaims) []string {
roles := []string{}
// Get groups from claims
groups, _ := claims.GetClaimStringSlice("groups")
// Basic role mapping based on groups
for _, group := range groups {
switch group {
case "admins":
roles = append(roles, "admin")
case "developers":
roles = append(roles, "readwrite")
case "users":
roles = append(roles, "readonly")
}
}
if len(roles) == 0 {
roles = []string{"readonly"} // Default role
}
return roles
}
// mapClaimsToRolesWithConfig maps token claims to roles using configured role mapping
func (p *OIDCProvider) mapClaimsToRolesWithConfig(claims *providers.TokenClaims) []string {
glog.V(3).Infof("mapClaimsToRolesWithConfig: RoleMapping is nil? %t", p.config.RoleMapping == nil)
if p.config.RoleMapping == nil {
glog.V(2).Infof("No role mapping configured for provider %s, using legacy mapping", p.name)
// Fallback to legacy mapping if no role mapping configured
return p.mapClaimsToRoles(claims)
}
glog.V(3).Infof("Applying %d role mapping rules", len(p.config.RoleMapping.Rules))
roles := []string{}
// Apply role mapping rules
for i, rule := range p.config.RoleMapping.Rules {
glog.V(3).Infof("Rule %d: claim=%s, value=%s, role=%s", i, rule.Claim, rule.Value, rule.Role)
if rule.Matches(claims) {
glog.V(2).Infof("Rule %d matched! Adding role: %s", i, rule.Role)
roles = append(roles, rule.Role)
} else {
glog.V(3).Infof("Rule %d did not match", i)
}
}
// Use default role if no rules matched
if len(roles) == 0 && p.config.RoleMapping.DefaultRole != "" {
glog.V(2).Infof("No rules matched, using default role: %s", p.config.RoleMapping.DefaultRole)
roles = []string{p.config.RoleMapping.DefaultRole}
}
glog.V(2).Infof("Role mapping result: %v", roles)
return roles
}
// getPublicKey retrieves the public key for the given key ID from JWKS.
// Cache hits use the read lock so concurrent token validations don't
// serialize on JWKS lookup. Misses and expirations promote to the write
// lock so the JWKS fetch + cache write happens once per refresh cycle.
func (p *OIDCProvider) getPublicKey(ctx context.Context, kid string) (interface{}, error) {
// Fast path: read lock and look in cache.
p.mu.RLock()
if p.jwksCache != nil && (p.jwksFetchedAt.IsZero() || time.Since(p.jwksFetchedAt) <= p.jwksTTL) {
for _, key := range p.jwksCache.Keys {
if key.Kid == kid {
k := key
p.mu.RUnlock()
return p.parseJWK(&k)
}
}
}
p.mu.RUnlock()
// Slow path: take the write lock for the (re)fetch + retry. Re-check the
// cache under the write lock in case another goroutine already refreshed.
p.mu.Lock()
defer p.mu.Unlock()
cacheValid := p.jwksCache != nil && (p.jwksFetchedAt.IsZero() || time.Since(p.jwksFetchedAt) <= p.jwksTTL)
if !cacheValid {
if err := p.fetchJWKSLocked(ctx); err != nil {
return nil, fmt.Errorf("failed to fetch JWKS: %v", err)
}
}
for _, key := range p.jwksCache.Keys {
if key.Kid == kid {
k := key
return p.parseJWK(&k)
}
}
// Key not found in cache. Refresh JWKS once to handle key rotation.
if err := p.fetchJWKSLocked(ctx); err != nil {
return nil, fmt.Errorf("failed to refresh JWKS after key miss: %v", err)
}
for _, key := range p.jwksCache.Keys {
if key.Kid == kid {
k := key
return p.parseJWK(&k)
}
}
return nil, fmt.Errorf("key with ID %s not found in JWKS after refresh", kid)
}
// discoveryDocument is the subset of the OpenID Provider Configuration we need.
// See https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata.
type discoveryDocument struct {
Issuer string `json:"issuer"`
JWKSUri string `json:"jwks_uri"`
}
// resolveJWKSUriLocked determines the JWKS URI for the provider. The caller
// must hold p.mu (write lock); the function reads/writes p.resolvedJWKSUri
// and p.discoveryFailed without taking the lock itself.
//
// Order of resolution:
// 1. explicit config.JWKSUri (operator override; never overridden by discovery).
// 2. cached resolvedJWKSUri from a prior discovery (refreshed when JWKS cache expires).
// 3. .well-known/openid-configuration discovery (per OIDC Discovery 1.0).
// 4. fallback to {issuer}/.well-known/jwks.json (compat path for IDPs that
// don't publish discovery).
func (p *OIDCProvider) resolveJWKSUriLocked(ctx context.Context) (string, error) {
if p.config.JWKSUri != "" {
return p.config.JWKSUri, nil
}
if p.resolvedJWKSUri != "" {
return p.resolvedJWKSUri, nil
}
issuer := strings.TrimSuffix(p.config.Issuer, "/")
if !p.discoveryFailed {
discoveryURL := issuer + "/.well-known/openid-configuration"
uri, err := p.fetchDiscoveryJWKSUri(ctx, discoveryURL)
switch {
case err == nil:
p.resolvedJWKSUri = uri
return uri, nil
default:
// Cache the failure so we don't pay the discovery RTT on every refresh.
// Operators with non-discovery IDPs see one failed lookup at startup.
glog.V(3).Infof("OIDC discovery at %s failed (%v); falling back to /.well-known/jwks.json", discoveryURL, err)
p.discoveryFailed = true
}
}
return issuer + "/.well-known/jwks.json", nil
}
// fetchDiscoveryJWKSUri retrieves the OIDC discovery document and returns
// the jwks_uri field. The issuer claim in the document must match config.Issuer
// to defend against issuer-substitution attacks during discovery.
func (p *OIDCProvider) fetchDiscoveryJWKSUri(ctx context.Context, discoveryURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil)
if err != nil {
return "", fmt.Errorf("create discovery request: %v", err)
}
req.Header.Set("Accept", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("fetch discovery document: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("discovery endpoint returned status %d", resp.StatusCode)
}
var doc discoveryDocument
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return "", fmt.Errorf("decode discovery document: %v", err)
}
if doc.JWKSUri == "" {
return "", fmt.Errorf("discovery document missing jwks_uri")
}
// Issuer must be present and match: a discovery doc that points to a
// different issuer is either a misconfiguration or an attack against
// issuer-confusion, and a doc that omits the issuer field entirely
// would have bypassed the previous check (doc.Issuer != "" guard) and
// silently accepted whatever JWKS URI the document supplied. OIDC
// Discovery 1.0 §3 mandates the issuer field, so treat missing as a
// hard failure. Compare after trimming a single trailing slash on each
// side because real IdPs disagree on whether the configured issuer
// has one.
if strings.TrimSuffix(doc.Issuer, "/") != strings.TrimSuffix(p.config.Issuer, "/") {
return "", fmt.Errorf("discovery issuer %q does not match configured issuer %q", doc.Issuer, p.config.Issuer)
}
// An https issuer's keys must come over https too; otherwise the issuer's
// TLS protects nothing. Refused here, discovery falls back to the
// issuer's own /.well-known/jwks.json.
if strings.HasPrefix(p.config.Issuer, "https://") && !strings.HasPrefix(doc.JWKSUri, "https://") {
return "", fmt.Errorf("discovery jwks_uri %q is not https for https issuer %q", doc.JWKSUri, p.config.Issuer)
}
return doc.JWKSUri, nil
}
// fetchJWKS is a thin wrapper around fetchJWKSLocked that acquires the
// write lock. Used by tests; production callers in getPublicKey already
// hold the lock and call fetchJWKSLocked directly.
func (p *OIDCProvider) fetchJWKS(ctx context.Context) error {
p.mu.Lock()
defer p.mu.Unlock()
return p.fetchJWKSLocked(ctx)
}
// fetchJWKSLocked fetches the JWKS from the provider. The caller must hold
// p.mu (write lock); the function writes p.jwksCache and p.jwksFetchedAt
// without taking the lock itself.
//
// Each fetch reattempts discovery if the previous attempt failed: a
// transient 5xx that flipped discoveryFailed at startup shouldn't lock the
// provider into the fallback path forever. The retry rate is bounded by
// the JWKS TTL (typically 1h), so the discovery RTT cost is amortized.
func (p *OIDCProvider) fetchJWKSLocked(ctx context.Context) error {
if p.config.JWKSUri == "" && p.resolvedJWKSUri == "" {
p.discoveryFailed = false
}
jwksURL, err := p.resolveJWKSUriLocked(ctx)
if err != nil {
return fmt.Errorf("resolve JWKS URI: %v", err)
}
req, err := http.NewRequestWithContext(ctx, "GET", jwksURL, nil)
if err != nil {
return fmt.Errorf("failed to create JWKS request: %v", err)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch JWKS: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("JWKS endpoint returned status: %d", resp.StatusCode)
}
var jwks JWKS
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
return fmt.Errorf("failed to decode JWKS response: %v", err)
}
p.jwksCache = &jwks
p.jwksFetchedAt = time.Now()
glog.V(3).Infof("Fetched JWKS with %d keys from %s", len(jwks.Keys), jwksURL)
return nil
}
// parseJWK converts a JWK to a public key
func (p *OIDCProvider) parseJWK(key *JWK) (interface{}, error) {
switch key.Kty {
case "RSA":
return p.parseRSAKey(key)
case "EC":
return p.parseECKey(key)
default:
return nil, fmt.Errorf("unsupported key type: %s", key.Kty)
}
}
// parseRSAKey parses an RSA key from JWK
func (p *OIDCProvider) parseRSAKey(key *JWK) (*rsa.PublicKey, error) {
// Decode the modulus (n)
nBytes, err := base64.RawURLEncoding.DecodeString(key.N)
if err != nil {
return nil, fmt.Errorf("failed to decode RSA modulus: %v", err)
}
// Decode the exponent (e)
eBytes, err := base64.RawURLEncoding.DecodeString(key.E)
if err != nil {
return nil, fmt.Errorf("failed to decode RSA exponent: %v", err)
}
// Convert exponent bytes to int
var exponent int
for _, b := range eBytes {
exponent = exponent*256 + int(b)
}
// Create RSA public key
pubKey := &rsa.PublicKey{
E: exponent,
}
pubKey.N = new(big.Int).SetBytes(nBytes)
return pubKey, nil
}
// parseECKey parses an Elliptic Curve key from JWK
func (p *OIDCProvider) parseECKey(key *JWK) (*ecdsa.PublicKey, error) {
// Validate required fields
if key.X == "" || key.Y == "" || key.Crv == "" {
return nil, fmt.Errorf("incomplete EC key: missing x, y, or crv parameter")
}
// Get the curve
var curve elliptic.Curve
switch key.Crv {
case "P-256":
curve = elliptic.P256()
case "P-384":
curve = elliptic.P384()
case "P-521":
curve = elliptic.P521()
default:
return nil, fmt.Errorf("unsupported EC curve: %s", key.Crv)
}
// Decode x coordinate
xBytes, err := base64.RawURLEncoding.DecodeString(key.X)
if err != nil {
return nil, fmt.Errorf("failed to decode EC x coordinate: %v", err)
}
// Decode y coordinate
yBytes, err := base64.RawURLEncoding.DecodeString(key.Y)
if err != nil {
return nil, fmt.Errorf("failed to decode EC y coordinate: %v", err)
}
// Create EC public key
pubKey := &ecdsa.PublicKey{
Curve: curve,
X: new(big.Int).SetBytes(xBytes),
Y: new(big.Int).SetBytes(yBytes),
}
// Validate that the point is on the curve
if !curve.IsOnCurve(pubKey.X, pubKey.Y) {
return nil, fmt.Errorf("EC key coordinates are not on the specified curve")
}
return pubKey, nil
}
// mapUserInfoToIdentity maps UserInfo response to ExternalIdentity
func (p *OIDCProvider) mapUserInfoToIdentity(userInfo map[string]interface{}) *providers.ExternalIdentity {
identity := &providers.ExternalIdentity{
Provider: p.name,
Attributes: make(map[string]string),
}
// Map standard OIDC claims
if sub, ok := userInfo["sub"].(string); ok {
identity.UserID = sub
}
if email, ok := userInfo["email"].(string); ok {
identity.Email = email
}
if name, ok := userInfo["name"].(string); ok {
identity.DisplayName = name
}
// Handle groups claim (can be array of strings or single string)
if groupsData, exists := userInfo["groups"]; exists {
switch groups := groupsData.(type) {
case []interface{}:
// Array of groups
for _, group := range groups {
if groupStr, ok := group.(string); ok {
identity.Groups = append(identity.Groups, groupStr)
}
}
case []string:
// Direct string array
identity.Groups = groups
case string:
// Single group as string
identity.Groups = []string{groups}
}
}
// Map configured custom claims
if p.config.ClaimsMapping != nil {
for identityField, oidcClaim := range p.config.ClaimsMapping {
if value, exists := userInfo[oidcClaim]; exists {
if strValue, ok := value.(string); ok {
switch identityField {
case "email":
if identity.Email == "" {
identity.Email = strValue
}
case "displayName":
if identity.DisplayName == "" {
identity.DisplayName = strValue
}
case "userID":
if identity.UserID == "" {
identity.UserID = strValue
}
default:
identity.Attributes[identityField] = strValue
}
}
}
}
}
// Store all additional claims as attributes
for key, value := range userInfo {
if key != "sub" && key != "email" && key != "name" && key != "groups" {
if strValue, ok := value.(string); ok {
identity.Attributes[key] = strValue
} else if jsonValue, err := json.Marshal(value); err == nil {
identity.Attributes[key] = string(jsonValue)
}
}
}
return identity
}
// evictExpiredJTIs removes entries whose expiry has passed and returns how
// many it deleted. Caller must hold jtiSweepMu.
func (p *OIDCProvider) evictExpiredJTIs(now time.Time, store *sync.Map, count *atomic.Int64) int {
removed := 0
store.Range(func(key, value interface{}) bool {
expiresAt := value.(*jtiEntry).expiresAt
if !expiresAt.IsZero() && now.After(expiresAt) {
// LoadAndDelete reports whether this call actually removed the
// entry, so concurrent deletes cannot decrement the count twice.
if v, loaded := store.LoadAndDelete(key); loaded {
if !v.(*jtiEntry).uncounted {
count.Add(-1)
}
removed++
}
}
return true
})
return removed
}
// replayState returns the provider's current store/count pair.
func (p *OIDCProvider) replayState() (*sync.Map, *atomic.Int64) {
p.jtiStoreMu.RLock()
defer p.jtiStoreMu.RUnlock()
return p.jtiStore, p.jtiCount
}
// sweepJTIStore runs one serialized expiry scan over the given store. It only
// takes jtiSweepMu, so callers already holding jtiStoreMu may use it.
func (p *OIDCProvider) sweepJTIStore(store *sync.Map, count *atomic.Int64) {
p.jtiSweepMu.Lock()
defer p.jtiSweepMu.Unlock()
p.jtiLastSweep.Store(time.Now().UnixNano())
if removed := p.evictExpiredJTIs(time.Now(), store, count); removed > 0 {
glog.V(4).Infof("OIDC provider %q: Cleaned up %d expired JTI entries (remaining: %d)",
p.name, removed, count.Load())
}
}
// sweepExpiredJTIs runs a scan that is guaranteed to complete before the
// caller proceeds; the capacity-rejection path uses it so a fresh token is
// not refused while expired entries still hold slots.
func (p *OIDCProvider) sweepExpiredJTIs() {
store, count := p.replayState()
p.sweepJTIStore(store, count)
}
// performEagerCleanup performs an opportunistic cleanup of expired JTI
// entries when the cache approaches capacity. At most one sweep runs at a
// time and no more often than eagerCleanupMinInterval, so a burst of
// requests near the limit cannot each turn into a full-cache scan.
func (p *OIDCProvider) performEagerCleanup() {
if !p.jtiEnabled.Load() {
return
}
if time.Since(time.Unix(0, p.jtiLastSweep.Load())) < eagerCleanupMinInterval {
return
}
store, count := p.replayState()
if !p.jtiSweepMu.TryLock() {
return
}
defer p.jtiSweepMu.Unlock()
p.jtiLastSweep.Store(time.Now().UnixNano())
beforeCount := count.Load()
if removed := p.evictExpiredJTIs(time.Now(), store, count); removed > 0 {
glog.Infof("OIDC provider %q: Eager cleanup removed %d expired JTI entries (%d -> %d)",
p.name, removed, beforeCount, count.Load())
} else {
glog.Warningf("OIDC provider %q: Eager cleanup found no expired entries to remove. "+
"Cache may be filled with valid long-lived tokens. Consider increasing JTICacheMaxSize.",
p.name)
}
}
// cleanupExpiredJTIs periodically removes expired JTI entries from the cache.
// This prevents memory growth by removing JTIs after their tokens have expired.
// Runs in a background goroutine until ctx is cancelled; done is closed on exit.
func (p *OIDCProvider) cleanupExpiredJTIs(ctx context.Context, done chan struct{}, interval time.Duration) {
defer close(done)
ticker := time.NewTicker(interval)
defer ticker.Stop()
glog.V(3).Infof("OIDC provider %q: JTI cleanup goroutine started (interval: %v)", p.name, interval)
for {
select {
case <-ctx.Done():
glog.V(3).Infof("OIDC provider %q: JTI cleanup goroutine stopping", p.name)
return
case <-ticker.C:
p.sweepExpiredJTIs()
}
}
}
// Shutdown gracefully stops the JTI cleanup goroutine.
// Should be called when the provider is being shut down to prevent goroutine leaks.
func (p *OIDCProvider) Shutdown(ctx context.Context) error {
p.jtiMu.Lock()
cancel, done := p.jtiCleanupCancel, p.jtiCleanupDone
p.jtiMu.Unlock()
if cancel == nil {
return nil
}
cancel()
select {
case <-done:
glog.V(2).Infof("OIDC provider %q: Cleanup goroutine shutdown complete", p.name)
case <-ctx.Done():
glog.Warningf("OIDC provider %q: Shutdown timeout waiting for cleanup goroutine: %v", p.name, ctx.Err())
return ctx.Err()
}
p.jtiMu.Lock()
if p.jtiCleanupDone == done {
p.jtiCleanupCancel = nil
p.jtiCleanupDone = nil
}
p.jtiMu.Unlock()
return nil
}
// AdoptReplayStateFrom takes over the consumed-token store of the provider it
// replaces, so tokens it already saw stay rejected across the rebuild. The
// caller must shut down the replaced provider; only one cleanup worker may
// sweep the shared store at a time. Call before the new provider serves
// validators; jtiSweepMu excludes its own cleanup worker during the swap.
func (p *OIDCProvider) AdoptReplayStateFrom(old *OIDCProvider) {
if old == nil || old == p {
return
}
p.jtiStoreMu.Lock()
p.jtiSweepMu.Lock()
p.jtiStore, p.jtiCount = old.jtiStore, old.jtiCount
p.jtiSweepMu.Unlock()
p.jtiStoreMu.Unlock()
}
// MergeReplayStateFrom hands this provider's store to another provider and
// then folds the other's consumed-token entries in, so rejections the other
// recorded keep holding when its records move to a shared provider. Repointing
// first means tokens the other still consumes — in-flight exchanges or ones
// routed to it before the provider map updates — land in the shared store
// instead of a store this merge already drained. Unlike AdoptReplayStateFrom
// it swaps nothing of its own, so it is safe on a provider that is already
// serving validators.
func (p *OIDCProvider) MergeReplayStateFrom(other *OIDCProvider) {
if other == nil || other == p {
return
}
p.jtiStoreMu.RLock()
defer p.jtiStoreMu.RUnlock()
store, count := p.jtiStore, p.jtiCount
other.jtiStoreMu.Lock()
oldStore := other.jtiStore
if oldStore == store {
other.jtiStoreMu.Unlock()
return
}
other.jtiStore, other.jtiCount = store, count
other.jtiStoreMu.Unlock()
p.jtiSweepMu.Lock()
defer p.jtiSweepMu.Unlock()
now := time.Now()
oldStore.Range(func(key, value interface{}) bool {
entry := value.(*jtiEntry)
if expiresAt := entry.expiresAt; !expiresAt.IsZero() && now.After(expiresAt) {
return true
}
// A consumed token is never dropped to fit the bound: merged entries
// stay but do not charge the count, so a store filled past the bound
// by the merge cannot reject new exchanges while it drains by expiry.
store.LoadOrStore(key, &jtiEntry{subject: entry.subject, expiresAt: entry.expiresAt, uncounted: true})
return true
})
}
// jtiLogKey redacts a token identifier for logs and error strings: a stable
// fingerprint that still lets operators correlate repeated presentations.
func jtiLogKey(jti string) string {
sum := sha256.Sum256([]byte(jti))
return hex.EncodeToString(sum[:8])
}