mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-26 15:00:48 +01:00
* iam/oidc: drop single-use web-identity tokens and the jti replay cache Reverts the single-use semantics added in #11936 (and the #12017 refactor): AssumeRoleWithWebIdentity consumed each web-identity token's jti into a bounded replay cache, so a second exchange of the same token was rejected until exp + skew. AWS STS accepts a web-identity token repeatedly until it expires, and Kubernetes workload identity depends on that: the kubelet keeps a projected ServiceAccount token until ~80% of its lifetime, so pod restarts, co-located containers sharing the token file, repeated CLI invocations, and pre-rotation SDK refreshes all re-present the same jti and were refused on 4.50. The replay machinery also carried a real maintenance cost — a bounded concurrent cache, fingerprint-keyed entries for jti-less tokens, adopt/merge handoffs so provider rebuilds could not reopen consumed tokens, and provider-reuse bookkeeping in the refresh path that existed only to preserve that state. Removing it restores AWS semantics and deletes the whole apparatus. Refs #12063. Alternative to the per-provider opt-out in #12064. * iam: reuse unchanged OIDC providers on refresh refreshOIDCProvidersFrom rebuilt every managed provider on any store change, so an unrelated write discarded each provider's JWKS cache and an unreachable issuer could then fail exchanges the cached keys would have allowed. Keep a per-record provider cache keyed on the record content fingerprint and only rebuild when the record changes. * iam/oidc: require exp in ValidateToken Dropping the single-use check also removed the implicit bound on expiry-less tokens: a signed web-identity token without exp could mint STS sessions indefinitely. Require the exp claim, matching AWS AssumeRoleWithWebIdentity which rejects tokens without one. * iam/oidc: bound idle connections on the provider transport A provider replaced on refresh drops its http.Transport without a close; give it an IdleConnTimeout so pooled connections and their goroutines cannot linger. * s3api: cover web-identity token reuse over HTTP Sign one token and exchange that exact string twice, directly covering the Kubernetes projected-token reuse this change restores. * test(iam): refresh reuses an unchanged record's provider Covers the provider-reuse cache: an unrelated record's refresh keeps a provider's JWKS state, a changed record rebuilds it.
1241 lines
47 KiB
Go
1241 lines
47 KiB
Go
package sts
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"sync"
|
|
"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/iam/utils"
|
|
)
|
|
|
|
// TrustPolicyValidator interface for validating trust policies during role assumption
|
|
type TrustPolicyValidator interface {
|
|
// ValidateTrustPolicyForWebIdentity validates if a web identity token can assume a role
|
|
// durationSeconds is optional and can be nil
|
|
ValidateTrustPolicyForWebIdentity(ctx context.Context, roleArn string, webIdentityToken string, durationSeconds *int64) error
|
|
|
|
// ValidateTrustPolicyForCredentials validates if credentials can assume a role
|
|
ValidateTrustPolicyForCredentials(ctx context.Context, roleArn string, identity *providers.ExternalIdentity) error
|
|
}
|
|
|
|
// FlexibleDuration wraps time.Duration to support both integer nanoseconds and duration strings in JSON
|
|
type FlexibleDuration struct {
|
|
time.Duration
|
|
}
|
|
|
|
// UnmarshalJSON implements JSON unmarshaling for FlexibleDuration
|
|
// Supports both: 3600000000000 (nanoseconds) and "1h" (duration string)
|
|
func (fd *FlexibleDuration) UnmarshalJSON(data []byte) error {
|
|
// Try to unmarshal as a duration string first (e.g., "1h", "30m")
|
|
var durationStr string
|
|
if err := json.Unmarshal(data, &durationStr); err == nil {
|
|
duration, parseErr := time.ParseDuration(durationStr)
|
|
if parseErr != nil {
|
|
return fmt.Errorf("invalid duration string %q: %w", durationStr, parseErr)
|
|
}
|
|
fd.Duration = duration
|
|
return nil
|
|
}
|
|
|
|
// If that fails, try to unmarshal as an integer (nanoseconds for backward compatibility)
|
|
var nanoseconds int64
|
|
if err := json.Unmarshal(data, &nanoseconds); err == nil {
|
|
fd.Duration = time.Duration(nanoseconds)
|
|
return nil
|
|
}
|
|
|
|
// If both fail, try unmarshaling as a quoted number string (edge case)
|
|
var numberStr string
|
|
if err := json.Unmarshal(data, &numberStr); err == nil {
|
|
if nanoseconds, parseErr := strconv.ParseInt(numberStr, 10, 64); parseErr == nil {
|
|
fd.Duration = time.Duration(nanoseconds)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("unable to parse duration from %s (expected duration string like \"1h\" or integer nanoseconds)", data)
|
|
}
|
|
|
|
// MarshalJSON implements JSON marshaling for FlexibleDuration
|
|
// Always marshals as a human-readable duration string
|
|
func (fd FlexibleDuration) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(fd.Duration.String())
|
|
}
|
|
|
|
// ClaimBasedPolicyRoleArn is the AWS-shaped sentinel ARN that callers pass
|
|
// in the AssumeRoleWithWebIdentity RoleArn field to opt into claim-based
|
|
// policy resolution. Using a recognisable role-style ARN here (rather than
|
|
// requiring an empty RoleArn) lets SDKs and AWS CLI builds that always
|
|
// require RoleArn to be set still reach this code path.
|
|
const ClaimBasedPolicyRoleArn = "arn:aws:iam:::role/sts-claim-based"
|
|
|
|
// IsClaimBasedPolicyRoleArn reports whether the supplied RoleArn opts the
|
|
// caller into claim-based policy mode. Either the empty string (caller
|
|
// omitted RoleArn entirely; common for SDKs that didn't expect to need one)
|
|
// or the explicit sentinel triggers the mode.
|
|
func IsClaimBasedPolicyRoleArn(arn string) bool {
|
|
return arn == "" || arn == ClaimBasedPolicyRoleArn
|
|
}
|
|
|
|
// STSService provides Security Token Service functionality
|
|
// This service is now completely stateless - all session information is embedded
|
|
// in JWT tokens, eliminating the need for session storage and enabling true
|
|
// distributed operation without shared state
|
|
type STSService struct {
|
|
Config *STSConfig // Public for access by other components
|
|
initialized bool
|
|
providers map[string]providers.IdentityProvider
|
|
issuerToProvider map[string]providers.IdentityProvider // Efficient issuer-based provider lookup
|
|
tokenGenerator *TokenGenerator
|
|
credGenerator *CredentialGenerator
|
|
trustPolicyValidator TrustPolicyValidator // Interface for trust policy validation
|
|
|
|
// iamManagedOIDCMu guards iamManagedOIDCByIssuer. The map is the live view
|
|
// of providers persisted in the IAM-managed OIDCProviderStore; it is
|
|
// atomically replaced by SetIAMManagedOIDCProviders whenever the store
|
|
// changes (either via a local IAM API call or a metadata-subscribe event
|
|
// from a peer). Lookups consult this map first and fall back to the
|
|
// static-config issuerToProvider so admin-managed entries always take
|
|
// precedence over the bootstrap config.
|
|
//
|
|
// The slice value lets multiple records share an issuer when each is
|
|
// scoped to a different account; lookup picks the entry whose AccountID
|
|
// matches the role being assumed (or the global, AccountID="" entry as a
|
|
// fallback). Without this, two accounts' records for the same issuer
|
|
// would race for one map slot and a token could be validated by a
|
|
// provider that wasn't scoped to the role's account.
|
|
iamManagedOIDCMu sync.RWMutex
|
|
iamManagedOIDCByIssuer map[string][]ScopedOIDCProvider
|
|
}
|
|
|
|
// ScopedOIDCProvider pairs an OIDC IdentityProvider with the account it is
|
|
// scoped to. AccountID="" means the provider is global (usable from any
|
|
// account).
|
|
type ScopedOIDCProvider struct {
|
|
AccountID string
|
|
Provider providers.IdentityProvider
|
|
}
|
|
|
|
// GetTokenGenerator returns the token generator used by the STS service.
|
|
// This keeps the underlying field unexported while still allowing read-only access.
|
|
func (s *STSService) GetTokenGenerator() *TokenGenerator {
|
|
return s.tokenGenerator
|
|
}
|
|
|
|
// GetCredentialGenerator returns the credential generator used by the STS service.
|
|
func (s *STSService) GetCredentialGenerator() *CredentialGenerator {
|
|
return s.credGenerator
|
|
}
|
|
|
|
// STSConfig holds STS service configuration
|
|
type STSConfig struct {
|
|
// TokenDuration is the default duration for issued tokens
|
|
TokenDuration FlexibleDuration `json:"tokenDuration"`
|
|
|
|
// MaxSessionLength is the maximum duration for any session
|
|
MaxSessionLength FlexibleDuration `json:"maxSessionLength"`
|
|
|
|
// Issuer is the STS issuer identifier
|
|
Issuer string `json:"issuer"`
|
|
|
|
// SigningKey is used to sign session tokens
|
|
SigningKey []byte `json:"signingKey"`
|
|
|
|
// AccountId is the AWS account ID used for federated user ARNs
|
|
// Defaults to "111122223333" if not specified
|
|
AccountId string `json:"accountId,omitempty"`
|
|
|
|
// Providers configuration - enables automatic provider loading
|
|
Providers []*ProviderConfig `json:"providers,omitempty"`
|
|
}
|
|
|
|
// ProviderConfig holds identity provider configuration
|
|
type ProviderConfig struct {
|
|
// Name is the unique identifier for the provider
|
|
Name string `json:"name"`
|
|
|
|
// Type specifies the provider type (oidc, ldap, etc.)
|
|
Type string `json:"type"`
|
|
|
|
// Config contains provider-specific configuration
|
|
Config map[string]interface{} `json:"config"`
|
|
|
|
// Enabled indicates if this provider should be active
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// AssumeRoleWithWebIdentityRequest represents a request to assume role with web identity
|
|
type AssumeRoleWithWebIdentityRequest struct {
|
|
// RoleArn is the ARN of the role to assume
|
|
RoleArn string `json:"RoleArn"`
|
|
|
|
// WebIdentityToken is the OIDC token from the identity provider
|
|
WebIdentityToken string `json:"WebIdentityToken"`
|
|
|
|
// RoleSessionName is a name for the assumed role session
|
|
RoleSessionName string `json:"RoleSessionName"`
|
|
|
|
// DurationSeconds is the duration of the role session (optional)
|
|
DurationSeconds *int64 `json:"DurationSeconds,omitempty"`
|
|
|
|
// Policy is an optional session policy (optional)
|
|
Policy *string `json:"Policy,omitempty"`
|
|
|
|
// RoleId is the assumed role's unique ID, set by the IAM manager after it
|
|
// resolves the role. It is embedded in the session so the session is bound
|
|
// to this role and not to whatever role later holds the same name.
|
|
RoleId string `json:"-"`
|
|
}
|
|
|
|
// AssumeRoleWithCredentialsRequest represents a request to assume role with username/password
|
|
type AssumeRoleWithCredentialsRequest struct {
|
|
// RoleArn is the ARN of the role to assume
|
|
RoleArn string `json:"RoleArn"`
|
|
|
|
// Username is the username for authentication
|
|
Username string `json:"Username"`
|
|
|
|
// Password is the password for authentication
|
|
Password string `json:"Password"`
|
|
|
|
// RoleSessionName is a name for the assumed role session
|
|
RoleSessionName string `json:"RoleSessionName"`
|
|
|
|
// ProviderName is the name of the identity provider to use
|
|
ProviderName string `json:"ProviderName"`
|
|
|
|
// DurationSeconds is the duration of the role session (optional)
|
|
DurationSeconds *int64 `json:"DurationSeconds,omitempty"`
|
|
|
|
// Policy is an optional session policy (optional)
|
|
Policy *string `json:"Policy,omitempty"`
|
|
|
|
// RoleId is set by the IAM manager; see AssumeRoleWithWebIdentityRequest.
|
|
RoleId string `json:"-"`
|
|
}
|
|
|
|
// AssumeRoleResponse represents the response from assume role operations
|
|
type AssumeRoleResponse struct {
|
|
// Credentials contains the temporary security credentials
|
|
Credentials *Credentials `json:"Credentials"`
|
|
|
|
// AssumedRoleUser contains information about the assumed role user
|
|
AssumedRoleUser *AssumedRoleUser `json:"AssumedRoleUser"`
|
|
|
|
// PackedPolicySize is the percentage of max policy size used (AWS compatibility)
|
|
PackedPolicySize *int64 `json:"PackedPolicySize,omitempty"`
|
|
}
|
|
|
|
// Credentials represents temporary security credentials
|
|
type Credentials struct {
|
|
// AccessKeyId is the access key ID
|
|
AccessKeyId string `json:"AccessKeyId"`
|
|
|
|
// SecretAccessKey is the secret access key
|
|
SecretAccessKey string `json:"SecretAccessKey"`
|
|
|
|
// SessionToken is the session token
|
|
SessionToken string `json:"SessionToken"`
|
|
|
|
// Expiration is when the credentials expire
|
|
Expiration time.Time `json:"Expiration"`
|
|
}
|
|
|
|
// AssumedRoleUser contains information about the assumed role user
|
|
type AssumedRoleUser struct {
|
|
// AssumedRoleId is the unique identifier of the assumed role
|
|
AssumedRoleId string `json:"AssumedRoleId"`
|
|
|
|
// Arn is the ARN of the assumed role user
|
|
Arn string `json:"Arn"`
|
|
|
|
// Subject is the subject identifier from the identity provider
|
|
Subject string `json:"Subject,omitempty"`
|
|
}
|
|
|
|
// SessionInfo represents information about an active session
|
|
type SessionInfo struct {
|
|
// SessionId is the unique identifier for the session
|
|
SessionId string `json:"sessionId"`
|
|
|
|
// SessionName is the name of the role session
|
|
SessionName string `json:"sessionName"`
|
|
|
|
// RoleArn is the ARN of the assumed role
|
|
RoleArn string `json:"roleArn"`
|
|
|
|
// AssumedRoleUser contains information about the assumed role user
|
|
AssumedRoleUser string `json:"assumedRoleUser"`
|
|
|
|
// Principal is the principal ARN
|
|
Principal string `json:"principal"`
|
|
|
|
// Subject is the subject identifier from the identity provider
|
|
Subject string `json:"subject"`
|
|
|
|
// Provider is the identity provider used (legacy field)
|
|
Provider string `json:"provider"`
|
|
|
|
// IdentityProvider is the identity provider used
|
|
IdentityProvider string `json:"identityProvider"`
|
|
|
|
// ExternalUserId is the external user identifier from the provider
|
|
ExternalUserId string `json:"externalUserId"`
|
|
|
|
// ProviderIssuer is the issuer from the identity provider
|
|
ProviderIssuer string `json:"providerIssuer"`
|
|
|
|
// Policies are the policies associated with this session
|
|
Policies []string `json:"policies"`
|
|
|
|
// SessionPolicy is the inline session policy JSON (optional)
|
|
SessionPolicy string `json:"sessionPolicy,omitempty"`
|
|
|
|
// RequestContext contains additional request context for policy evaluation
|
|
RequestContext map[string]interface{} `json:"requestContext,omitempty"`
|
|
|
|
// CreatedAt is when the session was created
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
|
|
// ExpiresAt is when the session expires
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
|
|
// Credentials are the temporary credentials for this session
|
|
Credentials *Credentials `json:"credentials"`
|
|
|
|
// ParentUser is the stable hashed identity (sub+iss) derived at federation time.
|
|
ParentUser string `json:"parentUser,omitempty"`
|
|
|
|
// RoleId is the unique ID of the role the session was issued for; empty
|
|
// for sessions issued before role IDs were recorded.
|
|
RoleId string `json:"roleId,omitempty"`
|
|
|
|
// SourceAccessKeyId is the access key the session was minted for, when the
|
|
// minting service recorded it (AssumeRoleForPrincipal).
|
|
SourceAccessKeyId string `json:"sourceAccessKeyId,omitempty"`
|
|
|
|
// BoundAccessKeyId is the stored credential the session is bound to, when
|
|
// the minting service recorded one (AssumeRoleForPrincipal). A legacy
|
|
// SourceAccessKeyId is never treated as a binding.
|
|
BoundAccessKeyId string `json:"boundAccessKeyId,omitempty"`
|
|
|
|
// BoundAccessKeyFingerprint identifies the credential instance the
|
|
// session is bound to, so a key re-created under the same id does not
|
|
// match.
|
|
BoundAccessKeyFingerprint string `json:"boundAccessKeyFingerprint,omitempty"`
|
|
}
|
|
|
|
// NewSTSService creates a new STS service
|
|
func NewSTSService() *STSService {
|
|
return &STSService{
|
|
providers: make(map[string]providers.IdentityProvider),
|
|
issuerToProvider: make(map[string]providers.IdentityProvider),
|
|
}
|
|
}
|
|
|
|
// Initialize initializes the STS service with configuration
|
|
func (s *STSService) Initialize(config *STSConfig) error {
|
|
if config == nil {
|
|
return fmt.Errorf(ErrConfigCannotBeNil)
|
|
}
|
|
|
|
// Apply defaults before validation
|
|
config.ApplyDefaults()
|
|
|
|
if err := s.validateConfig(config); err != nil {
|
|
return fmt.Errorf("invalid STS configuration: %w", err)
|
|
}
|
|
|
|
s.Config = config
|
|
|
|
// Initialize token generator for stateless JWT operations
|
|
s.tokenGenerator = NewTokenGenerator(config.SigningKey, config.Issuer)
|
|
s.credGenerator = NewCredentialGenerator(config.SigningKey)
|
|
|
|
// Load identity providers from configuration
|
|
if err := s.loadProvidersFromConfig(config); err != nil {
|
|
return fmt.Errorf("failed to load identity providers: %w", err)
|
|
}
|
|
|
|
s.initialized = true
|
|
return nil
|
|
}
|
|
|
|
// ApplyDefaults applies default values to the STS configuration
|
|
func (c *STSConfig) ApplyDefaults() {
|
|
if c.TokenDuration.Duration <= 0 {
|
|
c.TokenDuration.Duration = time.Duration(DefaultTokenDuration) * time.Second
|
|
}
|
|
|
|
if c.MaxSessionLength.Duration <= 0 {
|
|
c.MaxSessionLength.Duration = time.Duration(DefaultMaxSessionLength) * time.Second
|
|
}
|
|
|
|
if c.Issuer == "" {
|
|
c.Issuer = DefaultIssuer
|
|
}
|
|
}
|
|
|
|
// validateConfig validates the STS configuration
|
|
func (s *STSService) validateConfig(config *STSConfig) error {
|
|
if config.TokenDuration.Duration <= 0 {
|
|
return fmt.Errorf(ErrInvalidTokenDuration)
|
|
}
|
|
|
|
if config.MaxSessionLength.Duration <= 0 {
|
|
return fmt.Errorf(ErrInvalidMaxSessionLength)
|
|
}
|
|
|
|
if config.Issuer == "" {
|
|
return fmt.Errorf(ErrIssuerRequired)
|
|
}
|
|
|
|
if len(config.SigningKey) < MinSigningKeyLength {
|
|
return fmt.Errorf(ErrSigningKeyTooShort, MinSigningKeyLength)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadProvidersFromConfig loads identity providers from configuration
|
|
func (s *STSService) loadProvidersFromConfig(config *STSConfig) error {
|
|
if len(config.Providers) == 0 {
|
|
glog.V(2).Infof("No providers configured in STS config")
|
|
return nil
|
|
}
|
|
|
|
factory := NewProviderFactory()
|
|
|
|
// Load all providers from configuration
|
|
providersMap, err := factory.LoadProvidersFromConfig(config.Providers)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load providers from config: %w", err)
|
|
}
|
|
|
|
// Replace current providers with new ones
|
|
s.providers = providersMap
|
|
|
|
// Also populate the issuerToProvider map for efficient and secure JWT validation
|
|
s.issuerToProvider = make(map[string]providers.IdentityProvider)
|
|
for name, provider := range s.providers {
|
|
issuer := s.extractIssuerFromProvider(provider)
|
|
if issuer != "" {
|
|
if _, exists := s.issuerToProvider[issuer]; exists {
|
|
glog.Warningf("Duplicate issuer %s found for provider %s. Overwriting.", issuer, name)
|
|
}
|
|
s.issuerToProvider[issuer] = provider
|
|
glog.V(2).Infof("Registered provider %s with issuer %s for efficient lookup", name, issuer)
|
|
}
|
|
}
|
|
|
|
glog.V(1).Infof("Successfully loaded %d identity providers: %v",
|
|
len(s.providers), s.getProviderNames())
|
|
|
|
return nil
|
|
}
|
|
|
|
// getProviderNames returns list of loaded provider names
|
|
func (s *STSService) getProviderNames() []string {
|
|
names := make([]string, 0, len(s.providers))
|
|
for name := range s.providers {
|
|
names = append(names, name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// IsInitialized returns whether the service is initialized
|
|
func (s *STSService) IsInitialized() bool {
|
|
return s.initialized
|
|
}
|
|
|
|
// RegisterProvider registers an identity provider
|
|
func (s *STSService) RegisterProvider(provider providers.IdentityProvider) error {
|
|
if provider == nil {
|
|
return fmt.Errorf(ErrProviderCannotBeNil)
|
|
}
|
|
|
|
name := provider.Name()
|
|
if name == "" {
|
|
return fmt.Errorf(ErrProviderNameEmpty)
|
|
}
|
|
|
|
s.providers[name] = provider
|
|
|
|
// Try to extract issuer information for efficient lookup
|
|
// This is a best-effort approach for different provider types
|
|
issuer := s.extractIssuerFromProvider(provider)
|
|
if issuer != "" {
|
|
s.issuerToProvider[issuer] = provider
|
|
glog.V(2).Infof("Registered provider %s with issuer %s for efficient lookup", name, issuer)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractIssuerFromProvider attempts to extract issuer information from different provider types
|
|
func (s *STSService) extractIssuerFromProvider(provider providers.IdentityProvider) string {
|
|
// Handle different provider types
|
|
switch p := provider.(type) {
|
|
case interface{ GetIssuer() string }:
|
|
// For providers that implement GetIssuer() method
|
|
return p.GetIssuer()
|
|
default:
|
|
// For other provider types, we'll rely on JWT parsing during validation
|
|
// This is still more efficient than the current brute-force approach
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// GetProviders returns all registered identity providers
|
|
func (s *STSService) GetProviders() map[string]providers.IdentityProvider {
|
|
return s.providers
|
|
}
|
|
|
|
// SetIAMManagedOIDCProviders atomically replaces the IAM-managed OIDC
|
|
// provider map. Pass nil or an empty map to clear all managed entries. The
|
|
// caller passes a fully-built map keyed by issuer URL; the slice value lets
|
|
// per-account records coexist under the same issuer.
|
|
func (s *STSService) SetIAMManagedOIDCProviders(byIssuer map[string][]ScopedOIDCProvider) {
|
|
// Defensively copy so callers can keep mutating their map without affecting
|
|
// in-flight lookups. A nil input becomes an empty map for cheap reads.
|
|
cp := make(map[string][]ScopedOIDCProvider, len(byIssuer))
|
|
for issuer, scoped := range byIssuer {
|
|
if issuer == "" {
|
|
continue
|
|
}
|
|
entries := make([]ScopedOIDCProvider, 0, len(scoped))
|
|
for _, sp := range scoped {
|
|
if sp.Provider == nil {
|
|
continue
|
|
}
|
|
entries = append(entries, sp)
|
|
}
|
|
if len(entries) > 0 {
|
|
cp[issuer] = entries
|
|
}
|
|
}
|
|
s.iamManagedOIDCMu.Lock()
|
|
s.iamManagedOIDCByIssuer = cp
|
|
s.iamManagedOIDCMu.Unlock()
|
|
}
|
|
|
|
// lookupOIDCProviderForAccount returns the provider that should validate
|
|
// tokens from `issuer` when the caller is assuming a role in `accountID`.
|
|
// Selection order:
|
|
// 1. IAM-managed record exactly scoped to accountID (when accountID != "");
|
|
// 2. IAM-managed record with empty AccountID (global);
|
|
// 3. static-config issuerToProvider (legacy path; account-agnostic).
|
|
//
|
|
// Without the (issuer, account) key, two records for the same issuer (e.g.
|
|
// account A with clientIDs=[a] and account B with clientIDs=[b]) would race
|
|
// for one map slot, and a token destined for account B could be validated by
|
|
// account A's record. The role-account check in
|
|
// IAMManager.enforceProviderAccountScope blocks the cross-account assumption
|
|
// itself, but the validation must use the right record's clientIDs and
|
|
// thumbprints in the first place.
|
|
func (s *STSService) lookupOIDCProviderForAccount(issuer, accountID string) (providers.IdentityProvider, bool) {
|
|
s.iamManagedOIDCMu.RLock()
|
|
scoped := s.iamManagedOIDCByIssuer[issuer]
|
|
var globalMatch providers.IdentityProvider
|
|
for _, sp := range scoped {
|
|
if accountID != "" && sp.AccountID == accountID {
|
|
s.iamManagedOIDCMu.RUnlock()
|
|
return sp.Provider, true
|
|
}
|
|
if sp.AccountID == "" && globalMatch == nil {
|
|
globalMatch = sp.Provider
|
|
}
|
|
}
|
|
s.iamManagedOIDCMu.RUnlock()
|
|
if globalMatch != nil {
|
|
return globalMatch, true
|
|
}
|
|
p, ok := s.issuerToProvider[issuer]
|
|
return p, ok
|
|
}
|
|
|
|
// SetTrustPolicyValidator sets the trust policy validator for role assumption validation
|
|
func (s *STSService) SetTrustPolicyValidator(validator TrustPolicyValidator) {
|
|
s.trustPolicyValidator = validator
|
|
}
|
|
|
|
// AssumeRoleWithWebIdentity assumes a role using a web identity token (OIDC)
|
|
// This method is now completely stateless - all session information is embedded in the JWT token
|
|
func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *AssumeRoleWithWebIdentityRequest) (*AssumeRoleResponse, error) {
|
|
if !s.initialized {
|
|
return nil, fmt.Errorf(ErrSTSServiceNotInitialized)
|
|
}
|
|
|
|
if request == nil {
|
|
return nil, fmt.Errorf("request cannot be nil")
|
|
}
|
|
|
|
// Validate request parameters
|
|
if err := s.validateAssumeRoleWithWebIdentityRequest(request); err != nil {
|
|
return nil, fmt.Errorf("invalid request: %w", err)
|
|
}
|
|
|
|
sessionPolicy := ""
|
|
if request.Policy != nil {
|
|
normalized, err := NormalizeSessionPolicy(*request.Policy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid session policy: %w", err)
|
|
}
|
|
sessionPolicy = normalized
|
|
}
|
|
|
|
// 1. Validate the web identity token with appropriate provider. The role
|
|
// ARN's account scopes which IAM-managed record may validate the token —
|
|
// see lookupOIDCProviderForAccount. ParseRoleARN returns "" when the
|
|
// caller passed a legacy or claim-based ARN, in which case lookup falls
|
|
// back to a global (account-less) record only.
|
|
roleAccountID := utils.ParseRoleARN(request.RoleArn).AccountID
|
|
externalIdentity, provider, err := s.validateWebIdentityToken(ctx, request.WebIdentityToken, roleAccountID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to validate web identity token: %w", err)
|
|
}
|
|
|
|
// 2. Decide between concrete-role mode and claim-based policy mode.
|
|
// Claim-based mode requires both the sentinel RoleArn and a non-empty
|
|
// ClaimPolicies list — the second check guards against an IDP that just
|
|
// happens not to emit the policy claim today.
|
|
claimMode := IsClaimBasedPolicyRoleArn(request.RoleArn) && len(externalIdentity.ClaimPolicies) > 0
|
|
effectiveRoleArn := request.RoleArn
|
|
if claimMode {
|
|
// Replace an empty RoleArn with the constant sentinel so the assumed-
|
|
// role ARN GenerateAssumedRoleArn produces below carries a stable,
|
|
// session-name-keyed principal that policy evaluation can log and
|
|
// rate-limit on.
|
|
effectiveRoleArn = ClaimBasedPolicyRoleArn
|
|
} else if request.RoleArn == "" {
|
|
return nil, fmt.Errorf("RoleArn is required when claim-based policy mode is not configured")
|
|
} else if request.RoleArn == ClaimBasedPolicyRoleArn {
|
|
return nil, fmt.Errorf("claim-based policy mode requires the IDP to emit policies via the configured policyClaim")
|
|
}
|
|
|
|
// 3. Trust-policy validation only runs in concrete-role mode. In
|
|
// claim-mode the IDP is the sole authority for both authentication and
|
|
// authorization, so there is no role definition to consult.
|
|
if !claimMode {
|
|
if err := s.validateRoleAssumptionForWebIdentity(ctx, request.RoleArn, request.WebIdentityToken, request.DurationSeconds); err != nil {
|
|
return nil, fmt.Errorf("role assumption denied: %w", err)
|
|
}
|
|
}
|
|
|
|
// 4. Calculate session duration
|
|
sessionDuration := s.CalculateSessionDuration(request.DurationSeconds)
|
|
expiresAt := time.Now().Add(sessionDuration)
|
|
|
|
// 5. Generate session ID and credentials
|
|
sessionId, err := GenerateSessionId()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate session ID: %w", err)
|
|
}
|
|
|
|
credentials, err := s.credGenerator.GenerateTemporaryCredentials(sessionId, expiresAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate credentials: %w", err)
|
|
}
|
|
|
|
// 6. Create comprehensive JWT session token with all session information embedded
|
|
assumedRoleUser := &AssumedRoleUser{
|
|
AssumedRoleId: effectiveRoleArn,
|
|
Arn: GenerateAssumedRoleArn(effectiveRoleArn, request.RoleSessionName),
|
|
Subject: externalIdentity.UserID,
|
|
}
|
|
|
|
// Create request context from identity attributes for policy evaluation
|
|
requestContext := make(map[string]interface{}, len(externalIdentity.Attributes)+3)
|
|
|
|
// Add generic attributes (including preferred_username, etc.)
|
|
if externalIdentity.Attributes != nil {
|
|
for k, v := range externalIdentity.Attributes {
|
|
requestContext[k] = v
|
|
}
|
|
}
|
|
|
|
// Add standard OIDC fields if not already present
|
|
if _, ok := requestContext["email"]; !ok && externalIdentity.Email != "" {
|
|
requestContext["email"] = externalIdentity.Email
|
|
}
|
|
if _, ok := requestContext["name"]; !ok && externalIdentity.DisplayName != "" {
|
|
requestContext["name"] = externalIdentity.DisplayName
|
|
}
|
|
// Add sub as well since it's commonly used
|
|
requestContext["sub"] = externalIdentity.UserID
|
|
|
|
// Surface federated group memberships so resource (permission) policies can do
|
|
// group-based ABAC - not just role trust policies. Stored as a []string; the
|
|
// string-condition evaluator already handles multi-valued context keys, and the
|
|
// S3 middleware additionally exposes it as jwt:groups. Without this, "groups"
|
|
// was excluded from the OIDC attributes (see oidc_provider processedClaims) and
|
|
// so only usable at assume-time (trust policy), never at request-time - meaning
|
|
// a single role's permission policy could not scope access by the caller's
|
|
// groups (aggregate/ABAC).
|
|
if len(externalIdentity.Groups) > 0 {
|
|
requestContext["groups"] = externalIdentity.Groups
|
|
}
|
|
|
|
// Same for the caller's roles (the `roles` claim). Surfaced as a []string so a
|
|
// resource policy can gate on jwt:roles - the S3 middleware exposes it that way.
|
|
// Without this, "roles" was excluded from the OIDC attributes (processedClaims)
|
|
// and so unusable in a request-time permission policy.
|
|
if len(externalIdentity.Roles) > 0 {
|
|
requestContext["roles"] = externalIdentity.Roles
|
|
}
|
|
|
|
// Compute a stable parent-user hash from (sub, iss). Only this tuple is
|
|
// guaranteed stable across token refresh per OIDC Core 1.0, so this is the
|
|
// right key for any per-identity state (audit trail, future quotas).
|
|
parentUser := ComputeParentUser(externalIdentity.UserID, externalIdentity.Issuer)
|
|
if parentUser != "" {
|
|
// Surface as aws:userid so policies can reference it directly without
|
|
// caring about token-rotation churn.
|
|
requestContext["aws:userid"] = parentUser
|
|
}
|
|
|
|
// Surface principal session tags as aws:PrincipalTag/<key>. The OIDC
|
|
// provider has already filtered the claim namespace through its
|
|
// AllowedPrincipalTagKeys list (see filterPrincipalTags), so anything
|
|
// reaching us here is on the operator's opt-in list. The full claim
|
|
// is dropped if the allowlist is empty, which is the secure default.
|
|
for k, v := range externalIdentity.PrincipalTags {
|
|
requestContext["aws:PrincipalTag/"+k] = v
|
|
}
|
|
|
|
// Create rich JWT claims with all session information
|
|
sessionClaims := NewSTSSessionClaims(sessionId, s.Config.Issuer, expiresAt).
|
|
WithSessionName(request.RoleSessionName).
|
|
WithRoleInfo(effectiveRoleArn, assumedRoleUser.Arn, assumedRoleUser.Arn).
|
|
WithRoleId(request.RoleId).
|
|
WithIdentityProvider(provider.Name(), externalIdentity.UserID, externalIdentity.Issuer).
|
|
WithMaxDuration(sessionDuration).
|
|
WithRequestContext(requestContext)
|
|
if claimMode {
|
|
sessionClaims.WithPolicies(externalIdentity.ClaimPolicies)
|
|
}
|
|
if parentUser != "" {
|
|
sessionClaims.WithParentUser(parentUser)
|
|
}
|
|
if sessionPolicy != "" {
|
|
sessionClaims.WithSessionPolicy(sessionPolicy)
|
|
}
|
|
|
|
// Generate self-contained JWT token with all session information
|
|
jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate JWT session token: %w", err)
|
|
}
|
|
credentials.SessionToken = jwtToken
|
|
|
|
// 6. Build and return response (no session storage needed!)
|
|
|
|
return &AssumeRoleResponse{
|
|
Credentials: credentials,
|
|
AssumedRoleUser: assumedRoleUser,
|
|
}, nil
|
|
}
|
|
|
|
// AssumeRoleWithCredentials assumes a role using username/password credentials
|
|
// This method is now completely stateless - all session information is embedded in the JWT token
|
|
func (s *STSService) AssumeRoleWithCredentials(ctx context.Context, request *AssumeRoleWithCredentialsRequest) (*AssumeRoleResponse, error) {
|
|
if !s.initialized {
|
|
return nil, fmt.Errorf("STS service not initialized")
|
|
}
|
|
|
|
if request == nil {
|
|
return nil, fmt.Errorf("request cannot be nil")
|
|
}
|
|
|
|
// Validate request parameters
|
|
if err := s.validateAssumeRoleWithCredentialsRequest(request); err != nil {
|
|
return nil, fmt.Errorf("invalid request: %w", err)
|
|
}
|
|
|
|
sessionPolicy := ""
|
|
if request.Policy != nil {
|
|
normalized, err := NormalizeSessionPolicy(*request.Policy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid session policy: %w", err)
|
|
}
|
|
sessionPolicy = normalized
|
|
}
|
|
|
|
// 1. Get the specified provider
|
|
provider, exists := s.providers[request.ProviderName]
|
|
if !exists {
|
|
return nil, fmt.Errorf("identity provider not found: %s", request.ProviderName)
|
|
}
|
|
|
|
// 2. Validate credentials with the specified provider
|
|
credentials := request.Username + ":" + request.Password
|
|
externalIdentity, err := provider.Authenticate(ctx, credentials)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to authenticate credentials: %w", err)
|
|
}
|
|
|
|
// 3. Check if the role exists and can be assumed (includes trust policy validation)
|
|
if err := s.validateRoleAssumptionForCredentials(ctx, request.RoleArn, externalIdentity); err != nil {
|
|
return nil, fmt.Errorf("role assumption denied: %w", err)
|
|
}
|
|
|
|
// 4-7. Mint the session
|
|
return s.issueSession(request.RoleArn, request.RoleId, request.RoleSessionName, sessionPolicy,
|
|
request.DurationSeconds, provider.Name(), externalIdentity.UserID)
|
|
}
|
|
|
|
// issueSession mints temporary credentials and the self-contained JWT that
|
|
// carries the whole session, shared by every assume-role entry point.
|
|
func (s *STSService) issueSession(roleArn, roleId, roleSessionName, sessionPolicy string,
|
|
durationSeconds *int64, providerName, subject string, options ...func(*STSSessionClaims)) (*AssumeRoleResponse, error) {
|
|
|
|
sessionDuration := s.CalculateSessionDuration(durationSeconds)
|
|
expiresAt := time.Now().Add(sessionDuration)
|
|
|
|
sessionId, err := GenerateSessionId()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate session ID: %w", err)
|
|
}
|
|
|
|
tempCredentials, err := s.credGenerator.GenerateTemporaryCredentials(sessionId, expiresAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate credentials: %w", err)
|
|
}
|
|
|
|
assumedRoleUser := &AssumedRoleUser{
|
|
AssumedRoleId: roleArn,
|
|
Arn: GenerateAssumedRoleArn(roleArn, roleSessionName),
|
|
Subject: subject,
|
|
}
|
|
|
|
sessionClaims := NewSTSSessionClaims(sessionId, s.Config.Issuer, expiresAt).
|
|
WithSessionName(roleSessionName).
|
|
WithRoleInfo(roleArn, assumedRoleUser.Arn, assumedRoleUser.Arn).
|
|
WithRoleId(roleId).
|
|
WithIdentityProvider(providerName, subject, "").
|
|
WithMaxDuration(sessionDuration)
|
|
if sessionPolicy != "" {
|
|
sessionClaims.WithSessionPolicy(sessionPolicy)
|
|
}
|
|
for _, option := range options {
|
|
option(sessionClaims)
|
|
}
|
|
|
|
jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate JWT session token: %w", err)
|
|
}
|
|
tempCredentials.SessionToken = jwtToken
|
|
|
|
return &AssumeRoleResponse{
|
|
Credentials: tempCredentials,
|
|
AssumedRoleUser: assumedRoleUser,
|
|
}, nil
|
|
}
|
|
|
|
// validateSessionDurationSeconds bounds a requested session lifetime the way
|
|
// AWS STS does. Every assume-role entry point runs it, so a duration that came
|
|
// from configuration is checked the same as one from a request.
|
|
func (s *STSService) validateSessionDurationSeconds(durationSeconds *int64) error {
|
|
if durationSeconds == nil {
|
|
return nil
|
|
}
|
|
maxSec := int64(DefaultMaxSessionLength)
|
|
if s.Config != nil && s.Config.MaxSessionLength.Duration > 0 {
|
|
configuredMax := int64(s.Config.MaxSessionLength.Duration / time.Second)
|
|
if configuredMax >= 900 {
|
|
maxSec = configuredMax
|
|
}
|
|
}
|
|
if *durationSeconds < 900 || *durationSeconds > maxSec {
|
|
return fmt.Errorf("DurationSeconds must be between 900 and %d seconds", maxSec)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AssumeRoleForPrincipalRequest asks for a session on behalf of a principal the
|
|
// calling service has already authenticated.
|
|
type AssumeRoleForPrincipalRequest struct {
|
|
// RoleArn is the ARN of the role to assume.
|
|
RoleArn string
|
|
|
|
// Principal identifies the already-authenticated caller.
|
|
Principal string
|
|
|
|
// RoleSessionName names the session.
|
|
RoleSessionName string
|
|
|
|
// ProviderName records how the caller was authenticated.
|
|
ProviderName string
|
|
|
|
// Policy optionally narrows the session below the role's own permissions.
|
|
Policy *string
|
|
|
|
// DurationSeconds requests a session lifetime.
|
|
DurationSeconds *int64
|
|
|
|
// SourceAccessKeyId optionally records the access key the caller
|
|
// authenticated with, so the session can be tied to that key.
|
|
SourceAccessKeyId string
|
|
|
|
// BoundAccessKeyId binds the session to a stored credential: the session
|
|
// ends when that key is gone. Distinct from SourceAccessKeyId, which is
|
|
// audit-only on sessions minted before the binding existed.
|
|
BoundAccessKeyId string
|
|
|
|
// BoundAccessKeyFingerprint identifies the bound credential instance.
|
|
BoundAccessKeyFingerprint string
|
|
}
|
|
|
|
// AssumeRoleForPrincipal issues session credentials for a caller that a
|
|
// SeaweedFS service authenticated itself, such as the Iceberg catalog vending
|
|
// scoped credentials for a table it has already authorized. There is no
|
|
// external token left to verify at this point, so the role's trust policy is
|
|
// the control point and is still enforced.
|
|
func (s *STSService) AssumeRoleForPrincipal(ctx context.Context, request *AssumeRoleForPrincipalRequest) (*AssumeRoleResponse, error) {
|
|
if !s.initialized {
|
|
return nil, fmt.Errorf(ErrSTSServiceNotInitialized)
|
|
}
|
|
if request == nil {
|
|
return nil, fmt.Errorf("request cannot be nil")
|
|
}
|
|
if request.RoleArn == "" {
|
|
return nil, fmt.Errorf("role ARN cannot be empty")
|
|
}
|
|
if request.Principal == "" {
|
|
return nil, fmt.Errorf("principal cannot be empty")
|
|
}
|
|
if err := s.validateSessionDurationSeconds(request.DurationSeconds); err != nil {
|
|
return nil, fmt.Errorf("invalid request: %w", err)
|
|
}
|
|
|
|
sessionPolicy := ""
|
|
if request.Policy != nil {
|
|
normalized, err := NormalizeSessionPolicy(*request.Policy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid session policy: %w", err)
|
|
}
|
|
sessionPolicy = normalized
|
|
}
|
|
|
|
identity := &providers.ExternalIdentity{
|
|
UserID: request.Principal,
|
|
DisplayName: request.Principal,
|
|
Provider: request.ProviderName,
|
|
}
|
|
if err := s.validateRoleAssumptionForCredentials(ctx, request.RoleArn, identity); err != nil {
|
|
return nil, fmt.Errorf("role assumption denied: %w", err)
|
|
}
|
|
|
|
// Iceberg credential vending resolves no role definition, so its sessions
|
|
// carry no role ID and stay bound by name.
|
|
return s.issueSession(request.RoleArn, "", request.RoleSessionName, sessionPolicy,
|
|
request.DurationSeconds, request.ProviderName, request.Principal,
|
|
func(c *STSSessionClaims) {
|
|
c.WithSourceAccessKeyId(request.SourceAccessKeyId)
|
|
c.WithBoundAccessKey(request.BoundAccessKeyId, request.BoundAccessKeyFingerprint)
|
|
})
|
|
}
|
|
|
|
// ValidateSessionToken validates a session token and returns session information
|
|
// This method is now completely stateless - all session information is extracted from the JWT token
|
|
func (s *STSService) ValidateSessionToken(ctx context.Context, sessionToken string) (*SessionInfo, error) {
|
|
if !s.initialized {
|
|
return nil, fmt.Errorf(ErrSTSServiceNotInitialized)
|
|
}
|
|
|
|
if sessionToken == "" {
|
|
return nil, fmt.Errorf(ErrSessionTokenCannotBeEmpty)
|
|
}
|
|
|
|
// Validate JWT and extract comprehensive session claims
|
|
claims, err := s.tokenGenerator.ValidateJWTWithClaims(sessionToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(ErrSessionValidationFailed, err)
|
|
}
|
|
|
|
// Convert JWT claims back to SessionInfo
|
|
// All session information is embedded in the JWT token itself
|
|
return claims.ToSessionInfo(s.credGenerator), nil
|
|
}
|
|
|
|
// NOTE: Session revocation is not supported in the stateless JWT design.
|
|
//
|
|
// In a stateless JWT system, tokens cannot be revoked without implementing a token blacklist,
|
|
// which would break the stateless architecture. Tokens remain valid until their natural
|
|
// expiration time.
|
|
//
|
|
// For applications requiring token revocation, consider:
|
|
// 1. Using shorter token lifespans (e.g., 15-30 minutes)
|
|
// 2. Implementing a distributed token blacklist (breaks stateless design)
|
|
// 3. Including a "jti" (JWT ID) claim for tracking specific tokens
|
|
//
|
|
// Use ValidateSessionToken() to verify if a token is valid and not expired.
|
|
|
|
// Helper methods for AssumeRoleWithWebIdentity
|
|
|
|
// validateAssumeRoleWithWebIdentityRequest validates the request parameters.
|
|
//
|
|
// RoleArn validation lives in AssumeRoleWithWebIdentity itself rather than
|
|
// here: once we've parsed the JWT we know whether the caller has
|
|
// ClaimPolicies and can decide between concrete-role mode and claim-mode,
|
|
// which in turn determines whether an empty/sentinel RoleArn is acceptable.
|
|
func (s *STSService) validateAssumeRoleWithWebIdentityRequest(request *AssumeRoleWithWebIdentityRequest) error {
|
|
if request.WebIdentityToken == "" {
|
|
return fmt.Errorf("WebIdentityToken is required")
|
|
}
|
|
|
|
if request.RoleSessionName == "" {
|
|
return fmt.Errorf("RoleSessionName is required")
|
|
}
|
|
|
|
return s.validateSessionDurationSeconds(request.DurationSeconds)
|
|
}
|
|
|
|
// validateWebIdentityToken validates the web identity token with strict issuer-to-provider mapping
|
|
// SECURITY: JWT tokens with a specific issuer claim MUST only be validated by the provider for that issuer
|
|
// SECURITY: This method only accepts JWT tokens. Non-JWT authentication must use AssumeRoleWithCredentials with explicit ProviderName.
|
|
func (s *STSService) validateWebIdentityToken(ctx context.Context, token, roleAccountID string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
|
|
// Try to extract issuer from JWT token for strict validation
|
|
issuer, err := s.extractIssuerFromJWT(token)
|
|
if err != nil {
|
|
// Token is not a valid JWT or cannot be parsed
|
|
// SECURITY: Web identity tokens MUST be JWT tokens. Non-JWT authentication flows
|
|
// should use AssumeRoleWithCredentials with explicit ProviderName to prevent
|
|
// security vulnerabilities from non-deterministic provider selection.
|
|
return nil, nil, fmt.Errorf("web identity token must be a valid JWT token: %w", err)
|
|
}
|
|
|
|
// Look up the specific provider for this issuer, scoped to the role's
|
|
// account when known. IAM-managed records (admin-controlled, mutable at
|
|
// runtime) take precedence over the static-config map so an operator's
|
|
// CreateOpenIDConnectProvider call can shadow a bootstrap entry without
|
|
// requiring a restart.
|
|
provider, exists := s.lookupOIDCProviderForAccount(issuer, roleAccountID)
|
|
if !exists {
|
|
// SECURITY: If no provider is registered for this issuer, fail immediately
|
|
// This prevents JWT tokens from being validated by unintended providers
|
|
return nil, nil, fmt.Errorf("no identity provider registered for issuer: %s", issuer)
|
|
}
|
|
|
|
// Authenticate with the correct provider for this issuer
|
|
identity, err := provider.Authenticate(ctx, token)
|
|
if err != nil {
|
|
// Map provider errors to STS errors using errors.Is() for robust error checking
|
|
// This eliminates fragile string matching and provides reliable error classification
|
|
if errors.Is(err, providers.ErrProviderTokenExpired) {
|
|
return nil, nil, fmt.Errorf("%w: %v", ErrTypedTokenExpired, err)
|
|
} else if errors.Is(err, providers.ErrProviderInvalidToken) {
|
|
return nil, nil, fmt.Errorf("%w: %v", ErrTypedInvalidToken, err)
|
|
} else if errors.Is(err, providers.ErrProviderInvalidIssuer) {
|
|
return nil, nil, fmt.Errorf("%w: %v", ErrTypedInvalidIssuer, err)
|
|
} else if errors.Is(err, providers.ErrProviderInvalidAudience) {
|
|
return nil, nil, fmt.Errorf("%w: %v", ErrTypedInvalidAudience, err)
|
|
} else if errors.Is(err, providers.ErrProviderMissingClaims) {
|
|
return nil, nil, fmt.Errorf("%w: %v", ErrTypedMissingClaims, err)
|
|
}
|
|
// For other errors, return with context
|
|
return nil, nil, fmt.Errorf("token validation failed with provider for issuer %s: %w", issuer, err)
|
|
}
|
|
|
|
if identity == nil {
|
|
return nil, nil, fmt.Errorf("authentication succeeded but no identity returned for issuer %s", issuer)
|
|
}
|
|
|
|
return identity, provider, nil
|
|
}
|
|
|
|
// ValidateWebIdentityToken is a public method that exposes secure token validation for external use
|
|
// This method uses issuer-based lookup to select the correct provider, ensuring security and efficiency.
|
|
// External callers without role context get the account-agnostic lookup (global IAM-managed records
|
|
// only, then static-config); call ValidateWebIdentityTokenForAccount when the assumed-role account
|
|
// is known.
|
|
func (s *STSService) ValidateWebIdentityToken(ctx context.Context, token string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
|
|
return s.validateWebIdentityToken(ctx, token, "")
|
|
}
|
|
|
|
// ValidateWebIdentityTokenForAccount mirrors ValidateWebIdentityToken but
|
|
// scopes the IAM-managed provider lookup to roleAccountID. Pass "" for
|
|
// callers that don't yet know the account (e.g. claim-based mode).
|
|
func (s *STSService) ValidateWebIdentityTokenForAccount(ctx context.Context, token, roleAccountID string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
|
|
return s.validateWebIdentityToken(ctx, token, roleAccountID)
|
|
}
|
|
|
|
// extractIssuerFromJWT extracts the issuer (iss) claim from a JWT token without verification
|
|
func (s *STSService) extractIssuerFromJWT(token string) (string, error) {
|
|
// Parse token without verification to get claims
|
|
parsedToken, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse JWT token: %v", err)
|
|
}
|
|
|
|
// Extract claims
|
|
claims, ok := parsedToken.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return "", fmt.Errorf("invalid token claims")
|
|
}
|
|
|
|
// Get issuer claim
|
|
issuer, ok := claims["iss"].(string)
|
|
if !ok || issuer == "" {
|
|
return "", fmt.Errorf("missing or invalid issuer claim")
|
|
}
|
|
|
|
return issuer, nil
|
|
}
|
|
|
|
// validateRoleAssumptionForWebIdentity validates role assumption for web identity tokens
|
|
// This method performs complete trust policy validation to prevent unauthorized role assumptions
|
|
func (s *STSService) validateRoleAssumptionForWebIdentity(ctx context.Context, roleArn string, webIdentityToken string, durationSeconds *int64) error {
|
|
if roleArn == "" {
|
|
return fmt.Errorf("role ARN cannot be empty")
|
|
}
|
|
|
|
if webIdentityToken == "" {
|
|
return fmt.Errorf("web identity token cannot be empty")
|
|
}
|
|
|
|
// Validate role ARN and extract role information
|
|
// Accepts both arn:aws:iam::role/X and arn:aws:iam::ACCOUNT:role/X
|
|
arnInfo := utils.ParseRoleARN(roleArn)
|
|
if arnInfo.RoleName == "" {
|
|
return fmt.Errorf("invalid role ARN format: %s, expected format: arn:aws:iam::[ACCOUNT_ID:]role/ROLE_NAME", roleArn)
|
|
}
|
|
|
|
// Log ARN details for debugging
|
|
if arnInfo.AccountID != "" {
|
|
glog.V(4).Infof("Role ARN validation: role=%s, account=%s (standard format)", arnInfo.RoleName, arnInfo.AccountID)
|
|
} else {
|
|
glog.V(4).Infof("Role ARN validation: role=%s (legacy format)", arnInfo.RoleName)
|
|
}
|
|
|
|
// CRITICAL SECURITY: Perform trust policy validation
|
|
if s.trustPolicyValidator != nil {
|
|
if err := s.trustPolicyValidator.ValidateTrustPolicyForWebIdentity(ctx, roleArn, webIdentityToken, durationSeconds); err != nil {
|
|
return fmt.Errorf("trust policy validation failed: %w", err)
|
|
}
|
|
} else {
|
|
// If no trust policy validator is configured, fail closed for security
|
|
glog.Errorf("SECURITY WARNING: No trust policy validator configured - denying role assumption for security")
|
|
return fmt.Errorf("trust policy validation not available - role assumption denied for security")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateRoleAssumptionForCredentials validates role assumption for credential-based authentication
|
|
// This method performs complete trust policy validation to prevent unauthorized role assumptions
|
|
func (s *STSService) validateRoleAssumptionForCredentials(ctx context.Context, roleArn string, identity *providers.ExternalIdentity) error {
|
|
if roleArn == "" {
|
|
return fmt.Errorf("role ARN cannot be empty")
|
|
}
|
|
|
|
if identity == nil {
|
|
return fmt.Errorf("identity cannot be nil")
|
|
}
|
|
|
|
// Validate role ARN and extract role information
|
|
// Accepts both arn:aws:iam::role/X and arn:aws:iam::ACCOUNT:role/X
|
|
arnInfo := utils.ParseRoleARN(roleArn)
|
|
if arnInfo.RoleName == "" {
|
|
return fmt.Errorf("invalid role ARN format: %s, expected format: arn:aws:iam::[ACCOUNT_ID:]role/ROLE_NAME", roleArn)
|
|
}
|
|
|
|
// Log ARN details for debugging
|
|
if arnInfo.AccountID != "" {
|
|
glog.V(4).Infof("Role ARN validation: role=%s, account=%s (standard format)", arnInfo.RoleName, arnInfo.AccountID)
|
|
} else {
|
|
glog.V(4).Infof("Role ARN validation: role=%s (legacy format)", arnInfo.RoleName)
|
|
}
|
|
|
|
// CRITICAL SECURITY: Perform trust policy validation
|
|
if s.trustPolicyValidator != nil {
|
|
if err := s.trustPolicyValidator.ValidateTrustPolicyForCredentials(ctx, roleArn, identity); err != nil {
|
|
return fmt.Errorf("trust policy validation failed: %w", err)
|
|
}
|
|
} else {
|
|
// If no trust policy validator is configured, fail closed for security
|
|
glog.Errorf("SECURITY WARNING: No trust policy validator configured - denying role assumption for security")
|
|
return fmt.Errorf("trust policy validation not available - role assumption denied for security")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CalculateSessionDuration returns the requested DurationSeconds, or the
|
|
// configured TokenDuration default, capped at MaxSessionLength. The source
|
|
// token's exp deliberately plays no part: per AWS semantics the session
|
|
// outlives the (already verified) web identity token.
|
|
func (s *STSService) CalculateSessionDuration(durationSeconds *int64) time.Duration {
|
|
var duration time.Duration
|
|
if durationSeconds != nil {
|
|
duration = time.Duration(*durationSeconds) * time.Second
|
|
} else {
|
|
duration = s.Config.TokenDuration.Duration
|
|
}
|
|
|
|
// Cap at MaxSessionLength if configured
|
|
if s.Config.MaxSessionLength.Duration > 0 && duration > s.Config.MaxSessionLength.Duration {
|
|
glog.V(2).Infof("Limiting session duration from %v to %v based on MaxSessionLength config",
|
|
duration, s.Config.MaxSessionLength.Duration)
|
|
duration = s.Config.MaxSessionLength.Duration
|
|
}
|
|
|
|
return duration
|
|
}
|
|
|
|
// validateAssumeRoleWithCredentialsRequest validates the credentials request parameters
|
|
func (s *STSService) validateAssumeRoleWithCredentialsRequest(request *AssumeRoleWithCredentialsRequest) error {
|
|
if request.RoleArn == "" {
|
|
return fmt.Errorf("RoleArn is required")
|
|
}
|
|
|
|
if request.Username == "" {
|
|
return fmt.Errorf("Username is required")
|
|
}
|
|
|
|
if request.Password == "" {
|
|
return fmt.Errorf("Password is required")
|
|
}
|
|
|
|
if request.RoleSessionName == "" {
|
|
return fmt.Errorf("RoleSessionName is required")
|
|
}
|
|
|
|
if request.ProviderName == "" {
|
|
return fmt.Errorf("ProviderName is required")
|
|
}
|
|
|
|
return s.validateSessionDurationSeconds(request.DurationSeconds)
|
|
}
|
|
|
|
// ExpireSessionForTesting manually expires a session for testing purposes
|
|
func (s *STSService) ExpireSessionForTesting(ctx context.Context, sessionToken string) error {
|
|
if !s.initialized {
|
|
return fmt.Errorf("STS service not initialized")
|
|
}
|
|
|
|
if sessionToken == "" {
|
|
return fmt.Errorf("session token cannot be empty")
|
|
}
|
|
|
|
// Just validate the signature
|
|
_, err := s.tokenGenerator.ValidateJWTWithClaims(sessionToken)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid session token format: %w", err)
|
|
}
|
|
|
|
// In a stateless system, we cannot manually expire JWT tokens
|
|
// The token expiration is embedded in the token itself and handled by JWT validation
|
|
glog.V(1).Infof("Manual session expiration requested for stateless token - cannot expire JWT tokens manually")
|
|
|
|
return fmt.Errorf("manual session expiration not supported in stateless JWT system")
|
|
}
|