Files
seaweedfs/weed/s3api/s3api_sts_web_identity_http_test.go
T
Chris LuandDevin b65832bdc9 iam/oidc: reject reused jti claims with a bounded replay cache (#11936)
* iam/oidc: reject reused jti claims with a bounded replay cache

* iam/sts: pass jti replay settings through convertToOIDCConfig

* iam/oidc: keep consumed jtis and stop the cleanup worker across re-init

Re-initializing a provider reset the JTI store, so an already-used token
could be presented again after a config refresh; the store is now
allocated once and survives Initialize. The cleanup worker is stopped on
every re-init (previously only when protection stayed enabled), is
started only after the rest of Initialize has succeeded, and Shutdown no
longer consumes a permanent sync.Once, so a re-initialized worker can be
stopped again.

* iam/oidc: enforce the jti replay cache bound for every token

Tokens without a usable jti claim skipped the cache entirely and stayed
reusable; they are now keyed by the token's own fingerprint so a repeat
presentation is still a replay. Tokens without exp previously failed as
expired on first use; their entry now gets a bounded retention. The
replay test-and-set now runs before any capacity work so replayed tokens
cannot each trigger a full-cache scan, the entry count is incremented as
part of the insert so concurrent validations cannot overshoot the bound,
and a guaranteed serialized sweep runs before a capacity rejection while
opportunistic sweeps are rate limited and confined to one at a time. Raw
token identifiers are no longer written to logs or errors, a stable
fingerprint is logged instead.

* iam/sts: shut down providers dropped by the managed-provider swap

Each managed OIDC provider runs a background JTI cleanup worker, but
SetIAMManagedOIDCProviders replaced the provider map without stopping
the old providers, so every refresh leaked goroutines. Providers absent
from the new map are now shut down; providers that stay keep running.

* iam/integration: reuse providers across refreshes for unchanged records

Every refresh rebuilt providers from their records, so a refreshed
provider forgot the JTIs it had already consumed and an already-used
token could be presented again. Records are now fingerprinted and an
unchanged record reuses its provider, keeping the consumed-token cache;
changed records are rebuilt, removed records are dropped, and the swap
in STS releases the workers of whatever is no longer referenced.

* iam/oidc: consume replay entries only on the single-use exchange path

Bearer authentication validates the same token on every request, so
consuming it in shared ValidateToken/Authenticate made every token
single-use and rejected all but the first request. The replay check now
runs only for the STS exchange via AuthenticateSingleUse; bearer paths
validate without consuming. A token without exp also no longer gets a
bounded replay entry that ages out while the token stays valid: its
entry is kept until the capacity bound evicts it, so it can never become
usable again.

* iam/oidc: keep the mock provider on the mock path for single-use calls

* iam/oidc: require exp on single-use tokens

A cache slot frees only by expiry, so an expiry-less entry would hold it
forever; a burst of distinct tokens could exhaust the bound permanently
and block every later exchange.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3: give each web-identity test token a unique jti

Subtests minted tokens with identical claims inside one second, so the
exchange saw the same token fingerprint twice and the second call read
as a replay. Each exchange now carries its own jti.

* iam/oidc: carry the consumed-token replay store across provider rebuilds

A record change rebuilds the managed provider with a fresh JTI store, so a token consumed before the rebuild could be presented again until it expired. The new provider adopts the replaced provider's replay store before it is published, and the replaced provider is shut down so only one cleanup worker sweeps the shared store.

* iam/oidc: stop the cleanup worker of providers dropped from the store

A refresh removed the provider object from the managed cache but left its JTI cleanup goroutine running for the life of the process.

* iam/oidc: merge a provider's consumed replay entries into another's store

MergeReplayStateFrom folds one provider's consumed-token entries into
another's, skipping already-expired ones. Unlike AdoptReplayStateFrom it
swaps no pointers, so it is safe on a provider still serving validators.

* iam/integration: share one provider and replay store across identical issuer records

Records for the same issuer URL with identical validation settings now
reuse a single provider, so a token consumed through one account's
record is rejected through the other's — previously each record owned a
separate jti store and a spent token could be replayed cross-account.

A superseded provider's consumed entries merge into the shared store so
no rejection is lost, and a removed or rebuilt provider is only shut
down once no remaining record references it.

* iam/oidc: hand the superseded provider's live replay store to the shared one

* iam/oidc: carry merged replay entries without charging the capacity bound

* iam/oidc: key the jti-less replay fingerprint on the signed input

Hashing the whole token lets ECDSA signature malleability (s -> N-s)
mint a second cache key for identical claims, so a consumed token could
be replayed for another exchange. Key the fallback on header.payload
instead.

* iam/oidc: publish the jti cache bound before the enabled flag

Initialize stored jtiEnabled ahead of jtiMaxSize, so a provider flipped
from disabled to enabled exposed enabled=true with a zero bound for a
window; a single-use validation landing there would compute
newCount > maxSize and reject with "JTI cache capacity exceeded". Store
the bound first so the flag is the last thing published.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 00:46:14 -07:00

290 lines
11 KiB
Go

package s3api
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// AssumeRoleWithWebIdentity is the public, unauthenticated STS entry point, but
// every existing test reaches the OIDC path either at the IAMManager service
// layer or through the Authorization: Bearer shortcut. Nothing drove a real
// signed OIDC token through HandleSTSRequest, which is what an AWS SDK actually
// does - and that is the path carrying parameter parsing, the IAMManager
// dispatch, and the XML response shape.
func TestAssumeRoleWithWebIdentityOverHTTP(t *testing.T) {
ctx := context.Background()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
jwks := map[string]interface{}{
"keys": []map[string]interface{}{{
"kty": "RSA",
"kid": "web-identity-test-key",
"use": "sig",
"alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
"e": "AQAB",
}},
}
idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/jwks" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(jwks)
}))
defer idp.Close()
provider := oidc.NewOIDCProvider("test-oidc")
require.NoError(t, provider.Initialize(&oidc.OIDCConfig{
Issuer: idp.URL,
ClientID: "test-client",
JWKSUri: idp.URL + "/jwks",
}))
manager := newTestSTSIntegrationManager(t)
require.NoError(t, manager.RegisterIdentityProvider(provider))
require.NoError(t, manager.CreatePolicy(ctx, "", "WebIdentityPolicy", &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{
Effect: "Allow",
Action: []string{"s3:GetObject"},
Resource: []string{"arn:aws:s3:::*/*"},
}},
}))
trustPolicy := func(federatedProvider string) *policy.PolicyDocument {
return &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{
Effect: "Allow",
Principal: map[string]interface{}{"Federated": federatedProvider},
Action: []string{"sts:AssumeRoleWithWebIdentity"},
}},
}
}
require.NoError(t, manager.CreateRole(ctx, "", "WebIdentityRole", &integration.RoleDefinition{
RoleName: "WebIdentityRole",
TrustPolicy: trustPolicy("test-oidc"),
AttachedPolicies: []string{"WebIdentityPolicy"},
}))
require.NoError(t, manager.CreateRole(ctx, "", "WebIdentityDeniedRole", &integration.RoleDefinition{
RoleName: "WebIdentityDeniedRole",
TrustPolicy: trustPolicy("some-other-provider"),
AttachedPolicies: []string{"WebIdentityPolicy"},
}))
iam := &IdentityAccessManagement{iamIntegration: NewS3IAMIntegration(manager, "")}
handlers := NewSTSHandlers(manager.GetSTSService(), iam)
var jtiCounter atomic.Int64
signOIDCToken := func(t *testing.T, subject string) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": idp.URL,
"sub": subject,
"aud": "test-client",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
// a unique jti per token: the exchange consumes it, so two
// subtests minting the same claims in one second would otherwise
// collide on the token fingerprint and read as a replay
"jti": fmt.Sprintf("%s-%d", subject, jtiCounter.Add(1)),
})
token.Header["kid"] = "web-identity-test-key"
signed, err := token.SignedString(key)
require.NoError(t, err)
return signed
}
const trustedRoleArn = "arn:aws:iam::" + defaultAccountID + ":role/WebIdentityRole"
// The SDK may put the parameters in the query string or the form body; both
// reach HandleSTSRequest and must behave identically.
for _, encoding := range []string{"query", "body"} {
t.Run("succeeds with parameters in the "+encoding, func(t *testing.T) {
form := url.Values{
"Action": {"AssumeRoleWithWebIdentity"},
"Version": {"2011-06-15"},
"RoleArn": {trustedRoleArn},
"RoleSessionName": {"web-session"},
"WebIdentityToken": {signOIDCToken(t, "oidc-user-1")},
}
var req *http.Request
if encoding == "query" {
req = httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/?"+form.Encode(), nil)
} else {
req = httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
rec := httptest.NewRecorder()
handlers.HandleSTSRequest(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var resp AssumeRoleWithWebIdentityResponse
require.NoError(t, xml.Unmarshal(rec.Body.Bytes(), &resp))
assert.Equal(t, "oidc-user-1", resp.Result.SubjectFromWebIdentityToken)
assert.True(t, strings.HasPrefix(resp.Result.Credentials.AccessKeyId, "ASIA"),
"temporary credentials must use the ASIA prefix")
require.NotEmpty(t, resp.Result.Credentials.SessionToken)
assert.Nil(t, resp.Result.PackedPolicySize, "no inline session policy was sent")
// The minted session must resolve to the assumed-role principal.
// Unlike AssumeRole, this path does not embed the role's attached
// policies in the token - they are resolved from the role at request
// time - so assert the effective permission rather than the claim.
info, err := manager.GetSTSService().ValidateSessionToken(ctx, resp.Result.Credentials.SessionToken)
require.NoError(t, err)
assert.Contains(t, info.Principal, "assumed-role/WebIdentityRole/web-session")
allowed, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
Principal: info.Principal,
Action: "s3:GetObject",
Resource: "arn:aws:s3:::bucket/key",
SessionToken: resp.Result.Credentials.SessionToken,
})
require.NoError(t, err)
assert.True(t, allowed, "the role grants s3:GetObject")
denied, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
Principal: info.Principal,
Action: "s3:PutObject",
Resource: "arn:aws:s3:::bucket/key",
SessionToken: resp.Result.Credentials.SessionToken,
})
require.NoError(t, err)
assert.False(t, denied, "the role does not grant s3:PutObject")
})
}
t.Run("inline session policy is reported in PackedPolicySize", func(t *testing.T) {
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"]}]}`
form := url.Values{
"Action": {"AssumeRoleWithWebIdentity"},
"Version": {"2011-06-15"},
"RoleArn": {trustedRoleArn},
"RoleSessionName": {"web-session-scoped"},
"WebIdentityToken": {signOIDCToken(t, "oidc-user-2")},
"Policy": {sessionPolicy},
}
req := httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handlers.HandleSTSRequest(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var resp AssumeRoleWithWebIdentityResponse
require.NoError(t, xml.Unmarshal(rec.Body.Bytes(), &resp))
require.NotNil(t, resp.Result.PackedPolicySize)
assert.Greater(t, *resp.Result.PackedPolicySize, int64(0))
info, err := manager.GetSTSService().ValidateSessionToken(ctx, resp.Result.Credentials.SessionToken)
require.NoError(t, err)
require.NotEmpty(t, info.SessionPolicy, "the inline policy must travel in the session token")
// Carrying the policy is not the point - restricting the session is. The
// role allows s3:GetObject on any bucket; the session policy narrows that
// to one bucket, so the same action on another bucket must be refused.
allowed, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
Principal: info.Principal,
Action: "s3:GetObject",
Resource: "arn:aws:s3:::bucket/key",
SessionToken: resp.Result.Credentials.SessionToken,
})
require.NoError(t, err)
assert.True(t, allowed, "the session policy allows this bucket")
denied, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
Principal: info.Principal,
Action: "s3:GetObject",
Resource: "arn:aws:s3:::other-bucket/key",
SessionToken: resp.Result.Credentials.SessionToken,
})
require.NoError(t, err)
assert.False(t, denied, "the session policy must scope the session down to one bucket")
})
t.Run("role whose trust policy rejects the provider is denied", func(t *testing.T) {
form := url.Values{
"Action": {"AssumeRoleWithWebIdentity"},
"Version": {"2011-06-15"},
"RoleArn": {"arn:aws:iam::" + defaultAccountID + ":role/WebIdentityDeniedRole"},
"RoleSessionName": {"web-session"},
"WebIdentityToken": {signOIDCToken(t, "oidc-user-1")},
}
req := httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handlers.HandleSTSRequest(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String())
})
// Both rejection paths matter and they are not the same code: one fails
// signature verification against a key we do publish, the other never finds
// a key to verify against. The claim set is otherwise identical to a token
// signOIDCToken would mint, so neither can pass or fail for want of a claim.
forgedTokenCases := []struct {
name string
kid string
}{
{name: "token signed by a key we do not publish is rejected", kid: "web-identity-test-key"},
{name: "token naming an unknown key id is rejected", kid: "not-in-the-jwks"},
}
for _, tc := range forgedTokenCases {
t.Run(tc.name, func(t *testing.T) {
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": idp.URL,
"sub": "forged-user",
"aud": "test-client",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
})
token.Header["kid"] = tc.kid
forged, err := token.SignedString(otherKey)
require.NoError(t, err)
form := url.Values{
"Action": {"AssumeRoleWithWebIdentity"},
"Version": {"2011-06-15"},
"RoleArn": {trustedRoleArn},
"RoleSessionName": {"web-session"},
"WebIdentityToken": {forged},
}
req := httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handlers.HandleSTSRequest(rec, req)
assert.NotEqual(t, http.StatusOK, rec.Code, "a forged token must not mint a session")
})
}
}