Files
seaweedfs/weed/iam/sts/session_claims.go
T
Chris Lu d951a8df5a feat(iam): STS web-identity AWS-fidelity polish (Phase 1) (#9318)
* feat(iam): STS web-identity AWS-fidelity polish

- OIDC discovery via .well-known/openid-configuration; falls back to
  /.well-known/jwks.json when discovery is absent. Reject discovery docs
  whose issuer claim does not match the configured issuer to defend
  against issuer-substitution.
- ComputeParentUser derives a stable per-identity hash from (sub, iss).
  Surface as aws:userid in the request context and as a parent_user
  claim in the session JWT so per-user state survives token rotation.
- Per-role MaxSessionDuration (3600..43200) clamps requested
  DurationSeconds before the STS service applies its own caps.
- Tighten RoleSessionName to the AWS contract: 2..64 chars from
  [\w+=,.@-].
- Populate PackedPolicySize in AssumeRole / AssumeRoleWithWebIdentity /
  AssumeRoleWithLDAPIdentity responses as a percentage of the 2048-byte
  inline session policy budget.

* fix(iam): leave omitted DurationSeconds nil so STS default applies

capDurationByRole was substituting the role's MaxSessionDuration
when the caller omitted DurationSeconds entirely. AWS returns the
configured default (typically 1 hour) in that case, not the role's
upper bound — a 12h MaxSessionDuration shouldn't silently make every
no-duration assume-role mint a 12h session.

Return nil when requested is nil; let the downstream
calculateSessionDuration in the STS service apply its TokenDuration
default. The role-max upper bound still clamps when the request
arrives with a concrete value above the cap.

Addresses gemini high-priority review on PR #9318.

* fix(iam): synchronize OIDCProvider JWKS cache fields

jwksCache, jwksFetchedAt, resolvedJWKSUri, and discoveryFailed are
mutated lazily on the first token-validate call and refreshed
afterwards on TTL expiry. Multiple S3 requests can land here in
parallel, so the writes were racing against subsequent reads on
every other goroutine. resolvedJWKSUri/discoveryFailed inherited
the same un-protected pattern when discovery shipped.

Add sync.RWMutex; getPublicKey takes the read lock for the
common cache-hit path and promotes to the write lock for misses
+ refreshes. fetchJWKSLocked / resolveJWKSUriLocked assume the
write lock is held by the caller; fetchJWKS keeps the
test-friendly entry point that acquires the lock itself.

Addresses gemini high-priority review on PR #9318.

* fix(iam): trim trailing slash + retry discovery after transient failure

Two OIDC discovery edge cases reviewers flagged:

1. Issuer comparison was sensitive to trailing slashes. resolveJWKSUri
   trims them when building the discovery URL, but the doc.Issuer ↔
   p.config.Issuer check did not, so an IDP whose issuer claim drops or
   adds the slash relative to the configured value would be falsely
   rejected. Trim a single trailing slash on each side before comparing.

2. discoveryFailed flipped to true on any error and stayed there for the
   process lifetime. A transient 5xx at startup permanently locked the
   provider into the /.well-known/jwks.json fallback. Reset the flag at
   the top of fetchJWKSLocked when no URI has been cached yet, so each
   JWKS refresh (typically once per TTL = 1h) reattempts discovery.
   Successful discovery remains cached via resolvedJWKSUri so we don't
   pay the discovery RTT on every refresh.

Addresses gemini security-medium + medium reviews on PR #9318.

* fix(iam): require non-empty issuer in OIDC discovery doc

The previous "doc.Issuer != "" && ..." guard let a discovery document
that omitted the issuer field bypass the issuer-mismatch check
entirely, letting the doc steer fetchJWKS at any URL it provided.
OIDC Discovery 1.0 §3 mandates the issuer field; treat missing as a
hard failure same as mismatched. Trailing-slash equivalence still
applies.

Adds TestDiscoveryRejectsMissingIssuer alongside the existing
TestDiscoveryRejectsIssuerMismatch via a new omitDiscoveryIssuer
toggle on fakeIDP.
2026-05-04 22:10:49 -07:00

215 lines
7.2 KiB
Go

package sts
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
// ComputeParentUser returns a stable per-identity hash derived from the OIDC
// (sub, iss) tuple. Only the (sub, iss) pair is guaranteed stable across token
// refreshes per OpenID Connect Core 1.0 §5.7, so any per-user state (audit
// logs, quotas) must key off this value rather than the access-key or session
// id. The hash is base64-rawurl-encoded SHA-256 over "openid:<sub>:<iss>" so
// it stays filesystem-safe and bounded in length for storage in audit paths.
func ComputeParentUser(sub, iss string) string {
if sub == "" {
return ""
}
h := sha256.Sum256([]byte("openid:" + sub + ":" + iss))
return base64.RawURLEncoding.EncodeToString(h[:])
}
// defaultCredentialGenerator is a reusable instance for generating temporary credentials
// Reusing a single instance across all calls to ToSessionInfo() reduces allocation overhead
// since this method may be called frequently during signature verification
var defaultCredentialGenerator = NewCredentialGenerator()
// STSSessionClaims represents comprehensive session information embedded in JWT tokens
// This eliminates the need for separate session storage by embedding all session
// metadata directly in the token itself - enabling true stateless operation
type STSSessionClaims struct {
jwt.RegisteredClaims
// Session identification
SessionId string `json:"sid"` // session_id (abbreviated for smaller tokens)
SessionName string `json:"snam"` // session_name (abbreviated for smaller tokens)
TokenType string `json:"typ"` // token_type
// Role information
RoleArn string `json:"role"` // role_arn
AssumedRole string `json:"assumed"` // assumed_role_user
Principal string `json:"principal"` // principal_arn
// Authorization data
Policies []string `json:"pol,omitempty"` // policies (abbreviated)
// SessionPolicy contains inline session policy JSON (optional)
SessionPolicy string `json:"spol,omitempty"`
// Identity provider information
IdentityProvider string `json:"idp"` // identity_provider
ExternalUserId string `json:"ext_uid"` // external_user_id
ProviderIssuer string `json:"prov_iss"` // provider_issuer
// Request context (optional, for policy evaluation)
RequestContext map[string]interface{} `json:"req_ctx,omitempty"`
// Session metadata
AssumedAt time.Time `json:"assumed_at"` // when role was assumed
MaxDuration int64 `json:"max_dur,omitempty"` // maximum session duration in seconds
// ParentUser is a stable hash of (sub, iss) for tokens minted from an OIDC
// identity. It survives token rotation since only the (sub, iss) tuple is
// guaranteed stable per OpenID Connect Core 1.0. Empty for non-federated
// session types.
ParentUser string `json:"puid,omitempty"`
}
// NewSTSSessionClaims creates new STS session claims with all required information
func NewSTSSessionClaims(sessionId, issuer string, expiresAt time.Time) *STSSessionClaims {
now := time.Now()
return &STSSessionClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuer,
Subject: sessionId,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt),
NotBefore: jwt.NewNumericDate(now),
},
SessionId: sessionId,
TokenType: TokenTypeSession,
AssumedAt: now,
}
}
// ToSessionInfo converts JWT claims back to SessionInfo structure
// This enables seamless integration with existing code expecting SessionInfo
func (c *STSSessionClaims) ToSessionInfo() *SessionInfo {
var expiresAt time.Time
if c.ExpiresAt != nil {
expiresAt = c.ExpiresAt.Time
}
// Generate temporary credentials from the session ID
// This is deterministic based on the session ID, so the same credentials are regenerated
credentials, err := defaultCredentialGenerator.GenerateTemporaryCredentials(c.SessionId, expiresAt)
if err != nil {
// Log the error with context - credential generation failure is important for debugging
errMsg := fmt.Errorf("generate temporary credentials for session %s: %w", c.SessionId, err)
glog.Warningf("Failed to generate credentials for STS session: %v", errMsg)
// Return session info without credentials - validation will catch this as invalid
credentials = nil
}
return &SessionInfo{
SessionId: c.SessionId,
SessionName: c.SessionName,
RoleArn: c.RoleArn,
AssumedRoleUser: c.AssumedRole,
Principal: c.Principal,
Policies: c.Policies,
SessionPolicy: c.SessionPolicy,
ExpiresAt: expiresAt,
IdentityProvider: c.IdentityProvider,
ExternalUserId: c.ExternalUserId,
ProviderIssuer: c.ProviderIssuer,
RequestContext: c.RequestContext,
ParentUser: c.ParentUser,
// Provide the Subject (sub) from registered claims
Subject: c.Subject,
Credentials: credentials,
}
}
// IsValid checks if the session claims are valid (not expired, etc.)
func (c *STSSessionClaims) IsValid() bool {
now := time.Now()
// Check expiration
if c.ExpiresAt != nil && c.ExpiresAt.Before(now) {
return false
}
// Check not-before
if c.NotBefore != nil && c.NotBefore.After(now) {
return false
}
// Ensure required fields are present
if c.SessionId == "" || c.RoleArn == "" || c.Principal == "" {
return false
}
return true
}
// GetSessionId returns the session identifier
func (c *STSSessionClaims) GetSessionId() string {
return c.SessionId
}
// GetExpiresAt returns the expiration time
func (c *STSSessionClaims) GetExpiresAt() time.Time {
if c.ExpiresAt != nil {
return c.ExpiresAt.Time
}
return time.Time{}
}
// WithRoleInfo sets role-related information in the claims
func (c *STSSessionClaims) WithRoleInfo(roleArn, assumedRole, principal string) *STSSessionClaims {
c.RoleArn = roleArn
c.AssumedRole = assumedRole
c.Principal = principal
return c
}
// WithPolicies sets the policies associated with this session
func (c *STSSessionClaims) WithPolicies(policies []string) *STSSessionClaims {
c.Policies = policies
return c
}
// WithSessionPolicy sets the inline session policy JSON for this session
func (c *STSSessionClaims) WithSessionPolicy(policy string) *STSSessionClaims {
c.SessionPolicy = policy
return c
}
// WithIdentityProvider sets identity provider information
func (c *STSSessionClaims) WithIdentityProvider(providerName, externalUserId, providerIssuer string) *STSSessionClaims {
c.IdentityProvider = providerName
c.ExternalUserId = externalUserId
c.ProviderIssuer = providerIssuer
return c
}
// WithRequestContext sets request context for policy evaluation
func (c *STSSessionClaims) WithRequestContext(ctx map[string]interface{}) *STSSessionClaims {
c.RequestContext = ctx
return c
}
// WithMaxDuration sets the maximum session duration
func (c *STSSessionClaims) WithMaxDuration(duration time.Duration) *STSSessionClaims {
c.MaxDuration = int64(duration.Seconds())
return c
}
// WithSessionName sets the session name
func (c *STSSessionClaims) WithSessionName(sessionName string) *STSSessionClaims {
c.SessionName = sessionName
return c
}
// WithParentUser sets the stable per-identity hash for the session. See
// ComputeParentUser for the derivation rule.
func (c *STSSessionClaims) WithParentUser(parentUser string) *STSSessionClaims {
c.ParentUser = parentUser
return c
}