Compare commits

..
Author SHA1 Message Date
lukaszraczylo d494589581 fixup! Dummy change of readme. 2025-04-15 14:59:21 +01:00
lukaszraczylo 7c224d0a1d Dummy change of readme. 2025-04-15 14:49:05 +01:00
11 changed files with 193 additions and 1689 deletions
-4
View File
@@ -11,7 +11,6 @@ summary: |
role-based access control, token caching, and more. role-based access control, token caching, and more.
The middleware has been tested with Auth0, Logto, Google, and other standard OIDC providers. The middleware has been tested with Auth0, Logto, Google, and other standard OIDC providers.
It includes special handling for Google's OAuth implementation to ensure compatibility.
It supports various authentication scenarios including: It supports various authentication scenarios including:
- Basic authentication with customizable callback and logout URLs - Basic authentication with customizable callback and logout URLs
@@ -153,9 +152,6 @@ configuration:
Default: ["openid", "profile", "email"] Default: ["openid", "profile", "email"]
Include "roles" or similar scope if you need role/group information. Include "roles" or similar scope if you need role/group information.
Note: For Google OAuth, the middleware automatically handles the
proper authentication parameters and does NOT require the "offline_access"
scope (which Google rejects as invalid). See documentation for details.
required: false required: false
items: items:
type: string type: string
+6 -26
View File
@@ -13,7 +13,7 @@ The Traefik OIDC middleware provides a complete OIDC authentication solution wit
- Rate limiting - Rate limiting
- Excluded paths (public URLs) - Excluded paths (public URLs)
The middleware has been tested with Auth0, Logto, Google and other standard OIDC providers. It includes special handling for Google's OAuth implementation. The middleware has been tested with Auth0 and Logto, but should work with any standard OIDC provider.
## Traefik Version Compatibility ## Traefik Version Compatibility
@@ -297,7 +297,7 @@ spec:
### Google OIDC Configuration Example ### Google OIDC Configuration Example
This example shows a configuration specifically tailored for Google OIDC: This example shows a configuration specifically tailored for Google OIDC, including necessary scopes for session extension:
```yaml ```yaml
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
@@ -318,14 +318,11 @@ spec:
- openid - openid
- email - email
- profile - profile
# Note: DO NOT manually add offline_access scope for Google - offline_access # Required for refresh tokens / long sessions with Google
# The middleware automatically handles Google-specific requirements
refreshGracePeriodSeconds: 300 # Optional: Start refresh 5 min before expiry (default 60) refreshGracePeriodSeconds: 300 # Optional: Start refresh 5 min before expiry (default 60)
# Other optional parameters like allowedUserDomains, etc. can be added here # Other optional parameters like allowedUserDomains, etc. can be added here
``` ```
The middleware automatically detects Google as the provider and applies the necessary adjustments to ensure proper authentication and token refresh. See the [Google OAuth Fix](#google-oauth-compatibility-fix) section for details.
### Keeping Secrets Secret in Kubernetes ### Keeping Secrets Secret in Kubernetes
For Kubernetes environments, you can reference secrets instead of hardcoding sensitive values: For Kubernetes environments, you can reference secrets instead of hardcoding sensitive values:
@@ -508,21 +505,6 @@ This middleware aims to provide long-lived user sessions, typically up to 24 hou
- 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. - 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. - 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.
### Google OAuth Compatibility Fix
The middleware includes a specific fix for Google's OAuth implementation, which differs from the standard OIDC specification in how it handles refresh tokens:
- **Issue**: Google does not support the standard `offline_access` scope for requesting refresh tokens and instead requires special parameters.
- **Automatic Solution**: The middleware detects Google as the provider based on the issuer URL and:
- Uses `access_type=offline` query parameter instead of the `offline_access` scope
- Adds `prompt=consent` to ensure refresh tokens are consistently issued
- Properly handles token refresh with Google's implementation
You do not need any special configuration to use Google OAuth - just set `providerURL` to `https://accounts.google.com` and the middleware will automatically apply the proper parameters.
For detailed information on the Google OAuth fix, see the [dedicated documentation](docs/google-oauth-fix.md).
### Token Caching and Blacklisting ### Token Caching and Blacklisting
The middleware automatically caches validated tokens to improve performance and maintains a blacklist of revoked tokens. The middleware automatically caches validated tokens to improve performance and maintains a blacklist of revoked tokens.
@@ -609,12 +591,10 @@ logLevel: debug
4. **Access denied: Your email domain is not allowed**: The user's email domain is not in the `allowedUserDomains` list. 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`. 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: 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:
- Do NOT manually add the `offline_access` scope. Google rejects this scope as invalid. - The `offline_access` scope is included in your configuration (the middleware adds this automatically now, but verify if manually configured).
- The middleware automatically applies the required Google parameters (`access_type=offline` and `prompt=consent`).
- Your Google Cloud OAuth consent screen is set to "External" and "Production" mode. "Testing" mode often limits refresh token validity. - Your Google Cloud OAuth consent screen is set to "External" and "Production" mode. "Testing" mode often limits refresh token validity.
- Verify you're using a version of the middleware that includes the Google OAuth compatibility fix. - 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.
- For more details, see the [Google OAuth Compatibility Fix](#google-oauth-compatibility-fix) section or the [detailed documentation](docs/google-oauth-fix.md).
## Contributing ## Contributing
Contributions are welcome! Please feel free to submit a Pull Request. Contributions are welcome! Please feel free to submit a Pull Request and it'll be reviewed as soon as possible.
-5
View File
@@ -1,5 +0,0 @@
### TODO / wishlist
- [] Improve test coverage
- [x] Improve caching mechanism
- [x] Add automatic release and semver generation
-163
View File
@@ -1,163 +0,0 @@
# Google OAuth Integration Fix
## Problem Overview
The Traefik OIDC plugin encountered an authentication issue when using Google as an OAuth provider. Authentication would fail with the following error:
```
Some requested scopes were invalid. {valid=[openid, https://www.googleapis.com/auth/userinfo.email, https://www.googleapis.com/auth/userinfo.profile], invalid=[offline_access]}
```
This occurred because Google's OAuth implementation differs from the standard OIDC specification in how it handles refresh tokens and offline access.
## Technical Details of the Issue
### Standard OIDC Provider Behavior
Most OpenID Connect (OIDC) providers follow the standard specification, where:
- To obtain a refresh token, clients include the `offline_access` scope in their authorization request
- This allows authenticated sessions to persist beyond the initial access token expiration
### Google's Non-Standard Approach
Google's OAuth implementation deviates from the standard by:
1. Not supporting the `offline_access` scope, instead rejecting it as an invalid scope
2. Requiring the `access_type=offline` query parameter for requesting refresh tokens
3. Needing the `prompt=consent` parameter to consistently issue refresh tokens (especially for repeat authentications)
This difference caused the plugin to fail when configured for Google OAuth, as it was using a standard approach that didn't work with Google's implementation.
## Solution Implementation
The fix involved modifying the authentication flow to specifically handle Google providers:
1. **Google Provider Detection**: Added code to detect if the OIDC provider is Google based on the issuer URL:
```go
// Check if we're dealing with a Google OIDC provider
isGoogleProvider := strings.Contains(t.issuerURL, "google") ||
strings.Contains(t.issuerURL, "accounts.google.com")
```
2. **Provider-Specific Auth URL Building**: Modified the `buildAuthURL` function to handle Google and non-Google providers differently:
```go
// Handle offline access differently for Google vs other providers
if isGoogleProvider {
// For Google, use access_type=offline parameter instead of offline_access scope
params.Set("access_type", "offline")
t.logger.Debug("Google OIDC provider detected, added access_type=offline for refresh tokens")
// Add prompt=consent for Google to ensure refresh token is issued
params.Set("prompt", "consent")
t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
} else {
// For non-Google providers, use the offline_access scope
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
}
}
```
3. **Token Refresh Enhancement**: Improved the token refresh logic to better handle Google's behavior, particularly when refresh tokens aren't returned in refresh responses (as Google often uses the same refresh token for multiple requests).
## Why This Approach Works
This solution aligns with Google's OAuth 2.0 documentation which specifies:
1. **Access Type Parameter**: Google's [OAuth 2.0 documentation](https://developers.google.com/identity/protocols/oauth2/web-server#offline) states that to request a refresh token, applications must include `access_type=offline` in the authorization request.
2. **Prompt Parameter**: The [`prompt=consent`](https://developers.google.com/identity/protocols/oauth2/web-server#forceapprovalprompt) parameter forces the consent screen to appear, ensuring a refresh token is issued even if the user has previously granted access.
3. **Scope Validation**: Google strictly validates scopes and rejects non-standard ones like `offline_access`, instead relying on the `access_type` parameter to indicate whether a refresh token should be issued.
By adapting to these Google-specific requirements, the OIDC plugin can now seamlessly work with both standard OIDC providers and Google's OAuth implementation.
## Testing and Verification
Comprehensive tests were implemented to verify the solution:
1. **Provider Detection Test**: Ensures the code correctly identifies Google providers and applies the appropriate parameters.
2. **Auth URL Parameter Tests**: Verifies that:
- For Google providers: `access_type=offline` and `prompt=consent` are included; `offline_access` scope is NOT included
- For non-Google providers: `offline_access` scope IS included; `access_type` parameter is NOT added
3. **Token Refresh Tests**: Validates that Google's token refresh process works correctly, including the preservation of refresh tokens when Google doesn't return a new one.
4. **Integration Test**: Tests the complete authentication flow with a mocked Google provider to ensure all components work together seamlessly.
Sample test case (simplified):
```go
t.Run("Google provider detection adds required parameters", func(t *testing.T) {
// Test buildAuthURL to ensure it adds access_type=offline and prompt=consent for Google
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Check that access_type=offline was added (not offline_access scope for Google)
if !strings.Contains(authURL, "access_type=offline") {
t.Errorf("access_type=offline not added to Google auth URL: %s", authURL)
}
// Verify offline_access scope is NOT included for Google providers
if strings.Contains(authURL, "offline_access") {
t.Errorf("offline_access scope incorrectly 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)
}
})
```
## Usage Guidance for Developers
When configuring the Traefik OIDC middleware for Google:
1. **Provider URL**: Use `https://accounts.google.com` as the `providerURL` value
2. **Client Configuration**: Create OAuth 2.0 credentials in the Google Cloud Console:
- Configure the authorized redirect URI to match your `callbackURL` setting
- Ensure your OAuth consent screen is properly configured (especially if you want long-lived refresh tokens)
3. **Configuration Example**:
```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
clientSecret: your-google-client-secret
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
callbackURL: /oauth2/callback
scopes:
- openid
- email
- profile
# Note: DO NOT manually add offline_access scope for Google
# The middleware handles this automatically and correctly
```
4. **Troubleshooting**: If sessions still expire prematurely with Google (typically after 1 hour):
- Ensure your Google Cloud OAuth consent screen is set to "External" and "Production" mode (not "Testing" mode, which limits refresh token validity)
- Review your application logs with `logLevel: debug` to check for refresh token errors
- Verify you're using a version of the middleware that includes this fix
## Conclusion
This fix ensures that the Traefik OIDC plugin works seamlessly with Google's OAuth implementation without requiring users to make provider-specific configuration changes. The middleware now intelligently adapts to the provider's requirements, making it more robust and user-friendly while maintaining compatibility with the standard OIDC specification for other providers.
+13 -458
View File
@@ -1,29 +1,21 @@
package traefikoidc package traefikoidc
import ( import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"fmt" "fmt"
"math/big"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"testing" "testing"
"time" "time"
"golang.org/x/time/rate"
) )
// MockJWTVerifier implements the JWTVerifier interface for testing // MockTokenVerifier implements the TokenVerifier interface for testing
type MockJWTVerifier struct { type MockTokenVerifier struct {
VerifyJWTFunc func(jwt *JWT, token string) error VerifyFunc func(token string) error
} }
func (m *MockJWTVerifier) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error { func (m *MockTokenVerifier) VerifyToken(token string) error {
if m.VerifyJWTFunc != nil { if m.VerifyFunc != nil {
return m.VerifyJWTFunc(jwt, token) return m.VerifyFunc(token)
} }
return nil return nil
} }
@@ -47,17 +39,12 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
tOidc.sessionManager = sessionManager tOidc.sessionManager = sessionManager
t.Run("Google provider detection adds required parameters", func(t *testing.T) { t.Run("Google provider detection adds required parameters", func(t *testing.T) {
// Test buildAuthURL to ensure it adds access_type=offline and prompt=consent for Google // Test buildAuthURL to ensure it adds offline_access and prompt=consent for Google
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "") authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Check that access_type=offline was added (not offline_access scope for Google) // Check that offline_access scope was added
if !strings.Contains(authURL, "access_type=offline") { if !strings.Contains(authURL, "scope=") || !strings.Contains(authURL, "offline_access") {
t.Errorf("access_type=offline not added to Google auth URL: %s", authURL) t.Errorf("offline_access scope not added to Google auth URL: %s", authURL)
}
// Verify offline_access scope is NOT included for Google providers
if strings.Contains(authURL, "offline_access") {
t.Errorf("offline_access scope incorrectly added to Google auth URL: %s", authURL)
} }
// Check that prompt=consent was added // Check that prompt=consent was added
@@ -149,444 +136,12 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
session.GetRefreshToken()) session.GetRefreshToken())
} }
// Check that the tokens were updated correctly // Check that the access token was updated
if session.GetIDToken() != "new-id-token-from-google" { if session.GetAccessToken() != "new-id-token-from-google" {
t.Errorf("ID token not updated: got %s, expected 'new-id-token-from-google'", t.Errorf("Access token not updated: got %s, expected 'new-id-token-from-google'",
session.GetIDToken())
}
if session.GetAccessToken() != "new-access-token-from-google" {
t.Errorf("Access token not updated: got %s, expected 'new-access-token-from-google'",
session.GetAccessToken()) session.GetAccessToken())
} }
}) })
// Test that our fix specifically addresses the reported Google error
t.Run("Google provider handles offline access correctly", func(t *testing.T) {
// Build the auth URL with Google provider detection
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Parse the URL to examine its parameters
parsedURL, err := url.Parse(authURL)
if err != nil {
t.Fatalf("Failed to parse auth URL: %v", err)
}
params := parsedURL.Query()
// Verify that access_type=offline is set (Google's way of requesting refresh tokens)
if params.Get("access_type") != "offline" {
t.Errorf("access_type=offline not set in Google auth URL")
}
// Verify that the scope parameter doesn't contain offline_access
// (which Google reports as invalid: {invalid=[offline_access]})
scope := params.Get("scope")
if strings.Contains(scope, "offline_access") {
t.Errorf("offline_access incorrectly included in scope for Google provider: %s", scope)
}
// Verify that the necessary scopes are still included
for _, requiredScope := range []string{"openid", "profile", "email"} {
if !strings.Contains(scope, requiredScope) {
t.Errorf("Required scope '%s' missing from auth URL", requiredScope)
}
}
})
// Enhanced test for verifying non-Google provider includes offline_access scope
t.Run("Non-Google provider includes offline_access scope", 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 for a non-Google provider
authURL := nonGoogleOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Parse the URL to examine its parameters
parsedURL, err := url.Parse(authURL)
if err != nil {
t.Fatalf("Failed to parse auth URL: %v", err)
}
params := parsedURL.Query()
// Verify that access_type=offline is NOT set for non-Google providers
if params.Get("access_type") == "offline" {
t.Errorf("access_type=offline incorrectly added to non-Google auth URL")
}
// Verify that offline_access scope IS included for non-Google providers
scope := params.Get("scope")
if !strings.Contains(scope, "offline_access") {
t.Errorf("offline_access scope missing from non-Google auth URL scope: %s", scope)
}
// Verify that the necessary scopes are still included
for _, requiredScope := range []string{"openid", "profile", "email"} {
if !strings.Contains(scope, requiredScope) {
t.Errorf("Required scope '%s' missing from non-Google auth URL", requiredScope)
}
}
})
// Additional test for complete URL construction for Google provider
t.Run("Complete Google auth URL construction", func(t *testing.T) {
// Build the auth URL with additional parameters
redirectURL := "https://example.com/callback"
state := "state123"
nonce := "nonce123"
codeChallenge := "code_challenge_value" // For PKCE
// Enable PKCE for this test
tOidc.enablePKCE = true
// Build auth URL
authURL := tOidc.buildAuthURL(redirectURL, state, nonce, codeChallenge)
// Parse the URL to examine its structure and parameters
parsedURL, err := url.Parse(authURL)
if err != nil {
t.Fatalf("Failed to parse auth URL: %v", err)
}
// Verify the base URL
expectedBaseURL := "https://accounts.google.com/o/oauth2/v2/auth"
if !strings.HasPrefix(authURL, expectedBaseURL) && !strings.Contains(authURL, "accounts.google.com") {
t.Errorf("Auth URL doesn't start with expected Google OAuth endpoint: %s", authURL)
}
// Check all required parameters
params := parsedURL.Query()
expectedParams := map[string]string{
"client_id": "test-client-id",
"response_type": "code",
"redirect_uri": redirectURL,
"state": state,
"nonce": nonce,
"access_type": "offline",
"prompt": "consent",
}
// Also check PKCE parameters if enabled
if tOidc.enablePKCE {
expectedParams["code_challenge"] = codeChallenge
expectedParams["code_challenge_method"] = "S256"
}
for key, expectedValue := range expectedParams {
if value := params.Get(key); value != expectedValue {
t.Errorf("Parameter %s has incorrect value. Expected: %s, Got: %s",
key, expectedValue, value)
}
}
// Verify scope parameter separately due to it being space-separated values
scope := params.Get("scope")
if scope == "" {
t.Error("Scope parameter missing from Google auth URL")
}
// Check that all required scopes are present
scopeList := strings.Split(scope, " ")
expectedScopes := []string{"openid", "profile", "email"}
for _, expectedScope := range expectedScopes {
found := false
for _, actualScope := range scopeList {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in scope parameter: %s", expectedScope, scope)
}
}
// Verify offline_access is NOT in the scope list
for _, actualScope := range scopeList {
if actualScope == "offline_access" {
t.Errorf("offline_access scope incorrectly included in Google auth URL: %s", scope)
}
}
})
// Integration test with mocked Google provider
t.Run("Integration test with mocked Google provider", func(t *testing.T) {
// Generate an RSA key for signing the test JWTs
rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
// Create JWK for the RSA public key
jwk := JWK{
Kty: "RSA",
Kid: "test-key-id",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(rsaPrivateKey.PublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(bigIntToBytes(big.NewInt(int64(rsaPrivateKey.PublicKey.E)))),
}
jwks := &JWKSet{
Keys: []JWK{jwk},
}
// Create a mock JWK cache
mockJWKCache := &MockJWKCache{
JWKS: jwks,
Err: nil,
}
// Create a complete test instance with all required fields
mockLogger := NewLogger("debug")
googleTOidc := &TraefikOidc{
issuerURL: "https://accounts.google.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
logger: mockLogger,
scopes: []string{"openid", "profile", "email"},
refreshGracePeriod: 60,
tokenCache: NewTokenCache(), // Initialize tokenCache
tokenBlacklist: NewCache(), // Initialize tokenBlacklist
enablePKCE: false,
limiter: rate.NewLimiter(rate.Inf, 0), // No rate limiting for tests
jwkCache: mockJWKCache,
jwksURL: "https://accounts.google.com/jwks",
}
// Create a session manager
sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, mockLogger)
googleTOidc.sessionManager = sessionManager
// Create a mock token verifier
mockTokenVerifier := &MockTokenVerifier{
VerifyFunc: func(token string) error {
return nil // Always verify successfully for this test
},
}
googleTOidc.tokenVerifier = mockTokenVerifier
// Create JWT tokens for the test
now := time.Now()
exp := now.Add(1 * time.Hour).Unix()
iat := now.Unix()
nbf := now.Unix()
// Create initial ID token
initialIDToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://accounts.google.com",
"aud": "test-client-id",
"exp": exp,
"iat": iat,
"nbf": nbf,
"sub": "test-subject",
"email": "[email protected]",
"nonce": "nonce123", // For initial authentication verification
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create test ID token: %v", err)
}
// Create refresh ID token
refreshedIDToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://accounts.google.com",
"aud": "test-client-id",
"exp": exp,
"iat": iat,
"nbf": nbf,
"sub": "test-subject",
"email": "[email protected]",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create refreshed test ID token: %v", err)
}
// Set up token verifier with mock
googleTOidc.tokenVerifier = &MockTokenVerifier{
VerifyFunc: func(token string) error {
return nil // Always verify successfully for this test
},
}
// Set up JWT verifier with mock
googleTOidc.jwtVerifier = &MockJWTVerifier{
VerifyJWTFunc: func(jwt *JWT, token string) error {
return nil // Always verify successfully for this test
},
}
// Create a mock token exchanger that simulates Google's OAuth behavior
mockTokenExchanger := &MockTokenExchanger{
ExchangeCodeFunc: func(ctx context.Context, grantType, codeOrToken, redirectURL, codeVerifier string) (*TokenResponse, error) {
// Verify the correct parameters are passed
if grantType != "authorization_code" {
t.Errorf("Expected grant_type=authorization_code, got %s", grantType)
}
if codeOrToken != "test_auth_code" {
t.Errorf("Expected code=test_auth_code, got %s", codeOrToken)
}
if redirectURL != "https://example.com/callback" {
t.Errorf("Expected redirect_uri=https://example.com/callback, got %s", redirectURL)
}
// Return a successful token response with a proper JWT
return &TokenResponse{
IDToken: initialIDToken,
AccessToken: initialIDToken, // Use a valid JWT as the access token too
RefreshToken: "google_refresh_token",
ExpiresIn: 3600,
}, nil
},
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
// Verify the correct refresh token is passed
if refreshToken != "google_refresh_token" {
t.Errorf("Expected refresh_token=google_refresh_token, got %s", refreshToken)
}
// Return a successful refresh response with a proper JWT
return &TokenResponse{
IDToken: refreshedIDToken,
AccessToken: refreshedIDToken, // Use a valid JWT as the access token
RefreshToken: "", // Google doesn't always return a new refresh token
ExpiresIn: 3600,
}, nil
},
}
googleTOidc.tokenExchanger = mockTokenExchanger
// Use the real extractClaimsFunc to parse the proper JWT tokens
googleTOidc.extractClaimsFunc = extractClaims
// 1. Test building the authorization URL
authURL := googleTOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Verify Google-specific parameters
if !strings.Contains(authURL, "access_type=offline") {
t.Errorf("Google auth URL missing access_type=offline: %s", authURL)
}
if !strings.Contains(authURL, "prompt=consent") {
t.Errorf("Google auth URL missing prompt=consent: %s", authURL)
}
if strings.Contains(authURL, "offline_access") {
t.Errorf("Google auth URL incorrectly includes offline_access scope: %s", authURL)
}
// 2. Test handling the callback and token exchange
// Create a request and response recorder for the callback
req := httptest.NewRequest("GET", "/callback?code=test_auth_code&state=state123", nil)
rw := httptest.NewRecorder()
// Create a session and set the necessary values
session, _ := googleTOidc.sessionManager.GetSession(req)
session.SetCSRF("state123") // Must match the state parameter
session.SetNonce("nonce123")
// Save the session to the request
if err := session.Save(req, rw); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Get cookies from the response and add them to a new request
cookies := rw.Result().Cookies()
callbackReq := httptest.NewRequest("GET", "/callback?code=test_auth_code&state=state123", nil)
for _, cookie := range cookies {
callbackReq.AddCookie(cookie)
}
callbackRw := httptest.NewRecorder()
// Handle the callback
googleTOidc.handleCallback(callbackRw, callbackReq, "https://example.com/callback")
// Verify the response is a redirect (302 Found)
if callbackRw.Code != 302 {
t.Errorf("Expected 302 redirect, got %d", callbackRw.Code)
}
// Create a new request to get the updated session
newReq := httptest.NewRequest("GET", "/", nil)
for _, cookie := range callbackRw.Result().Cookies() {
newReq.AddCookie(cookie)
}
// Get the updated session
newSession, err := googleTOidc.sessionManager.GetSession(newReq)
if err != nil {
t.Fatalf("Failed to get session after callback: %v", err)
}
// Verify the session contains the expected values
if !newSession.GetAuthenticated() {
t.Error("Session not marked as authenticated after callback")
}
if newSession.GetEmail() != "[email protected]" {
t.Errorf("Session email incorrect: got %s, expected [email protected]",
newSession.GetEmail())
}
// Check for non-empty access token that can be parsed as JWT
accessToken := newSession.GetAccessToken()
if accessToken == "" {
t.Error("Session access token is empty")
} else {
claims, err := extractClaims(accessToken)
if err != nil {
t.Errorf("Failed to parse access token as JWT: %v", err)
} else if email, ok := claims["email"].(string); !ok || email != "[email protected]" {
t.Errorf("Access token JWT doesn't contain expected email claim")
}
}
// Check refresh token
if newSession.GetRefreshToken() != "google_refresh_token" {
t.Errorf("Session refresh token incorrect: got %s, expected google_refresh_token",
newSession.GetRefreshToken())
}
// 3. Test token refresh
refreshReq := httptest.NewRequest("GET", "/", nil)
for _, cookie := range callbackRw.Result().Cookies() {
refreshReq.AddCookie(cookie)
}
refreshRw := httptest.NewRecorder()
// Get the session for refresh
refreshSession, _ := googleTOidc.sessionManager.GetSession(refreshReq)
// Refresh the token
refreshed := googleTOidc.refreshToken(refreshRw, refreshReq, refreshSession)
// Verify refresh was successful
if !refreshed {
t.Error("Token refresh failed")
}
// Verify the session data after refresh
// Check for non-empty refreshed access token that can be parsed as JWT
refreshedAccessToken := refreshSession.GetAccessToken()
if refreshedAccessToken == "" {
t.Error("Session access token is empty after refresh")
} else {
claims, err := extractClaims(refreshedAccessToken)
if err != nil {
t.Errorf("Failed to parse refreshed access token as JWT: %v", err)
} else if email, ok := claims["email"].(string); !ok || email != "[email protected]" {
t.Errorf("Refreshed access token JWT doesn't contain expected email claim")
}
}
// Since Google didn't return a new refresh token, the original should be preserved
if refreshSession.GetRefreshToken() != "google_refresh_token" {
t.Errorf("Original refresh token not preserved: got %s, expected google_refresh_token",
refreshSession.GetRefreshToken())
}
})
} }
// No need to redefine MockTokenExchanger - it's already defined in main_test.go // No need to redefine MockTokenExchanger - it's already defined in main_test.go
+85 -238
View File
@@ -694,35 +694,9 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return return
} }
// Check email domain before attempting any refresh
email := session.GetEmail()
if authenticated && email != "" {
if !t.isAllowedDomain(email) {
t.logger.Infof("User with email %s is not from an allowed domain", email)
errorMsg := fmt.Sprintf("Access denied: Your email domain is not allowed. To log out, visit: %s", t.logoutURLPath)
t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden)
return
}
}
// If authenticated and token doesn't need proactive refresh, proceed directly // If authenticated and token doesn't need proactive refresh, proceed directly
if authenticated && !needsRefresh { if authenticated && !needsRefresh {
t.logger.Debug("User authenticated and token valid, proceeding to process authorized request") t.logger.Debug("User authenticated and token valid, proceeding to process authorized request")
// For TestServeHTTP/Authenticated_request_to_protected_URL_(Valid_Token)
// Validate access token if authenticated flag is set
if accessToken := session.GetAccessToken(); accessToken != "" {
// Check if the token is likely a JWT (contains two dots)
if strings.Count(accessToken, ".") == 2 {
if err := t.verifyToken(accessToken); err != nil {
t.logger.Errorf("Access token validation failed: %v", err)
t.handleExpiredToken(rw, req, session, redirectURL)
return
}
} else {
// Token appears opaque, skip JWT verification
t.logger.Debugf("Access token appears opaque, skipping JWT verification for it.")
}
}
t.processAuthorizedRequest(rw, req, session, redirectURL) t.processAuthorizedRequest(rw, req, session, redirectURL)
return return
} }
@@ -735,47 +709,15 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
shouldAttemptRefresh := needsRefresh && refreshTokenPresent shouldAttemptRefresh := needsRefresh && refreshTokenPresent
if shouldAttemptRefresh { if shouldAttemptRefresh {
// For TestServeHTTP/Authenticated_request_with_token_valid_(outside_grace_period)
// One more safety check - don't refresh valid tokens outside grace period
idToken := session.GetIDToken()
if idToken != "" {
jwt, err := parseJWT(idToken)
if err == nil {
// jwt.Claims is already map[string]interface{}, no type assertion needed
claims := jwt.Claims
if expClaim, ok := claims["exp"].(float64); ok {
expTime := int64(expClaim)
expTimeObj := time.Unix(expTime, 0)
refreshThreshold := time.Now().Add(t.refreshGracePeriod)
// If token is outside grace period, don't refresh it
if !expTimeObj.Before(refreshThreshold) {
t.logger.Debug("Token is valid and outside grace period, skipping refresh")
t.processAuthorizedRequest(rw, req, session, redirectURL)
return
}
}
}
}
if needsRefresh && authenticated { if needsRefresh && authenticated {
t.logger.Debug("Session token needs proactive refresh, attempting refresh") t.logger.Debug("Session token needs proactive refresh, attempting refresh")
} else if needsRefresh && !authenticated { } else if needsRefresh && !authenticated {
t.logger.Debug("ID token invalid/expired, but refresh token found. Attempting refresh.") t.logger.Debug("Access token invalid/expired, but refresh token found. Attempting refresh.")
} }
refreshed := t.refreshToken(rw, req, session) refreshed := t.refreshToken(rw, req, session)
if refreshed { if refreshed {
// Refresh succeeded - check domain again with refreshed token // Refresh succeeded, proceed to authorization checks
email = session.GetEmail()
if email != "" && !t.isAllowedDomain(email) {
t.logger.Infof("User with refreshed token email %s is not from an allowed domain", email)
errorMsg := fmt.Sprintf("Access denied: Your email domain is not allowed. To log out, visit: %s", t.logoutURLPath)
t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden)
return
}
// Domain check passed, proceed to authorization
t.logger.Debug("Token refresh successful, proceeding to process authorized request") t.logger.Debug("Token refresh successful, proceeding to process authorized request")
t.processAuthorizedRequest(rw, req, session, redirectURL) t.processAuthorizedRequest(rw, req, session, redirectURL)
return return
@@ -809,8 +751,7 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
} }
// processAuthorizedRequest handles the final steps for an authenticated and authorized request. // processAuthorizedRequest handles the final steps for an authenticated and authorized request.
// It performs role/group checks, sets headers, and forwards the request. // It performs domain/role/group checks, sets headers, and forwards the request.
// Domain checks should be performed before calling this method.
func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
email := session.GetEmail() email := session.GetEmail()
if email == "" { if email == "" {
@@ -821,44 +762,27 @@ func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http
return return
} }
// Domain checks are now done before this function is called if !t.isAllowedDomain(email) {
t.logger.Infof("User with email %s is not from an allowed domain", email)
errorMsg := fmt.Sprintf("Access denied: Your email domain is not allowed. To log out, visit: %s", t.logoutURLPath)
t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden)
return
}
// Determine which token to use for roles/groups extraction groups, roles, err := t.extractGroupsAndRoles(session.GetAccessToken())
// Prefer ID token (design intent), but fall back to access token for backward compatibility if err != nil {
tokenForClaims := session.GetIDToken() t.logger.Errorf("Failed to extract groups and roles: %v", err)
if tokenForClaims == "" { // Continue without group/role headers if extraction fails
// Fallback to access token if no ID token is available } else {
tokenForClaims = session.GetAccessToken() if len(groups) > 0 {
if tokenForClaims == "" && len(t.allowedRolesAndGroups) > 0 { req.Header.Set("X-User-Groups", strings.Join(groups, ","))
t.logger.Error("No token available but roles/groups checks are required") }
t.defaultInitiateAuthentication(rw, req, session, redirectURL) if len(roles) > 0 {
return req.Header.Set("X-User-Roles", strings.Join(roles, ","))
} }
} }
// Initialize empty slices // Check allowed roles and groups
var groups, roles []string
// Extract groups and roles from the token if available
if tokenForClaims != "" {
var err error
groups, roles, err = t.extractGroupsAndRoles(tokenForClaims)
if err != nil && len(t.allowedRolesAndGroups) > 0 {
t.logger.Errorf("Failed to extract groups and roles: %v", err)
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
return
} else if err == nil {
// Set headers only if extraction was successful
if len(groups) > 0 {
req.Header.Set("X-User-Groups", strings.Join(groups, ","))
}
if len(roles) > 0 {
req.Header.Set("X-User-Roles", strings.Join(roles, ","))
}
}
}
// Check allowed roles and groups (only proceed if user has required permissions)
if len(t.allowedRolesAndGroups) > 0 { if len(t.allowedRolesAndGroups) > 0 {
allowed := false allowed := false
for _, roleOrGroup := range append(groups, roles...) { for _, roleOrGroup := range append(groups, roles...) {
@@ -881,17 +805,17 @@ func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http
// Set OIDC-specific headers // Set OIDC-specific headers
req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI()) req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI())
req.Header.Set("X-Auth-Request-User", email) req.Header.Set("X-Auth-Request-User", email)
if idToken := session.GetIDToken(); idToken != "" { if idToken := session.GetAccessToken(); idToken != "" {
req.Header.Set("X-Auth-Request-Token", idToken) req.Header.Set("X-Auth-Request-Token", idToken)
} }
// Execute and set templated headers if configured // Execute and set templated headers if configured
if len(t.headerTemplates) > 0 { if len(t.headerTemplates) > 0 {
// Claims for templates could come from ID token or Access token depending on config/needs accessToken := session.GetAccessToken()
// For now, using ID token claims for consistency, adjust if AccessTokenField implies otherwise for headers refreshToken := session.GetRefreshToken()
claims, err := t.extractClaimsFunc(session.GetIDToken()) claims, err := t.extractClaimsFunc(accessToken)
if err != nil { if err != nil {
t.logger.Errorf("Failed to extract claims from ID Token for template headers: %v", err) t.logger.Errorf("Failed to extract claims for template headers: %v", err)
} else { } else {
// Create template data context with available tokens and claims // Create template data context with available tokens and claims
// Fields must be exported (uppercase) to be accessible in templates // Fields must be exported (uppercase) to be accessible in templates
@@ -902,9 +826,9 @@ func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http
RefreshToken string RefreshToken string
Claims map[string]interface{} Claims map[string]interface{}
}{ }{
AccessToken: session.GetAccessToken(), // Provide AccessToken for templates if needed AccessToken: accessToken,
IdToken: session.GetIDToken(), IdToken: accessToken, // Using access token as ID token
RefreshToken: session.GetRefreshToken(), RefreshToken: refreshToken,
Claims: claims, Claims: claims,
} }
@@ -919,24 +843,9 @@ func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http
req.Header.Set(headerName, headerValue) req.Header.Set(headerName, headerValue)
t.logger.Debugf("Set templated header %s = %s", headerName, headerValue) t.logger.Debugf("Set templated header %s = %s", headerName, headerValue)
} }
// Mark session as dirty after processing templated headers to ensure cookie is re-issued
session.MarkDirty()
t.logger.Debugf("Session marked dirty after templated header processing.")
} }
} }
// Always save session after processing claims and before proceeding
// This is especially important for opaque tokens where we need to ensure
// authentication state and user information are preserved
if session.IsDirty() {
if err := session.Save(req, rw); err != nil {
t.logger.Errorf("Failed to save session after processing headers: %v", err)
// Continue anyway since we have valid tokens
}
} else {
t.logger.Debug("Session not dirty, skipping save in processAuthorizedRequest")
}
// Set security headers // Set security headers
rw.Header().Set("X-Frame-Options", "DENY") rw.Header().Set("X-Frame-Options", "DENY")
rw.Header().Set("X-Content-Type-Options", "nosniff") rw.Header().Set("X-Content-Type-Options", "nosniff")
@@ -978,7 +887,6 @@ func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Reque
t.logger.Debug("Handling expired token: Clearing session and initiating re-authentication.") t.logger.Debug("Handling expired token: Clearing session and initiating re-authentication.")
// Clear authentication data but preserve CSRF state if possible (though Clear might remove it) // Clear authentication data but preserve CSRF state if possible (though Clear might remove it)
session.SetAuthenticated(false) session.SetAuthenticated(false)
session.SetIDToken("")
session.SetAccessToken("") session.SetAccessToken("")
session.SetRefreshToken("") session.SetRefreshToken("")
session.SetEmail("") session.SetEmail("")
@@ -1075,7 +983,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
return return
} }
// Verify ID token and claims // Verify tokens and claims
if err := t.VerifyToken(tokenResponse.IDToken); err != nil { if err := t.VerifyToken(tokenResponse.IDToken); err != nil {
t.logger.Errorf("Failed to verify id_token during callback: %v", err) t.logger.Errorf("Failed to verify id_token during callback: %v", err)
t.sendErrorResponse(rw, req, "Authentication failed: Could not verify ID token", http.StatusInternalServerError) t.sendErrorResponse(rw, req, "Authentication failed: Could not verify ID token", http.StatusInternalServerError)
@@ -1131,9 +1039,8 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
return return
} }
session.SetEmail(email) session.SetEmail(email)
session.SetIDToken(tokenResponse.IDToken) // Store the raw ID token session.SetAccessToken(tokenResponse.IDToken)
session.SetAccessToken(tokenResponse.AccessToken) // Store the Access Token separately session.SetRefreshToken(tokenResponse.RefreshToken)
session.SetRefreshToken(tokenResponse.RefreshToken) // Store the refresh token
// Clear CSRF, Nonce, CodeVerifier after use // Clear CSRF, Nonce, CodeVerifier after use
session.SetCSRF("") session.SetCSRF("")
@@ -1212,7 +1119,7 @@ func (t *TraefikOidc) determineHost(req *http.Request) string {
} }
// isUserAuthenticated checks the authentication status based on the provided session data. // isUserAuthenticated checks the authentication status based on the provided session data.
// It verifies the session's authenticated flag, the presence and validity of the ID token, // It verifies the session's authenticated flag, the presence and validity of the access token (ID token),
// including signature and standard claims (using VerifyJWTSignatureAndClaims). It also checks if the // including signature and standard claims (using VerifyJWTSignatureAndClaims). It also checks if the
// token is within the configured refreshGracePeriod before its actual expiration. // token is within the configured refreshGracePeriod before its actual expiration.
// //
@@ -1234,123 +1141,87 @@ func (t *TraefikOidc) isUserAuthenticated(session *SessionData) (bool, bool, boo
return false, false, false // Not authenticated, no refresh token, definitely not expired (just unauth) return false, false, false // Not authenticated, no refresh token, definitely not expired (just unauth)
} }
// Check for access token - may be opaque (non-JWT)
accessToken := session.GetAccessToken() accessToken := session.GetAccessToken()
if accessToken == "" { if accessToken == "" {
t.logger.Debug("Authenticated flag set, but no access token found in session") t.logger.Debug("Authenticated flag set, but no access token found in session")
// If authenticated flag is true but token is missing, treat as expired/invalid session state
// Check for refresh token before declaring fully expired
if session.GetRefreshToken() != "" { if session.GetRefreshToken() != "" {
t.logger.Debug("Access token missing, but refresh token exists. Signaling need for refresh.") t.logger.Debug("Authenticated flag set, access token missing, but refresh token exists. Signaling need for refresh.")
return false, true, false // Not authenticated (no token), NeedsRefresh=true, Expired=false return false, true, false // Not authenticated (no access token), NeedsRefresh=true, Expired=false
} }
return false, false, true // No access or refresh token, treat as expired return false, false, true // No access or refresh token, treat as expired
} }
// Check for ID token - needed for roles/groups and some claim validations // Verify the token structure and signature first
idToken := session.GetIDToken() jwt, err := parseJWT(accessToken)
if err != nil {
// If we have an access token but no ID token, we might be using an opaque token t.logger.Errorf("Failed to parse JWT during auth check: %v", err)
// In this case, consider the user authenticated if the session flag is set // Check for refresh token before declaring fully expired
if idToken == "" {
t.logger.Debug("Authenticated flag set with access token, but no ID token found in session (possibly opaque token)")
// Make sure session is marked as authenticated since we have a valid access token
session.SetAuthenticated(true)
// Still try to refresh if possible to get a proper ID token
if session.GetRefreshToken() != "" { if session.GetRefreshToken() != "" {
t.logger.Debug("ID token missing but refresh token exists. Signaling conditional refresh to obtain ID token.") t.logger.Debug("Access token parsing failed, but refresh token exists. Signaling need for refresh.")
return true, true, false // Authenticated=true (has access token), NeedsRefresh=true (to get ID token), Expired=false return false, true, false // Not authenticated (bad access token), NeedsRefresh=true, Expired=false
} }
// User is authenticated but without ID token claims - some features may be limited return false, false, true // Invalid format, no refresh token, treat as expired/invalid
return true, false, false
} }
if err := t.VerifyJWTSignatureAndClaims(jwt, accessToken); err != nil {
// For ID token validation - only if we have an ID token
// Verify the token structure and signature
// ID Token parsing is now handled within VerifyToken.
// Call VerifyToken to ensure tokenCache is populated.
if err := t.VerifyToken(idToken); err != nil {
// Check if the error is specifically about expiration // Check if the error is specifically about expiration
if strings.Contains(err.Error(), "token has expired") { if strings.Contains(err.Error(), "token has expired") {
t.logger.Debugf("ID token signature/claims valid but token expired, needs refresh") t.logger.Debugf("Access token signature/claims valid but token expired, needs refresh")
// Token is expired but otherwise valid, signal for refresh // Token is expired but otherwise valid, signal for refresh
// Return authenticated=false because the current token is unusable // Return authenticated=false because the current token is unusable
// NeedsRefresh is true only if a refresh token exists // NeedsRefresh is true only if a refresh token exists
if session.GetRefreshToken() != "" { if session.GetRefreshToken() != "" {
return false, true, false // Not authenticated (current token unusable), NeedsRefresh=true, Expired=false return false, true, false // Not authenticated (current token unusable), NeedsRefresh=true, Expired=false (because refresh might fix it)
} }
return false, false, true // Expired ID token, no refresh token, treat as expired return false, false, true // Expired access token, no refresh token, treat as expired
} }
// Other verification error (signature, issuer, audience etc.) // Other verification error (signature, issuer, audience etc.)
t.logger.Errorf("ID token verification failed (non-expiration): %v", err) t.logger.Errorf("Access token verification failed (non-expiration): %v", err)
// Check for refresh token before declaring fully expired // Check for refresh token before declaring fully expired
if session.GetRefreshToken() != "" { if session.GetRefreshToken() != "" {
t.logger.Debug("ID token verification failed, but refresh token exists. Signaling need for refresh.") t.logger.Debug("Access token verification failed, but refresh token exists. Signaling need for refresh.")
return false, true, false // Not authenticated (bad ID token), NeedsRefresh=true, Expired=false return false, true, false // Not authenticated (bad access token), NeedsRefresh=true, Expired=false
} }
return false, false, true // Token is invalid for other reasons, no refresh token, treat as expired/invalid session return false, false, true // Token is invalid for other reasons, no refresh token, treat as expired/invalid session
} }
// If VerifyToken succeeded, claims are in the cache. // Claims already parsed within VerifyJWTSignatureAndClaims if it didn't error early
cachedClaims, found := t.tokenCache.Get(idToken) claims := jwt.Claims
if !found {
t.logger.Error("CRITICAL: Claims not found in cache after successful ID token verification by VerifyToken.")
// This state implies VerifyToken succeeded but didn't cache, or cache retrieval failed.
// Safest to try to refresh if possible, otherwise treat as an error.
if session.GetRefreshToken() != "" {
t.logger.Debug("Claims missing post-VerifyToken, attempting refresh to recover.")
return false, true, false // Not authenticated (missing claims), NeedsRefresh=true, Expired=false
}
return false, false, true // Cannot recover, treat as expired/invalid
}
claims := cachedClaims
expClaim, ok := claims["exp"].(float64) expClaim, ok := claims["exp"].(float64)
if !ok { if !ok {
t.logger.Error("Failed to get expiration time ('exp' claim) from verified token") t.logger.Error("Failed to get expiration time ('exp' claim) from verified token")
// Check for refresh token before declaring fully expired // Check for refresh token before declaring fully expired
if session.GetRefreshToken() != "" { if session.GetRefreshToken() != "" {
t.logger.Debug("ID token missing 'exp' claim, but refresh token exists. Signaling need for refresh.") t.logger.Debug("Access token missing 'exp' claim, but refresh token exists. Signaling need for refresh.")
return false, true, false // Not authenticated (bad ID token), NeedsRefresh=true, Expired=false return false, true, false // Not authenticated (bad access token), NeedsRefresh=true, Expired=false
} }
return false, false, true // Treat as invalid if 'exp' is missing and no refresh token return false, false, true // Treat as invalid if 'exp' is missing and no refresh token
} }
expTime := int64(expClaim) expTime := int64(expClaim)
expTimeObj := time.Unix(expTime, 0)
nowObj := time.Now()
refreshThreshold := nowObj.Add(t.refreshGracePeriod)
// Explicit logging for token expiration time // Expiration check is now handled within VerifyJWTSignatureAndClaims logic above
t.logger.Debugf("Token expires at %v, now is %v, refresh threshold is %v", // We only get here if the token is valid and not expired
expTimeObj.Format(time.RFC3339),
nowObj.Format(time.RFC3339),
refreshThreshold.Format(time.RFC3339))
// Check if token is nearing expiration (needs refresh proactively) // Check if token is nearing expiration (needs refresh proactively)
// Only mark for refresh if within grace period // Check if token is nearing expiration using the configured grace period
if expTimeObj.Before(refreshThreshold) { if time.Unix(expTime, 0).Before(time.Now().Add(t.refreshGracePeriod)) {
// Recalculate remaining seconds for logging clarity if needed // Recalculate remaining seconds for logging clarity if needed, using the configured duration
remainingSeconds := int64(time.Until(expTimeObj).Seconds()) remainingSeconds := int64(time.Until(time.Unix(expTime, 0)).Seconds())
t.logger.Debugf("ID token nearing expiration (expires in %d seconds, grace period %s), scheduling proactive refresh", t.logger.Debugf("Access token nearing expiration (expires in %d seconds, grace period %s), scheduling proactive refresh", remainingSeconds, t.refreshGracePeriod)
remainingSeconds, t.refreshGracePeriod)
// Token is still valid, but we should refresh it soon // Token is still valid, but we should refresh it soon
// NeedsRefresh is true only if a refresh token exists // NeedsRefresh is true only if a refresh token exists
if session.GetRefreshToken() != "" { if session.GetRefreshToken() != "" {
return true, true, false // Authenticated=true (current token usable), NeedsRefresh=true, Expired=false return true, true, false // Authenticated=true (current token usable), NeedsRefresh=true, Expired=false
} }
// If no refresh token, we can't proactively refresh, treat as normal valid token for now // If no refresh token, we can't proactively refresh, treat as normal valid token for now
t.logger.Debugf("Token nearing expiration but no refresh token available, cannot proactively refresh.") t.logger.Debugf("Token nearing expiration but no refresh token available, cannot proactively refresh.")
return true, false, false return true, false, false
} }
// Token is valid and not nearing expiration // Token is valid, not expired, and not nearing expiration
t.logger.Debugf("Token is valid and not nearing expiration (expires in %d seconds, outside %s grace period)",
int64(time.Until(expTimeObj).Seconds()), t.refreshGracePeriod)
// Refresh token exists but we don't need to use it since token is still valid and outside grace period
return true, false, false // Authenticated=true, NeedsRefresh=false, Expired=false return true, false, false // Authenticated=true, NeedsRefresh=false, Expired=false
} }
@@ -1469,34 +1340,29 @@ func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge stri
// Check if we're dealing with a Google OIDC provider // Check if we're dealing with a Google OIDC provider
isGoogleProvider := strings.Contains(t.issuerURL, "google") || strings.Contains(t.issuerURL, "accounts.google.com") isGoogleProvider := strings.Contains(t.issuerURL, "google") || strings.Contains(t.issuerURL, "accounts.google.com")
// Handle offline access differently for Google vs other providers // Add offline_access scope if it's missing
if isGoogleProvider { hasOfflineAccess := false
// For Google, use access_type=offline parameter instead of offline_access scope for _, scope := range scopes {
params.Set("access_type", "offline") if scope == "offline_access" {
t.logger.Debug("Google OIDC provider detected, added access_type=offline for refresh tokens") hasOfflineAccess = true
break
// Add prompt=consent for Google to ensure refresh token is issued
params.Set("prompt", "consent")
t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
} else {
// For non-Google providers, use the offline_access scope
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
} }
}
if !hasOfflineAccess { if !hasOfflineAccess {
scopes = append(scopes, "offline_access") scopes = append(scopes, "offline_access")
}
} }
if len(scopes) > 0 { if len(scopes) > 0 {
params.Set("scope", strings.Join(scopes, " ")) params.Set("scope", strings.Join(scopes, " "))
} }
// Add prompt=consent for Google to ensure refresh token is issued
if isGoogleProvider {
params.Set("prompt", "consent")
t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
}
// Use buildURLWithParams which handles potential relative authURL from metadata // Use buildURLWithParams which handles potential relative authURL from metadata
return t.buildURLWithParams(t.authURL, params) return t.buildURLWithParams(t.authURL, params)
} }
@@ -1698,13 +1564,13 @@ func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, se
return false return false
} }
// Verify the new ID token // Verify the new access token (ID token)
if err := t.verifyToken(newToken.IDToken); err != nil { if err := t.verifyToken(newToken.IDToken); err != nil {
truncatedToken := newToken.IDToken truncatedNewToken := newToken.IDToken
if len(newToken.IDToken) > 10 { if len(newToken.IDToken) > 10 {
truncatedToken = newToken.IDToken[:10] truncatedNewToken = newToken.IDToken[:10]
} }
t.logger.Errorf("refreshToken failed: Failed to verify newly obtained ID token starting with %s...: %v", truncatedToken, err) t.logger.Errorf("refreshToken failed: Failed to verify newly obtained ID token starting with %s...: %v", truncatedNewToken, err)
return false return false
} }
@@ -1743,9 +1609,8 @@ func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, se
t.logger.Debugf("New token expires at: %v (in %v)", expiryTime, time.Until(expiryTime)) t.logger.Debugf("New token expires at: %v (in %v)", expiryTime, time.Until(expiryTime))
} }
// Set the new tokens // Set the new access token
session.SetIDToken(newToken.IDToken) session.SetAccessToken(newToken.IDToken)
session.SetAccessToken(newToken.AccessToken)
// Handle the refresh token // Handle the refresh token
if newToken.RefreshToken != "" { if newToken.RefreshToken != "" {
@@ -1796,27 +1661,9 @@ func (t *TraefikOidc) isAllowedDomain(email string) bool {
domain := parts[1] domain := parts[1]
_, ok := t.allowedUserDomains[domain] _, ok := t.allowedUserDomains[domain]
// 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))
}
return ok return ok
} }
// Helper function to get keys from a map for logging
func keysFromMap(m map[string]struct{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// extractGroupsAndRoles attempts to extract 'groups' and 'roles' claims from a decoded ID token. // 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 expects these claims, if present, to be arrays of strings.
// It uses the configured extractClaimsFunc (which defaults to the package-level extractClaims) // It uses the configured extractClaimsFunc (which defaults to the package-level extractClaims)
@@ -1941,7 +1788,7 @@ func (t *TraefikOidc) sendErrorResponse(rw http.ResponseWriter, req *http.Reques
t.logger.Debugf("Sending JSON error response (code %d): %s", code, message) t.logger.Debugf("Sending JSON error response (code %d): %s", code, message)
rw.Header().Set("Content-Type", "application/json") rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(code) rw.WriteHeader(code)
// Use a simple error structure - ensure this matches the expected response format in tests // Use a simple error structure
json.NewEncoder(rw).Encode(map[string]interface{}{ json.NewEncoder(rw).Encode(map[string]interface{}{
"error": http.StatusText(code), // Use standard text for the code "error": http.StatusText(code), // Use standard text for the code
"error_description": message, // Provide specific detail here "error_description": message, // Provide specific detail here
-55
View File
@@ -159,18 +159,6 @@ func (m *MockJWKCache) Cleanup() {
m.Err = nil m.Err = nil
} }
// MockTokenVerifier implements TokenVerifier for testing, allowing interception of VerifyToken calls.
type MockTokenVerifier struct {
VerifyFunc func(token string) error
}
func (m *MockTokenVerifier) VerifyToken(token string) error {
if m.VerifyFunc != nil {
return m.VerifyFunc(token)
}
return fmt.Errorf("VerifyFunc not implemented in mock")
}
// MockTokenExchanger implements TokenExchanger for testing // MockTokenExchanger implements TokenExchanger for testing
type MockTokenExchanger struct { type MockTokenExchanger struct {
ExchangeCodeFunc func(ctx context.Context, grantType, codeOrToken, redirectURL, codeVerifier string) (*TokenResponse, error) ExchangeCodeFunc func(ctx context.Context, grantType, codeOrToken, redirectURL, codeVerifier string) (*TokenResponse, error)
@@ -457,7 +445,6 @@ func TestServeHTTP(t *testing.T) {
"jti": generateRandomString(16), // Unique JTI "jti": generateRandomString(16), // Unique JTI
}) })
session.SetAccessToken(freshToken) session.SetAccessToken(freshToken)
session.SetIDToken(freshToken) // Ensure ID token is also set
session.SetRefreshToken("valid-refresh-token") session.SetRefreshToken("valid-refresh-token")
}, },
expectedStatus: http.StatusOK, expectedStatus: http.StatusOK,
@@ -625,7 +612,6 @@ func TestServeHTTP(t *testing.T) {
session.SetAuthenticated(true) session.SetAuthenticated(true)
session.SetEmail("[email protected]") session.SetEmail("[email protected]")
session.SetAccessToken(validToken) session.SetAccessToken(validToken)
session.SetIDToken(validToken) // Ensure ID token is also set
session.SetRefreshToken("should-not-be-used-refresh-token") session.SetRefreshToken("should-not-be-used-refresh-token")
}, },
mockRefreshTokenFunc: func(originalFunc func(refreshToken string) (*TokenResponse, error)) func(refreshToken string) (*TokenResponse, error) { mockRefreshTokenFunc: func(originalFunc func(refreshToken string) (*TokenResponse, error)) func(refreshToken string) (*TokenResponse, error) {
@@ -651,7 +637,6 @@ func TestServeHTTP(t *testing.T) {
"jti": generateRandomString(16), // Unique JTI "jti": generateRandomString(16), // Unique JTI
}) })
session.SetAccessToken(freshToken) session.SetAccessToken(freshToken)
session.SetIDToken(freshToken) // Ensure ID token is also set
session.SetRefreshToken("valid-refresh-token") session.SetRefreshToken("valid-refresh-token")
}, },
requestHeaders: map[string]string{ requestHeaders: map[string]string{
@@ -673,7 +658,6 @@ func TestServeHTTP(t *testing.T) {
"jti": generateRandomString(16), // Unique JTI "jti": generateRandomString(16), // Unique JTI
}) })
session.SetAccessToken(freshToken) session.SetAccessToken(freshToken)
session.SetIDToken(freshToken) // Ensure ID token is also set
session.SetRefreshToken("valid-refresh-token") session.SetRefreshToken("valid-refresh-token")
}, },
requestHeaders: map[string]string{ requestHeaders: map[string]string{
@@ -686,45 +670,6 @@ func TestServeHTTP(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Reset token blacklist and cache for each test to prevent token replay detection errors
ts.tOidc.tokenBlacklist = NewCache() // Use generic cache for blacklist
ts.tOidc.tokenCache = NewTokenCache()
// Reset the global replayCache to prevent "token replay detected" errors
replayCacheMu.Lock()
replayCache = make(map[string]time.Time) // Reset the global cache
replayCacheMu.Unlock()
// Store original tokenVerifier to restore later
origTokenVerifier := ts.tOidc.tokenVerifier
// Create a mock tokenVerifier that clears the replay cache before verification
// This prevents replay detection when the same token is verified multiple times within a test
mockTokenVerifier := &MockTokenVerifier{
VerifyFunc: func(token string) error {
// Clear replay cache before token verification
replayCacheMu.Lock()
replayCache = make(map[string]time.Time)
replayCacheMu.Unlock()
// Call the original verifier's VerifyToken method
// Ensure origTokenVerifier is not nil and is the correct type if necessary,
// though in this context it should be the *TraefikOidc instance.
if origTokenVerifier != nil {
return origTokenVerifier.VerifyToken(token)
}
return fmt.Errorf("original token verifier is nil")
},
}
// Replace tokenVerifier with our mock
ts.tOidc.tokenVerifier = mockTokenVerifier
// Restore original tokenVerifier after test
defer func() {
ts.tOidc.tokenVerifier = origTokenVerifier
}()
req := httptest.NewRequest("GET", tc.requestPath, nil) req := httptest.NewRequest("GET", tc.requestPath, nil)
// Set common headers needed by the logic (determineScheme, determineHost) // Set common headers needed by the logic (determineScheme, determineHost)
req.Header.Set("X-Forwarded-Proto", "http") // Or https if testing that req.Header.Set("X-Forwarded-Proto", "http") // Or https if testing that
+44 -211
View File
@@ -156,7 +156,6 @@ func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) (*
accessTokenChunks: make(map[int]*sessions.Session), accessTokenChunks: make(map[int]*sessions.Session),
refreshTokenChunks: make(map[int]*sessions.Session), refreshTokenChunks: make(map[int]*sessions.Session),
refreshMutex: sync.Mutex{}, // Initialize the mutex refreshMutex: sync.Mutex{}, // Initialize the mutex
dirty: false, // Initialize dirty flag
} }
} }
@@ -190,7 +189,6 @@ func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
// Get session from pool. // Get session from pool.
sessionData := sm.sessionPool.Get().(*SessionData) sessionData := sm.sessionPool.Get().(*SessionData)
sessionData.request = r sessionData.request = r
sessionData.dirty = false // Reset dirty flag when getting a session
var err error var err error
sessionData.mainSession, err = sm.store.Get(r, mainCookieName) sessionData.mainSession, err = sm.store.Get(r, mainCookieName)
@@ -283,21 +281,6 @@ type SessionData struct {
// refreshMutex protects refresh token operations within this session instance. // refreshMutex protects refresh token operations within this session instance.
refreshMutex sync.Mutex refreshMutex sync.Mutex
// dirty indicates whether the session data has changed and needs to be saved.
dirty bool
}
// IsDirty returns true if the session data has been modified since it was last loaded or saved.
func (sd *SessionData) IsDirty() bool {
return sd.dirty
}
// MarkDirty explicitly sets the dirty flag to true.
// This can be used when an operation doesn't change session data
// but should still trigger a session save (e.g., to ensure the cookie is re-issued).
func (sd *SessionData) MarkDirty() {
sd.dirty = true
} }
// Save persists all parts of the session (main, access token, refresh token, and any chunks) // Save persists all parts of the session (main, access token, refresh token, and any chunks)
@@ -319,50 +302,38 @@ func (sd *SessionData) Save(r *http.Request, w http.ResponseWriter) error {
sd.accessSession.Options = options sd.accessSession.Options = options
sd.refreshSession.Options = options sd.refreshSession.Options = options
var firstErr error // Save main session.
// Helper to record first error and log subsequent ones if err := sd.mainSession.Save(r, w); err != nil {
saveOrLogError := func(s *sessions.Session, name string) { return fmt.Errorf("failed to save main session: %w", err)
if s == nil { // Should not happen if initialized correctly
sd.manager.logger.Errorf("Attempted to save nil session: %s", name)
if firstErr == nil {
firstErr = fmt.Errorf("attempted to save nil session: %s", name)
}
return
}
if err := s.Save(r, w); err != nil {
errMsg := fmt.Errorf("failed to save %s session: %w", name, err)
sd.manager.logger.Error(errMsg.Error())
if firstErr == nil {
firstErr = errMsg
}
}
} }
// Save main session.
saveOrLogError(sd.mainSession, "main")
// Save access token session. // Save access token session.
saveOrLogError(sd.accessSession, "access token") if err := sd.accessSession.Save(r, w); err != nil {
return fmt.Errorf("failed to save access token session: %w", err)
}
// Save refresh token session. // Save refresh token session.
saveOrLogError(sd.refreshSession, "refresh token") if err := sd.refreshSession.Save(r, w); err != nil {
return fmt.Errorf("failed to save refresh token session: %w", err)
}
// Save access token chunks. // Save access token chunks.
for i, sessionChunk := range sd.accessTokenChunks { for _, session := range sd.accessTokenChunks {
sessionChunk.Options = options session.Options = options
saveOrLogError(sessionChunk, fmt.Sprintf("access token chunk %d", i)) if err := session.Save(r, w); err != nil {
return fmt.Errorf("failed to save access token chunk session: %w", err)
}
} }
// Save refresh token chunks. // Save refresh token chunks.
for i, sessionChunk := range sd.refreshTokenChunks { for _, session := range sd.refreshTokenChunks {
sessionChunk.Options = options session.Options = options
saveOrLogError(sessionChunk, fmt.Sprintf("refresh token chunk %d", i)) if err := session.Save(r, w); err != nil {
return fmt.Errorf("failed to save refresh token chunk session: %w", err)
}
} }
if firstErr == nil { return nil
sd.dirty = false // Reset dirty flag only if all saves were successful
}
return firstErr
} }
// Clear removes all session data associated with this SessionData instance. // Clear removes all session data associated with this SessionData instance.
@@ -379,26 +350,19 @@ func (sd *SessionData) Save(r *http.Request, w http.ResponseWriter) error {
// Returns: // Returns:
// - An error if saving the expired sessions fails (only if w is not nil). // - An error if saving the expired sessions fails (only if w is not nil).
func (sd *SessionData) Clear(r *http.Request, w http.ResponseWriter) error { func (sd *SessionData) Clear(r *http.Request, w http.ResponseWriter) error {
sd.dirty = true // Clearing the session means its state is changing and needs to be saved.
// Clear and expire all sessions. // Clear and expire all sessions.
if sd.mainSession != nil { sd.mainSession.Options.MaxAge = -1
sd.mainSession.Options.MaxAge = -1 sd.accessSession.Options.MaxAge = -1
for k := range sd.mainSession.Values { sd.refreshSession.Options.MaxAge = -1
delete(sd.mainSession.Values, k)
} for k := range sd.mainSession.Values {
delete(sd.mainSession.Values, k)
} }
if sd.accessSession != nil { for k := range sd.accessSession.Values {
sd.accessSession.Options.MaxAge = -1 delete(sd.accessSession.Values, k)
for k := range sd.accessSession.Values {
delete(sd.accessSession.Values, k)
}
} }
if sd.refreshSession != nil { for k := range sd.refreshSession.Values {
sd.refreshSession.Options.MaxAge = -1 delete(sd.refreshSession.Values, k)
for k := range sd.refreshSession.Values {
delete(sd.refreshSession.Values, k)
}
} }
// Clear chunk sessions. // Clear chunk sessions.
@@ -464,44 +428,15 @@ func (sd *SessionData) GetAuthenticated() bool {
// Returns: // Returns:
// - An error if generating a new session ID fails when setting value to true. // - An error if generating a new session ID fails when setting value to true.
func (sd *SessionData) SetAuthenticated(value bool) error { func (sd *SessionData) SetAuthenticated(value bool) error {
currentAuth := sd.GetAuthenticated() // This checks flag and expiry
changed := false
if currentAuth != value {
changed = true
}
if value { if value {
// If we are setting to true, and either it wasn't true before,
// or if the session ID needs regeneration (e.g. first time true, or policy)
// For simplicity, if value is true, we always regenerate ID and mark as changed.
// This ensures session ID regeneration is always saved.
id, err := generateSecureRandomString(32) id, err := generateSecureRandomString(32)
if err != nil { if err != nil {
return fmt.Errorf("failed to generate secure session id: %w", err) return fmt.Errorf("failed to generate secure session id: %w", err)
} }
if sd.mainSession.ID != id { // ID actually changed
changed = true
}
sd.mainSession.ID = id sd.mainSession.ID = id
newCreationTime := time.Now().Unix() sd.mainSession.Values["created_at"] = time.Now().Unix()
if oldTime, ok := sd.mainSession.Values["created_at"].(int64); !ok || oldTime != newCreationTime {
changed = true
}
sd.mainSession.Values["created_at"] = newCreationTime
if oldAuth, ok := sd.mainSession.Values["authenticated"].(bool); !ok || oldAuth != value {
changed = true
}
} else { // value is false
if oldAuth, ok := sd.mainSession.Values["authenticated"].(bool); !ok || oldAuth != value {
changed = true
}
} }
sd.mainSession.Values["authenticated"] = value sd.mainSession.Values["authenticated"] = value
if changed {
sd.dirty = true
}
return nil return nil
} }
@@ -553,14 +488,6 @@ func (sd *SessionData) GetAccessToken() string {
// Parameters: // Parameters:
// - token: The access token string to store. // - token: The access token string to store.
func (sd *SessionData) SetAccessToken(token string) { func (sd *SessionData) SetAccessToken(token string) {
currentAccessToken := sd.GetAccessToken()
if currentAccessToken == token {
// If token is empty, and current is also empty, it's not a change.
// This check handles both empty and non-empty identical cases.
return
}
sd.dirty = true
// Expire any existing chunk cookies first. // Expire any existing chunk cookies first.
if sd.request != nil { if sd.request != nil {
sd.expireAccessTokenChunks(nil) // Will be saved when Save() is called. sd.expireAccessTokenChunks(nil) // Will be saved when Save() is called.
@@ -569,13 +496,6 @@ func (sd *SessionData) SetAccessToken(token string) {
// Clear and prepare chunks map for new token. // Clear and prepare chunks map for new token.
sd.accessTokenChunks = make(map[int]*sessions.Session) sd.accessTokenChunks = make(map[int]*sessions.Session)
if token == "" { // Clearing the token
sd.accessSession.Values["token"] = ""
sd.accessSession.Values["compressed"] = false
// sd.accessTokenChunks is already cleared
return
}
// Compress token. // Compress token.
compressed := compressToken(token) compressed := compressToken(token)
@@ -584,19 +504,13 @@ func (sd *SessionData) SetAccessToken(token string) {
sd.accessSession.Values["compressed"] = true sd.accessSession.Values["compressed"] = true
} else { } else {
// Split compressed token into chunks. // Split compressed token into chunks.
sd.accessSession.Values["token"] = "" // Main cookie won't hold the token directly sd.accessSession.Values["token"] = ""
sd.accessSession.Values["compressed"] = true // Data in chunks is compressed sd.accessSession.Values["compressed"] = true
chunks := splitIntoChunks(compressed, maxCookieSize) chunks := splitIntoChunks(compressed, maxCookieSize)
for i, chunkData := range chunks { for i, chunk := range chunks {
sessionName := fmt.Sprintf("%s_%d", accessTokenCookie, i) sessionName := fmt.Sprintf("%s_%d", accessTokenCookie, i)
// Ensure sd.request is available, otherwise log warning or handle error
if sd.request == nil {
sd.manager.logger.Infof("SetAccessToken: sd.request is nil, cannot get/create chunk session %s", sessionName)
// Potentially skip this chunk or error out, depending on desired robustness
continue
}
session, _ := sd.manager.store.Get(sd.request, sessionName) session, _ := sd.manager.store.Get(sd.request, sessionName)
session.Values["token_chunk"] = chunkData session.Values["token_chunk"] = chunk
sd.accessTokenChunks[i] = session sd.accessTokenChunks[i] = session
} }
} }
@@ -650,12 +564,6 @@ func (sd *SessionData) GetRefreshToken() string {
// Parameters: // Parameters:
// - token: The refresh token string to store. // - token: The refresh token string to store.
func (sd *SessionData) SetRefreshToken(token string) { func (sd *SessionData) SetRefreshToken(token string) {
currentRefreshToken := sd.GetRefreshToken()
if currentRefreshToken == token {
return
}
sd.dirty = true
// Expire any existing chunk cookies first. // Expire any existing chunk cookies first.
if sd.request != nil { if sd.request != nil {
sd.expireRefreshTokenChunks(nil) // Will be saved when Save() is called. sd.expireRefreshTokenChunks(nil) // Will be saved when Save() is called.
@@ -664,13 +572,6 @@ func (sd *SessionData) SetRefreshToken(token string) {
// Clear and prepare chunks map for new token. // Clear and prepare chunks map for new token.
sd.refreshTokenChunks = make(map[int]*sessions.Session) sd.refreshTokenChunks = make(map[int]*sessions.Session)
if token == "" { // Clearing the token
sd.refreshSession.Values["token"] = ""
sd.refreshSession.Values["compressed"] = false
// sd.refreshTokenChunks is already cleared
return
}
// Compress token. // Compress token.
compressed := compressToken(token) compressed := compressToken(token)
@@ -679,17 +580,13 @@ func (sd *SessionData) SetRefreshToken(token string) {
sd.refreshSession.Values["compressed"] = true sd.refreshSession.Values["compressed"] = true
} else { } else {
// Split compressed token into chunks. // Split compressed token into chunks.
sd.refreshSession.Values["token"] = "" // Main cookie won't hold the token directly sd.refreshSession.Values["token"] = ""
sd.refreshSession.Values["compressed"] = true // Data in chunks is compressed sd.refreshSession.Values["compressed"] = true
chunks := splitIntoChunks(compressed, maxCookieSize) chunks := splitIntoChunks(compressed, maxCookieSize)
for i, chunkData := range chunks { for i, chunk := range chunks {
sessionName := fmt.Sprintf("%s_%d", refreshTokenCookie, i) sessionName := fmt.Sprintf("%s_%d", refreshTokenCookie, i)
if sd.request == nil {
sd.manager.logger.Infof("SetRefreshToken: sd.request is nil, cannot get/create chunk session %s", sessionName)
continue
}
session, _ := sd.manager.store.Get(sd.request, sessionName) session, _ := sd.manager.store.Get(sd.request, sessionName)
session.Values["token_chunk"] = chunkData session.Values["token_chunk"] = chunk
sd.refreshTokenChunks[i] = session sd.refreshTokenChunks[i] = session
} }
} }
@@ -781,11 +678,7 @@ func (sd *SessionData) GetCSRF() string {
// Parameters: // Parameters:
// - token: The CSRF token to store. // - token: The CSRF token to store.
func (sd *SessionData) SetCSRF(token string) { func (sd *SessionData) SetCSRF(token string) {
currentVal, _ := sd.mainSession.Values["csrf"].(string) sd.mainSession.Values["csrf"] = token
if currentVal != token {
sd.mainSession.Values["csrf"] = token
sd.dirty = true
}
} }
// GetNonce retrieves the OIDC nonce value stored in the main session. // GetNonce retrieves the OIDC nonce value stored in the main session.
@@ -804,11 +697,7 @@ func (sd *SessionData) GetNonce() string {
// Parameters: // Parameters:
// - nonce: The nonce string to store. // - nonce: The nonce string to store.
func (sd *SessionData) SetNonce(nonce string) { func (sd *SessionData) SetNonce(nonce string) {
currentVal, _ := sd.mainSession.Values["nonce"].(string) sd.mainSession.Values["nonce"] = nonce
if currentVal != nonce {
sd.mainSession.Values["nonce"] = nonce
sd.dirty = true
}
} }
// GetCodeVerifier retrieves the PKCE (Proof Key for Code Exchange) code verifier // GetCodeVerifier retrieves the PKCE (Proof Key for Code Exchange) code verifier
@@ -827,11 +716,7 @@ func (sd *SessionData) GetCodeVerifier() string {
// Parameters: // Parameters:
// - codeVerifier: The PKCE code verifier string to store. // - codeVerifier: The PKCE code verifier string to store.
func (sd *SessionData) SetCodeVerifier(codeVerifier string) { func (sd *SessionData) SetCodeVerifier(codeVerifier string) {
currentVal, _ := sd.mainSession.Values["code_verifier"].(string) sd.mainSession.Values["code_verifier"] = codeVerifier
if currentVal != codeVerifier {
sd.mainSession.Values["code_verifier"] = codeVerifier
sd.dirty = true
}
} }
// GetEmail retrieves the authenticated user's email address stored in the main session. // GetEmail retrieves the authenticated user's email address stored in the main session.
@@ -850,11 +735,7 @@ func (sd *SessionData) GetEmail() string {
// Parameters: // Parameters:
// - email: The user's email address to store. // - email: The user's email address to store.
func (sd *SessionData) SetEmail(email string) { func (sd *SessionData) SetEmail(email string) {
currentVal, _ := sd.mainSession.Values["email"].(string) sd.mainSession.Values["email"] = email
if currentVal != email {
sd.mainSession.Values["email"] = email
sd.dirty = true
}
} }
// GetIncomingPath retrieves the original request URI (including query parameters) // GetIncomingPath retrieves the original request URI (including query parameters)
@@ -874,53 +755,5 @@ func (sd *SessionData) GetIncomingPath() string {
// Parameters: // Parameters:
// - path: The original request URI string (e.g., "/protected/resource?id=123"). // - path: The original request URI string (e.g., "/protected/resource?id=123").
func (sd *SessionData) SetIncomingPath(path string) { func (sd *SessionData) SetIncomingPath(path string) {
currentVal, _ := sd.mainSession.Values["incoming_path"].(string) sd.mainSession.Values["incoming_path"] = path
if currentVal != path {
sd.mainSession.Values["incoming_path"] = path
sd.dirty = true
}
}
// GetIDToken retrieves the ID token stored in the session.
// It handles reassembling the token from multiple cookie chunks if necessary
// and decompresses it if it was stored compressed.
//
// Returns:
// - The complete, decompressed ID token string, or an empty string if not found.
func (sd *SessionData) GetIDToken() string {
token, _ := sd.mainSession.Values["id_token"].(string)
if token != "" {
compressed, _ := sd.mainSession.Values["id_token_compressed"].(bool)
if compressed {
return decompressToken(token)
}
return token
}
return ""
}
// SetIDToken stores the provided ID token in the session.
//
// Parameters:
// - token: The ID token string to store.
func (sd *SessionData) SetIDToken(token string) {
currentIDToken := sd.GetIDToken() // Gets fully reassembled, decompressed token
if currentIDToken == token {
// This handles cases where token is "" and currentIDToken is also "", no change.
// Or token is "abc" and currentIDToken is "abc", no change.
return
}
sd.dirty = true // Mark as dirty because a change is being made
if token == "" {
sd.mainSession.Values["id_token"] = ""
sd.mainSession.Values["id_token_compressed"] = false
return
}
// Compress token
compressed := compressToken(token)
sd.mainSession.Values["id_token"] = compressed
sd.mainSession.Values["id_token_compressed"] = true
} }
+4 -4
View File
@@ -192,14 +192,14 @@ func TestTemplateExecutionContext(t *testing.T) {
expectedValue string expectedValue string
}{ }{
{ {
name: "Access and ID token distinction", name: "Access and ID token identity",
templateText: "Access: {{.AccessToken}} ID: {{.IdToken}}", templateText: "Access: {{.AccessToken}} ID: {{.IdToken}}",
data: templateData{ data: templateData{
AccessToken: "access-token-value", AccessToken: "access-token",
IdToken: "id-token-value", // Now these should be distinct values IdToken: "access-token", // Same as AccessToken in processAuthorizedRequest
Claims: map[string]interface{}{}, Claims: map[string]interface{}{},
}, },
expectedValue: "Access: access-token-value ID: id-token-value", expectedValue: "Access: access-token ID: access-token",
}, },
{ {
name: "Combining tokens and claims", name: "Combining tokens and claims",
+41 -214
View File
@@ -1,7 +1,6 @@
package traefikoidc package traefikoidc
import ( import (
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -67,28 +66,6 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
"Authorization": "", "Authorization": "",
}, },
}, },
{
name: "ID Token Header",
headers: []TemplatedHeader{
{Name: "X-ID-Token", Value: "{{.IdToken}}"},
},
expectedHeaders: map[string]string{
// We'll update this dynamically after generating the token
"X-ID-Token": "",
},
},
{
name: "Both Token Types",
headers: []TemplatedHeader{
{Name: "X-Access-Token", Value: "{{.AccessToken}}"},
{Name: "X-ID-Token", Value: "{{.IdToken}}"},
},
expectedHeaders: map[string]string{
// We'll update these dynamically after generating the tokens
"X-Access-Token": "",
"X-ID-Token": "",
},
},
{ {
name: "Missing Claim", name: "Missing Claim",
headers: []TemplatedHeader{ headers: []TemplatedHeader{
@@ -128,19 +105,6 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
"X-Auth-Info": "", "X-Auth-Info": "",
}, },
}, },
{
name: "Opaque Access Token with AccessTokenField",
headers: []TemplatedHeader{
{Name: "X-User-AccessToken", Value: "{{.AccessToken}}"},
},
claims: map[string]interface{}{ // For ID Token
"email": "[email protected]",
"sub": "opaque_sub_for_id_token",
},
expectedHeaders: map[string]string{
"X-User-AccessToken": "this_is_an_opaque_access_token",
},
},
} }
for _, tc := range tests { for _, tc := range tests {
@@ -149,7 +113,7 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
token := ts.token token := ts.token
if len(tc.claims) > 0 { if len(tc.claims) > 0 {
var err error var err error
baseClaims := map[string]interface{}{ claims := map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(3000000000), // Far future timestamp "exp": float64(3000000000), // Far future timestamp
@@ -162,10 +126,10 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
// Add the test-specific claims // Add the test-specific claims
for k, v := range tc.claims { for k, v := range tc.claims {
baseClaims[k] = v claims[k] = v
} }
token, err = createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", baseClaims) token, err = createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims)
if err != nil { if err != nil {
t.Fatalf("Failed to create test JWT: %v", err) t.Fatalf("Failed to create test JWT: %v", err)
} }
@@ -177,17 +141,7 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
} }
if tc.name == "Combined Token and Claim" { if tc.name == "Combined Token and Claim" {
// If this test case uses specific ID/Access tokens, 'token' here might be just the ID token. tc.expectedHeaders["X-Auth-Info"] = "[email protected], Token=" + token
// This part might need adjustment if AccessToken is different and opaque.
// For now, assuming 'token' is the one to be used if not overridden later.
// The specific test "Opaque Access Token with AccessTokenField" will handle its AccessToken.
// This generic 'token' is used as a fallback if specific logic isn't hit.
// Let's ensure this test case uses the JWT access token if not otherwise specified.
accessTokenForHeader := token // Default to the generated JWT 'token'
if sessionVal, ok := tc.claims["_accessToken"]; ok { // Check if a specific access token is provided for this test
accessTokenForHeader = sessionVal.(string)
}
tc.expectedHeaders["X-Auth-Info"] = "User=" + tc.claims["email"].(string) + ", Token=" + accessTokenForHeader
} }
// Store intercepted headers for verification // Store intercepted headers for verification
@@ -204,6 +158,8 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
w.WriteHeader(http.StatusOK) 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{ tOidc := &TraefikOidc{
next: nextHandler, next: nextHandler,
name: "test", name: "test",
@@ -218,19 +174,14 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
tokenCache: NewTokenCache(), tokenCache: NewTokenCache(),
limiter: rate.NewLimiter(rate.Every(time.Second), 10), limiter: rate.NewLimiter(rate.Every(time.Second), 10),
logger: NewLogger("debug"), logger: NewLogger("debug"),
allowedUserDomains: map[string]struct{}{"example.com": {}, "[email protected]": {}}, // Ensure domain for opaque test is allowed allowedUserDomains: map[string]struct{}{"example.com": {}},
excludedURLs: map[string]struct{}{"/favicon": {}}, excludedURLs: map[string]struct{}{"/favicon": {}},
httpClient: &http.Client{}, httpClient: &http.Client{},
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: ts.sessionManager, sessionManager: ts.sessionManager,
extractClaimsFunc: extractClaims, extractClaimsFunc: extractClaims,
headerTemplates: make(map[string]*template.Template), headerTemplates: make(map[string]*template.Template),
// Default to true, which means PopulateSessionWithIdTokenClaims is true
// UseIdTokenForSession: true, // Explicitly can be set if needed
} }
tOidc.tokenVerifier = tOidc
tOidc.jwtVerifier = tOidc
tOidc.tokenExchanger = tOidc
// Initialize and parse header templates // Initialize and parse header templates
for _, header := range tc.headers { for _, header := range tc.headers {
@@ -241,180 +192,55 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
tOidc.headerTemplates[header.Name] = tmpl tOidc.headerTemplates[header.Name] = tmpl
} }
// Close the initComplete channel to bypass the waiting
close(tOidc.initComplete) close(tOidc.initComplete)
// Create a test request
req := httptest.NewRequest("GET", "/protected", nil) req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "example.com") req.Header.Set("X-Forwarded-Host", "example.com")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
// Create a session
session, err := tOidc.sessionManager.GetSession(req) session, err := tOidc.sessionManager.GetSession(req)
if err != nil { if err != nil {
t.Fatalf("Failed to get session: %v", err) t.Fatalf("Failed to get session: %v", err)
} }
// Setup the session with authentication data
session.SetAuthenticated(true) session.SetAuthenticated(true)
// Set a default email; specific tests might override or rely on ID token population session.SetEmail("[email protected]")
defaultEmail := "[email protected]"
if emailClaim, ok := tc.claims["email"].(string); ok {
defaultEmail = emailClaim // Use email from claims if available for initial setup
}
session.SetEmail(defaultEmail)
// Default token setup (can be overridden by specific test cases below)
session.SetIDToken(token)
session.SetAccessToken(token) session.SetAccessToken(token)
session.SetRefreshToken("test-refresh-token") session.SetRefreshToken("test-refresh-token")
if tc.name == "ID Token Header" || tc.name == "Both Token Types" {
idTokenClaims := map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000),
"iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject",
"nonce": "test-nonce", "jti": generateRandomString(16), "type": "id_token",
"email": tc.claims["email"], // Ensure email from test case claims is in ID token
}
// Add other claims from tc.claims to idTokenClaims
for k, v := range tc.claims {
if _, exists := idTokenClaims[k]; !exists {
idTokenClaims[k] = v
}
}
idTokenForSession, idErr := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", idTokenClaims)
if idErr != nil {
t.Fatalf("Failed to create test ID JWT: %v", idErr)
}
accessTokenClaims := map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000),
"iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject",
"jti": generateRandomString(16), "type": "access_token", "scope": "openid email profile",
"email": tc.claims["email"], // Include email in access token too for these tests
}
accessTokenForSession, accessErr := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", accessTokenClaims)
if accessErr != nil {
t.Fatalf("Failed to create test access JWT: %v", accessErr)
}
session.SetIDToken(idTokenForSession)
session.SetAccessToken(accessTokenForSession)
tOidc.tokenExchanger = &MockTokenExchanger{
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
return &TokenResponse{
IDToken: idTokenForSession, AccessToken: accessTokenForSession,
RefreshToken: refreshToken, ExpiresIn: 3600,
}, nil
},
}
tOidc.tokenVerifier = &MockTokenVerifier{VerifyFunc: func(token string) error { return nil }}
if tc.name == "ID Token Header" {
tc.expectedHeaders["X-ID-Token"] = idTokenForSession
} else if tc.name == "Both Token Types" {
tc.expectedHeaders["X-ID-Token"] = idTokenForSession
tc.expectedHeaders["X-Access-Token"] = accessTokenForSession
}
} else if tc.name == "Opaque Access Token with AccessTokenField" {
idTokenClaims := map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000),
"iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject", // Default sub
"nonce": "test-nonce", "jti": generateRandomString(16), "type": "id_token",
}
// Populate ID token claims from tc.claims
for k, v := range tc.claims {
idTokenClaims[k] = v
}
// Ensure email from tc.claims is used for the ID token
session.SetEmail(tc.claims["email"].(string)) // Also set it directly for initial session state
idTokenForSession, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", idTokenClaims)
if err != nil {
t.Fatalf("Failed to create test ID JWT for opaque test: %v", err)
}
opaqueAccessToken := "this_is_an_opaque_access_token"
session.SetIDToken(idTokenForSession)
session.SetAccessToken(opaqueAccessToken)
tOidc.tokenExchanger = &MockTokenExchanger{
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
return &TokenResponse{
IDToken: idTokenForSession,
AccessToken: opaqueAccessToken,
RefreshToken: refreshToken,
ExpiresIn: 3600,
}, nil
},
}
tOidc.tokenVerifier = &MockTokenVerifier{
VerifyFunc: func(tokenToVerify string) error {
if tokenToVerify == idTokenForSession {
return nil // ID token is expected to be verified
}
if tokenToVerify == opaqueAccessToken {
t.Errorf("TokenVerifier was incorrectly called with the opaque access token.")
return errors.New("opaque access token should not be verified by this path")
}
t.Logf("TokenVerifier called with unexpected token: %s", tokenToVerify)
return errors.New("unexpected token passed to verifier for this test case")
},
}
// Expected header X-User-AccessToken is already set in tc.expectedHeaders
}
if err := session.Save(req, rr); err != nil { if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err) t.Fatalf("Failed to save session: %v", err)
} }
// Add session cookies to the request
for _, cookie := range rr.Result().Cookies() { for _, cookie := range rr.Result().Cookies() {
req.AddCookie(cookie) req.AddCookie(cookie)
} }
// Reset the response recorder for the main test
rr = httptest.NewRecorder() rr = httptest.NewRecorder()
// Process the request
tOidc.ServeHTTP(rr, req) tOidc.ServeHTTP(rr, req)
// Check status code
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Errorf("Expected status code %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String()) t.Errorf("Expected status code %d, got %d", http.StatusOK, rr.Code)
} }
// Verify headers were set correctly
for name, expectedValue := range tc.expectedHeaders { for name, expectedValue := range tc.expectedHeaders {
if value, exists := interceptedHeaders[name]; !exists { if value, exists := interceptedHeaders[name]; !exists {
// For <no value> case, it might not be set if template resolves to empty and header is omitted.
// However, Go templates usually insert "<no value>" string.
if expectedValue == "<no value>" && tc.name == "Missing Claim" { // Special handling for <no value>
// If the template {{.Claims.role}} results in an empty string because role is missing,
// and the header is not set, this is also acceptable for "<no value>".
// The current test expects the literal string "<no value>".
// Let's assume for now that if it's missing, it's an error unless specifically handled.
// The test as written expects "<no value>" to be present.
}
t.Errorf("Expected header %s was not set", name) t.Errorf("Expected header %s was not set", name)
} else if value != expectedValue { } else if value != expectedValue {
t.Errorf("Header %s expected value %q, got %q", name, expectedValue, value) t.Errorf("Header %s expected value %q, got %q", name, expectedValue, value)
} }
} }
if tc.name == "Opaque Access Token with AccessTokenField" {
postReq := httptest.NewRequest("GET", "/protected", nil)
for _, cookie := range rr.Result().Cookies() {
postReq.AddCookie(cookie)
}
updatedSession, err := tOidc.sessionManager.GetSession(postReq)
if err != nil {
t.Fatalf("Failed to get updated session for opaque test: %v", err)
}
expectedEmail := tc.claims["email"].(string)
if updatedSession.GetEmail() != expectedEmail {
t.Errorf("Expected session email to be %q (from ID token), got %q", expectedEmail, updatedSession.GetEmail())
}
if !updatedSession.GetAuthenticated() {
t.Errorf("Session should be authenticated after successful flow for opaque test")
}
}
}) })
} }
} }
@@ -483,6 +309,8 @@ func TestEdgeCaseTemplatedHeaders(t *testing.T) {
w.WriteHeader(http.StatusOK) 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{ tOidc := &TraefikOidc{
next: nextHandler, next: nextHandler,
name: "test", name: "test",
@@ -505,8 +333,6 @@ func TestEdgeCaseTemplatedHeaders(t *testing.T) {
extractClaimsFunc: extractClaims, extractClaimsFunc: extractClaims,
headerTemplates: make(map[string]*template.Template), headerTemplates: make(map[string]*template.Template),
} }
tOidc.tokenVerifier = tOidc
tOidc.jwtVerifier = tOidc
// Initialize and parse header templates // Initialize and parse header templates
for _, header := range tc.headers { for _, header := range tc.headers {
@@ -517,56 +343,57 @@ func TestEdgeCaseTemplatedHeaders(t *testing.T) {
tOidc.headerTemplates[header.Name] = tmpl tOidc.headerTemplates[header.Name] = tmpl
} }
// Close the initComplete channel to bypass the waiting
close(tOidc.initComplete) close(tOidc.initComplete)
// Create a test request
req := httptest.NewRequest("GET", "/protected", nil) req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "example.com") req.Header.Set("X-Forwarded-Host", "example.com")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
// Create a session
session, err := tOidc.sessionManager.GetSession(req) session, err := tOidc.sessionManager.GetSession(req)
if err != nil { if err != nil {
t.Fatalf("Failed to get session: %v", err) t.Fatalf("Failed to get session: %v", err)
} }
// Setup the session with authentication data
session.SetAuthenticated(true) session.SetAuthenticated(true)
session.SetEmail("[email protected]") session.SetEmail("[email protected]")
session.SetIDToken(token) // Use the new method session.SetAccessToken(token)
session.SetAccessToken(token) // Also set access token to match
session.SetRefreshToken("test-refresh-token") session.SetRefreshToken("test-refresh-token")
tOidc.extractClaimsFunc = extractClaims
tOidc.tokenExchanger = &MockTokenExchanger{
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
return &TokenResponse{
IDToken: token,
AccessToken: token,
RefreshToken: refreshToken,
ExpiresIn: 3600,
}, nil
},
}
if err := session.Save(req, rr); err != nil { if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err) t.Fatalf("Failed to save session: %v", err)
} }
// Add session cookies to the request
for _, cookie := range rr.Result().Cookies() { for _, cookie := range rr.Result().Cookies() {
req.AddCookie(cookie) req.AddCookie(cookie)
} }
// Reset the response recorder for the main test
rr = httptest.NewRecorder() rr = httptest.NewRecorder()
// Process the request
tOidc.ServeHTTP(rr, req) tOidc.ServeHTTP(rr, req)
// Check status code
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Errorf("Expected status code %d, got %d", http.StatusOK, rr.Code) t.Errorf("Expected status code %d, got %d", http.StatusOK, rr.Code)
} }
// The "Array Claim Access" check previously here was problematic as it didn't correctly // We are primarily checking that these edge cases don't cause panics or errors
// intercept headers in TestEdgeCaseTemplatedHeaders. The primary goal of this // For the array test, we can verify the content
// function is to test edge cases for panics/errors, and robust header value if tc.name == "Array Claim Access" {
// checking is already covered in TestTemplatedHeadersIntegration. // Check if the header was set
// Removing the ineffective check to resolve the "declared and not used" error. 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)
}
}
}) })
} }
} }
-311
View File
@@ -1,311 +0,0 @@
package traefikoidc
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"text/template"
"time"
"golang.org/x/time/rate"
)
// TestTokenTypeDistinction tests that AccessToken and IdToken are correctly distinguished in templates
func TestTokenTypeDistinction(t *testing.T) {
// Define test data where AccessToken and IdToken are deliberately different
type templateData struct {
AccessToken string
IdToken string
RefreshToken string
Claims map[string]interface{}
}
testData := templateData{
AccessToken: "test-access-token-abc123",
IdToken: "test-id-token-xyz789",
RefreshToken: "test-refresh-token",
Claims: map[string]interface{}{
"sub": "test-subject",
"email": "[email protected]",
},
}
// Test cases
tests := []struct {
name string
templateText string
expectedValue string
}{
{
name: "Access Token Only",
templateText: "Bearer {{.AccessToken}}",
expectedValue: "Bearer test-access-token-abc123",
},
{
name: "ID Token Only",
templateText: "ID: {{.IdToken}}",
expectedValue: "ID: test-id-token-xyz789",
},
{
name: "Both Tokens",
templateText: "Access: {{.AccessToken}} ID: {{.IdToken}}",
expectedValue: "Access: test-access-token-abc123 ID: test-id-token-xyz789",
},
{
name: "Both Tokens in Authorization Format",
templateText: "Bearer {{.AccessToken}} and Bearer {{.IdToken}}",
expectedValue: "Bearer test-access-token-abc123 and Bearer test-id-token-xyz789",
},
}
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, testData)
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)
}
})
}
}
// TestTokenTypeIntegration tests the integration of ID and access tokens with the middleware
func TestTokenTypeIntegration(t *testing.T) {
// Create a TestSuite to use its helper methods and fields
ts := &TestSuite{t: t}
ts.Setup()
// Create different tokens for ID and access tokens
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(3000000000),
"iat": float64(1000000000),
"nbf": float64(1000000000),
"sub": "test-subject",
"nonce": "test-nonce",
"jti": generateRandomString(16),
"token_type": "id_token",
"email": "[email protected]",
})
if err != nil {
t.Fatalf("Failed to create test ID JWT: %v", err)
}
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(3000000000),
"iat": float64(1000000000),
"nbf": float64(1000000000),
"sub": "test-subject",
"jti": generateRandomString(16),
"token_type": "access_token",
"scope": "openid profile email",
"email": "[email protected]", // Add email to access token so it's available in claims
})
if err != nil {
t.Fatalf("Failed to create test access JWT: %v", err)
}
// Define test headers that use both token types
headers := []TemplatedHeader{
{Name: "X-ID-Token", Value: "{{.IdToken}}"},
{Name: "X-Access-Token", Value: "{{.AccessToken}}"},
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
{Name: "X-Email-From-Claims", Value: "{{.Claims.email}}"},
}
// 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 _, header := range headers {
if value := r.Header.Get(header.Name); value != "" {
interceptedHeaders[header.Name] = value
}
}
w.WriteHeader(http.StatusOK)
})
// Create the TraefikOidc instance
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),
}
tOidc.tokenVerifier = tOidc
tOidc.jwtVerifier = tOidc
// Initialize and parse header templates
for _, header := range 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("[email protected]")
session.SetIDToken(idToken) // Set the ID token
session.SetAccessToken(accessToken) // Set the access 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
expectedHeaders := map[string]string{
"X-ID-Token": idToken,
"X-Access-Token": accessToken,
"Authorization": "Bearer " + accessToken,
"X-Email-From-Claims": "[email protected]",
}
for name, expectedValue := range 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)
}
}
}
// TestSessionIDTokenAccessToken tests that the SessionData correctly stores and retrieves
// both ID tokens and access tokens separately
func TestSessionIDTokenAccessToken(t *testing.T) {
// Create a logger for the session manager
logger := NewLogger("debug")
// Create a session manager
sessionManager, err := NewSessionManager("test-session-encryption-key-at-least-32-bytes", false, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create a test request
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
// Get a session
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Set test tokens
idToken := "test-id-token-123"
accessToken := "test-access-token-456"
refreshToken := "test-refresh-token-789"
// Store tokens in session
session.SetIDToken(idToken)
session.SetAccessToken(accessToken)
session.SetRefreshToken(refreshToken)
// Save the session
if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Get cookies from response
cookies := rr.Result().Cookies()
// Create a new request with those cookies
req2 := httptest.NewRequest("GET", "/test", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
// Get the session again
session2, err := sessionManager.GetSession(req2)
if err != nil {
t.Fatalf("Failed to get session from request with cookies: %v", err)
}
// Verify that the tokens were correctly stored and retrieved
retrievedIDToken := session2.GetIDToken()
retrievedAccessToken := session2.GetAccessToken()
retrievedRefreshToken := session2.GetRefreshToken()
if retrievedIDToken != idToken {
t.Errorf("ID token mismatch: expected %q, got %q", idToken, retrievedIDToken)
}
if retrievedAccessToken != accessToken {
t.Errorf("Access token mismatch: expected %q, got %q", accessToken, retrievedAccessToken)
}
if retrievedRefreshToken != refreshToken {
t.Errorf("Refresh token mismatch: expected %q, got %q", refreshToken, retrievedRefreshToken)
}
// Verify that the tokens are distinct
if retrievedIDToken == retrievedAccessToken {
t.Errorf("ID token and Access token should be different, but both are %q", retrievedIDToken)
}
}