Files
seaweedFS/weed/s3api/s3api_sts.go
Chris Lu 059bee683f feat(s3): add STS GetFederationToken support (#8891)
* feat(s3): add STS GetFederationToken support

Implement the AWS STS GetFederationToken API, which allows long-term IAM
users to obtain temporary credentials scoped down by an optional inline
session policy. This is useful for server-side applications that mint
per-user temporary credentials.

Key behaviors:
- Requires SigV4 authentication from a long-term IAM user
- Rejects calls from temporary credentials (session tokens)
- Name parameter (2-64 chars) identifies the federated user
- DurationSeconds supports 900-129600 (15 min to 36 hours, default 12h)
- Optional inline session policy for permission scoping
- Caller's attached policies are embedded in the JWT token
- Returns federated user ARN: arn:aws:sts::<account>:federated-user/<Name>

No performance impact on the S3 hot path — credential vending is a
separate control-plane operation, and all policy data is embedded in
the stateless JWT token.

* fix(s3): address GetFederationToken PR review feedback

- Fix Name validation: max 32 chars (not 64) per AWS spec, add regex
  validation for [\w+=,.@-]+ character whitelist
- Refactor parseDurationSeconds into parseDurationSecondsWithBounds to
  eliminate duplicated duration parsing logic
- Add sts:GetFederationToken permission check via VerifyActionPermission
  mirroring the AssumeRole authorization pattern
- Change GetPoliciesForUser to return ([]string, error) so callers fail
  closed on policy-resolution failures instead of silently returning nil
- Move temporary-credentials rejection before SigV4 verification for
  early rejection and proper test coverage
- Update tests: verify specific error message for temp cred rejection,
  add regex validation test cases (spaces, slashes rejected)

* refactor(s3): use sts.Action* constants instead of hard-coded strings

Replace hard-coded "sts:AssumeRole" and "sts:GetFederationToken" strings
in VerifyActionPermission calls with sts.ActionAssumeRole and
sts.ActionGetFederationToken package constants.

* fix(s3): pass through sts: prefix in action resolver and merge policies

Two fixes:

1. mapBaseActionToS3Format now passes through "sts:" prefix alongside
   "s3:" and "iam:", preventing sts:GetFederationToken from being
   rewritten to s3:sts:GetFederationToken in VerifyActionPermission.
   This also fixes the existing sts:AssumeRole permission checks.

2. GetFederationToken policy embedding now merges identity.PolicyNames
   (from SigV4 identity) with policies from the IAM manager (which may
   include group-attached policies), deduplicated via a map. Previously
   the IAM manager lookup was skipped when identity.PolicyNames was
   non-empty, causing group policies to be omitted from the token.

* test(s3): add integration tests for sts: action passthrough and policy merge

Action resolver tests:
- TestMapBaseActionToS3Format_ServicePrefixPassthrough: verifies s3:, iam:,
  and sts: prefixed actions pass through unchanged while coarse actions
  (Read, Write) are mapped to S3 format
- TestResolveS3Action_STSActionsPassthrough: verifies sts:AssumeRole,
  sts:GetFederationToken, sts:GetCallerIdentity pass through ResolveS3Action
  unchanged with both nil and real HTTP requests

Policy merge tests:
- TestGetFederationToken_GetPoliciesForUser: tests IAMManager.GetPoliciesForUser
  with no user store (error), missing user, user with policies, user without
- TestGetFederationToken_PolicyMergeAndDedup: tests that identity.PolicyNames
  and IAM-manager-resolved policies are merged and deduplicated (SharedPolicy
  appears in both sources, result has 3 unique policies)
- TestGetFederationToken_PolicyMergeNoManager: tests that when IAM manager is
  unavailable, identity.PolicyNames alone are embedded

* test(s3): add end-to-end integration tests for GetFederationToken

Add integration tests that call GetFederationToken using real AWS SigV4
signed HTTP requests against a running SeaweedFS instance, following the
existing pattern in test/s3/iam/s3_sts_assume_role_test.go.

Tests:
- TestSTSGetFederationTokenValidation: missing name, name too short/long,
  invalid characters, duration too short/long, malformed policy, anonymous
  rejection (7 subtests)
- TestSTSGetFederationTokenRejectTemporaryCredentials: obtains temp creds
  via AssumeRole then verifies GetFederationToken rejects them
- TestSTSGetFederationTokenSuccess: basic success, custom 1h duration,
  36h max duration with expiration time verification
- TestSTSGetFederationTokenWithSessionPolicy: creates a bucket, obtains
  federated creds with GetObject-only session policy, verifies GetObject
  succeeds and PutObject is denied using the AWS SDK S3 client
2026-04-02 17:37:05 -07:00

1051 lines
36 KiB
Go

package s3api
// This file provides STS (Security Token Service) HTTP endpoints for AWS SDK compatibility.
// It exposes AssumeRoleWithWebIdentity as an HTTP endpoint that can be used with
// AWS SDKs to obtain temporary credentials using OIDC/JWT tokens.
import (
"context"
"encoding/xml"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/ldap"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
)
// STS API constants matching AWS STS specification
const (
stsAPIVersion = "2011-06-15"
stsAction = "Action"
stsVersion = "Version"
stsWebIdentityToken = "WebIdentityToken"
stsRoleArn = "RoleArn"
stsRoleSessionName = "RoleSessionName"
stsDurationSeconds = "DurationSeconds"
// STS Action names
actionAssumeRole = "AssumeRole"
actionAssumeRoleWithWebIdentity = "AssumeRoleWithWebIdentity"
actionAssumeRoleWithLDAPIdentity = "AssumeRoleWithLDAPIdentity"
actionGetCallerIdentity = "GetCallerIdentity"
actionGetFederationToken = "GetFederationToken"
// GetFederationToken-specific parameters
stsFederationName = "Name"
// LDAP parameter names
stsLDAPUsername = "LDAPUsername"
stsLDAPPassword = "LDAPPassword"
stsLDAPProviderName = "LDAPProviderName"
)
// federationNameRegex validates the Name parameter for GetFederationToken per AWS spec
var federationNameRegex = regexp.MustCompile(`^[\w+=,.@-]+$`)
// STS duration constants (AWS specification)
const (
minDurationSeconds = int64(900) // 15 minutes
maxDurationSeconds = int64(43200) // 12 hours (AssumeRole)
defaultFederationDurationSeconds = int64(43200) // 12 hours (GetFederationToken default)
maxFederationDurationSeconds = int64(129600) // 36 hours (GetFederationToken max)
)
// parseDurationSecondsWithBounds parses and validates the DurationSeconds parameter
// against the given min and max bounds. Returns nil if the parameter is not provided.
func parseDurationSecondsWithBounds(r *http.Request, minSec, maxSec int64) (*int64, STSErrorCode, error) {
dsStr := r.FormValue("DurationSeconds")
if dsStr == "" {
return nil, "", nil
}
ds, err := strconv.ParseInt(dsStr, 10, 64)
if err != nil {
return nil, STSErrInvalidParameterValue, fmt.Errorf("invalid DurationSeconds: %w", err)
}
if ds < minSec || ds > maxSec {
return nil, STSErrInvalidParameterValue,
fmt.Errorf("DurationSeconds must be between %d and %d seconds", minSec, maxSec)
}
return &ds, "", nil
}
// parseDurationSeconds parses DurationSeconds for AssumeRole (15 min to 12 hours)
func parseDurationSeconds(r *http.Request) (*int64, STSErrorCode, error) {
return parseDurationSecondsWithBounds(r, minDurationSeconds, maxDurationSeconds)
}
// Removed generateSecureCredentials - now using STS service's JWT token generation
// The STS service generates proper JWT tokens with embedded claims that can be validated
// across distributed instances without shared state.
// STSHandlers provides HTTP handlers for STS operations
type STSHandlers struct {
stsService *sts.STSService
iam *IdentityAccessManagement
}
// NewSTSHandlers creates a new STSHandlers instance
func NewSTSHandlers(stsService *sts.STSService, iam *IdentityAccessManagement) *STSHandlers {
return &STSHandlers{
stsService: stsService,
iam: iam,
}
}
func (h *STSHandlers) getAccountID() string {
if h.stsService != nil && h.stsService.Config != nil && h.stsService.Config.AccountId != "" {
return h.stsService.Config.AccountId
}
return defaultAccountID
}
// HandleSTSRequest is the main entry point for STS requests
// It routes requests based on the Action parameter
func (h *STSHandlers) HandleSTSRequest(w http.ResponseWriter, r *http.Request) {
r, _ = request_id.Ensure(r)
if err := r.ParseForm(); err != nil {
h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue, err)
return
}
// Validate API version
version := r.Form.Get(stsVersion)
if version != "" && version != stsAPIVersion {
h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue,
fmt.Errorf("invalid STS API version %s, expecting %s", version, stsAPIVersion))
return
}
// Route based on action
action := r.Form.Get(stsAction)
switch action {
case actionAssumeRole:
h.handleAssumeRole(w, r)
case actionAssumeRoleWithWebIdentity:
h.handleAssumeRoleWithWebIdentity(w, r)
case actionAssumeRoleWithLDAPIdentity:
h.handleAssumeRoleWithLDAPIdentity(w, r)
case actionGetCallerIdentity:
h.handleGetCallerIdentity(w, r)
case actionGetFederationToken:
h.handleGetFederationToken(w, r)
default:
h.writeSTSErrorResponse(w, r, STSErrInvalidAction,
fmt.Errorf("unsupported action: %s", action))
}
}
// handleAssumeRoleWithWebIdentity handles the AssumeRoleWithWebIdentity API action
func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract parameters from form (supports both query and POST body)
roleArn := r.FormValue("RoleArn")
webIdentityToken := r.FormValue("WebIdentityToken")
roleSessionName := r.FormValue("RoleSessionName")
// Validate required parameters
if webIdentityToken == "" {
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
fmt.Errorf("WebIdentityToken is required"))
return
}
if roleArn == "" {
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
fmt.Errorf("RoleArn is required"))
return
}
if roleSessionName == "" {
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
fmt.Errorf("RoleSessionName is required"))
return
}
// Parse and validate DurationSeconds using helper
durationSeconds, errCode, err := parseDurationSeconds(r)
if err != nil {
h.writeSTSErrorResponse(w, r, errCode, err)
return
}
// Check if STS service is initialized
if h.stsService == nil || !h.stsService.IsInitialized() {
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
fmt.Errorf("STS service not initialized"))
return
}
sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy"))
if err != nil {
h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument,
fmt.Errorf("invalid Policy document: %w", err))
return
}
var sessionPolicyPtr *string
if sessionPolicyJSON != "" {
sessionPolicyPtr = &sessionPolicyJSON
}
// Build request for STS service
request := &sts.AssumeRoleWithWebIdentityRequest{
RoleArn: roleArn,
WebIdentityToken: webIdentityToken,
RoleSessionName: roleSessionName,
DurationSeconds: durationSeconds,
Policy: sessionPolicyPtr,
}
// Call STS service
response, err := h.stsService.AssumeRoleWithWebIdentity(ctx, request)
if err != nil {
glog.V(2).Infof("AssumeRoleWithWebIdentity failed: %v", err)
// Use typed errors for robust error checking
// This decouples HTTP layer from service implementation details
errCode := STSErrAccessDenied
if errors.Is(err, sts.ErrTypedTokenExpired) {
errCode = STSErrExpiredToken
} else if errors.Is(err, sts.ErrTypedInvalidToken) {
errCode = STSErrInvalidParameterValue
} else if errors.Is(err, sts.ErrTypedInvalidIssuer) {
errCode = STSErrInvalidParameterValue
} else if errors.Is(err, sts.ErrTypedInvalidAudience) {
errCode = STSErrInvalidParameterValue
} else if errors.Is(err, sts.ErrTypedMissingClaims) {
errCode = STSErrInvalidParameterValue
}
h.writeSTSErrorResponse(w, r, errCode, err)
return
}
// Build and return XML response
xmlResponse := &AssumeRoleWithWebIdentityResponse{
Result: WebIdentityResult{
Credentials: STSCredentials{
AccessKeyId: response.Credentials.AccessKeyId,
SecretAccessKey: response.Credentials.SecretAccessKey,
SessionToken: response.Credentials.SessionToken,
Expiration: response.Credentials.Expiration.Format(time.RFC3339),
},
SubjectFromWebIdentityToken: response.AssumedRoleUser.Subject,
},
}
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
}
// handleAssumeRole handles the AssumeRole API action
// This requires AWS Signature V4 authentication
// Inline session policies (Policy parameter) are supported for AssumeRole,
// AssumeRoleWithWebIdentity, and AssumeRoleWithLDAPIdentity.
func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
// Extract parameters from form
roleArn := r.FormValue("RoleArn")
roleSessionName := r.FormValue("RoleSessionName")
// Validate required parameters
// RoleArn is optional to support S3-compatible clients that omit it
if roleSessionName == "" {
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
fmt.Errorf("RoleSessionName is required"))
return
}
// Parse and validate DurationSeconds using helper
durationSeconds, errCode, err := parseDurationSeconds(r)
if err != nil {
h.writeSTSErrorResponse(w, r, errCode, err)
return
}
// Check if STS service is initialized
if h.stsService == nil || !h.stsService.IsInitialized() {
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
fmt.Errorf("STS service not initialized"))
return
}
// Check if IAM is available for SigV4 verification
if h.iam == nil {
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
fmt.Errorf("IAM not configured for STS"))
return
}
// Validate AWS SigV4 authentication
identity, _, _, _, sigErrCode := h.iam.verifyV4Signature(r, false)
if sigErrCode != s3err.ErrNone {
glog.V(2).Infof("AssumeRole SigV4 verification failed: %v", sigErrCode)
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
fmt.Errorf("invalid AWS signature: %v", sigErrCode))
return
}
if identity == nil {
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
fmt.Errorf("unable to identify caller"))
return
}
glog.V(2).Infof("AssumeRole: caller identity=%s, roleArn=%s, sessionName=%s",
identity.Name, roleArn, roleSessionName)
// Check if the caller is authorized to assume the role (sts:AssumeRole permission)
// This validates that the caller has a policy allowing sts:AssumeRole on the target role
// Check authorizations
if roleArn != "" {
// Check if the caller is authorized to assume the role (sts:AssumeRole permission)
if authErr := h.iam.VerifyActionPermission(r, identity, Action(sts.ActionAssumeRole), "", roleArn); authErr != s3err.ErrNone {
glog.V(2).Infof("AssumeRole: caller %s is not authorized to assume role %s", identity.Name, roleArn)
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
fmt.Errorf("user %s is not authorized to assume role %s", identity.Name, roleArn))
return
}
// Validate that the target role trusts the caller (Trust Policy)
if err := h.iam.ValidateTrustPolicyForPrincipal(r.Context(), roleArn, identity.PrincipalArn); err != nil {
glog.V(2).Infof("AssumeRole: trust policy validation failed for %s to assume %s: %v", identity.Name, roleArn, err)
h.writeSTSErrorResponse(w, r, STSErrAccessDenied, fmt.Errorf("trust policy denies access"))
return
}
} else {
// If RoleArn is missing, default to the caller's identity (User Context)
// This allows the user to "assume" a session for themselves, inheriting their own permissions.
roleArn = identity.PrincipalArn
glog.V(2).Infof("AssumeRole: no RoleArn provided, defaulting to caller identity: %s", roleArn)
// We still enforce a global "sts:AssumeRole" check, similar to how we'd check if they can assume *any* role.
// However, for self-assumption, this might be implicit.
// For safety/consistency with previous logic, we keep the check but strictly it might not be required by AWS for GetSessionToken.
// But since this IS AssumeRole, let's keep it.
// Admin/Global check when no specific role is requested
if authErr := h.iam.VerifyActionPermission(r, identity, Action(sts.ActionAssumeRole), "", ""); authErr != s3err.ErrNone {
glog.Warningf("AssumeRole: caller %s attempted to assume role without RoleArn and lacks global sts:AssumeRole permission", identity.Name)
h.writeSTSErrorResponse(w, r, STSErrAccessDenied, fmt.Errorf("access denied"))
return
}
}
sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy"))
if err != nil {
h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument,
fmt.Errorf("invalid Policy document: %w", err))
return
}
// Prepare custom claims for the session
var modifyClaims func(claims *sts.STSSessionClaims)
if identity.isAdmin() {
modifyClaims = func(claims *sts.STSSessionClaims) {
if claims.RequestContext == nil {
claims.RequestContext = make(map[string]interface{})
}
claims.RequestContext["is_admin"] = true
}
}
// Generate common STS components
stsCreds, assumedUser, err := h.prepareSTSCredentials(r.Context(), roleArn, roleSessionName, durationSeconds, sessionPolicyJSON, modifyClaims)
if err != nil {
h.writeSTSErrorResponse(w, r, STSErrInternalError, err)
return
}
// Build and return response
xmlResponse := &AssumeRoleResponse{
Result: AssumeRoleResult{
Credentials: stsCreds,
AssumedRoleUser: assumedUser,
},
}
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
}
// handleAssumeRoleWithLDAPIdentity handles the AssumeRoleWithLDAPIdentity API action
func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r *http.Request) {
// Extract parameters from form
roleArn := r.FormValue("RoleArn")
roleSessionName := r.FormValue("RoleSessionName")
ldapUsername := r.FormValue(stsLDAPUsername)
ldapPassword := r.FormValue(stsLDAPPassword)
// Validate required parameters
if roleArn == "" {
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
fmt.Errorf("RoleArn is required"))
return
}
if roleSessionName == "" {
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
fmt.Errorf("RoleSessionName is required"))
return
}
if ldapUsername == "" {
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
fmt.Errorf("LDAPUsername is required"))
return
}
if ldapPassword == "" {
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
fmt.Errorf("LDAPPassword is required"))
return
}
// Parse and validate DurationSeconds using helper
durationSeconds, errCode, err := parseDurationSeconds(r)
if err != nil {
h.writeSTSErrorResponse(w, r, errCode, err)
return
}
// Check if STS service is initialized
if h.stsService == nil || !h.stsService.IsInitialized() {
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
fmt.Errorf("STS service not initialized"))
return
}
// Optional: specific LDAP provider name
ldapProviderName := r.FormValue(stsLDAPProviderName)
// Find an LDAP provider from the registered providers
var ldapProvider *ldap.LDAPProvider
ldapProvidersFound := 0
for _, provider := range h.stsService.GetProviders() {
// Check if this is an LDAP provider by type assertion
if p, ok := provider.(*ldap.LDAPProvider); ok {
if ldapProviderName != "" && p.Name() == ldapProviderName {
ldapProvider = p
break
} else if ldapProviderName == "" && ldapProvider == nil {
ldapProvider = p
}
ldapProvidersFound++
}
}
if ldapProvidersFound > 1 && ldapProviderName == "" {
glog.Warningf("Multiple LDAP providers found (%d). Using the first one found (non-deterministic). Consider specifying LDAPProviderName.", ldapProvidersFound)
}
if ldapProvider == nil {
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: no LDAP provider configured")
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
fmt.Errorf("no LDAP provider configured - please add an LDAP provider to IAM configuration"))
return
}
// Authenticate with LDAP provider
// The provider expects credentials in "username:password" format
credentials := ldapUsername + ":" + ldapPassword
identity, err := ldapProvider.Authenticate(r.Context(), credentials)
if err != nil {
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: LDAP authentication failed for user %s: %v", ldapUsername, err)
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
fmt.Errorf("authentication failed"))
return
}
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: user %s authenticated successfully, groups=%v",
ldapUsername, identity.Groups)
accountID := h.getAccountID()
ldapUserIdentity := &Identity{
Name: identity.UserID,
Account: &Account{
DisplayName: identity.DisplayName,
EmailAddress: identity.Email,
Id: identity.UserID,
},
PrincipalArn: fmt.Sprintf("arn:aws:iam::%s:user/%s", accountID, identity.UserID),
}
// Verify that the identity is allowed to assume the role by checking the Trust Policy
// The LDAP user doesn't have identity policies, so we strictly check if the Role trusts this principal.
if err := h.iam.ValidateTrustPolicyForPrincipal(r.Context(), roleArn, ldapUserIdentity.PrincipalArn); err != nil {
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: trust policy validation failed for %s to assume %s: %v", ldapUsername, roleArn, err)
h.writeSTSErrorResponse(w, r, STSErrAccessDenied, fmt.Errorf("trust policy denies access"))
return
}
sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy"))
if err != nil {
h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument,
fmt.Errorf("invalid Policy document: %w", err))
return
}
// Generate common STS components with LDAP-specific claims
modifyClaims := func(claims *sts.STSSessionClaims) {
claims.WithIdentityProvider("ldap", identity.UserID, identity.Provider)
}
stsCreds, assumedUser, err := h.prepareSTSCredentials(r.Context(), roleArn, roleSessionName, durationSeconds, sessionPolicyJSON, modifyClaims)
if err != nil {
h.writeSTSErrorResponse(w, r, STSErrInternalError, err)
return
}
// Build and return response
xmlResponse := &AssumeRoleWithLDAPIdentityResponse{
Result: LDAPIdentityResult{
Credentials: stsCreds,
AssumedRoleUser: assumedUser,
},
}
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
}
// handleGetFederationToken handles the GetFederationToken API action.
// This allows long-term IAM users to obtain temporary credentials scoped down
// by an optional inline session policy. Temporary credentials cannot call this action.
func (h *STSHandlers) handleGetFederationToken(w http.ResponseWriter, r *http.Request) {
// Extract parameters
name := r.FormValue(stsFederationName)
// Validate required parameters
if name == "" {
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
fmt.Errorf("Name is required"))
return
}
// AWS requires Name to be 2-32 characters matching [\w+=,.@-]+
if len(name) < 2 || len(name) > 32 {
h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue,
fmt.Errorf("Name must be between 2 and 32 characters"))
return
}
if !federationNameRegex.MatchString(name) {
h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue,
fmt.Errorf("Name contains invalid characters, must match [\\w+=,.@-]+"))
return
}
// Parse and validate DurationSeconds (GetFederationToken allows up to 36 hours)
durationSeconds, errCode, err := parseDurationSecondsWithBounds(r, minDurationSeconds, maxFederationDurationSeconds)
if err != nil {
h.writeSTSErrorResponse(w, r, errCode, err)
return
}
// Reject calls from temporary credentials (session tokens) early,
// before SigV4 verification — no need to authenticate first.
// GetFederationToken can only be called by long-term IAM users.
securityToken := r.Header.Get("X-Amz-Security-Token")
if securityToken == "" {
securityToken = r.URL.Query().Get("X-Amz-Security-Token")
}
if securityToken != "" {
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
fmt.Errorf("GetFederationToken cannot be called with temporary credentials"))
return
}
// Check if STS service is initialized
if h.stsService == nil || !h.stsService.IsInitialized() {
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
fmt.Errorf("STS service not initialized"))
return
}
// Check if IAM is available for SigV4 verification
if h.iam == nil {
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
fmt.Errorf("IAM not configured for STS"))
return
}
// Validate AWS SigV4 authentication
identity, _, _, _, sigErrCode := h.iam.verifyV4Signature(r, false)
if sigErrCode != s3err.ErrNone {
glog.V(2).Infof("GetFederationToken SigV4 verification failed: %v", sigErrCode)
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
fmt.Errorf("invalid AWS signature: %v", sigErrCode))
return
}
if identity == nil {
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
fmt.Errorf("unable to identify caller"))
return
}
glog.V(2).Infof("GetFederationToken: caller identity=%s, name=%s", identity.Name, name)
// Check if the caller is authorized to call GetFederationToken
if authErr := h.iam.VerifyActionPermission(r, identity, Action(sts.ActionGetFederationToken), "", ""); authErr != s3err.ErrNone {
glog.V(2).Infof("GetFederationToken: caller %s is not authorized to call GetFederationToken", identity.Name)
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
fmt.Errorf("user %s is not authorized to call GetFederationToken", identity.Name))
return
}
// Validate session policy if provided
sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy"))
if err != nil {
h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument,
fmt.Errorf("invalid Policy document: %w", err))
return
}
// Calculate duration (default 12 hours for GetFederationToken)
duration := time.Duration(defaultFederationDurationSeconds) * time.Second
if durationSeconds != nil {
duration = time.Duration(*durationSeconds) * time.Second
}
// Generate session ID
sessionId, err := sts.GenerateSessionId()
if err != nil {
h.writeSTSErrorResponse(w, r, STSErrInternalError,
fmt.Errorf("failed to generate session ID: %w", err))
return
}
expiration := time.Now().Add(duration)
accountID := h.getAccountID()
// Build federated user ARN: arn:aws:sts::<account>:federated-user/<Name>
federatedUserArn := fmt.Sprintf("arn:aws:sts::%s:federated-user/%s", accountID, name)
federatedUserId := fmt.Sprintf("%s:%s", accountID, name)
// Create session claims — use the caller's principal ARN as the RoleArn
// so that policy evaluation resolves the caller's attached policies
claims := sts.NewSTSSessionClaims(sessionId, h.stsService.Config.Issuer, expiration).
WithSessionName(name).
WithRoleInfo(identity.PrincipalArn, federatedUserId, federatedUserArn)
// Embed the caller's effective policies into the token.
// Merge identity.PolicyNames (from SigV4 identity) with policies resolved
// from the IAM manager (which may include group-attached policies).
policySet := make(map[string]struct{})
for _, p := range identity.PolicyNames {
policySet[p] = struct{}{}
}
var policyManager *integration.IAMManager
if h.iam.iamIntegration != nil {
if provider, ok := h.iam.iamIntegration.(IAMManagerProvider); ok {
policyManager = provider.GetIAMManager()
}
}
if policyManager != nil {
userPolicies, err := policyManager.GetPoliciesForUser(r.Context(), identity.Name)
if err != nil {
glog.V(2).Infof("GetFederationToken: failed to resolve policies for %s: %v", identity.Name, err)
h.writeSTSErrorResponse(w, r, STSErrInternalError,
fmt.Errorf("failed to resolve caller policies"))
return
}
for _, p := range userPolicies {
policySet[p] = struct{}{}
}
}
if len(policySet) > 0 {
merged := make([]string, 0, len(policySet))
for p := range policySet {
merged = append(merged, p)
}
claims.WithPolicies(merged)
}
if sessionPolicyJSON != "" {
claims.WithSessionPolicy(sessionPolicyJSON)
}
// Generate JWT session token
sessionToken, err := h.stsService.GetTokenGenerator().GenerateJWTWithClaims(claims)
if err != nil {
h.writeSTSErrorResponse(w, r, STSErrInternalError,
fmt.Errorf("failed to generate session token: %w", err))
return
}
// Generate temporary credentials
stsCredGen := sts.NewCredentialGenerator()
stsCredsDet, err := stsCredGen.GenerateTemporaryCredentials(sessionId, expiration)
if err != nil {
h.writeSTSErrorResponse(w, r, STSErrInternalError,
fmt.Errorf("failed to generate temporary credentials: %w", err))
return
}
// Build and return response
xmlResponse := &GetFederationTokenResponse{
Result: GetFederationTokenResult{
Credentials: STSCredentials{
AccessKeyId: stsCredsDet.AccessKeyId,
SecretAccessKey: stsCredsDet.SecretAccessKey,
SessionToken: sessionToken,
Expiration: expiration.Format(time.RFC3339),
},
FederatedUser: FederatedUser{
FederatedUserId: federatedUserId,
Arn: federatedUserArn,
},
},
}
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
}
// prepareSTSCredentials extracts common shared logic for credential generation
func (h *STSHandlers) prepareSTSCredentials(ctx context.Context, roleArn, roleSessionName string,
durationSeconds *int64, sessionPolicy string, modifyClaims func(*sts.STSSessionClaims)) (STSCredentials, *AssumedRoleUser, error) {
// Calculate duration
duration := time.Hour // Default 1 hour
if durationSeconds != nil {
duration = time.Duration(*durationSeconds) * time.Second
}
// Generate session ID
sessionId, err := sts.GenerateSessionId()
if err != nil {
return STSCredentials{}, nil, fmt.Errorf("failed to generate session ID: %w", err)
}
expiration := time.Now().Add(duration)
// Extract role name from ARN for proper response formatting
roleName := utils.ExtractRoleNameFromPrincipal(roleArn)
if roleName == "" {
// Try to extract user name if it's a user ARN (for "User Context" assumption)
roleName = utils.ExtractUserNameFromPrincipal(roleArn)
}
if roleName == "" {
roleName = roleArn // Fallback to full ARN if extraction fails
}
accountID := h.getAccountID()
// Construct AssumedRoleUser ARN - this will be used as the principal for the vended token
assumedRoleArn := fmt.Sprintf("arn:aws:sts::%s:assumed-role/%s/%s", accountID, roleName, roleSessionName)
// Use assumedRoleArn as RoleArn in claims if original RoleArn is empty
// This ensures STSSessionClaims.IsValid() passes (it requires non-empty RoleArn)
effectiveRoleArn := roleArn
if effectiveRoleArn == "" {
effectiveRoleArn = assumedRoleArn
}
// Create session claims with role information
// SECURITY: Use the assumedRoleArn as the principal in the token.
// This ensures that subsequent requests using this token are correctly identified as the assumed role.
claims := sts.NewSTSSessionClaims(sessionId, h.stsService.Config.Issuer, expiration).
WithSessionName(roleSessionName).
WithRoleInfo(effectiveRoleArn, fmt.Sprintf("%s:%s", roleName, roleSessionName), assumedRoleArn)
// If IAM integration is available, embed the role's attached policies into the session token.
// This makes the token self-sufficient for authorization even when role lookup is unavailable.
var policyManager *integration.IAMManager
if h.iam != nil && h.iam.iamIntegration != nil {
if provider, ok := h.iam.iamIntegration.(IAMManagerProvider); ok {
policyManager = provider.GetIAMManager()
}
}
if policyManager != nil {
roleNameForPolicies := utils.ExtractRoleNameFromArn(roleArn)
if roleNameForPolicies == "" {
roleNameForPolicies = utils.ExtractRoleNameFromPrincipal(roleArn)
}
if roleNameForPolicies != "" && len(claims.Policies) == 0 {
roleDef, err := policyManager.GetRole(ctx, roleNameForPolicies)
if err != nil {
glog.V(2).Infof("Failed to load role %q for policy embedding: %v", roleNameForPolicies, err)
} else if roleDef == nil {
glog.V(2).Infof("Role definition %q was missing for policy embedding", roleNameForPolicies)
} else if len(roleDef.AttachedPolicies) > 0 {
claims.WithPolicies(roleDef.AttachedPolicies)
}
}
}
if sessionPolicy != "" {
claims.WithSessionPolicy(sessionPolicy)
}
// Apply custom claims if provided (e.g., LDAP identity)
if modifyClaims != nil {
modifyClaims(claims)
}
// Generate JWT session token
sessionToken, err := h.stsService.GetTokenGenerator().GenerateJWTWithClaims(claims)
if err != nil {
return STSCredentials{}, nil, fmt.Errorf("failed to generate session token: %w", err)
}
// Generate temporary credentials (deterministic based on sessionId)
stsCredGen := sts.NewCredentialGenerator()
stsCredsDet, err := stsCredGen.GenerateTemporaryCredentials(sessionId, expiration)
if err != nil {
return STSCredentials{}, nil, fmt.Errorf("failed to generate temporary credentials: %w", err)
}
accessKeyId := stsCredsDet.AccessKeyId
secretAccessKey := stsCredsDet.SecretAccessKey
stsCreds := STSCredentials{
AccessKeyId: accessKeyId,
SecretAccessKey: secretAccessKey,
SessionToken: sessionToken,
Expiration: expiration.Format(time.RFC3339),
}
assumedUser := &AssumedRoleUser{
AssumedRoleId: fmt.Sprintf("%s:%s", roleName, roleSessionName),
Arn: assumedRoleArn,
}
return stsCreds, assumedUser, nil
}
// handleGetCallerIdentity handles the GetCallerIdentity API action.
// It returns the identity (ARN, account, user ID) of the caller based on SigV4 authentication.
func (h *STSHandlers) handleGetCallerIdentity(w http.ResponseWriter, r *http.Request) {
if h.iam == nil {
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
fmt.Errorf("IAM not configured for STS"))
return
}
identity, _, _, _, sigErrCode := h.iam.verifyV4Signature(r, false)
if sigErrCode != s3err.ErrNone {
glog.V(2).Infof("GetCallerIdentity SigV4 verification failed: %v", sigErrCode)
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
fmt.Errorf("invalid AWS signature: %v", sigErrCode))
return
}
if identity == nil {
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
fmt.Errorf("unable to identify caller"))
return
}
accountID := h.getAccountID()
arn := identity.PrincipalArn
if arn == "" {
arn = fmt.Sprintf("arn:aws:iam::%s:user/%s", accountID, identity.Name)
}
userId := identity.Name
glog.V(2).Infof("GetCallerIdentity: identity=%s, arn=%s, account=%s", identity.Name, arn, accountID)
xmlResponse := &GetCallerIdentityResponse{
Result: GetCallerIdentityResult{
Arn: arn,
UserId: userId,
Account: accountID,
},
}
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
}
// STS Response types for XML marshaling
// AssumeRoleWithWebIdentityResponse is the response for AssumeRoleWithWebIdentity
type AssumeRoleWithWebIdentityResponse struct {
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleWithWebIdentityResponse"`
Result WebIdentityResult `xml:"AssumeRoleWithWebIdentityResult"`
ResponseMetadata struct {
RequestId string `xml:"RequestId,omitempty"`
} `xml:"ResponseMetadata,omitempty"`
}
// WebIdentityResult contains the result of AssumeRoleWithWebIdentity
type WebIdentityResult struct {
Credentials STSCredentials `xml:"Credentials"`
SubjectFromWebIdentityToken string `xml:"SubjectFromWebIdentityToken,omitempty"`
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
}
// STSCredentials represents temporary security credentials
type STSCredentials struct {
AccessKeyId string `xml:"AccessKeyId"`
SecretAccessKey string `xml:"SecretAccessKey"`
SessionToken string `xml:"SessionToken"`
Expiration string `xml:"Expiration"`
}
// AssumedRoleUser contains information about the assumed role
type AssumedRoleUser struct {
AssumedRoleId string `xml:"AssumedRoleId"`
Arn string `xml:"Arn"`
}
// AssumeRoleResponse is the response for AssumeRole
type AssumeRoleResponse struct {
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleResponse"`
Result AssumeRoleResult `xml:"AssumeRoleResult"`
ResponseMetadata struct {
RequestId string `xml:"RequestId,omitempty"`
} `xml:"ResponseMetadata,omitempty"`
}
// AssumeRoleResult contains the result of AssumeRole
type AssumeRoleResult struct {
Credentials STSCredentials `xml:"Credentials"`
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
}
// AssumeRoleWithLDAPIdentityResponse is the response for AssumeRoleWithLDAPIdentity
type AssumeRoleWithLDAPIdentityResponse struct {
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleWithLDAPIdentityResponse"`
Result LDAPIdentityResult `xml:"AssumeRoleWithLDAPIdentityResult"`
ResponseMetadata struct {
RequestId string `xml:"RequestId,omitempty"`
} `xml:"ResponseMetadata,omitempty"`
}
// LDAPIdentityResult contains the result of AssumeRoleWithLDAPIdentity
type LDAPIdentityResult struct {
Credentials STSCredentials `xml:"Credentials"`
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
}
// GetCallerIdentityResponse is the response for GetCallerIdentity
type GetCallerIdentityResponse struct {
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ GetCallerIdentityResponse"`
Result GetCallerIdentityResult `xml:"GetCallerIdentityResult"`
ResponseMetadata struct {
RequestId string `xml:"RequestId,omitempty"`
} `xml:"ResponseMetadata,omitempty"`
}
// GetCallerIdentityResult contains the result of GetCallerIdentity
type GetCallerIdentityResult struct {
Arn string `xml:"Arn"`
UserId string `xml:"UserId"`
Account string `xml:"Account"`
}
// GetFederationTokenResponse is the response for GetFederationToken
type GetFederationTokenResponse struct {
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ GetFederationTokenResponse"`
Result GetFederationTokenResult `xml:"GetFederationTokenResult"`
ResponseMetadata struct {
RequestId string `xml:"RequestId,omitempty"`
} `xml:"ResponseMetadata,omitempty"`
}
// GetFederationTokenResult contains the result of GetFederationToken
type GetFederationTokenResult struct {
Credentials STSCredentials `xml:"Credentials"`
FederatedUser FederatedUser `xml:"FederatedUser"`
}
// FederatedUser contains information about the federated user
type FederatedUser struct {
FederatedUserId string `xml:"FederatedUserId"`
Arn string `xml:"Arn"`
}
// STS Error types
// STSErrorCode represents STS error codes
type STSErrorCode string
const (
STSErrAccessDenied STSErrorCode = "AccessDenied"
STSErrExpiredToken STSErrorCode = "ExpiredTokenException"
STSErrInvalidAction STSErrorCode = "InvalidAction"
STSErrInvalidParameterValue STSErrorCode = "InvalidParameterValue"
STSErrMalformedPolicyDocument STSErrorCode = "MalformedPolicyDocument"
STSErrMissingParameter STSErrorCode = "MissingParameter"
STSErrSTSNotReady STSErrorCode = "ServiceUnavailable"
STSErrInternalError STSErrorCode = "InternalError"
)
// stsErrorResponses maps error codes to HTTP status and messages
var stsErrorResponses = map[STSErrorCode]struct {
HTTPStatusCode int
Message string
}{
STSErrAccessDenied: {http.StatusForbidden, "Access Denied"},
STSErrExpiredToken: {http.StatusBadRequest, "Token has expired"},
STSErrInvalidAction: {http.StatusBadRequest, "Invalid action"},
STSErrInvalidParameterValue: {http.StatusBadRequest, "Invalid parameter value"},
STSErrMalformedPolicyDocument: {http.StatusBadRequest, "Malformed policy document"},
STSErrMissingParameter: {http.StatusBadRequest, "Missing required parameter"},
STSErrSTSNotReady: {http.StatusServiceUnavailable, "STS service not ready"},
STSErrInternalError: {http.StatusInternalServerError, "Internal error"},
}
// STSErrorResponse is the XML error response format
type STSErrorResponse struct {
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ ErrorResponse"`
Error struct {
Type string `xml:"Type"`
Code string `xml:"Code"`
Message string `xml:"Message"`
} `xml:"Error"`
RequestId string `xml:"RequestId"`
}
// writeSTSErrorResponse writes an STS error response
func (h *STSHandlers) writeSTSErrorResponse(w http.ResponseWriter, r *http.Request, code STSErrorCode, err error) {
errInfo, ok := stsErrorResponses[code]
if !ok {
errInfo = stsErrorResponses[STSErrInternalError]
}
message := errInfo.Message
if err != nil {
message = err.Error()
}
response := STSErrorResponse{
RequestId: request_id.GetFromRequest(r),
}
// Server-side errors use "Receiver" type per AWS spec
if code == STSErrInternalError || code == STSErrSTSNotReady {
response.Error.Type = "Receiver"
} else {
response.Error.Type = "Sender"
}
response.Error.Code = string(code)
response.Error.Message = message
glog.V(1).Infof("STS error response: code=%s, type=%s, message=%s", code, response.Error.Type, message)
s3err.WriteXMLResponse(w, r, errInfo.HTTPStatusCode, response)
}