mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-19 13:46:58 +00:00
fix oidc
This commit is contained in:
@@ -5,13 +5,51 @@
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc="
|
||||
},
|
||||
"identityProviders": [
|
||||
"providers": [
|
||||
{
|
||||
"name": "test-oidc",
|
||||
"type": "mock",
|
||||
"config": {
|
||||
"issuer": "test-oidc-issuer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "keycloak",
|
||||
"type": "oidc",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "http://localhost:8080/realms/seaweedfs-test",
|
||||
"clientId": "seaweedfs-s3",
|
||||
"clientSecret": "seaweedfs-s3-secret",
|
||||
"jwksUri": "http://localhost:8080/realms/seaweedfs-test/protocol/openid-connect/certs",
|
||||
"userInfoUri": "http://localhost:8080/realms/seaweedfs-test/protocol/openid-connect/userinfo",
|
||||
"scopes": ["openid", "profile", "email"],
|
||||
"claimsMapping": {
|
||||
"username": "preferred_username",
|
||||
"email": "email",
|
||||
"name": "name"
|
||||
},
|
||||
"roleMapping": {
|
||||
"rules": [
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-admin",
|
||||
"role": "arn:seaweed:iam::role/KeycloakAdminRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-read-only",
|
||||
"role": "arn:seaweed:iam::role/KeycloakReadOnlyRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-write-only",
|
||||
"role": "arn:seaweed:iam::role/KeycloakWriteOnlyRole"
|
||||
}
|
||||
],
|
||||
"defaultRole": "arn:seaweed:iam::role/KeycloakReadOnlyRole"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"policy": {
|
||||
@@ -72,6 +110,60 @@
|
||||
},
|
||||
"attachedPolicies": ["S3WriteOnlyPolicy"],
|
||||
"description": "Write-only role for testing"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakAdminRole",
|
||||
"roleArn": "arn:seaweed:iam::role/KeycloakAdminRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3AdminPolicy"],
|
||||
"description": "Admin role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakReadOnlyRole",
|
||||
"roleArn": "arn:seaweed:iam::role/KeycloakReadOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3ReadOnlyPolicy"],
|
||||
"description": "Read-only role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakWriteOnlyRole",
|
||||
"roleArn": "arn:seaweed:iam::role/KeycloakWriteOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3WriteOnlyPolicy"],
|
||||
"description": "Write-only role for Keycloak users"
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
|
||||
@@ -114,9 +114,10 @@ func NewKeycloakClient(baseURL, realm, clientID, clientSecret string) *KeycloakC
|
||||
// isKeycloakAvailable checks if Keycloak is running and accessible
|
||||
func (f *S3IAMTestFramework) isKeycloakAvailable(keycloakURL string) bool {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
healthURL := fmt.Sprintf("%s/health/ready", keycloakURL)
|
||||
// Use realms endpoint instead of health/ready for Keycloak v26+
|
||||
realmsURL := fmt.Sprintf("%s/realms/master", keycloakURL)
|
||||
|
||||
resp, err := client.Get(healthURL)
|
||||
resp, err := client.Get(realmsURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -268,16 +269,40 @@ func (t *BearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, err
|
||||
// Clone the request to avoid modifying the original
|
||||
newReq := req.Clone(req.Context())
|
||||
|
||||
// Add Bearer token authorization header
|
||||
newReq.Header.Set("Authorization", "Bearer "+t.Token)
|
||||
|
||||
// Remove AWS signature headers if present (they conflict with Bearer auth)
|
||||
// Remove ALL existing Authorization headers first to prevent conflicts
|
||||
newReq.Header.Del("Authorization")
|
||||
newReq.Header.Del("X-Amz-Date")
|
||||
newReq.Header.Del("X-Amz-Content-Sha256")
|
||||
newReq.Header.Del("X-Amz-Signature")
|
||||
newReq.Header.Del("X-Amz-Algorithm")
|
||||
newReq.Header.Del("X-Amz-Credential")
|
||||
newReq.Header.Del("X-Amz-SignedHeaders")
|
||||
newReq.Header.Del("X-Amz-Security-Token")
|
||||
|
||||
// Add Bearer token authorization header
|
||||
newReq.Header.Set("Authorization", "Bearer "+t.Token)
|
||||
|
||||
// Debug: log the token being sent (first 50 chars) and subject
|
||||
tokenPreview := t.Token
|
||||
if len(tokenPreview) > 50 {
|
||||
tokenPreview = tokenPreview[:50] + "..."
|
||||
}
|
||||
|
||||
// Debug logging can be enabled if needed
|
||||
// Extract subject from token for debugging
|
||||
// parts := strings.Split(t.Token, ".")
|
||||
// subject := "unknown"
|
||||
// if len(parts) >= 2 {
|
||||
// if decoded, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
|
||||
// var claims map[string]interface{}
|
||||
// if json.Unmarshal(decoded, &claims) == nil {
|
||||
// if sub, ok := claims["sub"].(string); ok {
|
||||
// subject = sub[:8] + "..." // First 8 chars of subject
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// fmt.Printf("DEBUG: Sending Bearer token (subject: %s): %s\n", subject, tokenPreview)
|
||||
|
||||
// Use underlying transport
|
||||
transport := t.Transport
|
||||
@@ -464,6 +489,79 @@ func (f *S3IAMTestFramework) CreateS3ClientWithSessionToken(sessionToken string)
|
||||
return s3.New(sess), nil
|
||||
}
|
||||
|
||||
// CreateS3ClientWithKeycloakToken creates an S3 client using a Keycloak JWT token
|
||||
func (f *S3IAMTestFramework) CreateS3ClientWithKeycloakToken(keycloakToken string) (*s3.S3, error) {
|
||||
// Create a fresh HTTP transport with aggressive timeouts to prevent hanging
|
||||
transport := &http.Transport{
|
||||
DisableKeepAlives: true, // Force new connections for each request
|
||||
DisableCompression: true, // Disable compression to simplify requests
|
||||
MaxIdleConns: 0, // No connection pooling
|
||||
MaxIdleConnsPerHost: 0, // No connection pooling per host
|
||||
IdleConnTimeout: 1 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
// Create a custom HTTP client with aggressive timeouts
|
||||
httpClient := &http.Client{
|
||||
Timeout: 30 * time.Second, // Overall request timeout
|
||||
Transport: &BearerTokenTransport{
|
||||
Token: keycloakToken,
|
||||
Transport: transport,
|
||||
},
|
||||
}
|
||||
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String(TestRegion),
|
||||
Endpoint: aws.String(TestS3Endpoint),
|
||||
Credentials: credentials.AnonymousCredentials,
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
HTTPClient: httpClient,
|
||||
MaxRetries: aws.Int(0), // No retries to avoid delays
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AWS session: %v", err)
|
||||
}
|
||||
|
||||
return s3.New(sess), nil
|
||||
}
|
||||
|
||||
// TestKeycloakTokenDirectly tests a Keycloak token with direct HTTP request (bypassing AWS SDK)
|
||||
func (f *S3IAMTestFramework) TestKeycloakTokenDirectly(keycloakToken string) error {
|
||||
// Create a simple HTTP client with timeout
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Create request to list buckets
|
||||
req, err := http.NewRequest("GET", TestS3Endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
// Add Bearer token
|
||||
req.Header.Set("Authorization", "Bearer "+keycloakToken)
|
||||
req.Header.Set("Host", "localhost:8333")
|
||||
|
||||
// Make request
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Direct HTTP test - Status: %d, Body: %s\n", resp.StatusCode, string(body))
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateJWTToken creates a JWT token for testing
|
||||
func (f *S3IAMTestFramework) generateJWTToken(username, roleName string, validDuration time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
@@ -43,7 +46,7 @@ func TestKeycloakAuthentication(t *testing.T) {
|
||||
assert.NotEmpty(t, token, "JWT token should not be empty")
|
||||
|
||||
// Verify token can be used to create S3 client
|
||||
s3Client, err := framework.CreateS3ClientWithJWT("admin-user", "S3AdminRole")
|
||||
s3Client, err := framework.CreateS3ClientWithKeycloakToken(token)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, s3Client, "S3 client should be created successfully")
|
||||
|
||||
@@ -71,17 +74,42 @@ func TestKeycloakAuthentication(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token, "JWT token should not be empty")
|
||||
|
||||
// Create S3 client with read-only user
|
||||
s3Client, err := framework.CreateS3ClientWithJWT("read-user", "S3ReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
// Debug: decode token to verify it's for read-user
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) >= 2 {
|
||||
payload := parts[1]
|
||||
// Add padding if needed
|
||||
for len(payload)%4 != 0 {
|
||||
payload += "="
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err == nil {
|
||||
var claims map[string]interface{}
|
||||
if json.Unmarshal(decoded, &claims) == nil {
|
||||
t.Logf("Token username: %v", claims["preferred_username"])
|
||||
t.Logf("Token roles: %v", claims["roles"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test that read-only user can list buckets
|
||||
_, err = s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
assert.NoError(t, err, "Read-only user should be able to list buckets")
|
||||
// First test with direct HTTP request to verify OIDC authentication works
|
||||
t.Logf("Testing with direct HTTP request...")
|
||||
err = framework.TestKeycloakTokenDirectly(token)
|
||||
require.NoError(t, err, "Direct HTTP test should succeed")
|
||||
|
||||
// Test that read-only user cannot create buckets
|
||||
err = framework.CreateBucket(s3Client, testKeycloakBucket+"-readonly")
|
||||
assert.Error(t, err, "Read-only user should not be able to create buckets")
|
||||
// Create S3 client with Keycloak token
|
||||
s3Client, err := framework.CreateS3ClientWithKeycloakToken(token)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test that read-only user can list buckets
|
||||
t.Logf("Testing ListBuckets with AWS SDK...")
|
||||
_, err = s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
assert.NoError(t, err, "Read-only user should be able to list buckets")
|
||||
|
||||
// Test that read-only user cannot create buckets
|
||||
t.Logf("Testing CreateBucket with AWS SDK...")
|
||||
err = framework.CreateBucket(s3Client, testKeycloakBucket+"-readonly")
|
||||
assert.Error(t, err, "Read-only user should not be able to create buckets")
|
||||
})
|
||||
|
||||
t.Run("invalid_user_authentication", func(t *testing.T) {
|
||||
@@ -110,7 +138,10 @@ func TestKeycloakTokenExpiration(t *testing.T) {
|
||||
assert.Greater(t, tokenResp.ExpiresIn, 0, "Token should have expiration time")
|
||||
|
||||
// Test that token works initially
|
||||
s3Client, err := framework.CreateS3ClientWithJWT("admin-user", "S3AdminRole")
|
||||
token, err := framework.getKeycloakToken("admin-user")
|
||||
require.NoError(t, err)
|
||||
|
||||
s3Client, err := framework.CreateS3ClientWithKeycloakToken(token)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
@@ -158,8 +189,12 @@ func TestKeycloakRoleMapping(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.username, func(t *testing.T) {
|
||||
// Create S3 client for the user
|
||||
s3Client, err := framework.CreateS3ClientWithJWT(tc.username, tc.expectedRole)
|
||||
// Get Keycloak token for the user
|
||||
token, err := framework.getKeycloakToken(tc.username)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create S3 client with Keycloak token
|
||||
s3Client, err := framework.CreateS3ClientWithKeycloakToken(token)
|
||||
require.NoError(t, err, tc.description)
|
||||
|
||||
// Test list buckets permission
|
||||
@@ -192,7 +227,10 @@ func TestKeycloakS3Operations(t *testing.T) {
|
||||
}
|
||||
|
||||
// Use admin user for comprehensive testing
|
||||
s3Client, err := framework.CreateS3ClientWithJWT("admin-user", "S3AdminRole")
|
||||
token, err := framework.getKeycloakToken("admin-user")
|
||||
require.NoError(t, err)
|
||||
|
||||
s3Client, err := framework.CreateS3ClientWithKeycloakToken(token)
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := testKeycloakBucket + "-operations"
|
||||
|
||||
@@ -144,11 +144,22 @@ func (p *OIDCProvider) Authenticate(ctx context.Context, token string) (*provide
|
||||
displayName, _ := claims.GetClaimString("name")
|
||||
groups, _ := claims.GetClaimStringSlice("groups")
|
||||
|
||||
// Map claims to roles using configured role mapping
|
||||
roles := p.mapClaimsToRolesWithConfig(claims)
|
||||
|
||||
// Create attributes map and add roles
|
||||
attributes := make(map[string]string)
|
||||
if len(roles) > 0 {
|
||||
// Store roles as a comma-separated string in attributes
|
||||
attributes["roles"] = strings.Join(roles, ",")
|
||||
}
|
||||
|
||||
return &providers.ExternalIdentity{
|
||||
UserID: claims.Subject,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
Groups: groups,
|
||||
Attributes: attributes,
|
||||
Provider: p.name,
|
||||
}, nil
|
||||
}
|
||||
@@ -288,11 +299,20 @@ func (p *OIDCProvider) ValidateToken(ctx context.Context, token string) (*provid
|
||||
return nil, fmt.Errorf("invalid or missing issuer claim")
|
||||
}
|
||||
|
||||
audience, ok := claims["aud"].(string)
|
||||
if !ok || audience != p.config.ClientID {
|
||||
// Check audience claim (aud) or authorized party (azp) - Keycloak uses azp
|
||||
var audience string
|
||||
if aud, ok := claims["aud"].(string); ok {
|
||||
audience = aud
|
||||
} else if azp, ok := claims["azp"].(string); ok {
|
||||
audience = azp
|
||||
} else {
|
||||
return nil, fmt.Errorf("invalid or missing audience claim")
|
||||
}
|
||||
|
||||
if audience != p.config.ClientID {
|
||||
return nil, fmt.Errorf("invalid audience claim: expected %s, got %s", p.config.ClientID, audience)
|
||||
}
|
||||
|
||||
subject, ok := claims["sub"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing subject claim")
|
||||
@@ -313,7 +333,7 @@ func (p *OIDCProvider) ValidateToken(ctx context.Context, token string) (*provid
|
||||
return tokenClaims, nil
|
||||
}
|
||||
|
||||
// mapClaimsToRoles maps token claims to SeaweedFS roles
|
||||
// mapClaimsToRoles maps token claims to SeaweedFS roles (legacy method)
|
||||
func (p *OIDCProvider) mapClaimsToRoles(claims *providers.TokenClaims) []string {
|
||||
roles := []string{}
|
||||
|
||||
@@ -339,6 +359,30 @@ func (p *OIDCProvider) mapClaimsToRoles(claims *providers.TokenClaims) []string
|
||||
return roles
|
||||
}
|
||||
|
||||
// mapClaimsToRolesWithConfig maps token claims to roles using configured role mapping
|
||||
func (p *OIDCProvider) mapClaimsToRolesWithConfig(claims *providers.TokenClaims) []string {
|
||||
if p.config.RoleMapping == nil {
|
||||
// Fallback to legacy mapping if no role mapping configured
|
||||
return p.mapClaimsToRoles(claims)
|
||||
}
|
||||
|
||||
roles := []string{}
|
||||
|
||||
// Apply role mapping rules
|
||||
for _, rule := range p.config.RoleMapping.Rules {
|
||||
if rule.Matches(claims) {
|
||||
roles = append(roles, rule.Role)
|
||||
}
|
||||
}
|
||||
|
||||
// Use default role if no rules matched
|
||||
if len(roles) == 0 && p.config.RoleMapping.DefaultRole != "" {
|
||||
roles = []string{p.config.RoleMapping.DefaultRole}
|
||||
}
|
||||
|
||||
return roles
|
||||
}
|
||||
|
||||
// getPublicKey retrieves the public key for the given key ID from JWKS
|
||||
func (p *OIDCProvider) getPublicKey(ctx context.Context, kid string) (interface{}, error) {
|
||||
// Fetch JWKS if not cached or refresh if needed
|
||||
|
||||
@@ -145,6 +145,13 @@ func (f *ProviderFactory) convertToOIDCConfig(configMap map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
// Convert role mapping
|
||||
if roleMappingInterface, ok := configMap["roleMapping"]; ok {
|
||||
if roleMapping, err := f.convertToRoleMapping(roleMappingInterface); err == nil {
|
||||
config.RoleMapping = roleMapping
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Converted OIDC config: issuer=%s, clientId=%s, jwksUri=%s",
|
||||
config.Issuer, config.ClientID, config.JWKSUri)
|
||||
|
||||
@@ -225,6 +232,56 @@ func (f *ProviderFactory) LoadProvidersFromConfig(configs []*ProviderConfig) (ma
|
||||
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
|
||||
}
|
||||
|
||||
// ValidateProviderConfig validates a provider configuration
|
||||
func (f *ProviderFactory) ValidateProviderConfig(config *ProviderConfig) error {
|
||||
if config == nil {
|
||||
@@ -263,7 +320,7 @@ func (f *ProviderFactory) validateOIDCConfig(config map[string]interface{}) erro
|
||||
if _, ok := config[ConfigFieldIssuer]; !ok {
|
||||
return fmt.Errorf("OIDC provider requires '%s' field", ConfigFieldIssuer)
|
||||
}
|
||||
|
||||
|
||||
if _, ok := config[ConfigFieldClientID]; !ok {
|
||||
return fmt.Errorf("OIDC provider requires '%s' field", ConfigFieldClientID)
|
||||
}
|
||||
|
||||
@@ -299,6 +299,11 @@ func (s *STSService) RegisterProvider(provider providers.IdentityProvider) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProviders returns all registered identity providers
|
||||
func (s *STSService) GetProviders() map[string]providers.IdentityProvider {
|
||||
return s.providers
|
||||
}
|
||||
|
||||
// AssumeRoleWithWebIdentity assumes a role using a web identity token (OIDC)
|
||||
// This method is now completely stateless - all session information is embedded in the JWT token
|
||||
func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *AssumeRoleWithWebIdentityRequest) (*AssumeRoleResponse, error) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
@@ -61,18 +62,36 @@ func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Requ
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Parse JWT token to extract claims
|
||||
// Try to parse as STS session token first
|
||||
tokenClaims, err := parseJWTToken(sessionToken)
|
||||
if err != nil {
|
||||
glog.V(3).Infof("Failed to parse JWT token: %v", err)
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Extract role information from token claims
|
||||
// Check if this is an STS session token (has "role" claim)
|
||||
roleName, ok := tokenClaims["role"].(string)
|
||||
if !ok || roleName == "" {
|
||||
glog.V(0).Info("No role found in JWT token")
|
||||
return nil, s3err.ErrAccessDenied
|
||||
// Not an STS session token, try to validate as OIDC token
|
||||
identity, err := s3iam.validateOIDCToken(ctx, sessionToken)
|
||||
if err != nil {
|
||||
glog.V(0).Infof("Failed to validate as OIDC token: %v", err)
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Extract role from OIDC identity
|
||||
if identity.RoleArn == "" {
|
||||
glog.V(0).Info("No role found in OIDC token")
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Return IAM identity for OIDC token
|
||||
return &IAMIdentity{
|
||||
Name: identity.UserID,
|
||||
Principal: identity.RoleArn,
|
||||
SessionToken: sessionToken,
|
||||
Account: nil, // OIDC tokens don't have account info
|
||||
}, s3err.ErrNone
|
||||
}
|
||||
|
||||
sessionName, ok := tokenClaims["snam"].(string)
|
||||
@@ -409,3 +428,68 @@ func (enhanced *EnhancedS3ApiServer) AuthorizeRequest(r *http.Request, identity
|
||||
// Use our IAM integration for authorization
|
||||
return enhanced.iamIntegration.AuthorizeAction(ctx, iamIdentity, action, bucket, object, r)
|
||||
}
|
||||
|
||||
// OIDCIdentity represents an identity validated through OIDC
|
||||
type OIDCIdentity struct {
|
||||
UserID string
|
||||
RoleArn string
|
||||
Provider string
|
||||
}
|
||||
|
||||
// validateOIDCToken validates an OIDC token using registered identity providers
|
||||
func (s3iam *S3IAMIntegration) validateOIDCToken(ctx context.Context, token string) (*OIDCIdentity, error) {
|
||||
if s3iam.iamManager == nil {
|
||||
return nil, fmt.Errorf("IAM manager not available")
|
||||
}
|
||||
|
||||
// Get STS service to access identity providers
|
||||
stsService := s3iam.iamManager.GetSTSService()
|
||||
if stsService == nil {
|
||||
return nil, fmt.Errorf("STS service not available")
|
||||
}
|
||||
|
||||
// Try to validate token with each registered OIDC provider
|
||||
providers := stsService.GetProviders()
|
||||
for providerName, provider := range providers {
|
||||
// Try to authenticate with this provider
|
||||
externalIdentity, err := provider.Authenticate(ctx, token)
|
||||
if err != nil {
|
||||
glog.V(3).Infof("Provider %s failed to authenticate token: %v", providerName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract role from external identity attributes
|
||||
rolesAttr, exists := externalIdentity.Attributes["roles"]
|
||||
if !exists || rolesAttr == "" {
|
||||
glog.V(3).Infof("No roles found in external identity from provider %s", providerName)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse roles (stored as comma-separated string)
|
||||
roles := strings.Split(rolesAttr, ",")
|
||||
if len(roles) == 0 {
|
||||
glog.V(3).Infof("Empty roles list from provider %s", providerName)
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the first role as the primary role
|
||||
roleArn := roles[0]
|
||||
|
||||
return &OIDCIdentity{
|
||||
UserID: externalIdentity.UserID,
|
||||
RoleArn: roleArn,
|
||||
Provider: providerName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("token not valid for any registered OIDC provider")
|
||||
}
|
||||
|
||||
// getProviderNames returns a list of provider names for debugging
|
||||
func getProviderNames(providers map[string]providers.IdentityProvider) []string {
|
||||
names := make([]string, 0, len(providers))
|
||||
for name := range providers {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -450,6 +450,28 @@ func loadIAMManagerFromConfig(configPath string) (*integration.IAMManager, error
|
||||
return nil, fmt.Errorf("failed to initialize IAM manager: %w", err)
|
||||
}
|
||||
|
||||
// Load identity providers
|
||||
providerFactory := sts.NewProviderFactory()
|
||||
for _, providerConfig := range configRoot.Providers {
|
||||
provider, err := providerFactory.CreateProvider(&sts.ProviderConfig{
|
||||
Name: providerConfig["name"].(string),
|
||||
Type: providerConfig["type"].(string),
|
||||
Enabled: true,
|
||||
Config: providerConfig["config"].(map[string]interface{}),
|
||||
})
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to create provider %s: %v", providerConfig["name"], err)
|
||||
continue
|
||||
}
|
||||
if provider != nil {
|
||||
if err := iamManager.RegisterIdentityProvider(provider); err != nil {
|
||||
glog.Warningf("Failed to register provider %s: %v", providerConfig["name"], err)
|
||||
} else {
|
||||
glog.V(1).Infof("Registered identity provider: %s", providerConfig["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load policies
|
||||
for _, policyDef := range configRoot.Policies {
|
||||
if err := iamManager.CreatePolicy(context.Background(), policyDef.Name, policyDef.Document); err != nil {
|
||||
@@ -464,7 +486,7 @@ func loadIAMManagerFromConfig(configPath string) (*integration.IAMManager, error
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(0).Infof("Loaded %d policies and %d roles from config", len(configRoot.Policies), len(configRoot.Roles))
|
||||
glog.V(0).Infof("Loaded %d providers, %d policies and %d roles from config", len(configRoot.Providers), len(configRoot.Policies), len(configRoot.Roles))
|
||||
|
||||
return iamManager, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user