mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-06-13 23:36:45 +03:00
d951a8df5a
* 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.
238 lines
6.7 KiB
Go
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
|
|
}
|