diff --git a/.traefik.yml b/.traefik.yml index c86c257..fb7efd9 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -45,6 +45,10 @@ testData: - company.com - subsidiary.com + allowedUsers: # Restricts access to specific email addresses regardless of domain + - specific-user@company.com + - another-user@gmail.com + allowedRolesAndGroups: # Restricts access to users with specific roles or groups (if not provided, no role/group restrictions) - guest-endpoints - admin @@ -215,6 +219,21 @@ configuration: items: type: string + allowedUsers: + type: array + description: | + Restricts access to specific email addresses. + If provided, only users with these exact email addresses will be allowed access, + in addition to any domain-level restrictions set by allowedUserDomains. + + This provides fine-grained control over individual access and can be used + together with allowedUserDomains for flexible access control strategies. + + Examples: ["user1@example.com", "admin@company.com"] + required: false + items: + type: string + allowedRolesAndGroups: type: array description: | diff --git a/README.md b/README.md index 96e8129..8a5a88a 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ The middleware supports the following configuration options: | `rateLimit` | Sets the maximum number of requests per second | `100` | `500` | | `excludedURLs` | Lists paths that bypass authentication | none | `["/health", "/metrics", "/public"]` | | `allowedUserDomains` | Restricts access to specific email domains | none | `["company.com", "subsidiary.com"]` | +| `allowedUsers` | A list of specific email addresses that are allowed access | none | `["user1@example.com", "user2@another.org"]` | | `allowedRolesAndGroups` | Restricts access to users with specific roles or groups | none | `["admin", "developer"]` | | `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` | @@ -159,6 +160,67 @@ spec: - subsidiary.com ``` +### With Specific User Access + +```yaml +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: oidc-specific-users + namespace: traefik +spec: + plugin: + traefikoidc: + providerURL: https://accounts.google.com + clientID: 1234567890.apps.googleusercontent.com + clientSecret: your-client-secret + sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long + callbackURL: /oauth2/callback + logoutURL: /oauth2/logout + scopes: + - openid + - email + - profile + allowedUsers: + - user1@example.com + - user2@another.org +``` + +### With Both Domain and Specific User Access + +```yaml +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: oidc-domain-and-users + namespace: traefik +spec: + plugin: + traefikoidc: + providerURL: https://accounts.google.com + clientID: 1234567890.apps.googleusercontent.com + clientSecret: your-client-secret + sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long + callbackURL: /oauth2/callback + logoutURL: /oauth2/logout + scopes: + - openid + - email + - profile + allowedUserDomains: + - company.com + allowedUsers: + - special-user@gmail.com + - contractor@external.org +``` + +When configuring access control: +- If only `allowedUsers` is set, only the specified email addresses will be granted access +- If only `allowedUserDomains` is set, only users with email addresses from those domains will be granted access +- If both are set, access is granted if the user's email is in `allowedUsers` OR their email's domain is in `allowedUserDomains` +- If neither is set, any authenticated user will be granted access +- Email matching is case-insensitive + ### With Role-Based Access Control ```yaml @@ -452,6 +514,9 @@ http: - profile allowedUserDomains: - company.com + allowedUsers: + - special-user@gmail.com + - contractor@external.org allowedRolesAndGroups: - admin - developer diff --git a/main.go b/main.go index 087039e..9fdc079 100644 --- a/main.go +++ b/main.go @@ -110,6 +110,7 @@ type TraefikOidc struct { jwtVerifier JWTVerifier excludedURLs map[string]struct{} allowedUserDomains map[string]struct{} + allowedUsers map[string]struct{} // Map for case-insensitive lookup of allowed email addresses allowedRolesAndGroups map[string]struct{} initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) // exchangeCodeForTokenFunc func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) // Replaced by interface @@ -404,6 +405,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h httpClient: httpClient, excludedURLs: createStringMap(config.ExcludedURLs), allowedUserDomains: createStringMap(config.AllowedUserDomains), + allowedUsers: createCaseInsensitiveStringMap(config.AllowedUsers), allowedRolesAndGroups: createStringMap(config.AllowedRolesAndGroups), initComplete: make(chan struct{}), logger: logger, @@ -1806,39 +1808,62 @@ func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, se return true } -// isAllowedDomain checks if the domain part of the provided email address is present -// in the configured list of allowed domains (t.allowedUserDomains). -// If the allowed domains list is empty, all domains are considered allowed. +// isAllowedDomain checks if the provided email address is authorized based on combined +// checks against the allowed users list and the allowed domains list. +// +// Authorization rules: +// - If both allowedUsers and allowedUserDomains are empty, any user with a valid OIDC session is authorized. +// - If allowedUsers is not empty, a user is authorized if their email address is present in the allowedUsers list. +// - If allowedUserDomains is not empty, a user is authorized if their email's domain is present in the allowedUserDomains list. +// - If both allowedUsers and allowedUserDomains are configured, a user is authorized if either condition is met. // // Parameters: // - email: The email address to check. // // Returns: -// - true if the domain is allowed or if no domain restrictions are configured. -// - false if the email format is invalid or the domain is not in the allowed list. +// - true if the user is authorized based on the rules above. +// - false if the user is not authorized or if the email format is invalid. func (t *TraefikOidc) isAllowedDomain(email string) bool { - if len(t.allowedUserDomains) == 0 { - return true // If no domains are specified, all are allowed + // If both lists are empty, all users are allowed + if len(t.allowedUserDomains) == 0 && len(t.allowedUsers) == 0 { + return true } - parts := strings.Split(email, "@") - if len(parts) != 2 { - t.logger.Errorf("Invalid email format encountered: %s", email) - return false // Invalid email format + // Check for specific user email (case-insensitive) + if len(t.allowedUsers) > 0 { + _, userAllowed := t.allowedUsers[strings.ToLower(email)] + if userAllowed { + t.logger.Debugf("Email %s is explicitly allowed in allowedUsers", email) + return true + } } - domain := parts[1] - _, ok := t.allowedUserDomains[domain] + // Check domain if there are domain restrictions + if len(t.allowedUserDomains) > 0 { + parts := strings.Split(email, "@") + if len(parts) != 2 { + t.logger.Errorf("Invalid email format encountered: %s", email) + return false // Invalid email format + } - // Add explicit logging for better debugging - if ok { - t.logger.Debugf("Email domain %s is allowed", domain) - } else { - t.logger.Debugf("Email domain %s is NOT allowed. Allowed domains: %v", - domain, keysFromMap(t.allowedUserDomains)) + domain := parts[1] + _, domainAllowed := t.allowedUserDomains[domain] + + if domainAllowed { + t.logger.Debugf("Email domain %s is allowed", domain) + return true + } else { + t.logger.Debugf("Email domain %s is NOT allowed. Allowed domains: %v", + domain, keysFromMap(t.allowedUserDomains)) + } + } else if len(t.allowedUsers) > 0 { + // If only specific users are allowed (no domains), and email wasn't in the list + t.logger.Debugf("Email %s is not in the allowed users list: %v", + email, keysFromMap(t.allowedUsers)) } - return ok + // If we reach here, the user is not authorized + return false } // Helper function to get keys from a map for logging @@ -1850,6 +1875,16 @@ func keysFromMap(m map[string]struct{}) []string { return keys } +// createCaseInsensitiveStringMap creates a map from a slice of strings where keys are lowercase +// for case-insensitive matching of email addresses +func createCaseInsensitiveStringMap(items []string) map[string]struct{} { + result := make(map[string]struct{}) + for _, item := range items { + result[strings.ToLower(item)] = struct{}{} + } + return result +} + // extractGroupsAndRoles attempts to extract 'groups' and 'roles' claims from a decoded ID token. // It expects these claims, if present, to be arrays of strings. // It uses the configured extractClaimsFunc (which defaults to the package-level extractClaims) diff --git a/main_test.go b/main_test.go index de721a9..447eb62 100644 --- a/main_test.go +++ b/main_test.go @@ -1210,30 +1210,79 @@ func TestIsAllowedDomain(t *testing.T) { ts.Setup() tests := []struct { - name string - email string - allowed bool + name string + email string + allowedDomains map[string]struct{} + allowedUsers map[string]struct{} + allowed bool + expectedLogOutput string // For testing log messages }{ { - name: "Allowed domain", - email: "user@example.com", - allowed: true, + name: "Allowed domain", + email: "user@example.com", + allowedDomains: map[string]struct{}{"example.com": {}}, + allowedUsers: map[string]struct{}{}, + allowed: true, }, { - name: "Disallowed domain", - email: "user@notallowed.com", - allowed: false, + name: "Disallowed domain", + email: "user@notallowed.com", + allowedDomains: map[string]struct{}{"example.com": {}}, + allowedUsers: map[string]struct{}{}, + allowed: false, }, { - name: "Invalid email", - email: "invalid-email", - allowed: false, + name: "Invalid email", + email: "invalid-email", + allowedDomains: map[string]struct{}{"example.com": {}}, + allowedUsers: map[string]struct{}{}, + allowed: false, + }, + { + name: "Specific user is allowed regardless of domain", + email: "specific.user@otherdomain.com", + allowedDomains: map[string]struct{}{"example.com": {}}, + allowedUsers: map[string]struct{}{"specific.user@otherdomain.com": {}}, + allowed: true, + }, + { + name: "Case-insensitive email matching for specific user", + email: "Specific.User@otherdomain.com", // Mixed case + allowedDomains: map[string]struct{}{"example.com": {}}, + allowedUsers: map[string]struct{}{"specific.user@otherdomain.com": {}}, // Lowercase + allowed: true, + }, + { + name: "Only allowed users configured (no domains)", + email: "specific.user@otherdomain.com", + allowedDomains: map[string]struct{}{}, // Empty allowed domains + allowedUsers: map[string]struct{}{"specific.user@otherdomain.com": {}}, + allowed: true, + }, + { + name: "User not in allowed list when only specific users configured", + email: "other.user@otherdomain.com", + allowedDomains: map[string]struct{}{}, // Empty allowed domains + allowedUsers: map[string]struct{}{"specific.user@otherdomain.com": {}}, + allowed: false, + }, + { + name: "No restrictions (both empty)", + email: "anyone@anydomain.com", + allowedDomains: map[string]struct{}{}, + allowedUsers: map[string]struct{}{}, + allowed: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - allowed := ts.tOidc.isAllowedDomain(tc.email) + // Configure TraefikOidc instance for this test case + tOidc := ts.tOidc + tOidc.allowedUserDomains = tc.allowedDomains + tOidc.allowedUsers = tc.allowedUsers + + allowed := tOidc.isAllowedDomain(tc.email) if allowed != tc.allowed { t.Errorf("Expected allowed=%v, got %v", tc.allowed, allowed) } diff --git a/settings.go b/settings.go index ae51a96..45268fa 100644 --- a/settings.go +++ b/settings.go @@ -82,6 +82,10 @@ type Config struct { // Example: ["company.com", "subsidiary.com"] AllowedUserDomains []string `json:"allowedUserDomains"` + // AllowedUsers restricts access to specific email addresses (optional) + // Example: ["user1@example.com", "user2@example.com"] + AllowedUsers []string `json:"allowedUsers"` + // AllowedRolesAndGroups restricts access to users with specific roles or groups (optional) // Example: ["admin", "developer"] AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"` diff --git a/settings_test.go b/settings_test.go index 83ae05a..fd95d0a 100644 --- a/settings_test.go +++ b/settings_test.go @@ -202,6 +202,20 @@ func TestConfigValidate(t *testing.T) { }, expectedError: "", }, + { + name: "Valid Config With AllowedUsers", + config: &Config{ + ProviderURL: "https://provider.com", + CallbackURL: "/callback", + ClientID: "client-id", + ClientSecret: "client-secret", + SessionEncryptionKey: "this-is-a-long-enough-encryption-key", + LogLevel: "debug", + RateLimit: 100, + AllowedUsers: []string{"user1@example.com", "user2@example.com"}, + }, + expectedError: "", + }, } for _, tc := range tests {