s3: cover three untested STS paths (#10521)

* s3: test the GetCallerIdentity handler

The handler had no test, only XML marshalling, so nothing pinned that a
caller presenting session credentials is reported as the assumed role
rather than the user who minted the session.

* s3: drive AssumeRoleWithWebIdentity over HTTP with a real OIDC token

Coverage reached the OIDC path either at the IAMManager service layer or
through the Authorization: Bearer shortcut. Nothing exercised the public
STS entry point an AWS SDK actually calls, which is where parameter
parsing, the IAMManager dispatch and the XML response shape live.

* s3: test that every STS route emits an audit entry

STS responses go out through WriteXMLResponse, which never calls PostLog,
so track() is the only thing that logs them. A route registered outside it
would mint credentials with no audit trail and nothing would notice. STS
has three routing layers, so a new action is easy to attach to the wrong
one.

* s3: make the STS tests assert what they claim to cover

The audit routing test ran against an uninitialized STS service, so every
case answered 503 and a non-404 status was the only evidence the request
had reached STS at all - the POST-body case could have been served by the
dispatcher's IAM branch and still passed. Back it with a real STS service
and assert the STS response namespace, which IAM and S3 responses do not
carry.

The session policy case checked that the policy travelled in the token
rather than that it restricted anything; assert the narrowed bucket is
allowed and another bucket is refused.

Give the forged-token case the same claim set a valid token gets, so it
cannot pass for want of a claim, and cover both rejection paths: a key we
do not publish, and a key id absent from the JWKS.
This commit is contained in:
Chris Lu
2026-07-31 23:56:04 -07:00
committed by GitHub
parent ef6a706c0e
commit c2701955c7
3 changed files with 604 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
package s3api
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stsXMLNamespace appears on every STS response, success or error. IAM and S3
// responses carry a different namespace, so this is what separates "the request
// reached the STS handler" from "it reached some other handler that also
// happened to answer".
const stsXMLNamespace = "https://sts.amazonaws.com/doc/2011-06-15/"
// setupAuditRoutingTestServer builds a server whose STS handler is backed by a
// real STS service, so the routes under test actually execute instead of
// short-circuiting on an uninitialized service.
func setupAuditRoutingTestServer(t *testing.T) *S3ApiServer {
t.Helper()
ctx := context.Background()
manager := newTestSTSIntegrationManager(t)
require.NoError(t, manager.CreatePolicy(ctx, "", "AuditPolicy", &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{
Effect: "Allow",
Action: []string{"s3:GetObject"},
Resource: []string{"arn:aws:s3:::*/*"},
}},
}))
require.NoError(t, manager.CreateRole(ctx, "", "AuditRole", &integration.RoleDefinition{
RoleName: "AuditRole",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{Effect: "Allow", Principal: "*", Action: []string{"sts:AssumeRole"}}},
},
AttachedPolicies: []string{"AuditPolicy"},
}))
opt := &S3ApiServerOption{EnableIam: true}
iam := NewIdentityAccessManagementWithStore(opt, nil, "memory")
iam.isAuthEnabled = true
iam.iamIntegration = NewS3IAMIntegration(manager, "")
if iam.credentialManager == nil {
cm, err := credential.NewCredentialManager("memory", util.GetViper(), "")
require.NoError(t, err)
iam.credentialManager = cm
}
// Mirror the production wiring in s3api_server.go: without a user store the
// manager cannot resolve caller policies and GetFederationToken fails closed.
manager.SetUserStore(iam.credentialManager)
testIdent := &Identity{
Name: routingTestUser,
Actions: []Action{s3_constants.ACTION_ADMIN},
IsStatic: true,
Credentials: []*Credential{{
AccessKey: routingTestAccessKey,
SecretKey: routingTestSecretKey,
}},
}
iam.m.Lock()
if iam.accessKeyIdent == nil {
iam.accessKeyIdent = make(map[string]*Identity)
}
if iam.nameToIdentity == nil {
iam.nameToIdentity = make(map[string]*Identity)
}
iam.identities = append(iam.identities, testIdent)
iam.accessKeyIdent[routingTestAccessKey] = testIdent
iam.nameToIdentity[routingTestUser] = testIdent
iam.m.Unlock()
return &S3ApiServer{
option: opt,
iam: iam,
credentialManager: iam.credentialManager,
embeddedIam: NewEmbeddedIamApi(iam.credentialManager, iam, false),
stsHandlers: NewSTSHandlers(manager.GetSTSService(), iam),
}
}
// Every STS route must be wrapped by track(), which is the only thing that
// emits an audit entry for these handlers: the STS responses go out through
// WriteXMLResponse, which never calls PostLog itself. A route registered
// outside track() would therefore mint credentials with no audit trail at all,
// and nothing else in the suite would notice. STS has three routing layers
// (explicit query-param routes, the authenticated POST dispatcher, and the
// anonymous fallback), so a new action is easy to attach to the wrong one.
func TestSTSRoutesEmitAuditEntries(t *testing.T) {
router := mux.NewRouter()
s3a := setupAuditRoutingTestServer(t)
s3a.registerRouter(router)
cases := []struct {
name string
action string
params url.Values
signed bool
inBody bool
wantStatus int
}{
{name: "AssumeRole", action: "AssumeRole", signed: true, wantStatus: http.StatusOK, params: url.Values{
"RoleArn": {"arn:aws:iam::" + defaultAccountID + ":role/AuditRole"},
"RoleSessionName": {"audit-session"},
}},
{name: "GetCallerIdentity", action: "GetCallerIdentity", signed: true, wantStatus: http.StatusOK},
{name: "GetFederationToken", action: "GetFederationToken", signed: true, wantStatus: http.StatusOK, params: url.Values{
"Name": {"audit-user"},
}},
// The anonymous actions still route to STS; they fail on the token or the
// missing provider, which is itself STS-handler behaviour we want to have
// reached rather than a 404 from somewhere else.
{name: "AssumeRoleWithWebIdentity", action: "AssumeRoleWithWebIdentity", params: url.Values{
"WebIdentityToken": {"not-a-real-token"},
"RoleArn": {"arn:aws:iam::" + defaultAccountID + ":role/AuditRole"},
"RoleSessionName": {"audit-session"},
}},
{name: "AssumeRoleWithLDAPIdentity", action: "AssumeRoleWithLDAPIdentity", params: url.Values{
"RoleArn": {"arn:aws:iam::" + defaultAccountID + ":role/AuditRole"},
"RoleSessionName": {"audit-session"},
"LDAPUsername": {"audit-user"},
"LDAPPassword": {"audit-password"},
}},
// The authenticated POST dispatcher is a separate routing layer from the
// explicit query-param routes above, and it can also hand a request to
// its IAM branch - the STS namespace is what proves it did not.
{name: "AssumeRole via POST body", action: "AssumeRole", signed: true, inBody: true, wantStatus: http.StatusOK, params: url.Values{
"RoleArn": {"arn:aws:iam::" + defaultAccountID + ":role/AuditRole"},
"RoleSessionName": {"audit-session"},
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
form := url.Values{"Action": {tc.action}, "Version": {"2011-06-15"}}
for k, vs := range tc.params {
form[k] = vs
}
var req *http.Request
var body string
if tc.inBody {
body = form.Encode()
req = httptest.NewRequest(http.MethodPost, "http://localhost/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
req = httptest.NewRequest(http.MethodPost, "http://localhost/?"+form.Encode(), nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if tc.signed {
signRoutingTestRequest(t, req, body, "sts")
}
// track() installs both of these itself, but on a request copy this
// test would never see. Installing them up front shares the
// underlying pointers so the middleware's writes stay observable.
req = s3err.EnsureAuditTracking(req)
req = s3_constants.EnsureIdentityHolder(req)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
assert.Contains(t, rr.Body.String(), stsXMLNamespace,
"request did not reach the STS handler: status=%d body=%s", rr.Code, rr.Body.String())
if tc.wantStatus != 0 {
assert.Equal(t, tc.wantStatus, rr.Code, rr.Body.String())
}
assert.True(t, s3err.AuditAlreadyLogged(req),
"STS route emitted no audit entry - is the route wrapped in track()? status=%d body=%s",
rr.Code, rr.Body.String())
})
}
}
@@ -0,0 +1,134 @@
package s3api
import (
"context"
"encoding/xml"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// GetCallerIdentity is what an AWS SDK calls first to discover who it is, but
// the handler had no test at all - only XML marshalling. Nothing pinned that a
// caller presenting session credentials is reported as the assumed role rather
// than the user who minted the session, which is the answer operators rely on
// to tell two sessions of the same role apart.
func TestGetCallerIdentityHandler(t *testing.T) {
ctx := context.Background()
manager := newTestSTSIntegrationManager(t)
require.NoError(t, manager.CreatePolicy(ctx, "", "CallerPolicy", &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{
Effect: "Allow",
Action: []string{"s3:*"},
Resource: []string{"arn:aws:s3:::*", "arn:aws:s3:::*/*"},
}},
}))
require.NoError(t, manager.CreateRole(ctx, "", "CallerRole", &integration.RoleDefinition{
RoleName: "CallerRole",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{Effect: "Allow", Principal: "*", Action: []string{"sts:AssumeRole"}}},
},
AttachedPolicies: []string{"CallerPolicy"},
}))
const accessKey, secretKey = "callerkey", "callersecret"
iam := &IdentityAccessManagement{iamIntegration: NewS3IAMIntegration(manager, "")}
require.NoError(t, iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{
Name: "alice",
Credentials: []*iam_pb.Credential{{AccessKey: accessKey, SecretKey: secretKey}},
Actions: []string{"Admin"},
}},
}))
handlers := NewSTSHandlers(manager.GetSTSService(), iam)
// newSignedSTSRequest builds a form-encoded STS POST and signs it with the
// given credentials, optionally carrying a session token.
newSignedSTSRequest := func(t *testing.T, form url.Values, ak, sk, sessionToken string) *http.Request {
t.Helper()
body := form.Encode()
req, err := newTestRequest(http.MethodPost, "http://sts.seaweedfs.test/", int64(len(body)), strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if sessionToken != "" {
req.Header.Set("X-Amz-Security-Token", sessionToken)
}
require.NoError(t, signRequestV4(req, ak, sk))
return req
}
callerIdentityForm := url.Values{
"Action": {"GetCallerIdentity"},
"Version": {"2011-06-15"},
}
t.Run("static credentials report the user ARN", func(t *testing.T) {
req := newSignedSTSRequest(t, callerIdentityForm, accessKey, secretKey, "")
rec := httptest.NewRecorder()
handlers.HandleSTSRequest(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var resp GetCallerIdentityResponse
require.NoError(t, xml.Unmarshal(rec.Body.Bytes(), &resp))
assert.Equal(t, "arn:aws:iam::"+defaultAccountID+":user/alice", resp.Result.Arn)
assert.Equal(t, "alice", resp.Result.UserId)
assert.Equal(t, defaultAccountID, resp.Result.Account)
})
t.Run("session credentials report the assumed role, not the minting user", func(t *testing.T) {
// Mint a session as alice, then ask who we are with the session creds.
assumeReq := newSignedSTSRequest(t, url.Values{
"Action": {"AssumeRole"},
"Version": {"2011-06-15"},
"RoleArn": {"arn:aws:iam::" + defaultAccountID + ":role/CallerRole"},
"RoleSessionName": {"caller-session"},
}, accessKey, secretKey, "")
assumeRec := httptest.NewRecorder()
handlers.HandleSTSRequest(assumeRec, assumeReq)
require.Equal(t, http.StatusOK, assumeRec.Code, assumeRec.Body.String())
var assumed AssumeRoleResponse
require.NoError(t, xml.Unmarshal(assumeRec.Body.Bytes(), &assumed))
creds := assumed.Result.Credentials
require.NotEmpty(t, creds.SessionToken)
req := newSignedSTSRequest(t, callerIdentityForm,
creds.AccessKeyId, creds.SecretAccessKey, creds.SessionToken)
rec := httptest.NewRecorder()
handlers.HandleSTSRequest(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var resp GetCallerIdentityResponse
require.NoError(t, xml.Unmarshal(rec.Body.Bytes(), &resp))
assert.Equal(t, "arn:aws:sts::"+defaultAccountID+":assumed-role/CallerRole/caller-session", resp.Result.Arn,
"session credentials must identify the assumed role and session, not alice")
assert.NotContains(t, resp.Result.Arn, "alice")
})
t.Run("bad signature is denied", func(t *testing.T) {
req := newSignedSTSRequest(t, callerIdentityForm, accessKey, "wrong-secret", "")
rec := httptest.NewRecorder()
handlers.HandleSTSRequest(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
})
t.Run("unsigned request is denied", func(t *testing.T) {
body := callerIdentityForm.Encode()
req := httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handlers.HandleSTSRequest(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
})
}
@@ -0,0 +1,282 @@
package s3api
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"encoding/xml"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"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)
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(),
})
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")
})
}
}