Compare commits

...

5 Commits

10 changed files with 1800 additions and 204 deletions
+38
View File
@@ -58,6 +58,16 @@ testData:
- /public
- /health
- /metrics
headers: # Custom headers to set with templated values from claims and tokens
- name: "X-User-Email"
value: "{{.Claims.email}}"
- name: "X-User-ID"
value: "{{.Claims.sub}}"
- name: "Authorization"
value: "Bearer {{.AccessToken}}"
- name: "X-User-Roles"
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
# Advanced parameters (usually discovered automatically from provider metadata)
revocationURL: https://accounts.google.com/revoke # Endpoint for revoking tokens
@@ -243,3 +253,31 @@ configuration:
Default: false
required: false
headers:
type: array
description: |
Custom HTTP headers to set with templated values derived from OIDC claims and tokens.
Each header has a name and a value template that can access:
- {{.Claims.field}} - Access ID token claims (e.g., email, sub, name)
- {{.AccessToken}} - The raw access token string
- {{.IdToken}} - The raw ID token string
- {{.RefreshToken}} - The raw refresh token string
Templates support Go template syntax including conditionals and iteration.
Variable names are case-sensitive - use .Claims not .claims.
Examples:
- name: "X-User-Email", value: "{{.Claims.email}}"
- name: "Authorization", value: "Bearer {{.AccessToken}}"
- name: "X-User-Roles", value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
required: false
items:
type: object
properties:
name:
type: string
description: The HTTP header name to set
value:
type: string
description: Template string for the header value
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Lukasz Raczylo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+148 -9
View File
@@ -69,14 +69,16 @@ The middleware supports the following configuration options:
| `postLogoutRedirectURI` | The URL to redirect to after logout | `/` | `/logged-out-page` |
| `scopes` | The OAuth 2.0 scopes to request | `["openid", "profile", "email"]` | `["openid", "email", "profile", "roles"]` |
| `logLevel` | Sets the logging verbosity | `info` | `debug`, `info`, `error` |
| | `forceHTTPS` | Forces the use of HTTPS for all URLs | `true` | `true`, `false` |
| | `rateLimit` | Sets the maximum number of requests per second | `100` | `500` |
| | `excludedURLs` | Lists paths that bypass authentication | `["/favicon"]` | `["/health", "/metrics", "/public"]` |
| | `allowedUserDomains` | Restricts access to specific email domains | none | `["company.com", "subsidiary.com"]` |
| | `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` |
| | `enablePKCE` | Enables PKCE (Proof Key for Code Exchange) for authorization code flow | `false` | `true`, `false` |
| `forceHTTPS` | Forces the use of HTTPS for all URLs | `true` | `true`, `false` |
| `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"]` |
| `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` |
| `enablePKCE` | Enables PKCE (Proof Key for Code Exchange) for authorization code flow | `false` | `true`, `false` |
| `refreshGracePeriodSeconds` | Seconds before token expiry to attempt proactive refresh | `60` | `120` |
| `headers` | Custom HTTP headers with templates that can access OIDC claims and tokens | none | See "Templated Headers" section |
## Usage Examples
@@ -234,6 +236,41 @@ spec:
- profile
```
### With Templated Headers
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-with-headers
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
- roles
headers:
- name: "X-User-Email"
value: "{{.Claims.email}}"
- name: "X-User-ID"
value: "{{.Claims.sub}}"
- name: "Authorization"
value: "Bearer {{.AccessToken}}"
- name: "X-User-Roles"
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
- name: "X-Is-Admin"
value: "{{if eq .Claims.role \"admin\"}}true{{else}}false{{end}}"
```
### With PKCE Enabled
```yaml
@@ -258,6 +295,34 @@ spec:
- profile
```
### Google OIDC Configuration Example
This example shows a configuration specifically tailored for Google OIDC, including necessary scopes for session extension:
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-google
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: your-google-client-id.apps.googleusercontent.com # Replace with your Client ID
clientSecret: your-google-client-secret # Replace with your Client Secret
sessionEncryptionKey: your-secure-encryption-key-min-32-chars # Replace with your key
callbackURL: /oauth2/callback # Adjust if needed
logoutURL: /oauth2/logout # Optional: Adjust if needed
scopes:
- openid
- email
- profile
- offline_access # Required for refresh tokens / long sessions with Google
refreshGracePeriodSeconds: 300 # Optional: Start refresh 5 min before expiry (default 60)
# Other optional parameters like allowedUserDomains, etc. can be added here
```
### Keeping Secrets Secret in Kubernetes
For Kubernetes environments, you can reference secrets instead of hardcoding sensitive values:
@@ -395,6 +460,15 @@ http:
- /public
- /health
- /metrics
headers:
- name: "X-User-Email"
value: "{{.Claims.email}}"
- name: "X-User-ID"
value: "{{.Claims.sub}}"
- name: "Authorization"
value: "Bearer {{.AccessToken}}"
- name: "X-User-Roles"
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
```
## Advanced Configuration
@@ -414,11 +488,72 @@ PKCE is recommended when:
Note that not all OIDC providers support PKCE, so check your provider's documentation before enabling this feature.
### Session Duration and Token Refresh
This middleware aims to provide long-lived user sessions, typically up to 24 hours, by utilizing OIDC refresh tokens.
**How it works:**
- When a user authenticates, the middleware requests an access token and, if available, a refresh token from the OIDC provider.
- The access token usually has a short lifespan (e.g., 1 hour).
- Before the access token expires (controlled by `refreshGracePeriodSeconds`), the middleware uses the refresh token to obtain a new access token from the provider without requiring the user to log in again.
- This process repeats, allowing the session to remain valid for as long as the refresh token is valid (often 24 hours or more, depending on the provider).
**Provider-Specific Considerations (e.g., Google):**
- Some providers, like Google, issue short-lived access tokens (e.g., 1 hour) and require specific configurations for long-term sessions.
- To enable session extension beyond the initial token expiry with Google and similar providers, the middleware automatically includes the `offline_access` scope in the authentication request. This scope is necessary to obtain a refresh token.
- For Google specifically, the middleware also adds the `prompt=consent` parameter to the initial authorization request. This ensures Google issues a refresh token, which is crucial for extending the session.
- If a refresh attempt fails (e.g., the refresh token is revoked or expired), the user will be required to re-authenticate. The middleware includes enhanced error handling and logging for these scenarios.
- Ensure your OIDC provider is configured to issue refresh tokens and allows their use for extending sessions. Check your provider's documentation for details on refresh token validity periods.
### Token Caching and Blacklisting
The middleware automatically caches validated tokens to improve performance and maintains a blacklist of revoked tokens.
### Templated Headers
The middleware supports setting custom HTTP headers with values templated from OIDC claims and tokens. This allows you to pass authentication information to downstream services in a flexible, customized format.
Templates can access the following variables:
- `{{.Claims.field}}` - Access individual claims from the ID token (e.g., `{{.Claims.email}}`, `{{.Claims.sub}}`)
- `{{.AccessToken}}` - The raw access token string
- `{{.IdToken}}` - The raw ID token string (same as AccessToken in most configurations)
- `{{.RefreshToken}}` - The raw refresh token string
**Example configuration:**
```yaml
headers:
- name: "X-User-Email"
value: "{{.Claims.email}}"
- name: "X-User-ID"
value: "{{.Claims.sub}}"
- name: "Authorization"
value: "Bearer {{.AccessToken}}"
- name: "X-User-Name"
value: "{{.Claims.given_name}} {{.Claims.family_name}}"
```
**Advanced template examples:**
Conditional logic:
```yaml
headers:
- name: "X-Is-Admin"
value: "{{if eq .Claims.role \"admin\"}}true{{else}}false{{end}}"
```
Array handling:
```yaml
headers:
- name: "X-User-Roles"
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
```
**Notes:**
- Variable names are case-sensitive (use `.Claims`, not `.claims`)
- Missing claims will result in `<no value>` in the header value
- The middleware validates templates during startup and logs errors for invalid templates
### Default Headers Set for Downstream Services
### Headers Set for Downstream Services
When a user is authenticated, the middleware sets the following headers for downstream services:
@@ -455,6 +590,10 @@ logLevel: debug
3. **No matching public key found**: The JWKS endpoint might be unavailable or the token's key ID (kid) doesn't match any key in the JWKS.
4. **Access denied: Your email domain is not allowed**: The user's email domain is not in the `allowedUserDomains` list.
5. **Access denied: You do not have any of the allowed roles or groups**: The user doesn't have any of the roles or groups specified in `allowedRolesAndGroups`.
6. **Google sessions expire after ~1 hour**: If using Google as the OIDC provider and sessions expire prematurely (around 1 hour instead of longer), ensure:
- The `offline_access` scope is included in your configuration (the middleware adds this automatically now, but verify if manually configured).
- Your Google Cloud OAuth consent screen is set to "External" and "Production" mode. "Testing" mode often limits refresh token validity.
- The fix involving automatic `offline_access` scope and `prompt=consent` for Google is active in your middleware version. Check the plugin version corresponds to when this fix was implemented. Enhanced logging around refresh token failures can provide more clues if issues persist.
## Contributing
+147
View File
@@ -0,0 +1,147 @@
package traefikoidc
import (
"fmt"
"net/http/httptest"
"strings"
"testing"
"time"
)
// MockTokenVerifier implements the TokenVerifier interface for testing
type MockTokenVerifier struct {
VerifyFunc func(token string) error
}
func (m *MockTokenVerifier) VerifyToken(token string) error {
if m.VerifyFunc != nil {
return m.VerifyFunc(token)
}
return nil
}
func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
// Create a mocked TraefikOidc instance that simulates Google provider behavior
mockLogger := NewLogger("debug")
// Create a test instance with a Google-like issuer URL
tOidc := &TraefikOidc{
issuerURL: "https://accounts.google.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
logger: mockLogger,
scopes: []string{"openid", "profile", "email"},
refreshGracePeriod: 60,
}
// Create a session manager
sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, mockLogger)
tOidc.sessionManager = sessionManager
t.Run("Google provider detection adds required parameters", func(t *testing.T) {
// Test buildAuthURL to ensure it adds offline_access and prompt=consent for Google
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Check that offline_access scope was added
if !strings.Contains(authURL, "scope=") || !strings.Contains(authURL, "offline_access") {
t.Errorf("offline_access scope not added to Google auth URL: %s", authURL)
}
// Check that prompt=consent was added
if !strings.Contains(authURL, "prompt=consent") {
t.Errorf("prompt=consent not added to Google auth URL: %s", authURL)
}
})
t.Run("Non-Google provider doesn't add Google-specific params", func(t *testing.T) {
// Create a test instance with a non-Google issuer URL
nonGoogleOidc := &TraefikOidc{
issuerURL: "https://auth.example.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
logger: mockLogger,
scopes: []string{"openid", "profile", "email"},
}
// Test buildAuthURL without Google-specific parameters
authURL := nonGoogleOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Check that prompt=consent is not automatically added
if strings.Contains(authURL, "prompt=consent") {
t.Errorf("prompt=consent added to non-Google auth URL: %s", authURL)
}
})
t.Run("Session refresh with Google provider", func(t *testing.T) {
// Create a request and response recorder
req := httptest.NewRequest("GET", "/test", nil)
rw := httptest.NewRecorder()
// Create a session and set a refresh token
session, _ := sessionManager.GetSession(req)
session.SetAuthenticated(true)
session.SetEmail("test@example.com")
session.SetAccessToken("old-access-token")
session.SetRefreshToken("valid-refresh-token")
// Create a mock token exchanger that simulates Google's behavior
mockTokenExchanger := &MockTokenExchanger{
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
// Check that the refresh token is passed correctly
if refreshToken != "valid-refresh-token" {
t.Errorf("Incorrect refresh token passed: %s", refreshToken)
return nil, fmt.Errorf("invalid token")
}
// Return a simulated Google token response with a new access token
// but without a new refresh token (Google doesn't always return a new refresh token)
return &TokenResponse{
IDToken: "new-id-token-from-google",
AccessToken: "new-access-token-from-google",
RefreshToken: "", // Google often doesn't return a new refresh token
ExpiresIn: 3600,
}, nil
},
}
// Set the mock token exchanger
tOidc.tokenExchanger = mockTokenExchanger
// Create a struct that implements the TokenVerifier interface
tOidc.tokenVerifier = &MockTokenVerifier{
VerifyFunc: func(token string) error {
return nil
},
}
tOidc.extractClaimsFunc = func(token string) (map[string]interface{}, error) {
// Return mock claims
return map[string]interface{}{
"email": "test@example.com",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
}, nil
}
// Attempt to refresh the token
refreshed := tOidc.refreshToken(rw, req, session)
// Verify the refresh was successful
if !refreshed {
t.Error("Token refresh failed for Google provider")
}
// Check that we kept the original refresh token since Google didn't provide a new one
if session.GetRefreshToken() != "valid-refresh-token" {
t.Errorf("Original refresh token not preserved: got %s, expected 'valid-refresh-token'",
session.GetRefreshToken())
}
// Check that the access token was updated
if session.GetAccessToken() != "new-id-token-from-google" {
t.Errorf("Access token not updated: got %s, expected 'new-id-token-from-google'",
session.GetAccessToken())
}
})
}
// No need to redefine MockTokenExchanger - it's already defined in main_test.go
+467 -178
View File
File diff suppressed because it is too large Load Diff
+71 -17
View File
@@ -385,9 +385,52 @@ func TestServeHTTP(t *testing.T) {
expectedBody: "OK",
},
{
name: "Unauthenticated request to protected URL",
requestPath: "/protected",
expectedStatus: http.StatusFound, // Expect redirect to OIDC
name: "Unauthenticated request (no refresh token) to protected URL",
requestPath: "/protected",
setupSession: func(session *SessionData) {
// Ensure no tokens are set
session.SetAuthenticated(false)
session.SetAccessToken("")
session.SetRefreshToken("")
},
expectedStatus: http.StatusFound, // Expect redirect to OIDC as there's no refresh token
},
{
name: "Unauthenticated request (with refresh token) to protected URL - Expect Refresh Attempt",
requestPath: "/protected",
setupSession: func(session *SessionData) {
session.SetAuthenticated(false) // Not authenticated
session.SetAccessToken("") // No access token
session.SetRefreshToken("valid-refresh-token-for-unauth-test") // BUT has refresh token
},
mockRefreshTokenFunc: func(originalFunc func(refreshToken string) (*TokenResponse, error)) func(refreshToken string) (*TokenResponse, error) {
return func(refreshToken string) (*TokenResponse, error) {
if refreshToken != "valid-refresh-token-for-unauth-test" {
return nil, fmt.Errorf("mock error: unexpected refresh token '%s'", refreshToken)
}
// Simulate successful refresh
newToken := createNewValidToken() // Use helper from TestServeHTTP
return &TokenResponse{IDToken: newToken, AccessToken: newToken, RefreshToken: "new-refresh-token-unauth", ExpiresIn: 3600}, nil
}
},
expectedStatus: http.StatusOK, // Expect OK after successful refresh
expectedBody: "OK",
},
{
name: "Unauthenticated request (with refresh token) to protected URL - Refresh Fails",
requestPath: "/protected",
setupSession: func(session *SessionData) {
session.SetAuthenticated(false) // Not authenticated
session.SetAccessToken("") // No access token
session.SetRefreshToken("invalid-refresh-token-for-unauth-test") // Invalid refresh token
},
mockRefreshTokenFunc: func(originalFunc func(refreshToken string) (*TokenResponse, error)) func(refreshToken string) (*TokenResponse, error) {
return func(refreshToken string) (*TokenResponse, error) {
// Simulate failed refresh
return nil, fmt.Errorf("mock error: refresh token invalid")
}
},
expectedStatus: http.StatusFound, // Expect redirect to OIDC after failed refresh
},
{
name: "Authenticated request to protected URL (Valid Token)",
@@ -407,11 +450,15 @@ func TestServeHTTP(t *testing.T) {
expectedStatus: http.StatusOK,
expectedBody: "OK",
},
// This test case remains valid as the logic should still attempt refresh when expired token + refresh token exist
{
name: "Authenticated request with expired token and successful refresh",
requestPath: "/protected",
setupSession: func(session *SessionData) {
session.SetAuthenticated(true) // Still marked authenticated initially
// NOTE: isUserAuthenticated now returns authenticated=false if access token is expired,
// even if session.SetAuthenticated(true) was called.
// We rely on needsRefresh=true and the presence of the refresh token to trigger the refresh attempt.
session.SetAuthenticated(true) // Set flag initially, though isUserAuthenticated will override based on token
session.SetEmail("user@example.com")
session.SetAccessToken(createExpiredToken()) // Set expired token
session.SetRefreshToken("valid-refresh-token") // Set valid refresh token
@@ -445,16 +492,19 @@ func TestServeHTTP(t *testing.T) {
t.Fatalf("Failed to get session after request: %v", err)
}
// Assert new tokens are in the session
// Direct comparison with createNewValidToken() is flawed as it generates a new token each time.
// Instead, check if the token was updated (not empty) and verify the refresh token.
if session.GetAccessToken() == "" {
t.Errorf("Expected access token to be updated in session, but it was empty")
if session.GetAccessToken() == "" || session.GetAccessToken() == createExpiredToken() {
t.Errorf("Expected access token to be updated in session, but it was empty or still the expired one")
}
if session.GetRefreshToken() != "new-refresh-token" {
t.Errorf("Expected refresh token to be updated to 'new-refresh-token', got '%s'", session.GetRefreshToken())
}
// Also check authenticated flag is now true
if !session.GetAuthenticated() {
t.Errorf("Expected session to be marked authenticated after successful refresh")
}
},
},
// This test case remains valid as the logic should still return 401 for API clients on refresh failure
{
name: "Logout URL",
requestPath: "/callback/logout", // Match the default logout path set in TestSuite.Setup
@@ -477,10 +527,10 @@ func TestServeHTTP(t *testing.T) {
name: "Authenticated request with expired token and FAILED refresh (Accept: JSON)",
requestPath: "/protected",
setupSession: func(session *SessionData) {
session.SetAuthenticated(true)
session.SetAuthenticated(true) // Set flag initially
session.SetEmail("user@example.com")
session.SetAccessToken(createExpiredToken())
session.SetRefreshToken("valid-refresh-token")
session.SetAccessToken(createExpiredToken()) // Expired access token
session.SetRefreshToken("valid-refresh-token") // Valid refresh token
},
mockRefreshTokenFunc: func(originalFunc func(refreshToken string) (*TokenResponse, error)) func(refreshToken string) (*TokenResponse, error) {
return func(refreshToken string) (*TokenResponse, error) {
@@ -491,17 +541,18 @@ func TestServeHTTP(t *testing.T) {
requestHeaders: map[string]string{
"Accept": "application/json",
},
expectedStatus: http.StatusUnauthorized, // Expect 401 for API client
expectedStatus: http.StatusUnauthorized, // Expect 401 for API client after failed refresh attempt
expectedBody: `{"error":"unauthorized","message":"Token refresh failed"}`,
},
// This test case remains valid as the logic should still redirect browser clients on refresh failure
{
name: "Authenticated request with expired token and FAILED refresh (Accept: HTML)",
requestPath: "/protected",
setupSession: func(session *SessionData) {
session.SetAuthenticated(true)
session.SetAuthenticated(true) // Set flag initially
session.SetEmail("user@example.com")
session.SetAccessToken(createExpiredToken())
session.SetRefreshToken("valid-refresh-token")
session.SetAccessToken(createExpiredToken()) // Expired access token
session.SetRefreshToken("valid-refresh-token") // Valid refresh token
},
mockRefreshTokenFunc: func(originalFunc func(refreshToken string) (*TokenResponse, error)) func(refreshToken string) (*TokenResponse, error) {
return func(refreshToken string) (*TokenResponse, error) {
@@ -512,8 +563,9 @@ func TestServeHTTP(t *testing.T) {
requestHeaders: map[string]string{
"Accept": "text/html", // Browser client
},
expectedStatus: http.StatusFound, // Expect redirect for browser client
expectedStatus: http.StatusFound, // Expect redirect to OIDC for browser client after failed refresh attempt
},
// This test case remains valid as proactive refresh should still be attempted
{
name: "Authenticated request with token nearing expiry (needs refresh)",
requestPath: "/protected",
@@ -529,7 +581,7 @@ func TestServeHTTP(t *testing.T) {
session.SetAuthenticated(true)
session.SetEmail("user@example.com")
session.SetAccessToken(nearExpiryToken)
session.SetRefreshToken("valid-refresh-token-for-near-expiry")
session.SetRefreshToken("valid-refresh-token-for-near-expiry") // Refresh token MUST exist for proactive refresh
},
mockRefreshTokenFunc: func(originalFunc func(refreshToken string) (*TokenResponse, error)) func(refreshToken string) (*TokenResponse, error) {
return func(refreshToken string) (*TokenResponse, error) {
@@ -544,6 +596,7 @@ func TestServeHTTP(t *testing.T) {
expectedStatus: http.StatusOK, // Expect success after proactive refresh
expectedBody: "OK",
},
// This test case remains valid as no refresh should be attempted
{
name: "Authenticated request with token valid (outside grace period)",
requestPath: "/protected",
@@ -1531,6 +1584,7 @@ func TestRevokeToken(t *testing.T) {
tOidc := &TraefikOidc{
tokenBlacklist: NewCache(), // Use generic cache for blacklist
tokenCache: NewTokenCache(),
logger: NewLogger("info"), // Initialize the logger
}
// Cache the token
+50
View File
@@ -10,6 +10,18 @@ import (
"strings"
)
// TemplatedHeader represents a custom HTTP header with a templated value.
// The value can contain template expressions that will be evaluated for each
// authenticated request, such as {{.claims.email}} or {{.accessToken}}.
type TemplatedHeader struct {
// Name is the HTTP header name to set (e.g., "X-Forwarded-Email")
Name string `json:"name"`
// Value is the template string for the header value
// Example: "{{.claims.email}}", "Bearer {{.accessToken}}"
Value string `json:"value"`
}
// Config holds the configuration for the OIDC middleware.
// It provides all necessary settings to configure OpenID Connect authentication
// with various providers like Auth0, Logto, or any standard OIDC provider.
@@ -89,6 +101,17 @@ type Config struct {
// the plugin should attempt to refresh it proactively (optional)
// Default: 60
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
// Headers defines custom HTTP headers to set with templated values (optional)
// Values can reference tokens and claims using Go templates with the following variables:
// - {{.AccessToken}} - The access token (ID token)
// - {{.IdToken}} - Same as AccessToken (for consistency)
// - {{.RefreshToken}} - The refresh token
// - {{.Claims.email}} - Access token claims (use proper case for claim names)
// Examples:
//
// [{Name: "X-Forwarded-Email", Value: "{{.Claims.email}}"}]
// [{Name: "Authorization", Value: "Bearer {{.AccessToken}}"}]
Headers []TemplatedHeader `json:"headers"`
}
const (
@@ -221,6 +244,33 @@ func (c *Config) Validate() error {
return fmt.Errorf("refreshGracePeriodSeconds cannot be negative")
}
// Validate headers configuration
for _, header := range c.Headers {
if header.Name == "" {
return fmt.Errorf("header name cannot be empty")
}
if header.Value == "" {
return fmt.Errorf("header value template cannot be empty")
}
if !strings.Contains(header.Value, "{{") || !strings.Contains(header.Value, "}}") {
return fmt.Errorf("header value '%s' does not appear to be a valid template (missing {{ }})", header.Value)
}
// Provide more helpful guidance for common template errors
if strings.Contains(header.Value, "{{.claims") {
return fmt.Errorf("header template '%s' appears to use lowercase 'claims' - use '{{.Claims...' instead (case sensitive)", header.Value)
}
if strings.Contains(header.Value, "{{.accessToken") {
return fmt.Errorf("header template '%s' appears to use lowercase 'accessToken' - use '{{.AccessToken...' instead (case sensitive)", header.Value)
}
if strings.Contains(header.Value, "{{.idToken") {
return fmt.Errorf("header template '%s' appears to use lowercase 'idToken' - use '{{.IdToken...' instead (case sensitive)", header.Value)
}
if strings.Contains(header.Value, "{{.refreshToken") {
return fmt.Errorf("header template '%s' appears to use lowercase 'refreshToken' - use '{{.RefreshToken...' instead (case sensitive)", header.Value)
}
}
return nil
}
+197
View File
@@ -0,0 +1,197 @@
package traefikoidc
import (
"testing"
"text/template"
)
func TestTemplatedHeaderValidation(t *testing.T) {
tests := []struct {
name string
header TemplatedHeader
expectedError string
}{
{
name: "Empty Name",
header: TemplatedHeader{Name: "", Value: "{{.Claims.email}}"},
expectedError: "header name cannot be empty",
},
{
name: "Empty Value",
header: TemplatedHeader{Name: "X-Email", Value: ""},
expectedError: "header value template cannot be empty",
},
{
name: "Not a Template",
header: TemplatedHeader{Name: "X-Email", Value: "static-value"},
expectedError: "header value 'static-value' does not appear to be a valid template (missing {{ }})",
},
{
name: "Lowercase claims",
header: TemplatedHeader{Name: "X-Email", Value: "{{.claims.email}}"},
expectedError: "header template '{{.claims.email}}' appears to use lowercase 'claims' - use '{{.Claims...' instead (case sensitive)",
},
{
name: "Lowercase accessToken",
header: TemplatedHeader{Name: "X-Token", Value: "Bearer {{.accessToken}}"},
expectedError: "header template 'Bearer {{.accessToken}}' appears to use lowercase 'accessToken' - use '{{.AccessToken...' instead (case sensitive)",
},
{
name: "Lowercase idToken",
header: TemplatedHeader{Name: "X-Token", Value: "Bearer {{.idToken}}"},
expectedError: "header template 'Bearer {{.idToken}}' appears to use lowercase 'idToken' - use '{{.IdToken...' instead (case sensitive)",
},
{
name: "Lowercase refreshToken",
header: TemplatedHeader{Name: "X-Refresh", Value: "Bearer {{.refreshToken}}"},
expectedError: "header template 'Bearer {{.refreshToken}}' appears to use lowercase 'refreshToken' - use '{{.RefreshToken...' instead (case sensitive)",
},
{
name: "Valid Template",
header: TemplatedHeader{Name: "X-Email", Value: "{{.Claims.email}}"},
expectedError: "",
},
{
name: "Valid Bearer Token Template",
header: TemplatedHeader{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
expectedError: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
config := &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
RateLimit: 10, // Adding minimum required rate limit
Headers: []TemplatedHeader{tc.header},
}
err := config.Validate()
if tc.expectedError == "" {
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
} else {
if err == nil {
t.Errorf("Expected error: %s, got nil", tc.expectedError)
} else if err.Error() != tc.expectedError {
t.Errorf("Expected error: %s, got: %s", tc.expectedError, err.Error())
}
}
})
}
}
func TestTemplateParsingInNew(t *testing.T) {
// Test successful parsing of templates during middleware creation
tests := []struct {
name string
headers []TemplatedHeader
expectedTemplates int
expectError bool
}{
{
name: "Single Valid Template",
headers: []TemplatedHeader{
{Name: "X-Email", Value: "{{.Claims.email}}"},
},
expectedTemplates: 1,
expectError: false,
},
{
name: "Multiple Valid Templates",
headers: []TemplatedHeader{
{Name: "X-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-ID", Value: "{{.Claims.sub}}"},
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
},
expectedTemplates: 3,
expectError: false,
},
{
name: "Invalid Template",
headers: []TemplatedHeader{
{Name: "X-Email", Value: "{{.Claims.email"}, // Missing closing braces
},
expectedTemplates: 0,
expectError: true,
},
{
name: "Mix of Valid and Invalid Templates",
headers: []TemplatedHeader{
{Name: "X-Email", Value: "{{.Claims.email}}"},
{Name: "X-Invalid", Value: "{{if .Claims.admin}}Admin{{end"}, // Invalid template
},
expectedTemplates: 1, // Only the valid template should be parsed
expectError: true, // We expect an error for the invalid template, but we'll handle it
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// For testing template parsing, we'll directly try to parse the templates instead of using New()
// This avoids the provider discovery that would fail in tests
headerTemplates := make(map[string]*template.Template)
// Special handling for the mixed valid/invalid templates case
if tc.name == "Mix of Valid and Invalid Templates" {
// Process templates one at a time so we can still have valid templates
for _, header := range tc.headers {
tmpl, err := template.New(header.Name).Parse(header.Value)
if err != nil {
// We expect an error for the invalid template
if !tc.expectError {
t.Errorf("Unexpected error parsing template %s: %v", header.Name, err)
}
// Skip this template but continue processing others
continue
}
headerTemplates[header.Name] = tmpl
}
} else {
// Normal handling for other test cases
var parseErr error
for _, header := range tc.headers {
tmpl, err := template.New(header.Name).Parse(header.Value)
if err != nil {
parseErr = err
break
}
headerTemplates[header.Name] = tmpl
}
if tc.expectError {
if parseErr == nil {
t.Error("Expected error parsing templates but got nil")
}
return
}
if parseErr != nil {
t.Fatalf("Unexpected error: %v", parseErr)
}
}
// Check the number of parsed templates
if len(headerTemplates) != tc.expectedTemplates {
t.Errorf("Expected %d parsed templates, got %d", tc.expectedTemplates, len(headerTemplates))
}
// Check each template was parsed
for _, header := range tc.headers {
// Skip the known invalid templates
if header.Value == "{{.Claims.email" || header.Value == "{{if .Claims.admin}}Admin{{end" {
continue
}
if _, ok := headerTemplates[header.Name]; !ok {
t.Errorf("Template for header %s was not parsed", header.Name)
}
}
})
}
}
+237
View File
@@ -0,0 +1,237 @@
package traefikoidc
import (
"bytes"
"testing"
"text/template"
)
// TestTemplateExecution tests that templates are executed correctly with different types of claims
func TestTemplateExecution(t *testing.T) {
tests := []struct {
name string
templateText string
data map[string]interface{}
expectedValue string
expectError bool
}{
{
name: "String Claim",
templateText: "{{.Claims.email}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "user@example.com",
},
},
expectedValue: "user@example.com",
expectError: false,
},
{
name: "Number Claim",
templateText: "{{.Claims.age}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"age": 30,
},
},
expectedValue: "30",
expectError: false,
},
{
name: "Boolean Claim",
templateText: "{{.Claims.admin}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"admin": true,
},
},
expectedValue: "true",
expectError: false,
},
{
name: "Array Claim",
templateText: "{{index .Claims.roles 0}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"roles": []string{"admin", "user"},
},
},
expectedValue: "admin",
expectError: false,
},
{
name: "Nested Object Claim",
templateText: "{{.Claims.user.name}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"user": map[string]interface{}{
"name": "John Doe",
},
},
},
expectedValue: "John Doe",
expectError: false,
},
{
name: "Access Token",
templateText: "Bearer {{.AccessToken}}",
data: map[string]interface{}{
"AccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
},
expectedValue: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
expectError: false,
},
{
name: "ID Token",
templateText: "{{.IdToken}}",
data: map[string]interface{}{
"IdToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
},
expectedValue: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
expectError: false,
},
{
name: "Refresh Token",
templateText: "{{.RefreshToken}}",
data: map[string]interface{}{
"RefreshToken": "refresh-token-value",
},
expectedValue: "refresh-token-value",
expectError: false,
},
{
name: "Conditional Template",
templateText: "{{if .Claims.admin}}Admin User{{else}}Regular User{{end}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"admin": true,
},
},
expectedValue: "Admin User",
expectError: false,
},
{
name: "Multiple Claims",
templateText: "{{.Claims.firstName}} {{.Claims.lastName}} <{{.Claims.email}}>",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com",
},
},
expectedValue: "John Doe <john.doe@example.com>",
expectError: false,
},
{
name: "Missing Claim",
templateText: "{{.Claims.missing}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{},
},
expectedValue: "<no value>",
expectError: false, // Go templates don't error on missing values
},
{
name: "Invalid Template Syntax",
templateText: "{{.Claims.email",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "user@example.com",
},
},
expectedValue: "",
expectError: true, // Parsing should fail
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
if tc.expectError {
if err == nil {
t.Fatal("Expected template parsing error, but got nil")
}
return
}
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, tc.data)
if err != nil {
t.Fatalf("Failed to execute template: %v", err)
}
result := buf.String()
if result != tc.expectedValue {
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
}
})
}
}
// TestTemplateExecutionContext tests the specific template data context used in processAuthorizedRequest
func TestTemplateExecutionContext(t *testing.T) {
// Define a test struct that matches the one used in processAuthorizedRequest
type templateData struct {
AccessToken string
IdToken string
RefreshToken string
Claims map[string]interface{}
}
// Test cases
tests := []struct {
name string
templateText string
data templateData
expectedValue string
}{
{
name: "Access and ID token identity",
templateText: "Access: {{.AccessToken}} ID: {{.IdToken}}",
data: templateData{
AccessToken: "access-token",
IdToken: "access-token", // Same as AccessToken in processAuthorizedRequest
Claims: map[string]interface{}{},
},
expectedValue: "Access: access-token ID: access-token",
},
{
name: "Combining tokens and claims",
templateText: "User: {{.Claims.sub}} Token: {{.AccessToken}}",
data: templateData{
AccessToken: "access-token",
IdToken: "access-token",
Claims: map[string]interface{}{
"sub": "user123",
},
},
expectedValue: "User: user123 Token: access-token",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, tc.data)
if err != nil {
t.Fatalf("Failed to execute template: %v", err)
}
result := buf.String()
if result != tc.expectedValue {
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
}
})
}
}
+424
View File
@@ -0,0 +1,424 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
"text/template"
"time"
"golang.org/x/time/rate"
)
// TestTemplatedHeadersIntegration tests that templated headers are correctly added to requests
// in the actual middleware flow
func TestTemplatedHeadersIntegration(t *testing.T) {
// Create a TestSuite to use its helper methods and fields
ts := &TestSuite{t: t}
ts.Setup()
tests := []struct {
name string
headers []TemplatedHeader
sessionSetup func(*SessionData)
claims map[string]interface{}
expectedHeaders map[string]string
interceptedHeaders map[string]string
}{
{
name: "Basic Email Header",
headers: []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
},
claims: map[string]interface{}{
"email": "user@example.com",
},
expectedHeaders: map[string]string{
"X-User-Email": "user@example.com",
},
},
{
name: "Multiple Headers",
headers: []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-ID", Value: "{{.Claims.sub}}"},
{Name: "X-User-Name", Value: "{{.Claims.given_name}} {{.Claims.family_name}}"},
},
claims: map[string]interface{}{
"email": "user@example.com",
"sub": "user123",
"given_name": "John",
"family_name": "Doe",
},
expectedHeaders: map[string]string{
"X-User-Email": "user@example.com",
"X-User-ID": "user123",
"X-User-Name": "John Doe",
},
},
{
name: "Authorization Header with Bearer Token",
headers: []TemplatedHeader{
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
},
expectedHeaders: map[string]string{
// We'll update this dynamically after generating the token
"Authorization": "",
},
},
{
name: "Missing Claim",
headers: []TemplatedHeader{
{Name: "X-User-Role", Value: "{{.Claims.role}}"},
},
claims: map[string]interface{}{
"email": "user@example.com",
// role claim is missing
},
expectedHeaders: map[string]string{
"X-User-Role": "<no value>", // Go templates provide <no value> for missing fields
},
},
{
name: "Conditional Header",
headers: []TemplatedHeader{
{Name: "X-User-Admin", Value: "{{if .Claims.is_admin}}true{{else}}false{{end}}"},
},
claims: map[string]interface{}{
"email": "admin@example.com",
"is_admin": true,
},
expectedHeaders: map[string]string{
"X-User-Admin": "true",
},
},
{
name: "Combined Token and Claim",
headers: []TemplatedHeader{
{Name: "X-Auth-Info", Value: "User={{.Claims.email}}, Token={{.AccessToken}}"},
},
claims: map[string]interface{}{
"email": "user@example.com",
},
expectedHeaders: map[string]string{
// We'll update this dynamically after generating the token
"X-Auth-Info": "",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Create token with the test claims
token := ts.token
if len(tc.claims) > 0 {
var err error
claims := map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(3000000000), // Far future timestamp
"iat": float64(1000000000),
"nbf": float64(1000000000),
"sub": "test-subject",
"nonce": "test-nonce",
"jti": generateRandomString(16),
}
// Add the test-specific claims
for k, v := range tc.claims {
claims[k] = v
}
token, err = createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims)
if err != nil {
t.Fatalf("Failed to create test JWT: %v", err)
}
}
// Update expectedHeaders for the token-based tests after token generation
if tc.name == "Authorization Header with Bearer Token" {
tc.expectedHeaders["Authorization"] = "Bearer " + token
}
if tc.name == "Combined Token and Claim" {
tc.expectedHeaders["X-Auth-Info"] = "User=user@example.com, Token=" + token
}
// Store intercepted headers for verification
interceptedHeaders := make(map[string]string)
// Create a test next handler that captures the headers
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture headers for verification
for name := range tc.expectedHeaders {
if value := r.Header.Get(name); value != "" {
interceptedHeaders[name] = value
}
}
w.WriteHeader(http.StatusOK)
})
// Instead of using New(), we'll directly create a TraefikOidc instance
// similar to how it's done in TestSuite.Setup()
tOidc := &TraefikOidc{
next: nextHandler,
name: "test",
redirURLPath: "/callback",
logoutURLPath: "/callback/logout",
issuerURL: "https://test-issuer.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
jwkCache: ts.mockJWKCache,
jwksURL: "https://test-jwks-url.com",
tokenBlacklist: NewCache(),
tokenCache: NewTokenCache(),
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
logger: NewLogger("debug"),
allowedUserDomains: map[string]struct{}{"example.com": {}},
excludedURLs: map[string]struct{}{"/favicon": {}},
httpClient: &http.Client{},
initComplete: make(chan struct{}),
sessionManager: ts.sessionManager,
extractClaimsFunc: extractClaims,
headerTemplates: make(map[string]*template.Template),
}
// Initialize and parse header templates
for _, header := range tc.headers {
tmpl, err := template.New(header.Name).Parse(header.Value)
if err != nil {
t.Fatalf("Failed to parse header template for %s: %v", header.Name, err)
}
tOidc.headerTemplates[header.Name] = tmpl
}
// Close the initComplete channel to bypass the waiting
close(tOidc.initComplete)
// Create a test request
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "example.com")
rr := httptest.NewRecorder()
// Create a session
session, err := tOidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Setup the session with authentication data
session.SetAuthenticated(true)
session.SetEmail("user@example.com")
session.SetAccessToken(token)
session.SetRefreshToken("test-refresh-token")
if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Add session cookies to the request
for _, cookie := range rr.Result().Cookies() {
req.AddCookie(cookie)
}
// Reset the response recorder for the main test
rr = httptest.NewRecorder()
// Process the request
tOidc.ServeHTTP(rr, req)
// Check status code
if rr.Code != http.StatusOK {
t.Errorf("Expected status code %d, got %d", http.StatusOK, rr.Code)
}
// Verify headers were set correctly
for name, expectedValue := range tc.expectedHeaders {
if value, exists := interceptedHeaders[name]; !exists {
t.Errorf("Expected header %s was not set", name)
} else if value != expectedValue {
t.Errorf("Header %s expected value %q, got %q", name, expectedValue, value)
}
}
})
}
}
// TestEdgeCaseTemplatedHeaders tests edge cases for templated headers
func TestEdgeCaseTemplatedHeaders(t *testing.T) {
// Create a TestSuite to use its helper methods and fields
ts := &TestSuite{t: t}
ts.Setup()
tests := []struct {
name string
headers []TemplatedHeader
claims map[string]interface{}
shouldExecuteCheck bool
}{
{
name: "Very Large Template",
headers: []TemplatedHeader{
{
Name: "X-Large-Header",
Value: createLargeTemplate(500), // Template with 500 variable references
},
},
claims: createLargeClaims(500), // Map with 500 claims
shouldExecuteCheck: true,
},
{
name: "Array Claim Access",
headers: []TemplatedHeader{
{Name: "X-Roles", Value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"},
},
claims: map[string]interface{}{
"roles": []interface{}{"admin", "user", "manager"},
},
shouldExecuteCheck: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Create token with the test claims
claims := map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(3000000000), // Far future timestamp
"iat": float64(1000000000),
"nbf": float64(1000000000),
"sub": "test-subject",
"nonce": "test-nonce",
"jti": generateRandomString(16),
}
// Add the test-specific claims
for k, v := range tc.claims {
claims[k] = v
}
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims)
if err != nil {
t.Fatalf("Failed to create test JWT: %v", err)
}
// Create a test next handler
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Instead of using New(), we'll directly create a TraefikOidc instance
// similar to how it's done in TestSuite.Setup()
tOidc := &TraefikOidc{
next: nextHandler,
name: "test",
redirURLPath: "/callback",
logoutURLPath: "/callback/logout",
issuerURL: "https://test-issuer.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
jwkCache: ts.mockJWKCache,
jwksURL: "https://test-jwks-url.com",
tokenBlacklist: NewCache(),
tokenCache: NewTokenCache(),
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
logger: NewLogger("debug"),
allowedUserDomains: map[string]struct{}{"example.com": {}},
excludedURLs: map[string]struct{}{"/favicon": {}},
httpClient: &http.Client{},
initComplete: make(chan struct{}),
sessionManager: ts.sessionManager,
extractClaimsFunc: extractClaims,
headerTemplates: make(map[string]*template.Template),
}
// Initialize and parse header templates
for _, header := range tc.headers {
tmpl, err := template.New(header.Name).Parse(header.Value)
if err != nil {
t.Fatalf("Failed to parse header template for %s: %v", header.Name, err)
}
tOidc.headerTemplates[header.Name] = tmpl
}
// Close the initComplete channel to bypass the waiting
close(tOidc.initComplete)
// Create a test request
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "example.com")
rr := httptest.NewRecorder()
// Create a session
session, err := tOidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Setup the session with authentication data
session.SetAuthenticated(true)
session.SetEmail("user@example.com")
session.SetAccessToken(token)
session.SetRefreshToken("test-refresh-token")
if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Add session cookies to the request
for _, cookie := range rr.Result().Cookies() {
req.AddCookie(cookie)
}
// Reset the response recorder for the main test
rr = httptest.NewRecorder()
// Process the request
tOidc.ServeHTTP(rr, req)
// Check status code
if rr.Code != http.StatusOK {
t.Errorf("Expected status code %d, got %d", http.StatusOK, rr.Code)
}
// We are primarily checking that these edge cases don't cause panics or errors
// For the array test, we can verify the content
if tc.name == "Array Claim Access" {
// Check if the header was set
headerValue := req.Header.Get("X-Roles")
expectedValue := "admin,user,manager"
if headerValue != expectedValue {
t.Errorf("Expected X-Roles header to be %q, got %q", expectedValue, headerValue)
}
}
})
}
}
// Helper functions for edge case tests
// createLargeTemplate creates a template with many variable references
func createLargeTemplate(size int) string {
template := "{{with .Claims}}"
for i := 0; i < size; i++ {
if i > 0 {
template += ","
}
template += "{{.field" + string(rune('a'+i%26)) + string(rune('0'+i%10)) + "}}"
}
template += "{{end}}"
return template
}
// createLargeClaims creates a map with many claims for testing large templates
func createLargeClaims(size int) map[string]interface{} {
claims := make(map[string]interface{})
for i := 0; i < size; i++ {
key := "field" + string(rune('a'+i%26)) + string(rune('0'+i%10))
claims[key] = "value" + string(rune('a'+i%26)) + string(rune('0'+i%10))
}
return claims
}