mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
feat: Support non-email user identifiers for Azure AD
Add userIdentifierClaim configuration option to support Azure AD users without email addresses. This allows using alternative JWT claims like "sub", "oid", "upn", or "preferred_username" for user identification. - Default behavior uses "email" claim (backward compatible) - Falls back to "sub" claim if configured claim is missing - allowedUsers matches against the configured claim value - allowedUserDomains only applies when using email-based identification Fixes #95
This commit is contained in:
@@ -77,6 +77,7 @@ testData:
|
|||||||
# Custom claim names for Auth0 and other providers with namespaced claims
|
# Custom claim names for Auth0 and other providers with namespaced claims
|
||||||
roleClaimName: roles # JWT claim name for extracting user roles (default: "roles")
|
roleClaimName: roles # JWT claim name for extracting user roles (default: "roles")
|
||||||
groupClaimName: groups # JWT claim name for extracting user groups (default: "groups")
|
groupClaimName: groups # JWT claim name for extracting user groups (default: "groups")
|
||||||
|
userIdentifierClaim: email # JWT claim for user identification (default: "email", alternatives: "sub", "oid", "upn", "preferred_username")
|
||||||
|
|
||||||
# ⚠️ CRITICAL for TLS termination scenarios (AWS ALB, Cloud Load Balancers, etc.)
|
# ⚠️ CRITICAL for TLS termination scenarios (AWS ALB, Cloud Load Balancers, etc.)
|
||||||
# When NOT specified in config: defaults to FALSE (Go zero value)
|
# When NOT specified in config: defaults to FALSE (Go zero value)
|
||||||
@@ -290,6 +291,26 @@ testDataWithRedis:
|
|||||||
# - "AppRoleName"
|
# - "AppRoleName"
|
||||||
# # See README.md "Provider Configuration Recommendations" for Azure AD.
|
# # See README.md "Provider Configuration Recommendations" for Azure AD.
|
||||||
|
|
||||||
|
# --- Azure AD Users Without Email Example (Issue #95) ---
|
||||||
|
# testDataAzureADNoEmail:
|
||||||
|
# providerURL: https://login.microsoftonline.com/your-tenant-id/v2.0
|
||||||
|
# clientID: your-azure-ad-client-id
|
||||||
|
# clientSecret: your-azure-ad-client-secret
|
||||||
|
# callbackURL: /oauth2/callback
|
||||||
|
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-azure"
|
||||||
|
# # Use 'sub' claim instead of 'email' for user identification
|
||||||
|
# userIdentifierClaim: sub # or "oid", "upn", "preferred_username"
|
||||||
|
# overrideScopes: true # Remove email scope if not needed
|
||||||
|
# scopes:
|
||||||
|
# - openid
|
||||||
|
# - profile
|
||||||
|
# - groups # For group-based access control
|
||||||
|
# # When using non-email identifiers, allowedUsers matches against the claim value
|
||||||
|
# allowedUsers:
|
||||||
|
# - "abc12345-6789-0abc-def0-123456789abc" # Azure AD user object ID (sub or oid claim)
|
||||||
|
# # NOTE: allowedUserDomains is ignored when userIdentifierClaim is not "email"
|
||||||
|
# # See: https://github.com/lukaszraczylo/traefikoidc/issues/95
|
||||||
|
|
||||||
# --- Google Workspace / Google Cloud Identity Example ---
|
# --- Google Workspace / Google Cloud Identity Example ---
|
||||||
# testDataGoogle:
|
# testDataGoogle:
|
||||||
# providerURL: https://accounts.google.com # Standard Google OIDC endpoint
|
# providerURL: https://accounts.google.com # Standard Google OIDC endpoint
|
||||||
@@ -608,6 +629,38 @@ configuration:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
userIdentifierClaim:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Specifies the JWT claim to use as the user identifier for authentication and authorization.
|
||||||
|
|
||||||
|
This allows authentication for users without email addresses, such as Azure AD service
|
||||||
|
accounts or organizational accounts that don't have email attributes configured.
|
||||||
|
|
||||||
|
When set to a non-email claim (e.g., "sub", "oid", "upn"):
|
||||||
|
- AllowedUsers will match against this claim value instead of email
|
||||||
|
- AllowedUserDomains validation is skipped (domains only apply to email addresses)
|
||||||
|
- The session stores this identifier as the user's identity
|
||||||
|
- If the configured claim is missing, falls back to "sub" (required by OIDC spec)
|
||||||
|
|
||||||
|
Common values by provider:
|
||||||
|
- Default: "email" (standard email-based identification)
|
||||||
|
- Azure AD: "sub", "oid" (object ID), "upn" (User Principal Name), "preferred_username"
|
||||||
|
- Generic OIDC: "sub" (always present per OIDC specification)
|
||||||
|
- Keycloak: "sub", "preferred_username"
|
||||||
|
|
||||||
|
Example for Azure AD users without email:
|
||||||
|
```yaml
|
||||||
|
userIdentifierClaim: sub
|
||||||
|
allowedUsers:
|
||||||
|
- "abc123-user-object-id"
|
||||||
|
- "xyz789-another-user-id"
|
||||||
|
```
|
||||||
|
|
||||||
|
Default: "email"
|
||||||
|
See: https://github.com/lukaszraczylo/traefikoidc/issues/95
|
||||||
|
required: false
|
||||||
|
|
||||||
revocationURL:
|
revocationURL:
|
||||||
type: string
|
type: string
|
||||||
description: |
|
description: |
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ The middleware supports the following configuration options:
|
|||||||
| `allowedRolesAndGroups` | Restricts access to users with specific roles or groups | none | `["admin", "developer"]` |
|
| `allowedRolesAndGroups` | Restricts access to users with specific roles or groups | none | `["admin", "developer"]` |
|
||||||
| `roleClaimName` | JWT claim name for extracting user roles (supports namespaced claims for Auth0) | `"roles"` | `"https://myapp.com/roles"`, `"user_roles"` |
|
| `roleClaimName` | JWT claim name for extracting user roles (supports namespaced claims for Auth0) | `"roles"` | `"https://myapp.com/roles"`, `"user_roles"` |
|
||||||
| `groupClaimName` | JWT claim name for extracting user groups (supports namespaced claims for Auth0) | `"groups"` | `"https://myapp.com/groups"`, `"user_groups"` |
|
| `groupClaimName` | JWT claim name for extracting user groups (supports namespaced claims for Auth0) | `"groups"` | `"https://myapp.com/groups"`, `"user_groups"` |
|
||||||
|
| `userIdentifierClaim` | JWT claim to use as user identifier (for users without email, e.g., Azure AD service accounts) | `"email"` | `"sub"`, `"oid"`, `"upn"`, `"preferred_username"` |
|
||||||
| `revocationURL` | The endpoint for revoking tokens | auto-discovered | `https://accounts.google.com/revoke` |
|
| `revocationURL` | The endpoint for revoking tokens | auto-discovered | `https://accounts.google.com/revoke` |
|
||||||
| `oidcEndSessionURL` | The provider's end session endpoint | auto-discovered | `https://accounts.google.com/logout` |
|
| `oidcEndSessionURL` | The provider's end session endpoint | auto-discovered | `https://accounts.google.com/logout` |
|
||||||
| `enablePKCE` | Enables PKCE (Proof Key for Code Exchange) for authorization code flow | `false` | `true`, `false` |
|
| `enablePKCE` | Enables PKCE (Proof Key for Code Exchange) for authorization code flow | `false` | `true`, `false` |
|
||||||
@@ -1242,6 +1243,45 @@ spec:
|
|||||||
- "AppRoleName" # Application role names
|
- "AppRoleName" # Application role names
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Azure AD Configuration (Users Without Email)
|
||||||
|
|
||||||
|
For Azure AD users without email addresses (service accounts, organizational accounts without mail attributes):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: oidc-azure-no-email
|
||||||
|
namespace: traefik
|
||||||
|
spec:
|
||||||
|
plugin:
|
||||||
|
traefikoidc:
|
||||||
|
providerURL: https://login.microsoftonline.com/your-tenant-id/v2.0
|
||||||
|
clientID: your-azure-ad-client-id
|
||||||
|
clientSecret: your-azure-ad-client-secret
|
||||||
|
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
|
||||||
|
callbackURL: /oauth2/callback
|
||||||
|
logoutURL: /oauth2/logout
|
||||||
|
|
||||||
|
# Use 'sub' instead of 'email' for user identification
|
||||||
|
userIdentifierClaim: sub # Can also use: "oid", "upn", "preferred_username"
|
||||||
|
|
||||||
|
overrideScopes: true # Optional: Don't request email scope if not needed
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- profile
|
||||||
|
- groups
|
||||||
|
|
||||||
|
# When using non-email identifiers, allowedUsers matches against the claim value
|
||||||
|
allowedUsers:
|
||||||
|
- "abc12345-6789-0abc-def0-123456789abc" # Azure AD user object ID
|
||||||
|
- "def67890-1234-5678-90ab-cdef12345678"
|
||||||
|
|
||||||
|
# NOTE: allowedUserDomains is ignored when userIdentifierClaim is not "email"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: When `userIdentifierClaim` is set to a non-email claim (like `sub`, `oid`, or `upn`), the `allowedUserDomains` configuration is ignored since domain-based validation only applies to email addresses. Use `allowedUsers` with the actual claim values instead.
|
||||||
|
|
||||||
### Auth0 Configuration
|
### Auth0 Configuration
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
+21
-20
@@ -849,26 +849,27 @@ func TestAudienceEndToEndScenario(t *testing.T) {
|
|||||||
customAudience := "https://api.company.com"
|
customAudience := "https://api.company.com"
|
||||||
|
|
||||||
tOidc := &TraefikOidc{
|
tOidc := &TraefikOidc{
|
||||||
next: nextHandler,
|
next: nextHandler,
|
||||||
name: "test",
|
name: "test",
|
||||||
redirURLPath: "/callback",
|
redirURLPath: "/callback",
|
||||||
logoutURLPath: "/callback/logout",
|
logoutURLPath: "/callback/logout",
|
||||||
issuerURL: "https://auth.company.com",
|
issuerURL: "https://auth.company.com",
|
||||||
clientID: "test-client-id",
|
clientID: "test-client-id",
|
||||||
clientSecret: "test-client-secret",
|
clientSecret: "test-client-secret",
|
||||||
audience: customAudience, // Set custom audience
|
audience: customAudience, // Set custom audience
|
||||||
jwkCache: mockJWKCache,
|
jwkCache: mockJWKCache,
|
||||||
jwksURL: "https://auth.company.com/.well-known/jwks.json",
|
jwksURL: "https://auth.company.com/.well-known/jwks.json",
|
||||||
tokenBlacklist: tokenBlacklist,
|
tokenBlacklist: tokenBlacklist,
|
||||||
tokenCache: tokenCache,
|
tokenCache: tokenCache,
|
||||||
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
allowedUserDomains: map[string]struct{}{"company.com": {}},
|
allowedUserDomains: map[string]struct{}{"company.com": {}},
|
||||||
excludedURLs: map[string]struct{}{},
|
userIdentifierClaim: "email", // Required for user identification
|
||||||
httpClient: &http.Client{},
|
excludedURLs: map[string]struct{}{},
|
||||||
initComplete: make(chan struct{}),
|
httpClient: &http.Client{},
|
||||||
sessionManager: sm,
|
initComplete: make(chan struct{}),
|
||||||
extractClaimsFunc: extractClaims,
|
sessionManager: sm,
|
||||||
|
extractClaimsFunc: extractClaims,
|
||||||
}
|
}
|
||||||
tOidc.jwtVerifier = tOidc
|
tOidc.jwtVerifier = tOidc
|
||||||
tOidc.tokenVerifier = tOidc
|
tOidc.tokenVerifier = tOidc
|
||||||
|
|||||||
+19
-9
@@ -223,15 +223,25 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email, _ := claims["email"].(string)
|
// Extract user identifier from the configured claim (defaults to "email" for backward compatibility)
|
||||||
if email == "" {
|
userIdentifier, _ := claims[t.userIdentifierClaim].(string)
|
||||||
t.logger.Errorf("Email claim missing or empty in token during callback")
|
if userIdentifier == "" {
|
||||||
t.sendErrorResponse(rw, req, "Authentication failed: Email missing in token", http.StatusInternalServerError)
|
// Try "sub" as fallback since it's required by OIDC spec
|
||||||
return
|
if t.userIdentifierClaim != "sub" {
|
||||||
|
userIdentifier, _ = claims["sub"].(string)
|
||||||
|
}
|
||||||
|
if userIdentifier == "" {
|
||||||
|
t.logger.Errorf("User identifier claim '%s' missing or empty in token during callback", t.userIdentifierClaim)
|
||||||
|
t.sendErrorResponse(rw, req, "Authentication failed: User identifier missing in token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.logger.Debugf("Configured claim '%s' not found, using 'sub' claim as fallback", t.userIdentifierClaim)
|
||||||
}
|
}
|
||||||
if !t.isAllowedDomain(email) {
|
|
||||||
t.logger.Errorf("Disallowed email domain during callback: %s", email)
|
// Validate user authorization
|
||||||
t.sendErrorResponse(rw, req, "Authentication failed: Email domain not allowed", http.StatusForbidden)
|
if !t.isAllowedUser(userIdentifier) {
|
||||||
|
t.logger.Errorf("User not authorized during callback: %s", userIdentifier)
|
||||||
|
t.sendErrorResponse(rw, req, "Authentication failed: User not authorized", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +250,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
|||||||
t.sendErrorResponse(rw, req, "Failed to update session", http.StatusInternalServerError)
|
t.sendErrorResponse(rw, req, "Failed to update session", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
session.SetEmail(email)
|
session.SetEmail(userIdentifier) // SetEmail stores the user identifier (email or other claim)
|
||||||
session.SetIDToken(tokenResponse.IDToken)
|
session.SetIDToken(tokenResponse.IDToken)
|
||||||
session.SetAccessToken(tokenResponse.AccessToken)
|
session.SetAccessToken(tokenResponse.AccessToken)
|
||||||
session.SetRefreshToken(tokenResponse.RefreshToken)
|
session.SetRefreshToken(tokenResponse.RefreshToken)
|
||||||
|
|||||||
+29
-12
@@ -15,7 +15,8 @@ type OAuthHandler struct {
|
|||||||
tokenExchanger TokenExchanger
|
tokenExchanger TokenExchanger
|
||||||
tokenVerifier TokenVerifier
|
tokenVerifier TokenVerifier
|
||||||
extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
|
extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
|
||||||
isAllowedDomainFunc func(email string) bool
|
isAllowedUserFunc func(userIdentifier string) bool // validates user authorization
|
||||||
|
userIdentifierClaim string // JWT claim to use for user identification
|
||||||
redirURLPath string
|
redirURLPath string
|
||||||
sendErrorResponseFunc func(rw http.ResponseWriter, req *http.Request, message string, code int)
|
sendErrorResponseFunc func(rw http.ResponseWriter, req *http.Request, message string, code int)
|
||||||
}
|
}
|
||||||
@@ -77,16 +78,22 @@ type TokenResponse struct {
|
|||||||
// NewOAuthHandler creates a new OAuth handler
|
// NewOAuthHandler creates a new OAuth handler
|
||||||
func NewOAuthHandler(logger Logger, sessionManager SessionManager, tokenExchanger TokenExchanger,
|
func NewOAuthHandler(logger Logger, sessionManager SessionManager, tokenExchanger TokenExchanger,
|
||||||
tokenVerifier TokenVerifier, extractClaimsFunc func(string) (map[string]interface{}, error),
|
tokenVerifier TokenVerifier, extractClaimsFunc func(string) (map[string]interface{}, error),
|
||||||
isAllowedDomainFunc func(string) bool, redirURLPath string,
|
isAllowedUserFunc func(string) bool, userIdentifierClaim string, redirURLPath string,
|
||||||
sendErrorResponseFunc func(http.ResponseWriter, *http.Request, string, int)) *OAuthHandler {
|
sendErrorResponseFunc func(http.ResponseWriter, *http.Request, string, int)) *OAuthHandler {
|
||||||
|
|
||||||
|
// Default to "email" for backward compatibility
|
||||||
|
if userIdentifierClaim == "" {
|
||||||
|
userIdentifierClaim = "email"
|
||||||
|
}
|
||||||
|
|
||||||
return &OAuthHandler{
|
return &OAuthHandler{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
sessionManager: sessionManager,
|
sessionManager: sessionManager,
|
||||||
tokenExchanger: tokenExchanger,
|
tokenExchanger: tokenExchanger,
|
||||||
tokenVerifier: tokenVerifier,
|
tokenVerifier: tokenVerifier,
|
||||||
extractClaimsFunc: extractClaimsFunc,
|
extractClaimsFunc: extractClaimsFunc,
|
||||||
isAllowedDomainFunc: isAllowedDomainFunc,
|
isAllowedUserFunc: isAllowedUserFunc,
|
||||||
|
userIdentifierClaim: userIdentifierClaim,
|
||||||
redirURLPath: redirURLPath,
|
redirURLPath: redirURLPath,
|
||||||
sendErrorResponseFunc: sendErrorResponseFunc,
|
sendErrorResponseFunc: sendErrorResponseFunc,
|
||||||
}
|
}
|
||||||
@@ -225,15 +232,25 @@ func (h *OAuthHandler) HandleCallback(rw http.ResponseWriter, req *http.Request,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email, _ := claims["email"].(string)
|
// Extract user identifier from the configured claim (defaults to "email" for backward compatibility)
|
||||||
if email == "" {
|
userIdentifier, _ := claims[h.userIdentifierClaim].(string)
|
||||||
h.logger.Errorf("Email claim missing or empty in token during callback")
|
if userIdentifier == "" {
|
||||||
h.sendErrorResponseFunc(rw, req, "Authentication failed: Email missing in token", http.StatusInternalServerError)
|
// Try "sub" as fallback since it's required by OIDC spec
|
||||||
return
|
if h.userIdentifierClaim != "sub" {
|
||||||
|
userIdentifier, _ = claims["sub"].(string)
|
||||||
|
}
|
||||||
|
if userIdentifier == "" {
|
||||||
|
h.logger.Errorf("User identifier claim '%s' missing or empty in token during callback", h.userIdentifierClaim)
|
||||||
|
h.sendErrorResponseFunc(rw, req, "Authentication failed: User identifier missing in token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Debugf("Configured claim '%s' not found, using 'sub' claim as fallback", h.userIdentifierClaim)
|
||||||
}
|
}
|
||||||
if !h.isAllowedDomainFunc(email) {
|
|
||||||
h.logger.Errorf("Disallowed email domain during callback: %s", email)
|
// Validate user authorization
|
||||||
h.sendErrorResponseFunc(rw, req, "Authentication failed: Email domain not allowed", http.StatusForbidden)
|
if !h.isAllowedUserFunc(userIdentifier) {
|
||||||
|
h.logger.Errorf("User not authorized during callback: %s", userIdentifier)
|
||||||
|
h.sendErrorResponseFunc(rw, req, "Authentication failed: User not authorized", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +259,7 @@ func (h *OAuthHandler) HandleCallback(rw http.ResponseWriter, req *http.Request,
|
|||||||
h.sendErrorResponseFunc(rw, req, "Failed to update session", http.StatusInternalServerError)
|
h.sendErrorResponseFunc(rw, req, "Failed to update session", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
session.SetEmail(email)
|
session.SetEmail(userIdentifier) // SetEmail stores the user identifier (email or other claim)
|
||||||
session.SetIDToken(tokenResponse.IDToken)
|
session.SetIDToken(tokenResponse.IDToken)
|
||||||
session.SetAccessToken(tokenResponse.AccessToken)
|
session.SetAccessToken(tokenResponse.AccessToken)
|
||||||
session.SetRefreshToken(tokenResponse.RefreshToken)
|
session.SetRefreshToken(tokenResponse.RefreshToken)
|
||||||
|
|||||||
@@ -108,11 +108,11 @@ func TestOAuthHandler_NewOAuthHandler(t *testing.T) {
|
|||||||
return map[string]interface{}{"email": "test@example.com", "nonce": "test-nonce"}, nil
|
return map[string]interface{}{"email": "test@example.com", "nonce": "test-nonce"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
isAllowed := func(email string) bool { return true }
|
isAllowedUser := func(userIdentifier string) bool { return true }
|
||||||
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {}
|
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowedUser, "email", "/callback", sendError)
|
||||||
|
|
||||||
if handler == nil {
|
if handler == nil {
|
||||||
t.Fatal("Expected handler to be created, got nil")
|
t.Fatal("Expected handler to be created, got nil")
|
||||||
@@ -151,7 +151,7 @@ func TestOAuthHandler_HandleCallback_SessionError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test&state=test", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test&state=test", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -190,7 +190,7 @@ func TestOAuthHandler_HandleCallback_ProviderError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
// Test with error parameter
|
// Test with error parameter
|
||||||
req := httptest.NewRequest("GET", "/callback?error=access_denied&error_description=User%20denied%20access", nil)
|
req := httptest.NewRequest("GET", "/callback?error=access_denied&error_description=User%20denied%20access", nil)
|
||||||
@@ -230,7 +230,7 @@ func TestOAuthHandler_HandleCallback_MissingState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -265,7 +265,7 @@ func TestOAuthHandler_HandleCallback_MissingCSRF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -300,7 +300,7 @@ func TestOAuthHandler_HandleCallback_CSRFMismatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -335,7 +335,7 @@ func TestOAuthHandler_HandleCallback_MissingCode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -370,7 +370,7 @@ func TestOAuthHandler_HandleCallback_TokenExchangeError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -406,7 +406,7 @@ func TestOAuthHandler_HandleCallback_TokenVerificationError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -444,7 +444,7 @@ func TestOAuthHandler_HandleCallback_ClaimsExtractionError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -483,7 +483,7 @@ func TestOAuthHandler_HandleCallback_MissingNonceInToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -521,7 +521,7 @@ func TestOAuthHandler_HandleCallback_MissingNonceInSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -559,7 +559,7 @@ func TestOAuthHandler_HandleCallback_NonceMismatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -591,13 +591,13 @@ func TestOAuthHandler_HandleCallback_MissingEmail(t *testing.T) {
|
|||||||
if code != http.StatusInternalServerError {
|
if code != http.StatusInternalServerError {
|
||||||
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, code)
|
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, code)
|
||||||
}
|
}
|
||||||
if !strings.Contains(msg, "Email missing in token") {
|
if !strings.Contains(msg, "User identifier missing in token") {
|
||||||
t.Errorf("Expected error message to contain 'Email missing in token', got '%s'", msg)
|
t.Errorf("Expected error message to contain 'User identifier missing in token', got '%s'", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -629,13 +629,13 @@ func TestOAuthHandler_HandleCallback_DisallowedDomain(t *testing.T) {
|
|||||||
if code != http.StatusForbidden {
|
if code != http.StatusForbidden {
|
||||||
t.Errorf("Expected status %d, got %d", http.StatusForbidden, code)
|
t.Errorf("Expected status %d, got %d", http.StatusForbidden, code)
|
||||||
}
|
}
|
||||||
if !strings.Contains(msg, "Email domain not allowed") {
|
if !strings.Contains(msg, "User not authorized") {
|
||||||
t.Errorf("Expected error message to contain 'Email domain not allowed', got '%s'", msg)
|
t.Errorf("Expected error message to contain 'User not authorized', got '%s'", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -677,7 +677,7 @@ func TestOAuthHandler_HandleCallback_SessionSaveError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -719,7 +719,7 @@ func TestOAuthHandler_HandleCallback_SetAuthenticatedError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -760,7 +760,7 @@ func TestOAuthHandler_HandleCallback_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -843,7 +843,7 @@ func TestOAuthHandler_HandleCallback_SuccessDefaultRedirect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
@@ -884,7 +884,7 @@ func TestOAuthHandler_HandleCallback_RedirectURLPathExcluded(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
|
||||||
extractClaims, isAllowed, "/callback", sendError)
|
extractClaims, isAllowed, "email", "/callback", sendError)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -177,6 +177,12 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
|
|||||||
}
|
}
|
||||||
return "groups" // Backward compatible default
|
return "groups" // Backward compatible default
|
||||||
}(),
|
}(),
|
||||||
|
userIdentifierClaim: func() string {
|
||||||
|
if config.UserIdentifierClaim != "" {
|
||||||
|
return config.UserIdentifierClaim
|
||||||
|
}
|
||||||
|
return "email" // Backward compatible default
|
||||||
|
}(),
|
||||||
forceHTTPS: config.ForceHTTPS,
|
forceHTTPS: config.ForceHTTPS,
|
||||||
enablePKCE: config.EnablePKCE,
|
enablePKCE: config.EnablePKCE,
|
||||||
overrideScopes: config.OverrideScopes,
|
overrideScopes: config.OverrideScopes,
|
||||||
|
|||||||
+243
-19
@@ -122,22 +122,23 @@ func (ts *TestSuite) Setup() {
|
|||||||
|
|
||||||
// Common TraefikOidc instance
|
// Common TraefikOidc instance
|
||||||
ts.tOidc = &TraefikOidc{
|
ts.tOidc = &TraefikOidc{
|
||||||
issuerURL: "https://test-issuer.com",
|
issuerURL: "https://test-issuer.com",
|
||||||
clientID: "test-client-id",
|
clientID: "test-client-id",
|
||||||
audience: "test-client-id",
|
audience: "test-client-id",
|
||||||
clientSecret: "test-client-secret",
|
clientSecret: "test-client-secret",
|
||||||
roleClaimName: "roles", // Set default for backward compatibility
|
roleClaimName: "roles", // Set default for backward compatibility
|
||||||
groupClaimName: "groups", // Set default for backward compatibility
|
groupClaimName: "groups", // Set default for backward compatibility
|
||||||
jwkCache: ts.mockJWKCache,
|
userIdentifierClaim: "email", // Set default for backward compatibility
|
||||||
jwksURL: "https://test-jwks-url.com",
|
jwkCache: ts.mockJWKCache,
|
||||||
revocationURL: "https://revocation-endpoint.com",
|
jwksURL: "https://test-jwks-url.com",
|
||||||
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
revocationURL: "https://revocation-endpoint.com",
|
||||||
tokenBlacklist: tokenBlacklist,
|
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
||||||
tokenCache: tokenCache,
|
tokenBlacklist: tokenBlacklist,
|
||||||
logger: logger,
|
tokenCache: tokenCache,
|
||||||
allowedUserDomains: map[string]struct{}{"example.com": {}},
|
logger: logger,
|
||||||
excludedURLs: map[string]struct{}{"/favicon": {}, "/health": {}},
|
allowedUserDomains: map[string]struct{}{"example.com": {}},
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
excludedURLs: map[string]struct{}{"/favicon": {}, "/health": {}},
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
// Explicitly set paths as New() is bypassed
|
// Explicitly set paths as New() is bypassed
|
||||||
redirURLPath: "/callback", // Assume default callback path for tests
|
redirURLPath: "/callback", // Assume default callback path for tests
|
||||||
logoutURLPath: "/callback/logout", // Assume default logout path for tests
|
logoutURLPath: "/callback/logout", // Assume default logout path for tests
|
||||||
@@ -784,7 +785,7 @@ func TestServeHTTP(t *testing.T) {
|
|||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
},
|
},
|
||||||
expectedStatus: http.StatusForbidden,
|
expectedStatus: http.StatusForbidden,
|
||||||
expectedBody: `{"error":"Forbidden","error_description":"Access denied: Your email domain is not allowed. To log out, visit: /callback/logout","status_code":403}`,
|
expectedBody: `{"error":"Forbidden","error_description":"Access denied: You are not authorized to access this resource. To log out, visit: /callback/logout","status_code":403}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Disallowed Domain (Accept: HTML)",
|
name: "Disallowed Domain (Accept: HTML)",
|
||||||
@@ -1282,8 +1283,9 @@ func TestHandleCallback(t *testing.T) {
|
|||||||
instanceExtractClaimsFunc = extractClaims // Default to the real function if not provided by test case
|
instanceExtractClaimsFunc = extractClaims // Default to the real function if not provided by test case
|
||||||
}
|
}
|
||||||
tOidc := &TraefikOidc{
|
tOidc := &TraefikOidc{
|
||||||
allowedUserDomains: map[string]struct{}{"example.com": {}},
|
allowedUserDomains: map[string]struct{}{"example.com": {}},
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
userIdentifierClaim: "email", // Required for claim extraction
|
||||||
// exchangeCodeForTokenFunc: tc.exchangeCodeForToken, // Removed field
|
// exchangeCodeForTokenFunc: tc.exchangeCodeForToken, // Removed field
|
||||||
extractClaimsFunc: instanceExtractClaimsFunc, // Use the potentially defaulted function
|
extractClaimsFunc: instanceExtractClaimsFunc, // Use the potentially defaulted function
|
||||||
tokenVerifier: nil, // Will be set to self below
|
tokenVerifier: nil, // Will be set to self below
|
||||||
@@ -1438,6 +1440,228 @@ func TestIsAllowedDomain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsAllowedUser(t *testing.T) {
|
||||||
|
ts := NewTestSuite(t)
|
||||||
|
ts.Setup()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
allowedDomains map[string]struct{}
|
||||||
|
allowedUsers map[string]struct{}
|
||||||
|
userIdentifierClaim string
|
||||||
|
name string
|
||||||
|
userIdentifier string
|
||||||
|
allowed bool
|
||||||
|
}{
|
||||||
|
// Email-based identification (default behavior)
|
||||||
|
{
|
||||||
|
name: "Email identifier - allowed domain",
|
||||||
|
userIdentifier: "user@example.com",
|
||||||
|
userIdentifierClaim: "email",
|
||||||
|
allowedDomains: map[string]struct{}{"example.com": {}},
|
||||||
|
allowedUsers: map[string]struct{}{},
|
||||||
|
allowed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Email identifier - disallowed domain",
|
||||||
|
userIdentifier: "user@notallowed.com",
|
||||||
|
userIdentifierClaim: "email",
|
||||||
|
allowedDomains: map[string]struct{}{"example.com": {}},
|
||||||
|
allowedUsers: map[string]struct{}{},
|
||||||
|
allowed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Email identifier - specific user allowed",
|
||||||
|
userIdentifier: "specific.user@otherdomain.com",
|
||||||
|
userIdentifierClaim: "email",
|
||||||
|
allowedDomains: map[string]struct{}{"example.com": {}},
|
||||||
|
allowedUsers: map[string]struct{}{"specific.user@otherdomain.com": {}},
|
||||||
|
allowed: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Non-email identifier (sub claim - for Azure AD users without email)
|
||||||
|
{
|
||||||
|
name: "Sub identifier - allowed in allowedUsers",
|
||||||
|
userIdentifier: "abc12345-6789-0abc-def0-123456789abc",
|
||||||
|
userIdentifierClaim: "sub",
|
||||||
|
allowedDomains: map[string]struct{}{},
|
||||||
|
allowedUsers: map[string]struct{}{"abc12345-6789-0abc-def0-123456789abc": {}},
|
||||||
|
allowed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sub identifier - not in allowedUsers",
|
||||||
|
userIdentifier: "xyz-not-allowed-user",
|
||||||
|
userIdentifierClaim: "sub",
|
||||||
|
allowedDomains: map[string]struct{}{},
|
||||||
|
allowedUsers: map[string]struct{}{"abc12345-6789-0abc-def0-123456789abc": {}},
|
||||||
|
allowed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sub identifier - allowedDomains ignored for non-email",
|
||||||
|
userIdentifier: "user-id-12345",
|
||||||
|
userIdentifierClaim: "sub",
|
||||||
|
allowedDomains: map[string]struct{}{"example.com": {}}, // Should be ignored
|
||||||
|
allowedUsers: map[string]struct{}{"user-id-12345": {}},
|
||||||
|
allowed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sub identifier - no restrictions allows all",
|
||||||
|
userIdentifier: "any-user-id",
|
||||||
|
userIdentifierClaim: "sub",
|
||||||
|
allowedDomains: map[string]struct{}{},
|
||||||
|
allowedUsers: map[string]struct{}{},
|
||||||
|
allowed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sub identifier - case insensitive matching",
|
||||||
|
userIdentifier: "ABC12345-6789-0ABC-DEF0-123456789ABC", // Uppercase
|
||||||
|
userIdentifierClaim: "sub",
|
||||||
|
allowedDomains: map[string]struct{}{},
|
||||||
|
allowedUsers: map[string]struct{}{"abc12345-6789-0abc-def0-123456789abc": {}}, // Lowercase
|
||||||
|
allowed: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// OID claim (Azure AD object ID)
|
||||||
|
{
|
||||||
|
name: "OID identifier - allowed user",
|
||||||
|
userIdentifier: "oid-12345-67890",
|
||||||
|
userIdentifierClaim: "oid",
|
||||||
|
allowedDomains: map[string]struct{}{},
|
||||||
|
allowedUsers: map[string]struct{}{"oid-12345-67890": {}},
|
||||||
|
allowed: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// UPN claim (Azure AD User Principal Name)
|
||||||
|
{
|
||||||
|
name: "UPN identifier - allowed user (looks like email but use sub logic)",
|
||||||
|
userIdentifier: "user@tenant.onmicrosoft.com",
|
||||||
|
userIdentifierClaim: "upn",
|
||||||
|
allowedDomains: map[string]struct{}{"example.com": {}}, // Different domain, should be ignored
|
||||||
|
allowedUsers: map[string]struct{}{"user@tenant.onmicrosoft.com": {}},
|
||||||
|
allowed: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Edge cases
|
||||||
|
{
|
||||||
|
name: "Empty identifier - not allowed",
|
||||||
|
userIdentifier: "",
|
||||||
|
userIdentifierClaim: "sub",
|
||||||
|
allowedDomains: map[string]struct{}{},
|
||||||
|
allowedUsers: map[string]struct{}{"some-user": {}},
|
||||||
|
allowed: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Configure TraefikOidc instance for this test case
|
||||||
|
tOidc := ts.tOidc
|
||||||
|
tOidc.allowedUserDomains = tc.allowedDomains
|
||||||
|
tOidc.allowedUsers = tc.allowedUsers
|
||||||
|
tOidc.userIdentifierClaim = tc.userIdentifierClaim
|
||||||
|
|
||||||
|
allowed := tOidc.isAllowedUser(tc.userIdentifier)
|
||||||
|
if allowed != tc.allowed {
|
||||||
|
t.Errorf("Expected allowed=%v, got %v for userIdentifier=%q with claim=%q",
|
||||||
|
tc.allowed, allowed, tc.userIdentifier, tc.userIdentifierClaim)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserIdentifierClaimExtraction(t *testing.T) {
|
||||||
|
// Test that the correct claim is extracted based on userIdentifierClaim config
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
userIdentifierClaim string
|
||||||
|
claims map[string]interface{}
|
||||||
|
expectedIdentifier string
|
||||||
|
shouldFallbackToSub bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Email claim extraction (default)",
|
||||||
|
userIdentifierClaim: "email",
|
||||||
|
claims: map[string]interface{}{
|
||||||
|
"sub": "user-sub-id",
|
||||||
|
"email": "user@example.com",
|
||||||
|
},
|
||||||
|
expectedIdentifier: "user@example.com",
|
||||||
|
shouldFallbackToSub: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sub claim extraction",
|
||||||
|
userIdentifierClaim: "sub",
|
||||||
|
claims: map[string]interface{}{
|
||||||
|
"sub": "user-sub-id",
|
||||||
|
"email": "user@example.com",
|
||||||
|
},
|
||||||
|
expectedIdentifier: "user-sub-id",
|
||||||
|
shouldFallbackToSub: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OID claim extraction (Azure AD)",
|
||||||
|
userIdentifierClaim: "oid",
|
||||||
|
claims: map[string]interface{}{
|
||||||
|
"sub": "user-sub-id",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"oid": "azure-object-id",
|
||||||
|
},
|
||||||
|
expectedIdentifier: "azure-object-id",
|
||||||
|
shouldFallbackToSub: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UPN claim extraction (Azure AD)",
|
||||||
|
userIdentifierClaim: "upn",
|
||||||
|
claims: map[string]interface{}{
|
||||||
|
"sub": "user-sub-id",
|
||||||
|
"upn": "user@tenant.onmicrosoft.com",
|
||||||
|
},
|
||||||
|
expectedIdentifier: "user@tenant.onmicrosoft.com",
|
||||||
|
shouldFallbackToSub: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Fallback to sub when configured claim is missing",
|
||||||
|
userIdentifierClaim: "email",
|
||||||
|
claims: map[string]interface{}{
|
||||||
|
"sub": "fallback-sub-id",
|
||||||
|
// email is missing
|
||||||
|
},
|
||||||
|
expectedIdentifier: "fallback-sub-id",
|
||||||
|
shouldFallbackToSub: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preferred_username claim extraction",
|
||||||
|
userIdentifierClaim: "preferred_username",
|
||||||
|
claims: map[string]interface{}{
|
||||||
|
"sub": "user-sub-id",
|
||||||
|
"preferred_username": "jdoe",
|
||||||
|
},
|
||||||
|
expectedIdentifier: "jdoe",
|
||||||
|
shouldFallbackToSub: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Extract user identifier using the same logic as auth_flow.go
|
||||||
|
userIdentifier, _ := tc.claims[tc.userIdentifierClaim].(string)
|
||||||
|
usedFallback := false
|
||||||
|
|
||||||
|
if userIdentifier == "" && tc.userIdentifierClaim != "sub" {
|
||||||
|
userIdentifier, _ = tc.claims["sub"].(string)
|
||||||
|
usedFallback = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if userIdentifier != tc.expectedIdentifier {
|
||||||
|
t.Errorf("Expected identifier %q, got %q", tc.expectedIdentifier, userIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usedFallback != tc.shouldFallbackToSub {
|
||||||
|
t.Errorf("Expected fallback=%v, got %v", tc.shouldFallbackToSub, usedFallback)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOIDCHandler(t *testing.T) {
|
func TestOIDCHandler(t *testing.T) {
|
||||||
ts := NewTestSuite(t)
|
ts := NewTestSuite(t)
|
||||||
ts.Setup()
|
ts.Setup()
|
||||||
|
|||||||
+10
-10
@@ -125,12 +125,12 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email := session.GetEmail()
|
userIdentifier := session.GetEmail() // GetEmail returns the stored user identifier (email or other claim)
|
||||||
// Domain restriction check removed debug output
|
// User authorization check
|
||||||
if authenticated && email != "" {
|
if authenticated && userIdentifier != "" {
|
||||||
if !t.isAllowedDomain(email) {
|
if !t.isAllowedUser(userIdentifier) {
|
||||||
t.logger.Infof("User with email %s is not from an allowed domain", email)
|
t.logger.Infof("User %s is not authorized", userIdentifier)
|
||||||
errorMsg := fmt.Sprintf("Access denied: Your email domain is not allowed. To log out, visit: %s", t.logoutURLPath)
|
errorMsg := fmt.Sprintf("Access denied: You are not authorized to access this resource. To log out, visit: %s", t.logoutURLPath)
|
||||||
t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden)
|
t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -193,10 +193,10 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
refreshed := t.refreshToken(rw, req, session)
|
refreshed := t.refreshToken(rw, req, session)
|
||||||
if refreshed {
|
if refreshed {
|
||||||
email = session.GetEmail()
|
userIdentifier = session.GetEmail() // GetEmail returns the stored user identifier
|
||||||
if email != "" && !t.isAllowedDomain(email) {
|
if userIdentifier != "" && !t.isAllowedUser(userIdentifier) {
|
||||||
t.logger.Infof("User with refreshed token email %s is not from an allowed domain", email)
|
t.logger.Infof("User with refreshed token %s is not authorized", userIdentifier)
|
||||||
errorMsg := fmt.Sprintf("Access denied: Your email domain is not allowed. To log out, visit: %s", t.logoutURLPath)
|
errorMsg := fmt.Sprintf("Access denied: You are not authorized to access this resource. To log out, visit: %s", t.logoutURLPath)
|
||||||
t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden)
|
t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -127,6 +127,22 @@ type Config struct {
|
|||||||
// Default: "groups"
|
// Default: "groups"
|
||||||
GroupClaimName string `json:"groupClaimName,omitempty"`
|
GroupClaimName string `json:"groupClaimName,omitempty"`
|
||||||
|
|
||||||
|
// UserIdentifierClaim specifies the JWT claim to use as the user identifier.
|
||||||
|
// This allows authentication for users without email addresses (e.g., Azure AD service accounts).
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// - Default (backward compatible): "email"
|
||||||
|
// - Azure AD without email: "sub", "oid", "upn", or "preferred_username"
|
||||||
|
// - Generic OIDC: "sub" (always present per OIDC spec)
|
||||||
|
//
|
||||||
|
// When set to a non-email claim:
|
||||||
|
// - AllowedUsers will match against this claim value instead of email
|
||||||
|
// - AllowedUserDomains validation is skipped (domains only apply to email)
|
||||||
|
// - The session will store this identifier as the user's identity
|
||||||
|
//
|
||||||
|
// Default: "email"
|
||||||
|
UserIdentifierClaim string `json:"userIdentifierClaim,omitempty"`
|
||||||
|
|
||||||
// DynamicClientRegistration enables OIDC Dynamic Client Registration (RFC 7591)
|
// DynamicClientRegistration enables OIDC Dynamic Client Registration (RFC 7591)
|
||||||
// When enabled, the middleware will automatically register as a client with
|
// When enabled, the middleware will automatically register as a client with
|
||||||
// the OIDC provider if ClientID/ClientSecret are not provided.
|
// the OIDC provider if ClientID/ClientSecret are not provided.
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ type TraefikOidc struct {
|
|||||||
audience string // Expected JWT audience, defaults to clientID
|
audience string // Expected JWT audience, defaults to clientID
|
||||||
roleClaimName string // JWT claim name for extracting roles, defaults to "roles"
|
roleClaimName string // JWT claim name for extracting roles, defaults to "roles"
|
||||||
groupClaimName string // JWT claim name for extracting groups, defaults to "groups"
|
groupClaimName string // JWT claim name for extracting groups, defaults to "groups"
|
||||||
|
userIdentifierClaim string // JWT claim for user identification, defaults to "email"
|
||||||
name string
|
name string
|
||||||
redirURLPath string
|
redirURLPath string
|
||||||
logoutURLPath string
|
logoutURLPath string
|
||||||
|
|||||||
@@ -55,6 +55,51 @@ func (t *TraefikOidc) safeLogInfo(msg string) {
|
|||||||
// DOMAIN VALIDATION
|
// DOMAIN VALIDATION
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
// isAllowedUser checks if a user identifier is authorized based on the configured user identifier claim.
|
||||||
|
// When using email as the identifier (default), it validates against allowedUsers and allowedUserDomains.
|
||||||
|
// When using non-email identifiers (sub, oid, upn, etc.), it only validates against allowedUsers
|
||||||
|
// since domain-based validation doesn't apply to non-email identifiers.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - userIdentifier: The user identifier to validate (email, sub, oid, upn, etc.).
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - true if the user is authorized, false otherwise.
|
||||||
|
func (t *TraefikOidc) isAllowedUser(userIdentifier string) bool {
|
||||||
|
// If no restrictions are configured, allow all authenticated users
|
||||||
|
if len(t.allowedUserDomains) == 0 && len(t.allowedUsers) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is explicitly allowed
|
||||||
|
if len(t.allowedUsers) > 0 {
|
||||||
|
_, userAllowed := t.allowedUsers[strings.ToLower(userIdentifier)]
|
||||||
|
if userAllowed {
|
||||||
|
t.logger.Debugf("User identifier %s is explicitly allowed in allowedUsers", userIdentifier)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For email-based identifiers, also check domain restrictions
|
||||||
|
// Only apply domain validation if using email as identifier AND identifier looks like an email
|
||||||
|
if t.userIdentifierClaim == "email" && strings.Contains(userIdentifier, "@") {
|
||||||
|
return t.isAllowedDomain(userIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-email identifiers with allowedUserDomains configured, log a warning
|
||||||
|
if len(t.allowedUserDomains) > 0 && t.userIdentifierClaim != "email" {
|
||||||
|
t.logger.Debugf("AllowedUserDomains is configured but userIdentifierClaim is '%s', not 'email'. Domain validation skipped for: %s",
|
||||||
|
t.userIdentifierClaim, userIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User not found in allowedUsers list
|
||||||
|
if len(t.allowedUsers) > 0 {
|
||||||
|
t.logger.Debugf("User identifier %s is not in the allowed users list", userIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// isAllowedDomain checks if an email address is authorized based on domain or user whitelist.
|
// isAllowedDomain checks if an email address is authorized based on domain or user whitelist.
|
||||||
// It validates against both allowed user domains and specific allowed users.
|
// It validates against both allowed user domains and specific allowed users.
|
||||||
// Parameters:
|
// Parameters:
|
||||||
|
|||||||
Reference in New Issue
Block a user