diff --git a/weed/s3api/s3api_sts_audit_routing_test.go b/weed/s3api/s3api_sts_audit_routing_test.go new file mode 100644 index 000000000..a0e7f2f37 --- /dev/null +++ b/weed/s3api/s3api_sts_audit_routing_test.go @@ -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()) + }) + } +} diff --git a/weed/s3api/s3api_sts_get_caller_identity_handler_test.go b/weed/s3api/s3api_sts_get_caller_identity_handler_test.go new file mode 100644 index 000000000..7ce40fd91 --- /dev/null +++ b/weed/s3api/s3api_sts_get_caller_identity_handler_test.go @@ -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) + }) +} diff --git a/weed/s3api/s3api_sts_web_identity_http_test.go b/weed/s3api/s3api_sts_web_identity_http_test.go new file mode 100644 index 000000000..1c045261c --- /dev/null +++ b/weed/s3api/s3api_sts_web_identity_http_test.go @@ -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") + }) + } +}