feat(iam): STS web-identity AWS-fidelity polish (Phase 1) (#9318)

* feat(iam): STS web-identity AWS-fidelity polish

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

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

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

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

Addresses gemini high-priority review on PR #9318.

* fix(iam): synchronize OIDCProvider JWKS cache fields

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

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

Addresses gemini high-priority review on PR #9318.

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

Two OIDC discovery edge cases reviewers flagged:

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

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

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

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

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

Adds TestDiscoveryRejectsMissingIssuer alongside the existing
TestDiscoveryRejectsIssuerMismatch via a new omitDiscoveryIssuer
toggle on fakeIDP.
This commit is contained in:
Chris Lu authored and GitHub committed 2026-05-04 22:10:49 -07:00
1 parent 2417ba0354
commit d951a8df5a
11 files changed
+698 -39

No files matched your search

+69 -17
View File
@@ -53,6 +53,34 @@ const (
// federationNameRegex validates the Name parameter for GetFederationToken per AWS spec
var federationNameRegex = regexp.MustCompile(`^[\w+=,.@-]+$`)
// roleSessionNameRegex validates RoleSessionName per AWS spec.
// Same character class as federation Name, but the length bounds differ
// (RoleSessionName is 2..64).
var roleSessionNameRegex = regexp.MustCompile(`^[\w+=,.@-]+$`)
const (
minRoleSessionNameLen = 2
maxRoleSessionNameLen = 64
)
// validateRoleSessionName enforces the AWS RoleSessionName contract:
// length 2..64, characters [\w+=,.@-]+. Returns the STS error code and a
// descriptive error suitable for callers to surface to the caller.
func validateRoleSessionName(name string) (STSErrorCode, error) {
if name == "" {
return STSErrMissingParameter, fmt.Errorf("RoleSessionName is required")
}
if len(name) < minRoleSessionNameLen || len(name) > maxRoleSessionNameLen {
return STSErrInvalidParameterValue,
fmt.Errorf("RoleSessionName must be between %d and %d characters", minRoleSessionNameLen, maxRoleSessionNameLen)
}
if !roleSessionNameRegex.MatchString(name) {
return STSErrInvalidParameterValue,
fmt.Errorf(`RoleSessionName contains invalid characters; allowed: [\w+=,.@-]`)
}
return "", nil
}
// STS duration constants (AWS specification)
const (
minDurationSeconds = int64(900) // 15 minutes
@@ -61,6 +89,27 @@ const (
maxFederationDurationSeconds = int64(129600) // 36 hours (GetFederationToken max)
)
// AWS limits inline session policies to 2048 characters for AssumeRole,
// AssumeRoleWithWebIdentity, and AssumeRoleWithSAML. PackedPolicySize is
// returned as a percentage of that budget so callers can detect how close
// they are to the limit.
const sessionPolicyBudgetBytes = 2048
// computePackedPolicySize returns the inline session policy size as a
// percentage of the per-action budget, or nil when no session policy was
// provided. Output is bounded to [0, 100] for AWS-compat reporting; the
// actual policy size validation happens upstream in NormalizeSessionPolicy.
func computePackedPolicySize(policyJSON string) *int64 {
if policyJSON == "" {
return nil
}
pct := int64(len(policyJSON)) * 100 / sessionPolicyBudgetBytes
if pct > 100 {
pct = 100
}
return &pct
}
// 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) {
@@ -170,9 +219,8 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
return
}
if roleSessionName == "" {
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
fmt.Errorf("RoleSessionName is required"))
if errCode, err := validateRoleSessionName(roleSessionName); err != nil {
h.writeSTSErrorResponse(w, r, errCode, err)
return
}
@@ -245,6 +293,7 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
Expiration: response.Credentials.Expiration.Format(time.RFC3339),
},
SubjectFromWebIdentityToken: response.AssumedRoleUser.Subject,
PackedPolicySize: computePackedPolicySize(sessionPolicyJSON),
},
}
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
@@ -264,9 +313,8 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
// 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"))
if errCode, err := validateRoleSessionName(roleSessionName); err != nil {
h.writeSTSErrorResponse(w, r, errCode, err)
return
}
@@ -373,8 +421,9 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
// Build and return response
xmlResponse := &AssumeRoleResponse{
Result: AssumeRoleResult{
Credentials: stsCreds,
AssumedRoleUser: assumedUser,
Credentials: stsCreds,
AssumedRoleUser: assumedUser,
PackedPolicySize: computePackedPolicySize(sessionPolicyJSON),
},
}
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
@@ -397,9 +446,8 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r
return
}
if roleSessionName == "" {
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
fmt.Errorf("RoleSessionName is required"))
if errCode, err := validateRoleSessionName(roleSessionName); err != nil {
h.writeSTSErrorResponse(w, r, errCode, err)
return
}
@@ -514,8 +562,9 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r
// Build and return response
xmlResponse := &AssumeRoleWithLDAPIdentityResponse{
Result: LDAPIdentityResult{
Credentials: stsCreds,
AssumedRoleUser: assumedUser,
Credentials: stsCreds,
AssumedRoleUser: assumedUser,
PackedPolicySize: computePackedPolicySize(sessionPolicyJSON),
},
}
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
@@ -906,6 +955,7 @@ type WebIdentityResult struct {
Credentials STSCredentials `xml:"Credentials"`
SubjectFromWebIdentityToken string `xml:"SubjectFromWebIdentityToken,omitempty"`
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
PackedPolicySize *int64 `xml:"PackedPolicySize,omitempty"`
}
// STSCredentials represents temporary security credentials
@@ -933,8 +983,9 @@ type AssumeRoleResponse struct {
// AssumeRoleResult contains the result of AssumeRole
type AssumeRoleResult struct {
Credentials STSCredentials `xml:"Credentials"`
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
Credentials STSCredentials `xml:"Credentials"`
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
PackedPolicySize *int64 `xml:"PackedPolicySize,omitempty"`
}
// AssumeRoleWithLDAPIdentityResponse is the response for AssumeRoleWithLDAPIdentity
@@ -948,8 +999,9 @@ type AssumeRoleWithLDAPIdentityResponse struct {
// LDAPIdentityResult contains the result of AssumeRoleWithLDAPIdentity
type LDAPIdentityResult struct {
Credentials STSCredentials `xml:"Credentials"`
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
Credentials STSCredentials `xml:"Credentials"`
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
PackedPolicySize *int64 `xml:"PackedPolicySize,omitempty"`
}
// GetCallerIdentityResponse is the response for GetCallerIdentity
+34
View File
@@ -0,0 +1,34 @@
package s3api
import "testing"
func TestComputePackedPolicySize(t *testing.T) {
cases := []struct {
name string
policyLen int
empty bool
want int64
}{
{"empty -> nil", 0, true, 0},
{"tiny policy -> 0%", 10, false, 0},
{"half budget -> 50%", sessionPolicyBudgetBytes / 2, false, 50},
{"full budget -> 100%", sessionPolicyBudgetBytes, false, 100},
{"oversized -> capped at 100", sessionPolicyBudgetBytes * 3, false, 100},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
policy := repeat('a', tc.policyLen)
got := computePackedPolicySize(policy)
switch {
case tc.empty:
if got != nil {
t.Fatalf("expected nil for empty input, got %d", *got)
}
case got == nil:
t.Fatalf("expected non-nil result, got nil")
case *got != tc.want:
t.Fatalf("got=%d want=%d", *got, tc.want)
}
})
}
}
+47
View File
@@ -0,0 +1,47 @@
package s3api
import "testing"
func TestValidateRoleSessionName(t *testing.T) {
cases := []struct {
name string
input string
wantErr bool
// wantCode is checked only when wantErr is true
wantCode STSErrorCode
}{
{"empty rejected", "", true, STSErrMissingParameter},
{"single char rejected (below min len 2)", "a", true, STSErrInvalidParameterValue},
{"min length 2 accepted", "ab", false, ""},
{"plain ascii accepted", "session-name_1", false, ""},
{"all special chars allowed", "+=,.@-", false, ""},
{"email-style accepted", "alice@example.com", false, ""},
{"max length 64 accepted", string(make([]byte, 64)), true, STSErrInvalidParameterValue}, // zero bytes -> invalid charset
{"max length 64 valid charset accepted", repeat('a', 64), false, ""},
{"length 65 rejected", repeat('a', 65), true, STSErrInvalidParameterValue},
{"space rejected", "alice bob", true, STSErrInvalidParameterValue},
{"slash rejected", "alice/bob", true, STSErrInvalidParameterValue},
{"colon rejected", "alice:bob", true, STSErrInvalidParameterValue},
{"unicode rejected", "alicé", true, STSErrInvalidParameterValue},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, err := validateRoleSessionName(tc.input)
gotErr := err != nil
if gotErr != tc.wantErr {
t.Fatalf("err mismatch: got=%v want=%v (err=%v)", gotErr, tc.wantErr, err)
}
if tc.wantErr && code != tc.wantCode {
t.Fatalf("code mismatch: got=%s want=%s", code, tc.wantCode)
}
})
}
}
func repeat(b byte, n int) string {
out := make([]byte, n)
for i := range out {
out[i] = b
}
return string(out)
}