snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
This commit is contained in:
23
weed/iam/sts/claim_based_policy_test.go
Normal file
23
weed/iam/sts/claim_based_policy_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package sts
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsClaimBasedPolicyRoleArn(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
arn string
|
||||
want bool
|
||||
}{
|
||||
{"empty matches", "", true},
|
||||
{"sentinel matches", ClaimBasedPolicyRoleArn, true},
|
||||
{"concrete role does not", "arn:aws:iam::123:role/admin", false},
|
||||
{"random ARN does not", "arn:aws:sts::123:assumed-role/X/Y", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsClaimBasedPolicyRoleArn(tc.arn); got != tc.want {
|
||||
t.Fatalf("got=%v want=%v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
166
weed/iam/sts/constants.go
Normal file
166
weed/iam/sts/constants.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Store Types
|
||||
const (
|
||||
StoreTypeMemory = "memory"
|
||||
StoreTypeFiler = "filer"
|
||||
StoreTypeRedis = "redis"
|
||||
)
|
||||
|
||||
// Provider Types
|
||||
const (
|
||||
ProviderTypeOIDC = "oidc"
|
||||
ProviderTypeLDAP = "ldap"
|
||||
ProviderTypeSAML = "saml"
|
||||
)
|
||||
|
||||
// Policy Effects
|
||||
const (
|
||||
EffectAllow = "Allow"
|
||||
EffectDeny = "Deny"
|
||||
)
|
||||
|
||||
// Default Paths - aligned with filer /etc/ convention
|
||||
const (
|
||||
DefaultSessionBasePath = "/etc/iam/sessions"
|
||||
DefaultPolicyBasePath = "/etc/iam/policies"
|
||||
DefaultRoleBasePath = "/etc/iam/roles"
|
||||
)
|
||||
|
||||
// Default Values
|
||||
const (
|
||||
DefaultTokenDuration = 3600 // 1 hour in seconds
|
||||
DefaultMaxSessionLength = 43200 // 12 hours in seconds
|
||||
DefaultIssuer = "seaweedfs-sts"
|
||||
DefaultStoreType = StoreTypeFiler // Default store type for persistence
|
||||
MinSigningKeyLength = 16 // Minimum signing key length in bytes
|
||||
)
|
||||
|
||||
// Configuration Field Names
|
||||
const (
|
||||
ConfigFieldFilerAddress = "filerAddress"
|
||||
ConfigFieldBasePath = "basePath"
|
||||
ConfigFieldIssuer = "issuer"
|
||||
ConfigFieldClientID = "clientId"
|
||||
ConfigFieldClientIDs = "clientIds"
|
||||
ConfigFieldThumbprints = "thumbprints"
|
||||
ConfigFieldAllowedPrincipalTagKeys = "allowedPrincipalTagKeys"
|
||||
ConfigFieldPolicyClaim = "policyClaim"
|
||||
ConfigFieldClientSecret = "clientSecret"
|
||||
ConfigFieldJWKSUri = "jwksUri"
|
||||
ConfigFieldScopes = "scopes"
|
||||
ConfigFieldUserInfoUri = "userInfoUri"
|
||||
ConfigFieldRedirectUri = "redirectUri"
|
||||
ConfigFieldTLSCACert = "tlsCaCert"
|
||||
ConfigFieldTLSInsecureSkipVerify = "tlsInsecureSkipVerify"
|
||||
)
|
||||
|
||||
// Error Messages
|
||||
const (
|
||||
ErrConfigCannotBeNil = "config cannot be nil"
|
||||
ErrProviderCannotBeNil = "provider cannot be nil"
|
||||
ErrProviderNameEmpty = "provider name cannot be empty"
|
||||
ErrProviderTypeEmpty = "provider type cannot be empty"
|
||||
ErrTokenCannotBeEmpty = "token cannot be empty"
|
||||
ErrSessionTokenCannotBeEmpty = "session token cannot be empty"
|
||||
ErrSessionIDCannotBeEmpty = "session ID cannot be empty"
|
||||
ErrSTSServiceNotInitialized = "STS service not initialized"
|
||||
ErrProviderNotInitialized = "provider not initialized"
|
||||
ErrInvalidTokenDuration = "token duration must be positive"
|
||||
ErrInvalidMaxSessionLength = "max session length must be positive"
|
||||
ErrIssuerRequired = "issuer is required"
|
||||
ErrSigningKeyTooShort = "signing key must be at least %d bytes"
|
||||
ErrFilerAddressRequired = "filer address is required"
|
||||
ErrClientIDRequired = "clientId is required for OIDC provider"
|
||||
ErrUnsupportedStoreType = "unsupported store type: %s"
|
||||
ErrUnsupportedProviderType = "unsupported provider type: %s"
|
||||
ErrInvalidTokenFormat = "invalid session token format: %w"
|
||||
ErrSessionValidationFailed = "session validation failed: %w"
|
||||
ErrInvalidToken = "invalid token: %w"
|
||||
ErrTokenNotValid = "token is not valid"
|
||||
ErrInvalidTokenClaims = "invalid token claims"
|
||||
ErrInvalidIssuer = "invalid issuer"
|
||||
ErrMissingSessionID = "missing session ID"
|
||||
)
|
||||
|
||||
// Typed errors for robust error checking with errors.Is()
|
||||
// These enable the HTTP layer to use errors.Is() instead of fragile string matching
|
||||
var (
|
||||
// ErrTokenExpired indicates that the provided token has expired
|
||||
ErrTypedTokenExpired = errors.New("token has expired")
|
||||
|
||||
// ErrTypedInvalidToken indicates that the token format is invalid or malformed
|
||||
ErrTypedInvalidToken = errors.New("invalid token format")
|
||||
|
||||
// ErrTypedInvalidIssuer indicates that the token issuer is not trusted
|
||||
ErrTypedInvalidIssuer = errors.New("invalid token issuer")
|
||||
|
||||
// ErrTypedInvalidAudience indicates that the token audience doesn't match expected value
|
||||
ErrTypedInvalidAudience = errors.New("invalid token audience")
|
||||
|
||||
// ErrTypedMissingClaims indicates that required claims are missing from the token
|
||||
ErrTypedMissingClaims = errors.New("missing required claims")
|
||||
)
|
||||
|
||||
// JWT Claims
|
||||
const (
|
||||
JWTClaimIssuer = "iss"
|
||||
JWTClaimSubject = "sub"
|
||||
JWTClaimAudience = "aud"
|
||||
JWTClaimExpiration = "exp"
|
||||
JWTClaimIssuedAt = "iat"
|
||||
JWTClaimTokenType = "token_type"
|
||||
)
|
||||
|
||||
// Token Types
|
||||
const (
|
||||
TokenTypeSession = "session"
|
||||
TokenTypeAccess = "access"
|
||||
TokenTypeRefresh = "refresh"
|
||||
)
|
||||
|
||||
// AWS STS Actions
|
||||
const (
|
||||
ActionAssumeRole = "sts:AssumeRole"
|
||||
ActionAssumeRoleWithWebIdentity = "sts:AssumeRoleWithWebIdentity"
|
||||
ActionAssumeRoleWithCredentials = "sts:AssumeRoleWithCredentials"
|
||||
ActionGetFederationToken = "sts:GetFederationToken"
|
||||
ActionValidateSession = "sts:ValidateSession"
|
||||
)
|
||||
|
||||
// Session File Prefixes
|
||||
const (
|
||||
SessionFilePrefix = "session_"
|
||||
SessionFileExt = ".json"
|
||||
PolicyFilePrefix = "policy_"
|
||||
PolicyFileExt = ".json"
|
||||
RoleFileExt = ".json"
|
||||
)
|
||||
|
||||
// HTTP Headers
|
||||
const (
|
||||
HeaderAuthorization = "Authorization"
|
||||
HeaderContentType = "Content-Type"
|
||||
HeaderUserAgent = "User-Agent"
|
||||
)
|
||||
|
||||
// Content Types
|
||||
const (
|
||||
ContentTypeJSON = "application/json"
|
||||
ContentTypeFormURLEncoded = "application/x-www-form-urlencoded"
|
||||
)
|
||||
|
||||
// Default Test Values
|
||||
const (
|
||||
TestSigningKey32Chars = "test-signing-key-32-characters-long"
|
||||
TestIssuer = "test-sts"
|
||||
TestClientID = "test-client"
|
||||
TestSessionID = "test-session-123"
|
||||
TestValidToken = "valid_test_token"
|
||||
TestInvalidToken = "invalid_token"
|
||||
TestExpiredToken = "expired_token"
|
||||
)
|
||||
100
weed/iam/sts/credential_prefix_test.go
Normal file
100
weed/iam/sts/credential_prefix_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestTemporaryCredentialPrefix verifies that temporary credentials use ASIA prefix
|
||||
// (not AKIA which is for permanent IAM user credentials)
|
||||
func TestTemporaryCredentialPrefix(t *testing.T) {
|
||||
sessionId := "test-session-for-prefix"
|
||||
expiration := time.Now().Add(time.Hour)
|
||||
|
||||
credGen := NewCredentialGenerator()
|
||||
cred, err := credGen.GenerateTemporaryCredentials(sessionId, expiration)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cred)
|
||||
|
||||
// Verify ASIA prefix for temporary credentials
|
||||
assert.True(t, strings.HasPrefix(cred.AccessKeyId, "ASIA"),
|
||||
"Temporary credentials must use ASIA prefix, got: %s", cred.AccessKeyId)
|
||||
|
||||
// Verify it's NOT using AKIA (permanent credentials)
|
||||
assert.False(t, strings.HasPrefix(cred.AccessKeyId, "AKIA"),
|
||||
"Temporary credentials must NOT use AKIA prefix (that's for permanent IAM keys)")
|
||||
}
|
||||
|
||||
// TestTemporaryCredentialFormat verifies the full format of temporary credentials
|
||||
func TestTemporaryCredentialFormat(t *testing.T) {
|
||||
sessionId := "format-test-session"
|
||||
expiration := time.Now().Add(time.Hour)
|
||||
|
||||
credGen := NewCredentialGenerator()
|
||||
cred, err := credGen.GenerateTemporaryCredentials(sessionId, expiration)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cred)
|
||||
|
||||
// AWS temporary access key format: ASIA + 16 hex characters = 20 chars total
|
||||
assert.Equal(t, 20, len(cred.AccessKeyId),
|
||||
"Access key ID should be 20 characters (ASIA + 16 hex chars)")
|
||||
|
||||
// Verify it starts with ASIA
|
||||
assert.True(t, strings.HasPrefix(cred.AccessKeyId, "ASIA"),
|
||||
"Access key must start with ASIA prefix")
|
||||
|
||||
// Verify the rest is hex (after ASIA prefix)
|
||||
hexPart := cred.AccessKeyId[4:]
|
||||
assert.Equal(t, 16, len(hexPart), "Hex part should be 16 characters")
|
||||
_, err = hex.DecodeString(hexPart)
|
||||
assert.NoError(t, err, "The part after ASIA prefix should be valid hex")
|
||||
|
||||
// Verify secret key is not empty and is a valid base64-encoded SHA256 hash
|
||||
assert.NotEmpty(t, cred.SecretAccessKey)
|
||||
assert.Equal(t, 44, len(cred.SecretAccessKey),
|
||||
"SecretAccessKey should be 44 characters for a base64-encoded 32-byte hash")
|
||||
_, err = base64.StdEncoding.DecodeString(cred.SecretAccessKey)
|
||||
assert.NoError(t, err, "SecretAccessKey should be a valid base64 string")
|
||||
|
||||
// Verify session token is not empty
|
||||
assert.NotEmpty(t, cred.SessionToken)
|
||||
}
|
||||
|
||||
// A duration that came from configuration is bounded the same as one from a
|
||||
// request: the catalog passes -s3.iceberg.credentialDurationSeconds straight
|
||||
// through.
|
||||
func TestAssumeRoleForPrincipalValidatesDuration(t *testing.T) {
|
||||
svc := NewSTSService()
|
||||
err := svc.Initialize(&STSConfig{
|
||||
TokenDuration: FlexibleDuration{time.Hour},
|
||||
MaxSessionLength: FlexibleDuration{12 * time.Hour},
|
||||
Issuer: "test-issuer",
|
||||
SigningKey: []byte("test-signing-key-at-least-32-bytes-long"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() error = %v", err)
|
||||
}
|
||||
|
||||
for _, seconds := range []int64{60, 100000} {
|
||||
duration := seconds
|
||||
_, err := svc.AssumeRoleForPrincipal(context.Background(), &AssumeRoleForPrincipalRequest{
|
||||
RoleArn: "arn:aws:iam::role/IcebergTableAccess",
|
||||
Principal: "admin",
|
||||
DurationSeconds: &duration,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("AssumeRoleForPrincipal(%ds) error = nil, want a validation error", seconds)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "DurationSeconds") {
|
||||
t.Errorf("AssumeRoleForPrincipal(%ds) error = %v, want it to name DurationSeconds", seconds, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
105
weed/iam/sts/oidc_provider_lookup_test.go
Normal file
105
weed/iam/sts/oidc_provider_lookup_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
)
|
||||
|
||||
// stubIdentityProvider is the minimal IdentityProvider needed to drive
|
||||
// lookupOIDCProviderForAccount; the lookup never calls Authenticate.
|
||||
type stubIdentityProvider struct{ name string }
|
||||
|
||||
func (s *stubIdentityProvider) Name() string { return s.name }
|
||||
func (s *stubIdentityProvider) Initialize(interface{}) error { return nil }
|
||||
func (s *stubIdentityProvider) Authenticate(context.Context, string) (*providers.ExternalIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubIdentityProvider) GetUserInfo(context.Context, string) (*providers.ExternalIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubIdentityProvider) ValidateToken(context.Context, string) (*providers.TokenClaims, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestLookupOIDCProviderForAccountPrefersAccountMatch(t *testing.T) {
|
||||
const issuer = "https://example.com"
|
||||
accountA := &stubIdentityProvider{name: "A"}
|
||||
accountB := &stubIdentityProvider{name: "B"}
|
||||
|
||||
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{}}
|
||||
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
|
||||
issuer: {
|
||||
{AccountID: "111111111111", Provider: accountA},
|
||||
{AccountID: "222222222222", Provider: accountB},
|
||||
},
|
||||
})
|
||||
|
||||
got, ok := s.lookupOIDCProviderForAccount(issuer, "222222222222")
|
||||
if !ok || got != accountB {
|
||||
t.Fatalf("expected accountB provider for matching account, got %v ok=%v", got, ok)
|
||||
}
|
||||
got, ok = s.lookupOIDCProviderForAccount(issuer, "111111111111")
|
||||
if !ok || got != accountA {
|
||||
t.Fatalf("expected accountA provider for matching account, got %v ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupOIDCProviderForAccountFallsBackToGlobal(t *testing.T) {
|
||||
const issuer = "https://example.com"
|
||||
global := &stubIdentityProvider{name: "global"}
|
||||
accountA := &stubIdentityProvider{name: "A"}
|
||||
|
||||
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{}}
|
||||
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
|
||||
issuer: {
|
||||
{AccountID: "", Provider: global},
|
||||
{AccountID: "111111111111", Provider: accountA},
|
||||
},
|
||||
})
|
||||
|
||||
// Account not represented in records → global match.
|
||||
got, ok := s.lookupOIDCProviderForAccount(issuer, "999999999999")
|
||||
if !ok || got != global {
|
||||
t.Fatalf("expected global provider as fallback, got %v ok=%v", got, ok)
|
||||
}
|
||||
// Empty account hint → never picks an account-scoped record arbitrarily;
|
||||
// only the global record is eligible.
|
||||
got, ok = s.lookupOIDCProviderForAccount(issuer, "")
|
||||
if !ok || got != global {
|
||||
t.Fatalf("expected global provider when account unknown, got %v ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupOIDCProviderForAccountSkipsAccountSpecificWhenAccountUnknown(t *testing.T) {
|
||||
const issuer = "https://example.com"
|
||||
accountA := &stubIdentityProvider{name: "A"}
|
||||
|
||||
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{}}
|
||||
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
|
||||
issuer: {
|
||||
{AccountID: "111111111111", Provider: accountA},
|
||||
},
|
||||
})
|
||||
|
||||
// No account hint AND no global record → must fall through (return false),
|
||||
// not silently pick the account-A entry. Picking an arbitrary entry is the
|
||||
// pre-fix bug that lets a token be validated by the wrong tenant's record.
|
||||
if _, ok := s.lookupOIDCProviderForAccount(issuer, ""); ok {
|
||||
t.Fatalf("expected no match when account unknown and only account-scoped records exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupOIDCProviderForAccountFallsBackToStatic(t *testing.T) {
|
||||
const issuer = "https://example.com"
|
||||
static := &stubIdentityProvider{name: "static"}
|
||||
|
||||
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{issuer: static}}
|
||||
s.SetIAMManagedOIDCProviders(nil)
|
||||
|
||||
got, ok := s.lookupOIDCProviderForAccount(issuer, "111111111111")
|
||||
if !ok || got != static {
|
||||
t.Fatalf("expected static-config provider as last resort, got %v ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
132
weed/iam/sts/parent_user_test.go
Normal file
132
weed/iam/sts/parent_user_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestComputeParentUserStability(t *testing.T) {
|
||||
// Same (sub, iss) must produce the same hash, regardless of order or
|
||||
// whitespace mutations callers should never apply.
|
||||
a := ComputeParentUser("alice", "https://idp.example/")
|
||||
b := ComputeParentUser("alice", "https://idp.example/")
|
||||
if a == "" {
|
||||
t.Fatal("parent user should not be empty for non-empty sub")
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("expected stable hash, got %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserDistinguishesIssuer(t *testing.T) {
|
||||
// The point of incorporating iss is that the same `sub` from two providers
|
||||
// must not collide. If this assertion ever fails, the hash input is wrong.
|
||||
a := ComputeParentUser("alice", "https://idp-a.example/")
|
||||
b := ComputeParentUser("alice", "https://idp-b.example/")
|
||||
if a == b {
|
||||
t.Fatalf("hashes for different issuers must differ, both = %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserDistinguishesSubject(t *testing.T) {
|
||||
a := ComputeParentUser("alice", "https://idp.example/")
|
||||
b := ComputeParentUser("bob", "https://idp.example/")
|
||||
if a == b {
|
||||
t.Fatalf("hashes for different subjects must differ, both = %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserEmptySub(t *testing.T) {
|
||||
if got := ComputeParentUser("", "https://idp.example/"); got != "" {
|
||||
t.Fatalf("empty sub should produce empty parent user, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserEmptyIss(t *testing.T) {
|
||||
// Without an issuer, two different IDPs that both name a user "alice"
|
||||
// would collide on the same parent_user. Refuse rather than hash.
|
||||
if got := ComputeParentUser("alice", ""); got != "" {
|
||||
t.Fatalf("empty iss should produce empty parent user, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserEncoding(t *testing.T) {
|
||||
got := ComputeParentUser("alice", "https://idp.example/")
|
||||
// Base64 RawURL has no padding and uses URL-safe alphabet — important
|
||||
// because parent_user shows up in filer paths and audit log fields.
|
||||
if strings.ContainsAny(got, "=+/") {
|
||||
t.Fatalf("parent user should be base64 raw url, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionClaimsRoundTripParentUser(t *testing.T) {
|
||||
parent := ComputeParentUser("alice", "https://idp.example/")
|
||||
claims := NewSTSSessionClaims("sid-1", "issuer", time.Now().Add(time.Hour)).
|
||||
WithRoleInfo("arn:aws:iam::123:role/r", "arn:aws:sts::123:assumed-role/r/s", "arn:aws:sts::123:assumed-role/r/s").
|
||||
WithParentUser(parent)
|
||||
|
||||
info := claims.ToSessionInfo()
|
||||
if info.ParentUser != parent {
|
||||
t.Fatalf("ParentUser lost on round-trip: got %q want %q", info.ParentUser, parent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityClaimPrefersPreferredUsername(t *testing.T) {
|
||||
ctx := map[string]interface{}{
|
||||
"preferred_username": "grant.west",
|
||||
"email": "grant.west@example.com",
|
||||
"sub": "alice-sub",
|
||||
}
|
||||
if got := ResolveIdentityClaim(ctx); got != "grant.west" {
|
||||
t.Fatalf("expected preferred_username, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityClaimFallsBackThroughPriority(t *testing.T) {
|
||||
if got := ResolveIdentityClaim(map[string]interface{}{"email": "g@example.com", "sub": "s"}); got != "g@example.com" {
|
||||
t.Fatalf("expected email fallback, got %q", got)
|
||||
}
|
||||
if got := ResolveIdentityClaim(map[string]interface{}{"sub": "s"}); got != "s" {
|
||||
t.Fatalf("expected sub fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityClaimEmpty(t *testing.T) {
|
||||
if got := ResolveIdentityClaim(nil); got != "" {
|
||||
t.Fatalf("expected empty for nil ctx, got %q", got)
|
||||
}
|
||||
if got := ResolveIdentityClaim(map[string]interface{}{}); got != "" {
|
||||
t.Fatalf("expected empty for empty ctx, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityClaimSkipsNonString(t *testing.T) {
|
||||
ctx := map[string]interface{}{
|
||||
"preferred_username": []string{"no"},
|
||||
"sub": "real-sub",
|
||||
}
|
||||
if got := ResolveIdentityClaim(ctx); got != "real-sub" {
|
||||
t.Fatalf("expected sub after skipping non-string claim, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityClaimTrimsWhitespace(t *testing.T) {
|
||||
ctx := map[string]interface{}{
|
||||
"preferred_username": " grant.west ",
|
||||
"email": "grant.west@example.com",
|
||||
}
|
||||
if got := ResolveIdentityClaim(ctx); got != "grant.west" {
|
||||
t.Fatalf("expected trimmed preferred_username, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityClaimSkipsWhitespaceOnly(t *testing.T) {
|
||||
ctx := map[string]interface{}{
|
||||
"preferred_username": " ",
|
||||
"email": "grant.west@example.com",
|
||||
}
|
||||
if got := ResolveIdentityClaim(ctx); got != "grant.west@example.com" {
|
||||
t.Fatalf("expected email after skipping whitespace-only preferred_username, got %q", got)
|
||||
}
|
||||
}
|
||||
303
weed/iam/sts/provider_factory.go
Normal file
303
weed/iam/sts/provider_factory.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/ldap"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
)
|
||||
|
||||
// ProviderFactory creates identity providers from configuration
|
||||
type ProviderFactory struct{}
|
||||
|
||||
// NewProviderFactory creates a new provider factory
|
||||
func NewProviderFactory() *ProviderFactory {
|
||||
return &ProviderFactory{}
|
||||
}
|
||||
|
||||
// CreateProvider creates an identity provider from configuration
|
||||
func (f *ProviderFactory) CreateProvider(config *ProviderConfig) (providers.IdentityProvider, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf(ErrConfigCannotBeNil)
|
||||
}
|
||||
|
||||
if config.Name == "" {
|
||||
return nil, fmt.Errorf(ErrProviderNameEmpty)
|
||||
}
|
||||
|
||||
if config.Type == "" {
|
||||
return nil, fmt.Errorf(ErrProviderTypeEmpty)
|
||||
}
|
||||
|
||||
if !config.Enabled {
|
||||
glog.V(2).Infof("Provider %s is disabled, skipping", config.Name)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Creating provider: name=%s, type=%s", config.Name, config.Type)
|
||||
|
||||
switch config.Type {
|
||||
case ProviderTypeOIDC:
|
||||
return f.createOIDCProvider(config)
|
||||
case ProviderTypeLDAP:
|
||||
return f.createLDAPProvider(config)
|
||||
case ProviderTypeSAML:
|
||||
return f.createSAMLProvider(config)
|
||||
default:
|
||||
return nil, fmt.Errorf(ErrUnsupportedProviderType, config.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// createOIDCProvider creates an OIDC provider from configuration
|
||||
func (f *ProviderFactory) createOIDCProvider(config *ProviderConfig) (providers.IdentityProvider, error) {
|
||||
oidcConfig, err := f.convertToOIDCConfig(config.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert OIDC config: %w", err)
|
||||
}
|
||||
|
||||
provider := oidc.NewOIDCProvider(config.Name)
|
||||
if err := provider.Initialize(oidcConfig); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err)
|
||||
}
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// createLDAPProvider creates an LDAP provider from configuration
|
||||
func (f *ProviderFactory) createLDAPProvider(config *ProviderConfig) (providers.IdentityProvider, error) {
|
||||
provider := ldap.NewLDAPProvider(config.Name)
|
||||
if err := provider.Initialize(config.Config); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize LDAP provider: %w", err)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// createSAMLProvider creates a SAML provider from configuration
|
||||
func (f *ProviderFactory) createSAMLProvider(config *ProviderConfig) (providers.IdentityProvider, error) {
|
||||
// TODO: Implement SAML provider when available
|
||||
return nil, fmt.Errorf("SAML provider not implemented yet")
|
||||
}
|
||||
|
||||
// convertToOIDCConfig converts generic config map to OIDC config struct
|
||||
func (f *ProviderFactory) convertToOIDCConfig(configMap map[string]interface{}) (*oidc.OIDCConfig, error) {
|
||||
config := &oidc.OIDCConfig{}
|
||||
|
||||
// Required fields
|
||||
if issuer, ok := configMap[ConfigFieldIssuer].(string); ok {
|
||||
config.Issuer = issuer
|
||||
} else {
|
||||
return nil, fmt.Errorf(ErrIssuerRequired)
|
||||
}
|
||||
|
||||
clientIDPresent := false
|
||||
if clientID, ok := configMap[ConfigFieldClientID].(string); ok && clientID != "" {
|
||||
config.ClientID = clientID
|
||||
clientIDPresent = true
|
||||
}
|
||||
// Accept the AWS-compatible plural form. Either field satisfies the
|
||||
// "at least one client id" requirement; both can be set together.
|
||||
if rawList, ok := configMap[ConfigFieldClientIDs]; ok {
|
||||
if list, err := f.convertToStringSlice(rawList); err == nil && len(list) > 0 {
|
||||
config.ClientIDs = list
|
||||
clientIDPresent = true
|
||||
}
|
||||
}
|
||||
if !clientIDPresent {
|
||||
return nil, fmt.Errorf(ErrClientIDRequired)
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
if clientSecret, ok := configMap[ConfigFieldClientSecret].(string); ok {
|
||||
config.ClientSecret = clientSecret
|
||||
}
|
||||
|
||||
if jwksUri, ok := configMap[ConfigFieldJWKSUri].(string); ok {
|
||||
config.JWKSUri = jwksUri
|
||||
}
|
||||
|
||||
if userInfoUri, ok := configMap[ConfigFieldUserInfoUri].(string); ok {
|
||||
config.UserInfoUri = userInfoUri
|
||||
}
|
||||
|
||||
// Convert scopes array
|
||||
if scopesInterface, ok := configMap[ConfigFieldScopes]; ok {
|
||||
scopes, err := f.convertToStringSlice(scopesInterface)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert scopes: %w", err)
|
||||
}
|
||||
config.Scopes = scopes
|
||||
}
|
||||
|
||||
if tlsCaCert, ok := configMap[ConfigFieldTLSCACert].(string); ok {
|
||||
config.TLSCACert = tlsCaCert
|
||||
}
|
||||
|
||||
if rawThumbprints, ok := configMap[ConfigFieldThumbprints]; ok {
|
||||
if list, err := f.convertToStringSlice(rawThumbprints); err == nil {
|
||||
config.Thumbprints = list
|
||||
}
|
||||
}
|
||||
|
||||
if rawAllowed, ok := configMap[ConfigFieldAllowedPrincipalTagKeys]; ok {
|
||||
if list, err := f.convertToStringSlice(rawAllowed); err == nil {
|
||||
config.AllowedPrincipalTagKeys = list
|
||||
}
|
||||
}
|
||||
|
||||
if policyClaim, ok := configMap[ConfigFieldPolicyClaim].(string); ok {
|
||||
config.PolicyClaim = policyClaim
|
||||
}
|
||||
|
||||
if tlsInsecureSkipVerify, ok := configMap[ConfigFieldTLSInsecureSkipVerify].(bool); ok {
|
||||
config.TLSInsecureSkipVerify = tlsInsecureSkipVerify
|
||||
}
|
||||
|
||||
// Convert claims mapping
|
||||
if claimsMapInterface, ok := configMap["claimsMapping"]; ok {
|
||||
claimsMap, err := f.convertToStringMap(claimsMapInterface)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert claimsMapping: %w", err)
|
||||
}
|
||||
config.ClaimsMapping = claimsMap
|
||||
}
|
||||
|
||||
// Convert role mapping
|
||||
if roleMappingInterface, ok := configMap["roleMapping"]; ok {
|
||||
roleMapping, err := f.convertToRoleMapping(roleMappingInterface)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert roleMapping: %w", err)
|
||||
}
|
||||
config.RoleMapping = roleMapping
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Converted OIDC config: issuer=%s, clientId=%s, jwksUri=%s",
|
||||
config.Issuer, config.ClientID, config.JWKSUri)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// convertToStringSlice converts interface{} to []string
|
||||
func (f *ProviderFactory) convertToStringSlice(value interface{}) ([]string, error) {
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
return v, nil
|
||||
case []interface{}:
|
||||
result := make([]string, len(v))
|
||||
for i, item := range v {
|
||||
if str, ok := item.(string); ok {
|
||||
result[i] = str
|
||||
} else {
|
||||
return nil, fmt.Errorf("non-string item in slice: %v", item)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("cannot convert %T to []string", value)
|
||||
}
|
||||
}
|
||||
|
||||
// convertToStringMap converts interface{} to map[string]string
|
||||
func (f *ProviderFactory) convertToStringMap(value interface{}) (map[string]string, error) {
|
||||
switch v := value.(type) {
|
||||
case map[string]string:
|
||||
return v, nil
|
||||
case map[string]interface{}:
|
||||
result := make(map[string]string)
|
||||
for key, val := range v {
|
||||
if str, ok := val.(string); ok {
|
||||
result[key] = str
|
||||
} else {
|
||||
return nil, fmt.Errorf("non-string value for key %s: %v", key, val)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("cannot convert %T to map[string]string", value)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadProvidersFromConfig creates providers from configuration
|
||||
func (f *ProviderFactory) LoadProvidersFromConfig(configs []*ProviderConfig) (map[string]providers.IdentityProvider, error) {
|
||||
providersMap := make(map[string]providers.IdentityProvider)
|
||||
|
||||
for _, config := range configs {
|
||||
if config == nil {
|
||||
glog.V(1).Infof("Skipping nil provider config")
|
||||
continue
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Loading provider: %s (type: %s, enabled: %t)",
|
||||
config.Name, config.Type, config.Enabled)
|
||||
|
||||
if !config.Enabled {
|
||||
glog.V(2).Infof("Provider %s is disabled, skipping", config.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
provider, err := f.CreateProvider(config)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to create provider %s: %v", config.Name, err)
|
||||
return nil, fmt.Errorf("failed to create provider %s: %w", config.Name, err)
|
||||
}
|
||||
|
||||
if provider != nil {
|
||||
providersMap[config.Name] = provider
|
||||
glog.V(1).Infof("Successfully loaded provider: %s", config.Name)
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(1).Infof("Loaded %d identity providers from configuration", len(providersMap))
|
||||
return providersMap, nil
|
||||
}
|
||||
|
||||
// convertToRoleMapping converts interface{} to *providers.RoleMapping
|
||||
func (f *ProviderFactory) convertToRoleMapping(value interface{}) (*providers.RoleMapping, error) {
|
||||
roleMappingMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("roleMapping must be an object")
|
||||
}
|
||||
|
||||
roleMapping := &providers.RoleMapping{}
|
||||
|
||||
// Convert rules
|
||||
if rulesInterface, ok := roleMappingMap["rules"]; ok {
|
||||
rulesSlice, ok := rulesInterface.([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rules must be an array")
|
||||
}
|
||||
|
||||
rules := make([]providers.MappingRule, len(rulesSlice))
|
||||
for i, ruleInterface := range rulesSlice {
|
||||
ruleMap, ok := ruleInterface.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rule must be an object")
|
||||
}
|
||||
|
||||
rule := providers.MappingRule{}
|
||||
if claim, ok := ruleMap["claim"].(string); ok {
|
||||
rule.Claim = claim
|
||||
}
|
||||
if value, ok := ruleMap["value"].(string); ok {
|
||||
rule.Value = value
|
||||
}
|
||||
if role, ok := ruleMap["role"].(string); ok {
|
||||
rule.Role = role
|
||||
}
|
||||
if condition, ok := ruleMap["condition"].(string); ok {
|
||||
rule.Condition = condition
|
||||
}
|
||||
|
||||
rules[i] = rule
|
||||
}
|
||||
roleMapping.Rules = rules
|
||||
}
|
||||
|
||||
// Convert default role
|
||||
if defaultRole, ok := roleMappingMap["defaultRole"].(string); ok {
|
||||
roleMapping.DefaultRole = defaultRole
|
||||
}
|
||||
|
||||
return roleMapping, nil
|
||||
}
|
||||
248
weed/iam/sts/session_claims.go
Normal file
248
weed/iam/sts/session_claims.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
)
|
||||
|
||||
// ComputeParentUser returns a stable per-identity hash derived from the OIDC
|
||||
// (sub, iss) tuple. Only the (sub, iss) pair is guaranteed stable across token
|
||||
// refreshes per OpenID Connect Core 1.0 §5.7, so any per-user state (audit
|
||||
// logs, quotas) must key off this value rather than the access-key or session
|
||||
// id. The hash is base64-rawurl-encoded SHA-256 over "openid:<sub>:<iss>" so
|
||||
// it stays filesystem-safe and bounded in length for storage in audit paths.
|
||||
func ComputeParentUser(sub, iss string) string {
|
||||
if sub == "" || iss == "" {
|
||||
return ""
|
||||
}
|
||||
h := sha256.Sum256([]byte("openid:" + sub + ":" + iss))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// identityClaimPriority is the order in which ResolveIdentityClaim looks for a
|
||||
// human-readable, authoritative identity attribute in an STS request context.
|
||||
// The context is populated at federation time from the validated OIDC token
|
||||
// (see AssumeRoleWithWebIdentity), so every entry here is server-asserted and
|
||||
// not client-supplied. preferred_username/email/name are conventional OIDC
|
||||
// user claims; sub is the always-present stable subject identifier and the
|
||||
// final fallback so a federated session never audits as fully anonymous.
|
||||
var identityClaimPriority = []string{"preferred_username", "email", "name", "sub"}
|
||||
|
||||
// ResolveIdentityClaim returns the most human-readable authoritative identity
|
||||
// claim available in ctx, or "" when none is present. ctx is the STS request
|
||||
// context (sessionInfo.RequestContext) populated from the validated OIDC token
|
||||
// at federation time. Non-string values are skipped so a structured claim
|
||||
// never leaks into an audit-facing field. Whitespace-only values are treated
|
||||
// as absent so a blank preferred claim does not mask a usable email or sub.
|
||||
func ResolveIdentityClaim(ctx map[string]interface{}) string {
|
||||
if len(ctx) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range identityClaimPriority {
|
||||
if v, ok := ctx[key].(string); ok {
|
||||
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// defaultCredentialGenerator is a reusable instance for generating temporary credentials
|
||||
// Reusing a single instance across all calls to ToSessionInfo() reduces allocation overhead
|
||||
// since this method may be called frequently during signature verification
|
||||
var defaultCredentialGenerator = NewCredentialGenerator()
|
||||
|
||||
// STSSessionClaims represents comprehensive session information embedded in JWT tokens
|
||||
// This eliminates the need for separate session storage by embedding all session
|
||||
// metadata directly in the token itself - enabling true stateless operation
|
||||
type STSSessionClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
|
||||
// Session identification
|
||||
SessionId string `json:"sid"` // session_id (abbreviated for smaller tokens)
|
||||
SessionName string `json:"snam"` // session_name (abbreviated for smaller tokens)
|
||||
TokenType string `json:"typ"` // token_type
|
||||
|
||||
// Role information
|
||||
RoleArn string `json:"role"` // role_arn
|
||||
AssumedRole string `json:"assumed"` // assumed_role_user
|
||||
Principal string `json:"principal"` // principal_arn
|
||||
|
||||
// Authorization data
|
||||
Policies []string `json:"pol,omitempty"` // policies (abbreviated)
|
||||
// SessionPolicy contains inline session policy JSON (optional)
|
||||
SessionPolicy string `json:"spol,omitempty"`
|
||||
|
||||
// Identity provider information
|
||||
IdentityProvider string `json:"idp"` // identity_provider
|
||||
ExternalUserId string `json:"ext_uid"` // external_user_id
|
||||
ProviderIssuer string `json:"prov_iss"` // provider_issuer
|
||||
|
||||
// Request context (optional, for policy evaluation)
|
||||
RequestContext map[string]interface{} `json:"req_ctx,omitempty"`
|
||||
|
||||
// Session metadata
|
||||
AssumedAt time.Time `json:"assumed_at"` // when role was assumed
|
||||
MaxDuration int64 `json:"max_dur,omitempty"` // maximum session duration in seconds
|
||||
|
||||
// ParentUser is a stable hash of (sub, iss) for tokens minted from an OIDC
|
||||
// identity. It survives token rotation since only the (sub, iss) tuple is
|
||||
// guaranteed stable per OpenID Connect Core 1.0. Empty for non-federated
|
||||
// session types.
|
||||
ParentUser string `json:"puid,omitempty"`
|
||||
}
|
||||
|
||||
// NewSTSSessionClaims creates new STS session claims with all required information
|
||||
func NewSTSSessionClaims(sessionId, issuer string, expiresAt time.Time) *STSSessionClaims {
|
||||
now := time.Now()
|
||||
return &STSSessionClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: issuer,
|
||||
Subject: sessionId,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
// jti = sessionId. The session id is already a unique random
|
||||
// identifier, so reusing it as the JWT id avoids a second secret
|
||||
// while still giving the revocation layer a stable lookup key.
|
||||
ID: sessionId,
|
||||
},
|
||||
SessionId: sessionId,
|
||||
TokenType: TokenTypeSession,
|
||||
AssumedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// ToSessionInfo converts JWT claims back to SessionInfo structure
|
||||
// This enables seamless integration with existing code expecting SessionInfo
|
||||
func (c *STSSessionClaims) ToSessionInfo() *SessionInfo {
|
||||
var expiresAt time.Time
|
||||
if c.ExpiresAt != nil {
|
||||
expiresAt = c.ExpiresAt.Time
|
||||
}
|
||||
|
||||
// Generate temporary credentials from the session ID
|
||||
// This is deterministic based on the session ID, so the same credentials are regenerated
|
||||
credentials, err := defaultCredentialGenerator.GenerateTemporaryCredentials(c.SessionId, expiresAt)
|
||||
if err != nil {
|
||||
// Log the error with context - credential generation failure is important for debugging
|
||||
errMsg := fmt.Errorf("generate temporary credentials for session %s: %w", c.SessionId, err)
|
||||
glog.Warningf("Failed to generate credentials for STS session: %v", errMsg)
|
||||
// Return session info without credentials - validation will catch this as invalid
|
||||
credentials = nil
|
||||
}
|
||||
|
||||
return &SessionInfo{
|
||||
SessionId: c.SessionId,
|
||||
SessionName: c.SessionName,
|
||||
RoleArn: c.RoleArn,
|
||||
AssumedRoleUser: c.AssumedRole,
|
||||
Principal: c.Principal,
|
||||
Policies: c.Policies,
|
||||
SessionPolicy: c.SessionPolicy,
|
||||
ExpiresAt: expiresAt,
|
||||
IdentityProvider: c.IdentityProvider,
|
||||
ExternalUserId: c.ExternalUserId,
|
||||
ProviderIssuer: c.ProviderIssuer,
|
||||
RequestContext: c.RequestContext,
|
||||
ParentUser: c.ParentUser,
|
||||
// Provide the Subject (sub) from registered claims
|
||||
Subject: c.Subject,
|
||||
Credentials: credentials,
|
||||
}
|
||||
}
|
||||
|
||||
// IsValid checks if the session claims are valid (not expired, etc.)
|
||||
func (c *STSSessionClaims) IsValid() bool {
|
||||
now := time.Now()
|
||||
|
||||
// Check expiration
|
||||
if c.ExpiresAt != nil && c.ExpiresAt.Before(now) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check not-before
|
||||
if c.NotBefore != nil && c.NotBefore.After(now) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Ensure required fields are present
|
||||
if c.SessionId == "" || c.RoleArn == "" || c.Principal == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetSessionId returns the session identifier
|
||||
func (c *STSSessionClaims) GetSessionId() string {
|
||||
return c.SessionId
|
||||
}
|
||||
|
||||
// GetExpiresAt returns the expiration time
|
||||
func (c *STSSessionClaims) GetExpiresAt() time.Time {
|
||||
if c.ExpiresAt != nil {
|
||||
return c.ExpiresAt.Time
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// WithRoleInfo sets role-related information in the claims
|
||||
func (c *STSSessionClaims) WithRoleInfo(roleArn, assumedRole, principal string) *STSSessionClaims {
|
||||
c.RoleArn = roleArn
|
||||
c.AssumedRole = assumedRole
|
||||
c.Principal = principal
|
||||
return c
|
||||
}
|
||||
|
||||
// WithPolicies sets the policies associated with this session
|
||||
func (c *STSSessionClaims) WithPolicies(policies []string) *STSSessionClaims {
|
||||
c.Policies = policies
|
||||
return c
|
||||
}
|
||||
|
||||
// WithSessionPolicy sets the inline session policy JSON for this session
|
||||
func (c *STSSessionClaims) WithSessionPolicy(policy string) *STSSessionClaims {
|
||||
c.SessionPolicy = policy
|
||||
return c
|
||||
}
|
||||
|
||||
// WithIdentityProvider sets identity provider information
|
||||
func (c *STSSessionClaims) WithIdentityProvider(providerName, externalUserId, providerIssuer string) *STSSessionClaims {
|
||||
c.IdentityProvider = providerName
|
||||
c.ExternalUserId = externalUserId
|
||||
c.ProviderIssuer = providerIssuer
|
||||
return c
|
||||
}
|
||||
|
||||
// WithRequestContext sets request context for policy evaluation
|
||||
func (c *STSSessionClaims) WithRequestContext(ctx map[string]interface{}) *STSSessionClaims {
|
||||
c.RequestContext = ctx
|
||||
return c
|
||||
}
|
||||
|
||||
// WithMaxDuration sets the maximum session duration
|
||||
func (c *STSSessionClaims) WithMaxDuration(duration time.Duration) *STSSessionClaims {
|
||||
c.MaxDuration = int64(duration.Seconds())
|
||||
return c
|
||||
}
|
||||
|
||||
// WithSessionName sets the session name
|
||||
func (c *STSSessionClaims) WithSessionName(sessionName string) *STSSessionClaims {
|
||||
c.SessionName = sessionName
|
||||
return c
|
||||
}
|
||||
|
||||
// WithParentUser sets the stable per-identity hash for the session. See
|
||||
// ComputeParentUser for the derivation rule.
|
||||
func (c *STSSessionClaims) WithParentUser(parentUser string) *STSSessionClaims {
|
||||
c.ParentUser = parentUser
|
||||
return c
|
||||
}
|
||||
285
weed/iam/sts/session_claims_test.go
Normal file
285
weed/iam/sts/session_claims_test.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSTSSessionClaimsToSessionInfo tests the ToSessionInfo conversion
|
||||
func TestSTSSessionClaimsToSessionInfo(t *testing.T) {
|
||||
sessionId := "test-session-123"
|
||||
issuer := "test-issuer"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
|
||||
claims := NewSTSSessionClaims(sessionId, issuer, expiresAt).
|
||||
WithSessionName("test-session-name").
|
||||
WithRoleInfo(
|
||||
"arn:aws:iam::123456789012:role/test-role",
|
||||
"arn:aws:iam::123456789012:assumed-role/test-role/session",
|
||||
"arn:aws:iam::123456789012:assumed-role/test-role/session",
|
||||
).
|
||||
WithIdentityProvider("oidc", "user-123", "https://issuer.example.com").
|
||||
WithMaxDuration(time.Hour)
|
||||
|
||||
sessionInfo := claims.ToSessionInfo()
|
||||
|
||||
// Verify basic claims are converted
|
||||
assert.Equal(t, sessionId, sessionInfo.SessionId)
|
||||
assert.Equal(t, "test-session-name", sessionInfo.SessionName)
|
||||
assert.Equal(t, "arn:aws:iam::123456789012:role/test-role", sessionInfo.RoleArn)
|
||||
assert.Equal(t, "arn:aws:iam::123456789012:assumed-role/test-role/session", sessionInfo.AssumedRoleUser)
|
||||
assert.Equal(t, "oidc", sessionInfo.IdentityProvider)
|
||||
assert.Equal(t, "user-123", sessionInfo.ExternalUserId)
|
||||
|
||||
// Verify credentials are generated
|
||||
assert.NotNil(t, sessionInfo.Credentials, "credentials should be populated")
|
||||
assert.NotEmpty(t, sessionInfo.Credentials.AccessKeyId, "access key should be generated")
|
||||
assert.NotEmpty(t, sessionInfo.Credentials.SecretAccessKey, "secret key should be generated")
|
||||
// Credential expiration may have sub-second differences, so just check they're close
|
||||
assert.True(t, sessionInfo.Credentials.Expiration.Sub(expiresAt) < time.Second, "credential expiration should match session expiration")
|
||||
|
||||
// Verify expiration is preserved (within 1 second tolerance for timing differences)
|
||||
assert.WithinDuration(t, expiresAt, sessionInfo.ExpiresAt, 1*time.Second)
|
||||
}
|
||||
|
||||
// TestSTSSessionClaimsToSessionInfoCredentialGeneration tests that credentials are deterministically generated for access key and session token
|
||||
func TestSTSSessionClaimsToSessionInfoCredentialGeneration(t *testing.T) {
|
||||
sessionId := "deterministic-session-id"
|
||||
issuer := "test-issuer"
|
||||
expiresAt := time.Now().Add(time.Hour).Truncate(time.Second)
|
||||
|
||||
claims1 := NewSTSSessionClaims(sessionId, issuer, expiresAt)
|
||||
sessionInfo1 := claims1.ToSessionInfo()
|
||||
|
||||
// Create another claims object with the same session ID and expiration
|
||||
claims2 := NewSTSSessionClaims(sessionId, issuer, expiresAt)
|
||||
sessionInfo2 := claims2.ToSessionInfo()
|
||||
|
||||
// Verify that both have valid credentials
|
||||
assert.NotNil(t, sessionInfo1.Credentials, "credentials should be populated")
|
||||
assert.NotNil(t, sessionInfo2.Credentials, "credentials should be populated")
|
||||
|
||||
// Verify deterministic generation: same SessionId should produce identical access key ID
|
||||
// (based on hash of session ID, not random)
|
||||
assert.Equal(t, sessionInfo1.Credentials.AccessKeyId, sessionInfo2.Credentials.AccessKeyId,
|
||||
"same session ID should produce identical access key ID (deterministic hash-based generation)")
|
||||
|
||||
// Session token is also deterministic (hash-based on session ID)
|
||||
assert.Equal(t, sessionInfo1.Credentials.SessionToken, sessionInfo2.Credentials.SessionToken,
|
||||
"same session ID should produce identical session token (deterministic hash-based generation)")
|
||||
|
||||
// Secret access key is NOW deterministic (hash-based on session ID, not random!)
|
||||
// This is critical for signature verification: the same session ID must regenerate
|
||||
// the same secret key so that signature verification succeeds.
|
||||
assert.Equal(t, sessionInfo1.Credentials.SecretAccessKey, sessionInfo2.Credentials.SecretAccessKey,
|
||||
"same session ID should produce identical secret access key (deterministic hash-based generation)")
|
||||
|
||||
// Expiration should match
|
||||
assert.WithinDuration(t, sessionInfo1.Credentials.Expiration, sessionInfo2.Credentials.Expiration, 1*time.Second,
|
||||
"credentials expiration should match")
|
||||
}
|
||||
|
||||
// TestSTSSessionClaimsToSessionInfoPreservesAllFields tests that all fields are preserved
|
||||
func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
|
||||
sessionId := "test-session-id"
|
||||
issuer := "test-issuer"
|
||||
expiresAt := time.Now().Add(2 * time.Hour)
|
||||
|
||||
policies := []string{"policy1", "policy2"}
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"]}]}`
|
||||
requestContext := map[string]interface{}{
|
||||
"sourceIp": "192.168.1.1",
|
||||
"userAgent": "test-agent",
|
||||
}
|
||||
|
||||
claims := NewSTSSessionClaims(sessionId, issuer, expiresAt).
|
||||
WithSessionName("session-name").
|
||||
WithRoleInfo("role-arn", "assumed-role", "principal").
|
||||
WithIdentityProvider("provider", "external-id", "issuer").
|
||||
WithPolicies(policies).
|
||||
WithSessionPolicy(sessionPolicy).
|
||||
WithRequestContext(requestContext).
|
||||
WithMaxDuration(2 * time.Hour)
|
||||
|
||||
sessionInfo := claims.ToSessionInfo()
|
||||
|
||||
// Verify all fields are preserved
|
||||
assert.Equal(t, sessionId, sessionInfo.SessionId)
|
||||
assert.Equal(t, "session-name", sessionInfo.SessionName)
|
||||
assert.Equal(t, "role-arn", sessionInfo.RoleArn)
|
||||
assert.Equal(t, "assumed-role", sessionInfo.AssumedRoleUser)
|
||||
assert.Equal(t, "principal", sessionInfo.Principal)
|
||||
assert.Equal(t, "provider", sessionInfo.IdentityProvider)
|
||||
assert.Equal(t, "external-id", sessionInfo.ExternalUserId)
|
||||
assert.Equal(t, "issuer", sessionInfo.ProviderIssuer)
|
||||
assert.Equal(t, policies, sessionInfo.Policies)
|
||||
assert.Equal(t, sessionPolicy, sessionInfo.SessionPolicy)
|
||||
assert.Equal(t, requestContext, sessionInfo.RequestContext)
|
||||
assert.WithinDuration(t, expiresAt, sessionInfo.ExpiresAt, 1*time.Second)
|
||||
}
|
||||
|
||||
// TestSTSSessionClaimsToSessionInfoEmptyFields tests handling of empty/nil fields
|
||||
func TestSTSSessionClaimsToSessionInfoEmptyFields(t *testing.T) {
|
||||
sessionId := "minimal-session"
|
||||
issuer := "issuer"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
|
||||
// Create claims with minimal fields
|
||||
claims := NewSTSSessionClaims(sessionId, issuer, expiresAt)
|
||||
|
||||
sessionInfo := claims.ToSessionInfo()
|
||||
|
||||
// Verify basic fields are preserved
|
||||
assert.Equal(t, sessionId, sessionInfo.SessionId)
|
||||
assert.Empty(t, sessionInfo.SessionName)
|
||||
assert.Empty(t, sessionInfo.RoleArn)
|
||||
|
||||
// Verify credentials are still generated even with minimal fields
|
||||
assert.NotNil(t, sessionInfo.Credentials)
|
||||
assert.NotEmpty(t, sessionInfo.Credentials.AccessKeyId)
|
||||
assert.NotEmpty(t, sessionInfo.Credentials.SecretAccessKey)
|
||||
}
|
||||
|
||||
// TestSTSSessionClaimsToSessionInfoCredentialExpiration tests credential expiration
|
||||
func TestSTSSessionClaimsToSessionInfoCredentialExpiration(t *testing.T) {
|
||||
sessionId := "test-session"
|
||||
issuer := "issuer"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt time.Time
|
||||
expectNotExpired bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "future_expiration",
|
||||
expiresAt: time.Now().Add(time.Hour),
|
||||
expectNotExpired: true,
|
||||
description: "Credentials should not be expired if ExpiresAt is in the future",
|
||||
},
|
||||
{
|
||||
name: "past_expiration",
|
||||
expiresAt: time.Now().Add(-time.Hour),
|
||||
expectNotExpired: false,
|
||||
description: "Credentials should be expired if ExpiresAt is in the past",
|
||||
},
|
||||
{
|
||||
name: "near_future_expiration",
|
||||
expiresAt: time.Now().Add(time.Minute),
|
||||
expectNotExpired: true,
|
||||
description: "Credentials should not be expired even if close to expiration",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
claims := NewSTSSessionClaims(sessionId, issuer, tc.expiresAt)
|
||||
sessionInfo := claims.ToSessionInfo()
|
||||
|
||||
assert.NotNil(t, sessionInfo.Credentials)
|
||||
// Check expiration within 1 second due to timing precision (symmetric tolerance)
|
||||
assert.WithinDuration(t, tc.expiresAt, sessionInfo.Credentials.Expiration, time.Second,
|
||||
"credential expiration should be within 1 second of session expiration")
|
||||
// We set tc.expiresAt to past/future values to exercise expiration handling.
|
||||
// Assert the credentials' expiration relative to now to exercise code behavior
|
||||
if tc.expectNotExpired {
|
||||
assert.True(t, time.Now().Before(sessionInfo.Credentials.Expiration), tc.description)
|
||||
} else {
|
||||
assert.True(t, time.Now().After(sessionInfo.Credentials.Expiration), tc.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionInfoIntegration tests the full integration of session info flow
|
||||
func TestSessionInfoIntegration(t *testing.T) {
|
||||
// Create a session claim
|
||||
sessionId, err := GenerateSessionId()
|
||||
require.NoError(t, err)
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
claims := NewSTSSessionClaims(sessionId, "test-issuer", expiresAt).
|
||||
WithSessionName("integration-test").
|
||||
WithRoleInfo(
|
||||
"arn:aws:iam::123456789012:role/integration",
|
||||
"arn:aws:iam::123456789012:assumed-role/integration/test",
|
||||
"arn:aws:iam::123456789012:assumed-role/integration/test",
|
||||
).
|
||||
WithIdentityProvider("test-provider", "user-id", "https://test.example.com")
|
||||
|
||||
// Convert to SessionInfo
|
||||
sessionInfo := claims.ToSessionInfo()
|
||||
|
||||
// Verify the session info has valid credentials
|
||||
assert.NotNil(t, sessionInfo.Credentials)
|
||||
assert.NotEmpty(t, sessionInfo.Credentials.AccessKeyId)
|
||||
assert.NotEmpty(t, sessionInfo.Credentials.SecretAccessKey)
|
||||
|
||||
// Verify basic session properties
|
||||
assert.Equal(t, sessionId, sessionInfo.SessionId)
|
||||
assert.Equal(t, "integration-test", sessionInfo.SessionName)
|
||||
assert.False(t, sessionInfo.ExpiresAt.IsZero())
|
||||
|
||||
// Verify that the session is valid
|
||||
assert.True(t, sessionInfo.ExpiresAt.After(time.Now()), "session should not be expired")
|
||||
assert.False(t, sessionInfo.Credentials.Expiration.Before(time.Now()), "credentials should not be expired")
|
||||
}
|
||||
|
||||
// TestSecretAccessKeyDeterminism verifies that secret access keys are deterministically
|
||||
// generated from the session ID. This is CRITICAL for STS signature verification:
|
||||
// The client generates a secret key and signs the request. When the server receives
|
||||
// the request, it must regenerate the exact same secret key from the JWT claims
|
||||
// to verify the signature. If the secret key is random, verification will always fail.
|
||||
func TestSecretAccessKeyDeterminism(t *testing.T) {
|
||||
sessionId := "critical-determinism-test"
|
||||
expiration := time.Now().Add(time.Hour)
|
||||
|
||||
// Generate credentials multiple times with the same session ID
|
||||
credGen := NewCredentialGenerator()
|
||||
|
||||
cred1, err := credGen.GenerateTemporaryCredentials(sessionId, expiration)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cred1)
|
||||
|
||||
cred2, err := credGen.GenerateTemporaryCredentials(sessionId, expiration)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cred2)
|
||||
|
||||
cred3, err := credGen.GenerateTemporaryCredentials(sessionId, expiration)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cred3)
|
||||
|
||||
// All three should have IDENTICAL secret access keys
|
||||
assert.Equal(t, cred1.SecretAccessKey, cred2.SecretAccessKey,
|
||||
"same sessionId must produce identical secret key on first and second call")
|
||||
assert.Equal(t, cred2.SecretAccessKey, cred3.SecretAccessKey,
|
||||
"same sessionId must produce identical secret key on second and third call")
|
||||
|
||||
// All three should have IDENTICAL access key IDs
|
||||
assert.Equal(t, cred1.AccessKeyId, cred2.AccessKeyId,
|
||||
"same sessionId must produce identical access key ID")
|
||||
assert.Equal(t, cred2.AccessKeyId, cred3.AccessKeyId,
|
||||
"same sessionId must produce identical access key ID")
|
||||
|
||||
// All three should have IDENTICAL session tokens
|
||||
assert.Equal(t, cred1.SessionToken, cred2.SessionToken,
|
||||
"same sessionId must produce identical session token")
|
||||
assert.Equal(t, cred2.SessionToken, cred3.SessionToken,
|
||||
"same sessionId must produce identical session token")
|
||||
|
||||
// Different session IDs should produce different secrets
|
||||
otherSessionId := "different-session"
|
||||
credOther, err := credGen.GenerateTemporaryCredentials(otherSessionId, expiration)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, credOther)
|
||||
|
||||
assert.NotEqual(t, cred1.SecretAccessKey, credOther.SecretAccessKey,
|
||||
"different session IDs must produce different secret keys")
|
||||
assert.NotEqual(t, cred1.AccessKeyId, credOther.AccessKeyId,
|
||||
"different session IDs must produce different access key IDs")
|
||||
assert.NotEqual(t, cred1.SessionToken, credOther.SessionToken,
|
||||
"different session IDs must produce different session tokens")
|
||||
}
|
||||
35
weed/iam/sts/session_policy.go
Normal file
35
weed/iam/sts/session_policy.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
)
|
||||
|
||||
// NormalizeSessionPolicy validates and normalizes inline session policy JSON.
|
||||
// It returns an empty string if the input is empty or whitespace.
|
||||
func NormalizeSessionPolicy(policyJSON string) (string, error) {
|
||||
trimmed := strings.TrimSpace(policyJSON)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
const maxSessionPolicySize = 2048
|
||||
if len(trimmed) > maxSessionPolicySize {
|
||||
return "", fmt.Errorf("session policy exceeds maximum size of %d characters", maxSessionPolicySize)
|
||||
}
|
||||
|
||||
var policyDoc policy.PolicyDocument
|
||||
if err := json.Unmarshal([]byte(trimmed), &policyDoc); err != nil {
|
||||
return "", fmt.Errorf("invalid session policy JSON: %w", err)
|
||||
}
|
||||
if err := policy.ValidatePolicyDocument(&policyDoc); err != nil {
|
||||
return "", fmt.Errorf("invalid session policy document: %w", err)
|
||||
}
|
||||
normalized, err := json.Marshal(&policyDoc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to normalize session policy: %w", err)
|
||||
}
|
||||
return string(normalized), nil
|
||||
}
|
||||
1186
weed/iam/sts/sts_service.go
Normal file
1186
weed/iam/sts/sts_service.go
Normal file
File diff suppressed because it is too large
Load Diff
57
weed/iam/sts/sts_service_duration_test.go
Normal file
57
weed/iam/sts/sts_service_duration_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func newSTSServiceWithMaxSession(t *testing.T, maxSession time.Duration) *STSService {
|
||||
t.Helper()
|
||||
s := NewSTSService()
|
||||
assert.NoError(t, s.Initialize(&STSConfig{
|
||||
Issuer: "test-issuer",
|
||||
SigningKey: []byte("test-signing-key-at-least-32-bytes-long"),
|
||||
TokenDuration: FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: FlexibleDuration{Duration: maxSession},
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
func secondsPtr(v int64) *int64 { return &v }
|
||||
|
||||
func TestValidateSessionDurationSeconds_RespectsConfiguredMaxSessionLength(t *testing.T) {
|
||||
s := newSTSServiceWithMaxSession(t, 168*time.Hour)
|
||||
assert.NoError(t, s.validateSessionDurationSeconds(secondsPtr(604800)))
|
||||
}
|
||||
|
||||
func TestValidateSessionDurationSeconds_RejectsAboveConfiguredMaxSessionLength(t *testing.T) {
|
||||
s := newSTSServiceWithMaxSession(t, 24*time.Hour)
|
||||
err := s.validateSessionDurationSeconds(secondsPtr(90000))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestValidateSessionDurationSeconds_FallsBackToDefaultWhenUnset(t *testing.T) {
|
||||
s := &STSService{}
|
||||
assert.NoError(t, s.validateSessionDurationSeconds(secondsPtr(43200)))
|
||||
err := s.validateSessionDurationSeconds(secondsPtr(43201))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestValidateSessionDurationSeconds_EnforcesMinimum(t *testing.T) {
|
||||
s := newSTSServiceWithMaxSession(t, 168*time.Hour)
|
||||
assert.Error(t, s.validateSessionDurationSeconds(secondsPtr(899)))
|
||||
}
|
||||
|
||||
func TestValidateSessionDurationSeconds_NilReturnsNil(t *testing.T) {
|
||||
s := newSTSServiceWithMaxSession(t, 168*time.Hour)
|
||||
assert.NoError(t, s.validateSessionDurationSeconds(nil))
|
||||
}
|
||||
|
||||
func TestValidateSessionDurationSeconds_SubMinimumMaxSessionLengthKeepsCapping(t *testing.T) {
|
||||
s := newSTSServiceWithMaxSession(t, 5*time.Minute)
|
||||
assert.NoError(t, s.validateSessionDurationSeconds(secondsPtr(900)))
|
||||
assert.NoError(t, s.validateSessionDurationSeconds(secondsPtr(43200)))
|
||||
assert.Error(t, s.validateSessionDurationSeconds(secondsPtr(43201)))
|
||||
}
|
||||
38
weed/iam/sts/sts_service_test.go
Normal file
38
weed/iam/sts/sts_service_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCalculateSessionDuration(t *testing.T) {
|
||||
svc := NewSTSService()
|
||||
if err := svc.Initialize(&STSConfig{
|
||||
TokenDuration: FlexibleDuration{time.Hour},
|
||||
MaxSessionLength: FlexibleDuration{12 * time.Hour},
|
||||
Issuer: "test-issuer",
|
||||
SigningKey: []byte("test-signing-key-at-least-32-bytes-long"),
|
||||
}); err != nil {
|
||||
t.Fatalf("Initialize() error = %v", err)
|
||||
}
|
||||
|
||||
seconds := func(v int64) *int64 { return &v }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
durationSeconds *int64
|
||||
want time.Duration
|
||||
}{
|
||||
{"default from config", nil, time.Hour},
|
||||
{"explicit request", seconds(1800), 30 * time.Minute},
|
||||
{"capped at MaxSessionLength", seconds(86400), 12 * time.Hour},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := svc.calculateSessionDuration(tc.durationSeconds); got != tc.want {
|
||||
t.Errorf("calculateSessionDuration(%v) = %v, want %v", tc.durationSeconds, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
4
weed/iam/sts/test_utils.go
Normal file
4
weed/iam/sts/test_utils.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package sts
|
||||
|
||||
// MockTrustPolicyValidator is a simple mock for testing STS functionality
|
||||
type MockTrustPolicyValidator struct{}
|
||||
246
weed/iam/sts/token_utils.go
Normal file
246
weed/iam/sts/token_utils.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
||||
)
|
||||
|
||||
// TokenGenerator handles token generation and validation
|
||||
type TokenGenerator struct {
|
||||
signingKey []byte
|
||||
issuer string
|
||||
}
|
||||
|
||||
// NewTokenGenerator creates a new token generator
|
||||
func NewTokenGenerator(signingKey []byte, issuer string) *TokenGenerator {
|
||||
return &TokenGenerator{
|
||||
signingKey: signingKey,
|
||||
issuer: issuer,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSessionToken creates a signed JWT session token (legacy method for compatibility)
|
||||
func (t *TokenGenerator) GenerateSessionToken(sessionId string, expiresAt time.Time) (string, error) {
|
||||
claims := NewSTSSessionClaims(sessionId, t.issuer, expiresAt)
|
||||
return t.GenerateJWTWithClaims(claims)
|
||||
}
|
||||
|
||||
// GenerateJWTWithClaims creates a signed JWT token with comprehensive session claims
|
||||
func (t *TokenGenerator) GenerateJWTWithClaims(claims *STSSessionClaims) (string, error) {
|
||||
if claims == nil {
|
||||
return "", fmt.Errorf("claims cannot be nil")
|
||||
}
|
||||
|
||||
// Ensure issuer is set from token generator
|
||||
if claims.Issuer == "" {
|
||||
claims.Issuer = t.issuer
|
||||
}
|
||||
|
||||
// SECURITY: Use deterministic signing results for troubleshooting if needed,
|
||||
// but standard HS256 with common secret is usually sufficient.
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(t.signingKey)
|
||||
}
|
||||
|
||||
// ValidateSessionToken validates and extracts claims from a session token
|
||||
func (t *TokenGenerator) ValidateSessionToken(tokenString string) (*SessionTokenClaims, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return t.signingKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, fmt.Errorf(ErrTokenNotValid)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(ErrInvalidTokenClaims)
|
||||
}
|
||||
|
||||
// Verify issuer
|
||||
if iss, ok := claims[JWTClaimIssuer].(string); !ok || iss != t.issuer {
|
||||
return nil, fmt.Errorf(ErrInvalidIssuer)
|
||||
}
|
||||
|
||||
// Extract session ID
|
||||
sessionId, ok := claims[JWTClaimSubject].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(ErrMissingSessionID)
|
||||
}
|
||||
|
||||
return &SessionTokenClaims{
|
||||
SessionId: sessionId,
|
||||
ExpiresAt: time.Unix(int64(claims[JWTClaimExpiration].(float64)), 0),
|
||||
IssuedAt: time.Unix(int64(claims[JWTClaimIssuedAt].(float64)), 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateJWTWithClaims validates and extracts comprehensive session claims from a JWT token
|
||||
func (t *TokenGenerator) ValidateJWTWithClaims(tokenString string) (*STSSessionClaims, error) {
|
||||
// 1. Parse into MapClaims to capture ALL claims including custom ones
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return t.signingKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, fmt.Errorf(ErrTokenNotValid)
|
||||
}
|
||||
|
||||
mapClaims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(ErrInvalidTokenClaims)
|
||||
}
|
||||
|
||||
// 2. Decode into STSSessionClaims using JSON round-trip to respect tags
|
||||
jsonBytes, err := json.Marshal(mapClaims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal claims: %v", err)
|
||||
}
|
||||
|
||||
claims := &STSSessionClaims{}
|
||||
if err := json.Unmarshal(jsonBytes, claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal claims: %v", err)
|
||||
}
|
||||
|
||||
// 3. Ensure RequestContext contains all claims for policy evaluation
|
||||
// This preserves custom claims (like jwt:preferred_username) that are not in the struct
|
||||
if claims.RequestContext == nil {
|
||||
claims.RequestContext = make(map[string]interface{})
|
||||
}
|
||||
for k, v := range mapClaims {
|
||||
// Add valid claim values to RequestContext
|
||||
// We don't overwrite existing RequestContext keys if they were explicitly set
|
||||
if _, exists := claims.RequestContext[k]; !exists {
|
||||
claims.RequestContext[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Validate issuer
|
||||
if claims.Issuer != t.issuer {
|
||||
return nil, fmt.Errorf(ErrInvalidIssuer)
|
||||
}
|
||||
|
||||
// Validate that required fields are present
|
||||
if claims.SessionId == "" {
|
||||
return nil, fmt.Errorf(ErrMissingSessionID)
|
||||
}
|
||||
|
||||
// Additional validation using the claims' own validation method
|
||||
if !claims.IsValid() {
|
||||
return nil, fmt.Errorf(ErrTokenNotValid)
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// SessionTokenClaims represents parsed session token claims
|
||||
type SessionTokenClaims struct {
|
||||
SessionId string
|
||||
ExpiresAt time.Time
|
||||
IssuedAt time.Time
|
||||
}
|
||||
|
||||
// CredentialGenerator generates AWS-compatible temporary credentials
|
||||
type CredentialGenerator struct{}
|
||||
|
||||
// NewCredentialGenerator creates a new credential generator
|
||||
func NewCredentialGenerator() *CredentialGenerator {
|
||||
return &CredentialGenerator{}
|
||||
}
|
||||
|
||||
// GenerateTemporaryCredentials creates temporary AWS credentials
|
||||
func (c *CredentialGenerator) GenerateTemporaryCredentials(sessionId string, expiration time.Time) (*Credentials, error) {
|
||||
accessKeyId, err := c.generateTemporaryAccessKeyId(sessionId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate access key ID: %w", err)
|
||||
}
|
||||
|
||||
secretAccessKey, err := c.generateSecretAccessKey(sessionId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate secret access key: %w", err)
|
||||
}
|
||||
|
||||
sessionToken, err := c.generateSessionTokenId(sessionId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
||||
}
|
||||
|
||||
return &Credentials{
|
||||
AccessKeyId: accessKeyId,
|
||||
SecretAccessKey: secretAccessKey,
|
||||
SessionToken: sessionToken,
|
||||
Expiration: expiration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateTemporaryAccessKeyId generates an AWS-style access key ID for temporary STS credentials
|
||||
func (c *CredentialGenerator) generateTemporaryAccessKeyId(sessionId string) (string, error) {
|
||||
// Create a deterministic but unique access key ID based on session
|
||||
hash := sha256.Sum256([]byte("access-key:" + sessionId))
|
||||
// Use ASIA prefix for temporary credentials (STS), not AKIA (permanent IAM keys)
|
||||
return "ASIA" + hex.EncodeToString(hash[:8]), nil // AWS format: ASIA + 16 chars
|
||||
}
|
||||
|
||||
// generateSecretAccessKey generates a deterministic secret access key based on sessionId
|
||||
// This ensures the same secret key is regenerated from the JWT claims during signature verification
|
||||
func (c *CredentialGenerator) generateSecretAccessKey(sessionId string) (string, error) {
|
||||
// Create deterministic secret key based on session ID (not random!)
|
||||
// This is critical for STS because:
|
||||
// 1. AssumeRoleWithWebIdentity generates the secret key once
|
||||
// 2. During signature verification, ToSessionInfo() regenerates credentials from JWT
|
||||
// 3. Both must generate the same secret key for signature verification to succeed
|
||||
hash := sha256.Sum256([]byte("secret-key:" + sessionId))
|
||||
return base64.StdEncoding.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
// generateSessionTokenId generates a session token identifier
|
||||
func (c *CredentialGenerator) generateSessionTokenId(sessionId string) (string, error) {
|
||||
// Create session token with session ID embedded
|
||||
hash := sha256.Sum256([]byte("session-token:" + sessionId))
|
||||
return "ST" + hex.EncodeToString(hash[:16]), nil // Custom format
|
||||
}
|
||||
|
||||
// generateSessionId generates a unique session ID
|
||||
func GenerateSessionId() (string, error) {
|
||||
randomBytes := make([]byte, 16)
|
||||
_, err := rand.Read(randomBytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(randomBytes), nil
|
||||
}
|
||||
|
||||
// generateAssumedRoleArn generates the ARN for an assumed role user
|
||||
func GenerateAssumedRoleArn(roleArn, sessionName string) string {
|
||||
// Convert role ARN to assumed role user ARN
|
||||
// arn:aws:iam::role/RoleName -> arn:aws:sts::assumed-role/RoleName/SessionName
|
||||
roleName := utils.ExtractRoleNameFromArn(roleArn)
|
||||
if roleName == "" {
|
||||
// This should not happen if validation is done properly upstream
|
||||
return fmt.Sprintf("arn:aws:sts::assumed-role/INVALID-ARN/%s", sessionName)
|
||||
}
|
||||
return fmt.Sprintf("arn:aws:sts::assumed-role/%s/%s", roleName, sessionName)
|
||||
}
|
||||
Reference in New Issue
Block a user