Files
seaweedfs/weed/iam/providers/provider.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

238 lines
6.7 KiB
Go

package providers
import (
"context"
"fmt"
"net/mail"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
)
// IdentityProvider defines the interface for external identity providers
type IdentityProvider interface {
// Name returns the unique name of the provider
Name() string
// Initialize initializes the provider with configuration
Initialize(config interface{}) error
// Authenticate authenticates a user with a token and returns external identity
Authenticate(ctx context.Context, token string) (*ExternalIdentity, error)
// GetUserInfo retrieves user information by user ID
GetUserInfo(ctx context.Context, userID string) (*ExternalIdentity, error)
// ValidateToken validates a token and returns claims
ValidateToken(ctx context.Context, token string) (*TokenClaims, error)
}
// ExternalIdentity represents an identity from an external provider
type ExternalIdentity struct {
// UserID is the unique identifier from the external provider
UserID string `json:"userId"`
// Email is the user's email address
Email string `json:"email"`
// DisplayName is the user's display name
DisplayName string `json:"displayName"`
// Groups are the groups the user belongs to
Groups []string `json:"groups,omitempty"`
// Attributes are additional user attributes
Attributes map[string]string `json:"attributes,omitempty"`
// Provider is the name of the identity provider
Provider string `json:"provider"`
// Issuer is the OIDC `iss` claim (or equivalent) from the source token.
// Stable per (provider, identity) and used together with UserID to derive
// a stable parent-user hash that survives token rotation.
Issuer string `json:"issuer,omitempty"`
// TokenExpiration is the expiration time of the source identity token
// This is used to limit session duration to not exceed the token's exp claim
TokenExpiration *time.Time `json:"tokenExpiration,omitempty"`
}
// Validate validates the external identity structure
func (e *ExternalIdentity) Validate() error {
if e.UserID == "" {
return fmt.Errorf("user ID is required")
}
if e.Provider == "" {
return fmt.Errorf("provider is required")
}
if e.Email != "" {
if _, err := mail.ParseAddress(e.Email); err != nil {
return fmt.Errorf("invalid email format: %w", err)
}
}
return nil
}
// TokenClaims represents claims from a validated token
type TokenClaims struct {
// Subject (sub) - user identifier
Subject string `json:"sub"`
// Issuer (iss) - token issuer
Issuer string `json:"iss"`
// Audience (aud) - intended audience
Audience string `json:"aud"`
// ExpiresAt (exp) - expiration time
ExpiresAt time.Time `json:"exp"`
// IssuedAt (iat) - issued at time
IssuedAt time.Time `json:"iat"`
// NotBefore (nbf) - not valid before time
NotBefore time.Time `json:"nbf,omitempty"`
// Claims are additional claims from the token
Claims map[string]interface{} `json:"claims,omitempty"`
}
// IsValid checks if the token claims are valid (not expired, etc.)
func (c *TokenClaims) IsValid() bool {
now := time.Now()
// Check expiration
if !c.ExpiresAt.IsZero() && now.After(c.ExpiresAt) {
return false
}
// Check not before
if !c.NotBefore.IsZero() && now.Before(c.NotBefore) {
return false
}
// Check issued at (shouldn't be in the future)
if !c.IssuedAt.IsZero() && now.Before(c.IssuedAt) {
return false
}
return true
}
// GetClaimString returns a string claim value
func (c *TokenClaims) GetClaimString(key string) (string, bool) {
if value, exists := c.Claims[key]; exists {
if str, ok := value.(string); ok {
return str, true
}
}
return "", false
}
// GetClaimStringSlice returns a string slice claim value
func (c *TokenClaims) GetClaimStringSlice(key string) ([]string, bool) {
if value, exists := c.Claims[key]; exists {
switch v := value.(type) {
case []string:
return v, true
case []interface{}:
var result []string
for _, item := range v {
if str, ok := item.(string); ok {
result = append(result, str)
}
}
return result, len(result) > 0
case string:
// Single string can be treated as slice
return []string{v}, true
}
}
return nil, false
}
// ProviderConfig represents configuration for identity providers
type ProviderConfig struct {
// Type of provider (oidc, ldap, saml)
Type string `json:"type"`
// Name of the provider instance
Name string `json:"name"`
// Enabled indicates if the provider is active
Enabled bool `json:"enabled"`
// Config is provider-specific configuration
Config map[string]interface{} `json:"config"`
// RoleMapping defines how to map external identities to roles
RoleMapping *RoleMapping `json:"roleMapping,omitempty"`
}
// RoleMapping defines rules for mapping external identities to roles
type RoleMapping struct {
// Rules are the mapping rules
Rules []MappingRule `json:"rules"`
// DefaultRole is assigned if no rules match
DefaultRole string `json:"defaultRole,omitempty"`
}
// MappingRule defines a single mapping rule
type MappingRule struct {
// Claim is the claim key to check
Claim string `json:"claim"`
// Value is the expected claim value (supports wildcards)
Value string `json:"value"`
// Role is the role ARN to assign
Role string `json:"role"`
// Condition is additional condition logic (optional)
Condition string `json:"condition,omitempty"`
}
// Matches checks if a rule matches the given claims
func (r *MappingRule) Matches(claims *TokenClaims) bool {
if r.Claim == "" || r.Value == "" {
glog.V(3).Infof("Rule invalid: claim=%s, value=%s", r.Claim, r.Value)
return false
}
claimValue, exists := claims.GetClaimString(r.Claim)
if !exists {
glog.V(3).Infof("Claim '%s' not found as string, trying as string slice", r.Claim)
// Try as string slice
if claimSlice, sliceExists := claims.GetClaimStringSlice(r.Claim); sliceExists {
glog.V(3).Infof("Claim '%s' found as string slice: %v", r.Claim, claimSlice)
for _, val := range claimSlice {
glog.V(3).Infof("Checking if '%s' matches rule value '%s'", val, r.Value)
if r.matchValue(val) {
glog.V(3).Infof("Match found: '%s' matches '%s'", val, r.Value)
return true
}
}
} else {
glog.V(3).Infof("Claim '%s' not found in any format", r.Claim)
}
return false
}
glog.V(3).Infof("Claim '%s' found as string: '%s'", r.Claim, claimValue)
return r.matchValue(claimValue)
}
// matchValue checks if a value matches the rule value (with wildcard support)
// Uses AWS IAM-compliant case-insensitive wildcard matching for consistency with policy engine
func (r *MappingRule) matchValue(value string) bool {
matched := wildcard.MatchesWildcard(strings.ToLower(r.Value), strings.ToLower(value))
glog.V(3).Infof("AWS IAM pattern match result: '%s' matches '%s' = %t", value, r.Value, matched)
return matched
}