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.
This commit is contained in:
Chris Lu committed 2026-05-04 22:06:19 -07:00
1 parent 6aa353716a
commit f9dfc0ea37
11 files changed
+596 -21

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)
}