Files
seaweedfs/weed/iam/oidc/oidc_provider_test.go
T
Chris Lu 6dd6ad512c iam/oidc: drop single-use web-identity tokens and the jti replay cache (#12065)
* 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.
2026-10-25 12:04:59 -07:00

675 lines
20 KiB
Go

package oidc
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestOIDCProviderInitialization tests OIDC provider initialization
func TestOIDCProviderInitialization(t *testing.T) {
tests := []struct {
name string
config *OIDCConfig
wantErr bool
}{
{
name: "valid config",
config: &OIDCConfig{
Issuer: "https://accounts.google.com",
ClientID: "test-client-id",
JWKSUri: "https://www.googleapis.com/oauth2/v3/certs",
},
wantErr: false,
},
{
name: "missing issuer",
config: &OIDCConfig{
ClientID: "test-client-id",
},
wantErr: true,
},
{
name: "missing client id",
config: &OIDCConfig{
Issuer: "https://accounts.google.com",
},
wantErr: true,
},
{
name: "invalid issuer url",
config: &OIDCConfig{
Issuer: "not-a-url",
ClientID: "test-client-id",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider := NewOIDCProvider("test-provider")
err := provider.Initialize(tt.config)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, "test-provider", provider.Name())
}
})
}
}
// TestOIDCProviderJWTValidation tests JWT token validation
func TestOIDCProviderJWTValidation(t *testing.T) {
// Set up test server with JWKS endpoint
privateKey, publicKey := generateTestKeys(t)
jwks := map[string]interface{}{
"keys": []map[string]interface{}{
{
"kty": "RSA",
"kid": "test-key-id",
"use": "sig",
"alg": "RS256",
"n": encodePublicKey(t, publicKey),
"e": "AQAB",
},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/openid_configuration" {
config := map[string]interface{}{
"issuer": "http://" + r.Host,
"jwks_uri": "http://" + r.Host + "/jwks",
}
json.NewEncoder(w).Encode(config)
} else if r.URL.Path == "/jwks" {
json.NewEncoder(w).Encode(jwks)
}
}))
defer server.Close()
provider := NewOIDCProvider("test-oidc")
config := &OIDCConfig{
Issuer: server.URL,
ClientID: "test-client",
JWKSUri: server.URL + "/jwks",
}
err := provider.Initialize(config)
require.NoError(t, err)
t.Run("valid token", func(t *testing.T) {
// Create valid JWT token
token := createTestJWT(t, privateKey, jwt.MapClaims{
"iss": server.URL,
"aud": "test-client",
"sub": "user123",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"email": "user@example.com",
"name": "Test User",
})
claims, err := provider.ValidateToken(context.Background(), token)
require.NoError(t, err)
require.NotNil(t, claims)
assert.Equal(t, "user123", claims.Subject)
assert.Equal(t, server.URL, claims.Issuer)
email, exists := claims.GetClaimString("email")
assert.True(t, exists)
assert.Equal(t, "user@example.com", email)
})
t.Run("valid token with array audience", func(t *testing.T) {
// Create valid JWT token with audience as an array (per RFC 7519)
token := createTestJWT(t, privateKey, jwt.MapClaims{
"iss": server.URL,
"aud": []string{"test-client", "another-client"},
"sub": "user456",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"email": "user2@example.com",
"name": "Test User 2",
})
claims, err := provider.ValidateToken(context.Background(), token)
require.NoError(t, err)
require.NotNil(t, claims)
assert.Equal(t, "user456", claims.Subject)
assert.Equal(t, server.URL, claims.Issuer)
email, exists := claims.GetClaimString("email")
assert.True(t, exists)
assert.Equal(t, "user2@example.com", email)
})
t.Run("expired token", func(t *testing.T) {
// Create expired JWT token
token := createTestJWT(t, privateKey, jwt.MapClaims{
"iss": server.URL,
"aud": "test-client",
"sub": "user123",
"exp": time.Now().Add(-time.Hour).Unix(), // Expired
"iat": time.Now().Add(-time.Hour * 2).Unix(),
})
_, err := provider.ValidateToken(context.Background(), token)
assert.Error(t, err)
assert.Contains(t, err.Error(), "expired")
})
t.Run("missing exp", func(t *testing.T) {
token := createTestJWT(t, privateKey, jwt.MapClaims{
"iss": server.URL,
"aud": "test-client",
"sub": "user123",
"iat": time.Now().Unix(),
})
_, err := provider.ValidateToken(context.Background(), token)
assert.Error(t, err)
})
t.Run("invalid signature", func(t *testing.T) {
// Create token with wrong key
wrongKey, _ := generateTestKeys(t)
token := createTestJWT(t, wrongKey, jwt.MapClaims{
"iss": server.URL,
"aud": "test-client",
"sub": "user123",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
})
_, err := provider.ValidateToken(context.Background(), token)
require.Error(t, err)
assert.ErrorIs(t, err, providers.ErrProviderInvalidToken)
})
}
func TestOIDCProviderJWTValidationECDSA(t *testing.T) {
privateKey, publicKey := generateTestECKeys(t)
x, y := encodeECPublicKey(t, publicKey)
jwks := map[string]interface{}{
"keys": []map[string]interface{}{
{
"kty": "EC",
"kid": "test-ec-key-id",
"use": "sig",
"alg": "ES256",
"crv": "P-256",
"x": x,
"y": y,
},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/openid_configuration" {
config := map[string]interface{}{
"issuer": "http://" + r.Host,
"jwks_uri": "http://" + r.Host + "/jwks",
}
json.NewEncoder(w).Encode(config)
} else if r.URL.Path == "/jwks" {
json.NewEncoder(w).Encode(jwks)
}
}))
defer server.Close()
provider := NewOIDCProvider("test-oidc-ecdsa")
config := &OIDCConfig{
Issuer: server.URL,
ClientID: "test-client",
JWKSUri: server.URL + "/jwks",
}
err := provider.Initialize(config)
require.NoError(t, err)
t.Run("valid token", func(t *testing.T) {
token := createTestECDSAJWT(t, privateKey, jwt.MapClaims{
"iss": server.URL,
"aud": "test-client",
"sub": "user789",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
})
claims, err := provider.ValidateToken(context.Background(), token)
require.NoError(t, err)
require.NotNil(t, claims)
assert.Equal(t, "user789", claims.Subject)
assert.Equal(t, server.URL, claims.Issuer)
})
t.Run("expired token", func(t *testing.T) {
token := createTestECDSAJWT(t, privateKey, jwt.MapClaims{
"iss": server.URL,
"aud": "test-client",
"sub": "user789",
"exp": time.Now().Add(-time.Hour).Unix(),
"iat": time.Now().Add(-time.Hour * 2).Unix(),
})
_, err := provider.ValidateToken(context.Background(), token)
require.Error(t, err)
assert.ErrorIs(t, err, providers.ErrProviderTokenExpired)
})
t.Run("invalid signature", func(t *testing.T) {
wrongKey, _ := generateTestECKeys(t)
token := createTestECDSAJWT(t, wrongKey, jwt.MapClaims{
"iss": server.URL,
"aud": "test-client",
"sub": "user789",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
})
_, err := provider.ValidateToken(context.Background(), token)
require.Error(t, err)
assert.ErrorIs(t, err, providers.ErrProviderInvalidToken)
})
t.Run("invalid issuer", func(t *testing.T) {
token := createTestECDSAJWT(t, privateKey, jwt.MapClaims{
"iss": "http://wrong-issuer",
"aud": "test-client",
"sub": "user789",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
})
_, err := provider.ValidateToken(context.Background(), token)
require.Error(t, err)
assert.ErrorIs(t, err, providers.ErrProviderInvalidIssuer)
})
t.Run("invalid audience", func(t *testing.T) {
token := createTestECDSAJWT(t, privateKey, jwt.MapClaims{
"iss": server.URL,
"aud": "wrong-client",
"sub": "user789",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
})
_, err := provider.ValidateToken(context.Background(), token)
require.Error(t, err)
assert.ErrorIs(t, err, providers.ErrProviderInvalidAudience)
})
}
// TestOIDCProviderAuthentication tests authentication flow
func TestOIDCProviderAuthentication(t *testing.T) {
// Set up test OIDC provider
privateKey, publicKey := generateTestKeys(t)
server := setupOIDCTestServer(t, publicKey)
defer server.Close()
provider := NewOIDCProvider("test-oidc")
config := &OIDCConfig{
Issuer: server.URL,
ClientID: "test-client",
JWKSUri: server.URL + "/jwks",
RoleMapping: &providers.RoleMapping{
Rules: []providers.MappingRule{
{
Claim: "email",
Value: "*@example.com",
Role: "arn:aws:iam::role/UserRole",
},
{
Claim: "groups",
Value: "admins",
Role: "arn:aws:iam::role/AdminRole",
},
},
DefaultRole: "arn:aws:iam::role/GuestRole",
},
}
err := provider.Initialize(config)
require.NoError(t, err)
t.Run("successful authentication", func(t *testing.T) {
token := createTestJWT(t, privateKey, jwt.MapClaims{
"iss": server.URL,
"aud": "test-client",
"sub": "user123",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"email": "user@example.com",
"name": "Test User",
"groups": []string{"users", "developers"},
})
identity, err := provider.Authenticate(context.Background(), token)
require.NoError(t, err)
require.NotNil(t, identity)
assert.Equal(t, "user123", identity.UserID)
assert.Equal(t, "user@example.com", identity.Email)
assert.Equal(t, "Test User", identity.DisplayName)
assert.Equal(t, "test-oidc", identity.Provider)
assert.Contains(t, identity.Groups, "users")
assert.Contains(t, identity.Groups, "developers")
})
t.Run("successful authentication with additional attributes", func(t *testing.T) {
token := createTestJWT(t, privateKey, jwt.MapClaims{
"iss": server.URL,
"aud": "test-client",
"sub": "user123",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"email": "user@example.com",
"name": "Test User",
"groups": []string{"users"},
"preferred_username": "myusername", // Extra claim
"department": "engineering", // Extra claim
"custom_number": 42, // Non-string claim
"custom_avg": 98.6, // Non-string claim
"custom_object": map[string]interface{}{"nested": "value"}, // Nested object claim
})
identity, err := provider.Authenticate(context.Background(), token)
require.NoError(t, err)
require.NotNil(t, identity)
// Check standard fields
assert.Equal(t, "user123", identity.UserID)
// Check attributes
val, exists := identity.Attributes["preferred_username"]
assert.True(t, exists, "preferred_username should be in attributes")
assert.Equal(t, "myusername", val)
val, exists = identity.Attributes["department"]
assert.True(t, exists, "department should be in attributes")
assert.Equal(t, "engineering", val)
// Test non-string claims (should be JSON marshaled)
val, exists = identity.Attributes["custom_number"]
assert.True(t, exists, "custom_number should be in attributes")
assert.Equal(t, "42", val)
val, exists = identity.Attributes["custom_avg"]
assert.True(t, exists, "custom_avg should be in attributes")
assert.Contains(t, val, "98.6") // JSON number formatting might vary
val, exists = identity.Attributes["custom_object"]
assert.True(t, exists, "custom_object should be in attributes")
assert.Contains(t, val, "\"nested\":\"value\"")
// Verify structural JWT claims are excluded from attributes
excludedClaims := []string{"iss", "aud", "exp", "iat"}
for _, claim := range excludedClaims {
_, exists := identity.Attributes[claim]
assert.False(t, exists, "standard claim %s should not be in attributes", claim)
}
})
t.Run("authentication with invalid token", func(t *testing.T) {
_, err := provider.Authenticate(context.Background(), "invalid-token")
assert.Error(t, err)
})
}
// TestOIDCProviderUserInfo tests user info retrieval
func TestOIDCProviderUserInfo(t *testing.T) {
// Set up test server with UserInfo endpoint
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/userinfo" {
// Check for Authorization header
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "unauthorized"}`))
return
}
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
// Return 401 for explicitly invalid tokens
if accessToken == "invalid-token" {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "invalid_token"}`))
return
}
// Mock user info response
userInfo := map[string]interface{}{
"sub": "user123",
"email": "user@example.com",
"name": "Test User",
"groups": []string{"users", "developers"},
}
// Customize response based on token
if strings.Contains(accessToken, "admin") {
userInfo["groups"] = []string{"admins"}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(userInfo)
}
}))
defer server.Close()
provider := NewOIDCProvider("test-oidc")
config := &OIDCConfig{
Issuer: server.URL,
ClientID: "test-client",
UserInfoUri: server.URL + "/userinfo",
}
err := provider.Initialize(config)
require.NoError(t, err)
t.Run("get user info with access token", func(t *testing.T) {
// Test using access token (real UserInfo endpoint call)
identity, err := provider.GetUserInfoWithToken(context.Background(), "valid-access-token")
require.NoError(t, err)
require.NotNil(t, identity)
assert.Equal(t, "user123", identity.UserID)
assert.Equal(t, "user@example.com", identity.Email)
assert.Equal(t, "Test User", identity.DisplayName)
assert.Contains(t, identity.Groups, "users")
assert.Contains(t, identity.Groups, "developers")
assert.Equal(t, "test-oidc", identity.Provider)
})
t.Run("get admin user info", func(t *testing.T) {
// Test admin token response
identity, err := provider.GetUserInfoWithToken(context.Background(), "admin-access-token")
require.NoError(t, err)
require.NotNil(t, identity)
assert.Equal(t, "user123", identity.UserID)
assert.Contains(t, identity.Groups, "admins")
})
t.Run("get user info without token", func(t *testing.T) {
// Test without access token (should fail)
_, err := provider.GetUserInfoWithToken(context.Background(), "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "access token cannot be empty")
})
t.Run("get user info with invalid token", func(t *testing.T) {
// Test with invalid access token (should get 401)
_, err := provider.GetUserInfoWithToken(context.Background(), "invalid-token")
assert.Error(t, err)
assert.Contains(t, err.Error(), "UserInfo endpoint returned status 401")
})
t.Run("get user info with custom claims mapping", func(t *testing.T) {
// Create provider with custom claims mapping
customProvider := NewOIDCProvider("test-custom-oidc")
customConfig := &OIDCConfig{
Issuer: server.URL,
ClientID: "test-client",
UserInfoUri: server.URL + "/userinfo",
ClaimsMapping: map[string]string{
"customEmail": "email",
"customName": "name",
},
}
err := customProvider.Initialize(customConfig)
require.NoError(t, err)
identity, err := customProvider.GetUserInfoWithToken(context.Background(), "valid-access-token")
require.NoError(t, err)
require.NotNil(t, identity)
// Standard claims should still work
assert.Equal(t, "user123", identity.UserID)
assert.Equal(t, "user@example.com", identity.Email)
assert.Equal(t, "Test User", identity.DisplayName)
})
t.Run("get user info with empty id", func(t *testing.T) {
_, err := provider.GetUserInfo(context.Background(), "")
assert.Error(t, err)
})
}
// Helper functions for testing
func generateTestKeys(t *testing.T) (*rsa.PrivateKey, *rsa.PublicKey) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
return privateKey, &privateKey.PublicKey
}
func generateTestECKeys(t *testing.T) (*ecdsa.PrivateKey, *ecdsa.PublicKey) {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
return privateKey, &privateKey.PublicKey
}
func createTestJWT(t *testing.T, privateKey *rsa.PrivateKey, claims jwt.MapClaims) string {
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = "test-key-id"
tokenString, err := token.SignedString(privateKey)
require.NoError(t, err)
return tokenString
}
func createTestECDSAJWT(t *testing.T, privateKey *ecdsa.PrivateKey, claims jwt.MapClaims) string {
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = "test-ec-key-id"
tokenString, err := token.SignedString(privateKey)
require.NoError(t, err)
return tokenString
}
func encodePublicKey(t *testing.T, publicKey *rsa.PublicKey) string {
// Properly encode the RSA modulus (N) as base64url
return base64.RawURLEncoding.EncodeToString(publicKey.N.Bytes())
}
func encodeECPublicKey(t *testing.T, publicKey *ecdsa.PublicKey) (string, string) {
// RFC 7518 §6.2.1.2 requires EC coordinates to be zero-padded to the full field size
curveParams := publicKey.Curve.Params()
size := (curveParams.BitSize + 7) / 8
xBytes := publicKey.X.Bytes()
yBytes := publicKey.Y.Bytes()
xPadded := make([]byte, size)
yPadded := make([]byte, size)
// Right-align the coordinate bytes and leave leading zeros for padding
copy(xPadded[size-len(xBytes):], xBytes)
copy(yPadded[size-len(yBytes):], yBytes)
return base64.RawURLEncoding.EncodeToString(xPadded),
base64.RawURLEncoding.EncodeToString(yPadded)
}
func setupOIDCTestServer(t *testing.T, publicKey *rsa.PublicKey) *httptest.Server {
jwks := map[string]interface{}{
"keys": []map[string]interface{}{
{
"kty": "RSA",
"kid": "test-key-id",
"use": "sig",
"alg": "RS256",
"n": encodePublicKey(t, publicKey),
"e": "AQAB",
},
},
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid_configuration":
config := map[string]interface{}{
"issuer": "http://" + r.Host,
"jwks_uri": "http://" + r.Host + "/jwks",
"userinfo_endpoint": "http://" + r.Host + "/userinfo",
}
json.NewEncoder(w).Encode(config)
case "/jwks":
json.NewEncoder(w).Encode(jwks)
case "/userinfo":
// Mock UserInfo endpoint
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "unauthorized"}`))
return
}
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
// Return 401 for explicitly invalid tokens
if accessToken == "invalid-token" {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "invalid_token"}`))
return
}
// Mock user info response based on access token
userInfo := map[string]interface{}{
"sub": "user123",
"email": "user@example.com",
"name": "Test User",
"groups": []string{"users", "developers"},
}
// Customize response based on token
if strings.Contains(accessToken, "admin") {
userInfo["groups"] = []string{"admins"}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(userInfo)
default:
http.NotFound(w, r)
}
}))
}