mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-26 23:10:44 +01:00
* iam/oidc: drop single-use web-identity tokens and the jti replay cache Reverts the single-use semantics added in #11936 (and the #12017 refactor): AssumeRoleWithWebIdentity consumed each web-identity token's jti into a bounded replay cache, so a second exchange of the same token was rejected until exp + skew. AWS STS accepts a web-identity token repeatedly until it expires, and Kubernetes workload identity depends on that: the kubelet keeps a projected ServiceAccount token until ~80% of its lifetime, so pod restarts, co-located containers sharing the token file, repeated CLI invocations, and pre-rotation SDK refreshes all re-present the same jti and were refused on 4.50. The replay machinery also carried a real maintenance cost — a bounded concurrent cache, fingerprint-keyed entries for jti-less tokens, adopt/merge handoffs so provider rebuilds could not reopen consumed tokens, and provider-reuse bookkeeping in the refresh path that existed only to preserve that state. Removing it restores AWS semantics and deletes the whole apparatus. Refs #12063. Alternative to the per-provider opt-out in #12064. * iam: reuse unchanged OIDC providers on refresh refreshOIDCProvidersFrom rebuilt every managed provider on any store change, so an unrelated write discarded each provider's JWKS cache and an unreachable issuer could then fail exchanges the cached keys would have allowed. Keep a per-record provider cache keyed on the record content fingerprint and only rebuild when the record changes. * iam/oidc: require exp in ValidateToken Dropping the single-use check also removed the implicit bound on expiry-less tokens: a signed web-identity token without exp could mint STS sessions indefinitely. Require the exp claim, matching AWS AssumeRoleWithWebIdentity which rejects tokens without one. * iam/oidc: bound idle connections on the provider transport A provider replaced on refresh drops its http.Transport without a close; give it an IdleConnTimeout so pooled connections and their goroutines cannot linger. * s3api: cover web-identity token reuse over HTTP Sign one token and exchange that exact string twice, directly covering the Kubernetes projected-token reuse this change restores. * test(iam): refresh reuses an unchanged record's provider Covers the provider-reuse cache: an unrelated record's refresh keeps a provider's JWKS state, a changed record rebuilds it.
302 lines
11 KiB
Go
302 lines
11 KiB
Go
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")
|
|
})
|
|
}
|
|
|
|
// Kubernetes workload identity re-presents the same projected token until
|
|
// ~80% of its lifetime, so one token must exchange more than once.
|
|
t.Run("the same token exchanges twice", func(t *testing.T) {
|
|
form := url.Values{
|
|
"Action": {"AssumeRoleWithWebIdentity"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {trustedRoleArn},
|
|
"RoleSessionName": {"web-session-reuse"},
|
|
"WebIdentityToken": {signOIDCToken(t, "oidc-user-1")},
|
|
}
|
|
for attempt := 1; attempt <= 2; attempt++ {
|
|
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, "attempt %d: %s", attempt, rec.Body.String())
|
|
}
|
|
})
|
|
|
|
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")
|
|
})
|
|
}
|
|
}
|