Compare commits

..
Author SHA1 Message Date
lukaszraczylo 0dfc252c95 fixup! fixup! Bugfix #51: Ensures that user provided scopes overrides work. 2025-06-21 22:50:32 +01:00
lukaszraczylo 71574090bf fixup! Bugfix #51: Ensures that user provided scopes overrides work. 2025-06-21 22:39:45 +01:00
lukaszraczylo de91edb514 Bugfix #51: Ensures that user provided scopes overrides work. 2025-06-20 23:58:42 +01:00
lukaszraczylo 667b4213fe Well.. that escalated quickly.
Completely forgot that Traefik uses outdated Yaegi and requires compatibility with 1.20 ( pre-generic Go code ).
2025-06-20 19:23:50 +01:00
27 changed files with 1156 additions and 447 deletions
+93
View File
@@ -76,6 +76,99 @@ testData:
oidcEndSessionURL: https://accounts.google.com/logout # Provider's end session endpoint oidcEndSessionURL: https://accounts.google.com/logout # Provider's end session endpoint
enablePKCE: false # Enables PKCE (Proof Key for Code Exchange) for additional security enablePKCE: false # Enables PKCE (Proof Key for Code Exchange) for additional security
# --- Provider Specific Configuration Examples ---
#
# Below are example configurations tailored for specific OIDC providers.
# Uncomment and adapt the relevant section for your provider.
# Remember to replace placeholder values (like client IDs, secrets, domains)
# with your actual credentials and settings.
#
# For all providers, ensure claims like email, roles, and groups are
# configured to be included in the ID TOKEN. This plugin validates ID tokens.
# --- Keycloak Example ---
# testDataKeycloak:
# providerURL: https://your-keycloak-domain/realms/your-realm # e.g., http://localhost:8080/realms/master
# clientID: your-keycloak-client-id
# clientSecret: your-keycloak-client-secret # Store securely, e.g., urn:k8s:secret:namespace:secret-name:key
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-keycloak"
# scopes: # Default ["openid", "profile", "email"] are usually sufficient. Add others if mappers depend on them.
# - roles # Example: if you mapped Keycloak roles to a 'roles' claim in the ID token
# - groups # Example: if you mapped Keycloak groups to a 'groups' claim in the ID token
# allowedRolesAndGroups: # Corresponds to 'Token Claim Name' in Keycloak mappers
# - admin
# - editor
# # Ensure Keycloak client mappers add 'email', 'roles', 'groups' etc. to the ID Token.
# # See README.md "Provider Configuration Recommendations" for Keycloak.
# --- Azure AD (Microsoft Entra ID) Example ---
# testDataAzureAD:
# providerURL: https://login.microsoftonline.com/your-tenant-id/v2.0 # Replace your-tenant-id
# clientID: your-azure-ad-client-id
# clientSecret: your-azure-ad-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-azure"
# scopes: # Defaults ["openid", "profile", "email"] are good.
# # Azure AD may require specific scopes for certain graph API permissions if you were to use the access token,
# # but for ID token claims, defaults are often enough.
# # Group claims need to be configured in Azure AD App Registration -> Token Configuration -> Add groups claim.
# allowedUserDomains:
# - yourcompany.com
# allowedRolesAndGroups: # If you configured group claims (typically 'groups') or app roles in Azure AD
# - "group-object-id-1" # Azure AD group claims can be Object IDs by default
# - "AppRoleName"
# # See README.md "Provider Configuration Recommendations" for Azure AD.
# --- Google Workspace / Google Cloud Identity Example ---
# testDataGoogle:
# providerURL: https://accounts.google.com # This is standard for Google
# clientID: your-google-client-id.apps.googleusercontent.com
# clientSecret: your-google-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-google"
# scopes: # Defaults ["openid", "profile", "email"] are handled. Plugin manages Google-specifics.
# # Do NOT add 'offline_access' - plugin handles this.
# allowedUserDomains: # Useful for Google Workspace users
# - your-gsuite-domain.com
# # Google includes 'hd' (hosted domain) claim which can be used with allowedUserDomains.
# # Other claims like 'email', 'sub', 'name' are standard.
# # See README.md "Provider Configuration Recommendations" for Google.
# --- Auth0 Example ---
# testDataAuth0:
# providerURL: https://your-auth0-domain.auth0.com # Replace with your Auth0 domain
# clientID: your-auth0-client-id
# clientSecret: your-auth0-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-auth0"
# scopes: # Defaults ["openid", "profile", "email"]. Add custom scopes if your Auth0 Rules/Actions require them.
# - read:custom_data # Example custom scope
# allowedRolesAndGroups: # Based on claims added via Auth0 Rules or Actions (e.g. namespaced claims)
# - "https://your-app.com/roles:admin"
# - editor
# # Use Auth0 Rules or Actions to add custom claims (roles, permissions) to the ID Token.
# # Ensure postLogoutRedirectURI is in Auth0 app's "Allowed Logout URLs".
# # See README.md "Provider Configuration Recommendations" for Auth0.
# --- Generic OIDC Provider Example ---
# testDataGenericOIDC:
# providerURL: https://your-generic-oidc-provider.com/oidc # Issuer URL for your provider
# clientID: your-generic-client-id
# clientSecret: your-generic-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-generic"
# scopes: # Must include "openid". "profile" and "email" are common.
# - openid
# - profile
# - email
# - custom_scope_for_claims # If your provider needs specific scopes for ID token claims
# allowedRolesAndGroups:
# - user_role_from_id_token
# # Consult your provider's documentation on how to map attributes/roles/groups to ID Token claims.
# # Verify ID Token contents (e.g. jwt.io) to see available claims.
# # See README.md "Provider Configuration Recommendations" for Generic OIDC.
# Configuration documentation # Configuration documentation
configuration: configuration:
providerURL: providerURL:
+91
View File
@@ -13,6 +13,8 @@ The Traefik OIDC middleware provides a complete OIDC authentication solution wit
- Rate limiting - Rate limiting
- Excluded paths (public URLs) - Excluded paths (public URLs)
**Important Note on Token Validation:** This middleware performs authentication and claim extraction based on the **ID Token** provided by the OIDC provider. It does not primarily use the Access Token for these purposes (though the Access Token is available for templated headers if needed). Therefore, ensure that all necessary claims (e.g., email, roles, custom attributes) are included in the ID Token by your OIDC provider's configuration.
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, Logto, Google and other standard OIDC providers. It includes special handling for Google's OAuth implementation.
## Traefik Version Compatibility ## Traefik Version Compatibility
@@ -702,6 +704,89 @@ The middleware also sets the following security headers:
- `X-XSS-Protection: 1; mode=block` - `X-XSS-Protection: 1; mode=block`
- `Referrer-Policy: strict-origin-when-cross-origin` - `Referrer-Policy: strict-origin-when-cross-origin`
## Provider Configuration Recommendations
**Important: ID Token Validation**
This Traefik OIDC plugin performs authentication and extracts user claims (like email, roles, groups) exclusively from the **ID Token** provided by your OIDC provider. It does not primarily use the Access Token for these critical functions. Therefore, it is crucial to ensure that all necessary claims are included in the ID Token itself. A common issue is that some OIDC providers might, by default, place certain claims only in the Access Token or UserInfo endpoint.
This section provides guidance on configuring popular OIDC providers to work optimally with this plugin.
### Keycloak
Keycloak is highly configurable, which means you need to ensure your client mappers are set up correctly to include necessary claims in the ID Token.
* **Ensure Claims in ID Token**:
* **Email**: Navigate to your Keycloak realm -> Clients -> Your Client ID -> Mappers. Ensure there's a mapper for 'email' (e.g., a "User Property" mapper for the `email` property) and that "Add to ID token" is **ON**.
* **Roles**: For client roles or realm roles, create or edit mappers (e.g., "User Client Role" or "User Realm Role"). Ensure "Add to ID token" is **ON**. You might want to customize the "Token Claim Name" (e.g., to `roles` or `groups`).
* **Groups**: Similarly, for group membership, use a "Group Membership" mapper and ensure "Add to ID token" is **ON**. Customize the "Token Claim Name" as needed (e.g., `groups`).
* **Scopes**: Ensure your client requests appropriate scopes that trigger the inclusion of these claims if your mappers are scope-dependent. The default `openid`, `profile`, `email` scopes are a good starting point.
* **Troubleshooting**: If claims are missing, double-check the "Mappers" tab for your client in Keycloak. The "Token Claim Name" you define here is what you'll use in the `allowedRolesAndGroups` or `headers` configuration in this plugin. (See also the [Troubleshooting](#troubleshooting) section for Keycloak).
### Azure AD (Microsoft Entra ID)
Azure AD generally works well with standard OIDC configurations.
* **ID Token Claims**: Azure AD typically includes standard claims like `email`, `name`, `preferred_username`, and `oid` (Object ID) in the ID Token by default when `openid profile email` scopes are requested.
* **Group Claims**: To include group claims in the ID Token, you need to configure this in the Azure AD application registration:
* Go to your App Registration -> Token configuration -> Add groups claim.
* You can choose which types of groups (Security groups, Directory roles, All groups) to include.
* Be aware of the "overage" issue: If a user is a member of too many groups, Azure AD will send a link to fetch groups instead of embedding them. This plugin currently expects group claims to be directly in the ID token. For users with many groups, consider alternative role/permission management strategies.
* The claim name for groups is typically `groups`.
* **Optional Claims**: You can add other optional claims via the "Token configuration" section of your App Registration. Ensure these are configured for the ID token.
* **Endpoints**: The `providerURL` should be `https://login.microsoftonline.com/{your-tenant-id}/v2.0`. The plugin will auto-discover the necessary endpoints.
* **Optimization**: Ensure your application manifest in Azure AD is configured for the desired token version (v1.0 or v2.0). This plugin works with v2.0 endpoints.
### Google Workspace / Google Cloud Identity
Google's OIDC implementation is well-supported.
* **Optimal Configuration**: The plugin automatically handles Google-specific requirements, such as using `access_type=offline` and `prompt=consent` to ensure refresh tokens are issued for long-lived sessions. You do not need to add `offline_access` to scopes.
* **ID Token Claims**: Google includes standard claims like `email`, `sub`, `name`, `given_name`, `family_name`, `picture` in the ID Token by default with `openid profile email` scopes.
* **Hosted Domain (hd claim)**: If you are using Google Workspace and want to restrict access to users within your organization's domain, Google includes an `hd` (hosted domain) claim in the ID Token. You can use this with the `allowedUserDomains` setting or for custom header logic.
* **Best Practices**:
* Use the `providerURL`: `https://accounts.google.com`.
* Ensure your OAuth consent screen in Google Cloud Console is configured correctly and published. For production, it should be "External" and in "Production" status. "Testing" status limits refresh token lifetime.
* Refer to the [Google OAuth Compatibility Fix](#google-oauth-compatibility-fix) section for more details on how the plugin handles Google's specifics.
### Auth0
Auth0 is generally OIDC compliant and works well.
* **ID Token Claims**:
* To add custom claims or standard claims not included by default (like roles or permissions) to the ID Token, you'll need to use Auth0 Rules or Actions.
* **Using Actions (Recommended)**: Create a custom Action that runs after login to add claims to the ID Token. Example:
```javascript
// Auth0 Action to add email and roles to ID Token
exports.onExecutePostLogin = async (event, api) => {
const namespace = 'https://your-app.com/'; // Or your custom namespace
if (event.authorization) {
api.idToken.setCustomClaim(namespace + 'roles', event.authorization.roles);
api.idToken.setCustomClaim('email', event.user.email); // Standard claim, ensure it's there
// Add other claims as needed
}
};
```
* Ensure the claims you add (e.g., `https://your-app.com/roles`) are then used in the plugin's `allowedRolesAndGroups` or `headers` configuration.
* **Scopes**: Request appropriate scopes. You might need custom scopes if your Actions/Rules depend on them to add specific claims.
* **Endpoints**: Your `providerURL` will be `https://your-auth0-domain.auth0.com`.
* **Logout**: Ensure `postLogoutRedirectURI` is registered in your Auth0 application settings under "Allowed Logout URLs".
### Generic OIDC Providers
For other OIDC providers (e.g., Okta, Zitadel, self-hosted solutions):
* **ID Token is Key**: The primary requirement is that all claims needed for authentication decisions (email, roles, groups, custom attributes for headers) **must** be included in the ID Token.
* **Check Provider Documentation**: Consult your OIDC provider's documentation on how to:
* Configure client applications.
* Map user attributes, roles, or group memberships to claims in the ID Token.
* Define custom scopes if they are necessary to include certain claims.
* **Standard Endpoints**: Ensure your provider exposes a standard OIDC discovery document (`.well-known/openid-configuration`) at the `providerURL`. The plugin uses this to find authorization, token, JWKS, and end_session endpoints.
* **Scopes**: Always include `openid` in your scopes. `profile` and `email` are generally recommended. Add other scopes as required by your provider to release specific claims to the ID Token.
* **Troubleshooting**: If the plugin isn't working as expected (e.g., access denied, claims missing), the first step is to decode the ID Token received from your provider (e.g., using jwt.io) to verify its contents. This will show you exactly what claims the plugin is seeing.
For common issues and general troubleshooting, please refer to the [Troubleshooting](#troubleshooting) section.
## Troubleshooting ## Troubleshooting
### Logging ### Logging
@@ -726,6 +811,12 @@ logLevel: debug
- Verify you're using a version of the middleware that includes the Google OAuth compatibility fix. - Verify you're using a version of the middleware that includes the Google OAuth compatibility fix.
- For more details, see the [Google OAuth Compatibility Fix](#google-oauth-compatibility-fix) section or the [detailed documentation](docs/google-oauth-fix.md). - For more details, see the [Google OAuth Compatibility Fix](#google-oauth-compatibility-fix) section or the [detailed documentation](docs/google-oauth-fix.md).
7. **Keycloak: Claims Missing from ID Token (e.g., email, roles)**
If you are using Keycloak and claims like `email`, `roles`, or `groups` are missing from the ID Token, this plugin may not function as expected (e.g., for domain restrictions or RBAC).
* **Solution**: This plugin validates the **ID Token**. You **must** configure Keycloak client mappers to add all necessary claims (email, roles, groups, etc.) to the ID Token.
* For detailed instructions, please see the [Keycloak](#keycloak) section under [Provider Configuration Recommendations](#provider-configuration-recommendations).
## Contributing ## Contributing
Contributions are welcome! Please feel free to submit a Pull Request. Contributions are welcome! Please feel free to submit a Pull Request.
+11 -11
View File
@@ -17,7 +17,7 @@ type mockTraefikOidc struct {
// Override VerifyToken to avoid JWKS lookup in tests // Override VerifyToken to avoid JWKS lookup in tests
func (m *mockTraefikOidc) VerifyToken(token string) error { func (m *mockTraefikOidc) VerifyToken(token string) error {
// Cache test claims to avoid "claims not found" errors // Cache test claims to avoid "claims not found" errors
testClaims := map[string]any{ testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()), "iat": float64(time.Now().Unix()),
"sub": "test-user", "sub": "test-user",
@@ -30,7 +30,7 @@ func (m *mockTraefikOidc) VerifyToken(token string) error {
// Override VerifyJWTSignatureAndClaims to avoid JWKS lookup in tests // Override VerifyJWTSignatureAndClaims to avoid JWKS lookup in tests
func (m *mockTraefikOidc) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error { func (m *mockTraefikOidc) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error {
// Cache test claims to avoid "claims not found" errors // Cache test claims to avoid "claims not found" errors
testClaims := map[string]any{ testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()), "iat": float64(time.Now().Unix()),
"sub": "test-user", "sub": "test-user",
@@ -80,7 +80,7 @@ func TestAzureOIDCRegression(t *testing.T) {
// For test tokens, always return success and cache claims // For test tokens, always return success and cache claims
if strings.HasPrefix(token, "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0") { if strings.HasPrefix(token, "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0") {
// Cache test claims for JWT tokens // Cache test claims for JWT tokens
testClaims := map[string]any{ testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()), "iat": float64(time.Now().Unix()),
"sub": "test-user", "sub": "test-user",
@@ -94,7 +94,7 @@ func TestAzureOIDCRegression(t *testing.T) {
return nil return nil
} }
// For JWT tokens, cache basic claims to avoid cache lookup issues // For JWT tokens, cache basic claims to avoid cache lookup issues
testClaims := map[string]any{ testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()), "iat": float64(time.Now().Unix()),
"sub": "test-user", "sub": "test-user",
@@ -109,7 +109,7 @@ func TestAzureOIDCRegression(t *testing.T) {
tOidc.jwtVerifier = &mockJWTVerifier{ tOidc.jwtVerifier = &mockJWTVerifier{
verifyFunc: func(jwt *JWT, token string) error { verifyFunc: func(jwt *JWT, token string) error {
// Also cache claims here to ensure they're available // Also cache claims here to ensure they're available
testClaims := map[string]any{ testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()), "iat": float64(time.Now().Unix()),
"sub": "test-user", "sub": "test-user",
@@ -163,7 +163,7 @@ func TestAzureOIDCRegression(t *testing.T) {
session.SetEmail("[email protected]") session.SetEmail("[email protected]")
// Create a valid JWT access token for testing // Create a valid JWT access token for testing
accessTokenClaims := map[string]any{ accessTokenClaims := map[string]interface{}{
"iss": "https://login.microsoftonline.com/tenant-id/v2.0", "iss": "https://login.microsoftonline.com/tenant-id/v2.0",
"aud": "test-client-id", "aud": "test-client-id",
"exp": time.Now().Add(1 * time.Hour).Unix(), "exp": time.Now().Add(1 * time.Hour).Unix(),
@@ -175,7 +175,7 @@ func TestAzureOIDCRegression(t *testing.T) {
session.SetAccessToken(accessToken) session.SetAccessToken(accessToken)
// Create an invalid/expired ID token // Create an invalid/expired ID token
idTokenClaims := map[string]any{ idTokenClaims := map[string]interface{}{
"iss": "https://login.microsoftonline.com/tenant-id/v2.0", "iss": "https://login.microsoftonline.com/tenant-id/v2.0",
"aud": "test-client-id", "aud": "test-client-id",
"exp": time.Now().Add(-1 * time.Hour).Unix(), // Expired "exp": time.Now().Add(-1 * time.Hour).Unix(), // Expired
@@ -192,7 +192,7 @@ func TestAzureOIDCRegression(t *testing.T) {
verifyFunc: func(token string) error { verifyFunc: func(token string) error {
if token == accessToken { if token == accessToken {
// Access token validation succeeds - cache claims // Access token validation succeeds - cache claims
testClaims := map[string]any{ testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()), "iat": float64(time.Now().Unix()),
"sub": "test-user", "sub": "test-user",
@@ -240,7 +240,7 @@ func TestAzureOIDCRegression(t *testing.T) {
session.SetAccessToken(ValidAccessToken) session.SetAccessToken(ValidAccessToken)
// Create a valid ID token for claims extraction // Create a valid ID token for claims extraction
idTokenClaims := map[string]any{ idTokenClaims := map[string]interface{}{
"iss": "https://login.microsoftonline.com/tenant-id/v2.0", "iss": "https://login.microsoftonline.com/tenant-id/v2.0",
"aud": "test-client-id", "aud": "test-client-id",
"exp": time.Now().Add(1 * time.Hour).Unix(), "exp": time.Now().Add(1 * time.Hour).Unix(),
@@ -257,7 +257,7 @@ func TestAzureOIDCRegression(t *testing.T) {
verifyFunc: func(token string) error { verifyFunc: func(token string) error {
if token == idToken { if token == idToken {
// ID token is valid - cache claims // ID token is valid - cache claims
testClaims := map[string]any{ testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()), "iat": float64(time.Now().Unix()),
"sub": "test-user", "sub": "test-user",
@@ -336,7 +336,7 @@ func TestAzureOIDCRegression(t *testing.T) {
} }
// createMockJWT creates a basic JWT token for testing purposes // createMockJWT creates a basic JWT token for testing purposes
func createMockJWT(claims map[string]any) (string, error) { func createMockJWT(claims map[string]interface{}) (string, error) {
// Simple mock JWT - in real tests you'd use a proper JWT library // Simple mock JWT - in real tests you'd use a proper JWT library
// For this test, we'll create a basic three-part token structure // For this test, we'll create a basic three-part token structure
header := "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0" // {"alg":"RS256","kid":"test-key-id","typ":"JWT"} header := "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0" // {"alg":"RS256","kid":"test-key-id","typ":"JWT"}
+3 -3
View File
@@ -9,7 +9,7 @@ import (
// CacheItem represents an item stored in the cache with its associated metadata. // CacheItem represents an item stored in the cache with its associated metadata.
type CacheItem struct { type CacheItem struct {
// Value is the cached data of any type. // Value is the cached data of any type.
Value any Value interface{}
// ExpiresAt is the timestamp when this item should be considered expired. // ExpiresAt is the timestamp when this item should be considered expired.
ExpiresAt time.Time ExpiresAt time.Time
@@ -66,7 +66,7 @@ func NewCacheWithLogger(logger *Logger) *Cache {
// If the key does not exist and the cache is full, the least recently used item is evicted // If the key does not exist and the cache is full, the least recently used item is evicted
// before adding the new item. // before adding the new item.
// The expiration duration is relative to the time Set is called. // The expiration duration is relative to the time Set is called.
func (c *Cache) Set(key string, value any, expiration time.Duration) { func (c *Cache) Set(key string, value interface{}, expiration time.Duration) {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
@@ -104,7 +104,7 @@ func (c *Cache) Set(key string, value any, expiration time.Duration) {
// Accessing an item moves it to the most recently used position in the LRU list. // Accessing an item moves it to the most recently used position in the LRU list.
// If the item does not exist or has expired, nil and false are returned, and the // If the item does not exist or has expired, nil and false are returned, and the
// expired item is removed from the cache. // expired item is removed from the cache.
func (c *Cache) Get(key string) (any, bool) { func (c *Cache) Get(key string) (interface{}, bool) {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
+1 -1
View File
@@ -49,7 +49,7 @@ func TestCache_SetMaxSize(t *testing.T) {
newMaxSize := 3 newMaxSize := 3
// Add more items than the new max size // Add more items than the new max size
for i := range originalMaxSize { for i := 0; i < originalMaxSize; i++ {
key := "key" + string(rune('A'+i)) key := "key" + string(rune('A'+i))
c.Set(key, i, 1*time.Hour) c.Set(key, i, 1*time.Hour)
} }
+24 -23
View File
@@ -3,7 +3,6 @@ package traefikoidc
import ( import (
"context" "context"
"fmt" "fmt"
"maps"
"math" "math"
"math/rand/v2" "math/rand/v2"
"net" "net"
@@ -17,7 +16,7 @@ type ErrorRecoveryMechanism interface {
// ExecuteWithContext executes a function with error recovery // ExecuteWithContext executes a function with error recovery
ExecuteWithContext(ctx context.Context, fn func() error) error ExecuteWithContext(ctx context.Context, fn func() error) error
// GetMetrics returns metrics about the error recovery mechanism // GetMetrics returns metrics about the error recovery mechanism
GetMetrics() map[string]any GetMetrics() map[string]interface{}
// Reset resets the state of the error recovery mechanism // Reset resets the state of the error recovery mechanism
Reset() Reset()
// IsAvailable returns whether the mechanism is available for use // IsAvailable returns whether the mechanism is available for use
@@ -74,11 +73,11 @@ func (b *BaseRecoveryMechanism) RecordFailure() {
} }
// GetBaseMetrics returns base metrics common to all recovery mechanisms // GetBaseMetrics returns base metrics common to all recovery mechanisms
func (b *BaseRecoveryMechanism) GetBaseMetrics() map[string]any { func (b *BaseRecoveryMechanism) GetBaseMetrics() map[string]interface{} {
b.mutex.RLock() b.mutex.RLock()
defer b.mutex.RUnlock() defer b.mutex.RUnlock()
metrics := map[string]any{ metrics := map[string]interface{}{
"total_requests": atomic.LoadInt64(&b.totalRequests), "total_requests": atomic.LoadInt64(&b.totalRequests),
"total_failures": atomic.LoadInt64(&b.totalFailures), "total_failures": atomic.LoadInt64(&b.totalFailures),
"total_successes": atomic.LoadInt64(&b.totalSuccesses), "total_successes": atomic.LoadInt64(&b.totalSuccesses),
@@ -108,23 +107,23 @@ func (b *BaseRecoveryMechanism) GetBaseMetrics() map[string]any {
} }
// LogInfo logs an informational message // LogInfo logs an informational message
func (b *BaseRecoveryMechanism) LogInfo(format string, args ...any) { func (b *BaseRecoveryMechanism) LogInfo(format string, args ...interface{}) {
if b.logger != nil { if b.logger != nil {
b.logger.Infof("%s: "+format, append([]any{b.name}, args...)...) b.logger.Infof("%s: "+format, append([]interface{}{b.name}, args...)...)
} }
} }
// LogError logs an error message // LogError logs an error message
func (b *BaseRecoveryMechanism) LogError(format string, args ...any) { func (b *BaseRecoveryMechanism) LogError(format string, args ...interface{}) {
if b.logger != nil { if b.logger != nil {
b.logger.Errorf("%s: "+format, append([]any{b.name}, args...)...) b.logger.Errorf("%s: "+format, append([]interface{}{b.name}, args...)...)
} }
} }
// LogDebug logs a debug message // LogDebug logs a debug message
func (b *BaseRecoveryMechanism) LogDebug(format string, args ...any) { func (b *BaseRecoveryMechanism) LogDebug(format string, args ...interface{}) {
if b.logger != nil { if b.logger != nil {
b.logger.Debugf("%s: "+format, append([]any{b.name}, args...)...) b.logger.Debugf("%s: "+format, append([]interface{}{b.name}, args...)...)
} }
} }
@@ -296,7 +295,7 @@ func (cb *CircuitBreaker) IsAvailable() bool {
} }
// GetMetrics returns metrics about the circuit breaker // GetMetrics returns metrics about the circuit breaker
func (cb *CircuitBreaker) GetMetrics() map[string]any { func (cb *CircuitBreaker) GetMetrics() map[string]interface{} {
cb.mutex.RLock() cb.mutex.RLock()
state := cb.state state := cb.state
failures := cb.failures failures := cb.failures
@@ -498,7 +497,7 @@ func (re *RetryExecutor) IsAvailable() bool {
} }
// GetMetrics returns metrics about the retry executor // GetMetrics returns metrics about the retry executor
func (re *RetryExecutor) GetMetrics() map[string]any { func (re *RetryExecutor) GetMetrics() map[string]interface{} {
metrics := re.GetBaseMetrics() metrics := re.GetBaseMetrics()
// Add retry executor specific metrics // Add retry executor specific metrics
@@ -526,7 +525,7 @@ func (e *HTTPError) Error() string {
// GracefulDegradation implements graceful degradation patterns // GracefulDegradation implements graceful degradation patterns
type GracefulDegradation struct { type GracefulDegradation struct {
*BaseRecoveryMechanism *BaseRecoveryMechanism
fallbacks map[string]func() (any, error) fallbacks map[string]func() (interface{}, error)
healthChecks map[string]func() bool healthChecks map[string]func() bool
degradedServices map[string]time.Time degradedServices map[string]time.Time
config GracefulDegradationConfig config GracefulDegradationConfig
@@ -553,7 +552,7 @@ func DefaultGracefulDegradationConfig() GracefulDegradationConfig {
func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *GracefulDegradation { func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *GracefulDegradation {
gd := &GracefulDegradation{ gd := &GracefulDegradation{
BaseRecoveryMechanism: NewBaseRecoveryMechanism("graceful-degradation", logger), BaseRecoveryMechanism: NewBaseRecoveryMechanism("graceful-degradation", logger),
fallbacks: make(map[string]func() (any, error)), fallbacks: make(map[string]func() (interface{}, error)),
healthChecks: make(map[string]func() bool), healthChecks: make(map[string]func() bool),
degradedServices: make(map[string]time.Time), degradedServices: make(map[string]time.Time),
config: config, config: config,
@@ -566,7 +565,7 @@ func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *G
} }
// RegisterFallback registers a fallback function for a service // RegisterFallback registers a fallback function for a service
func (gd *GracefulDegradation) RegisterFallback(serviceName string, fallback func() (any, error)) { func (gd *GracefulDegradation) RegisterFallback(serviceName string, fallback func() (interface{}, error)) {
gd.mutex.Lock() gd.mutex.Lock()
defer gd.mutex.Unlock() defer gd.mutex.Unlock()
gd.fallbacks[serviceName] = fallback gd.fallbacks[serviceName] = fallback
@@ -584,7 +583,7 @@ func (gd *GracefulDegradation) ExecuteWithContext(ctx context.Context, fn func()
gd.RecordRequest() gd.RecordRequest()
// Execute with a simple wrapper // Execute with a simple wrapper
_, err := gd.ExecuteWithFallback("default", func() (any, error) { _, err := gd.ExecuteWithFallback("default", func() (interface{}, error) {
return nil, fn() return nil, fn()
}) })
@@ -598,7 +597,7 @@ func (gd *GracefulDegradation) ExecuteWithContext(ctx context.Context, fn func()
} }
// ExecuteWithFallback executes a function with fallback support // ExecuteWithFallback executes a function with fallback support
func (gd *GracefulDegradation) ExecuteWithFallback(serviceName string, primary func() (any, error)) (any, error) { func (gd *GracefulDegradation) ExecuteWithFallback(serviceName string, primary func() (interface{}, error)) (interface{}, error) {
// Check if service is degraded // Check if service is degraded
if gd.isServiceDegraded(serviceName) { if gd.isServiceDegraded(serviceName) {
gd.LogInfo("Service %s is degraded, using fallback", serviceName) gd.LogInfo("Service %s is degraded, using fallback", serviceName)
@@ -656,7 +655,7 @@ func (gd *GracefulDegradation) markServiceDegraded(serviceName string) {
} }
// executeFallback executes the fallback function for a service // executeFallback executes the fallback function for a service
func (gd *GracefulDegradation) executeFallback(serviceName string) (any, error) { func (gd *GracefulDegradation) executeFallback(serviceName string) (interface{}, error) {
gd.mutex.RLock() gd.mutex.RLock()
fallback, exists := gd.fallbacks[serviceName] fallback, exists := gd.fallbacks[serviceName]
gd.mutex.RUnlock() gd.mutex.RUnlock()
@@ -684,7 +683,9 @@ func (gd *GracefulDegradation) startHealthCheckRoutine() {
func (gd *GracefulDegradation) performHealthChecks() { func (gd *GracefulDegradation) performHealthChecks() {
gd.mutex.RLock() gd.mutex.RLock()
healthChecks := make(map[string]func() bool) healthChecks := make(map[string]func() bool)
maps.Copy(healthChecks, gd.healthChecks) for k, v := range gd.healthChecks {
healthChecks[k] = v
}
gd.mutex.RUnlock() gd.mutex.RUnlock()
for serviceName, healthCheck := range healthChecks { for serviceName, healthCheck := range healthChecks {
@@ -732,7 +733,7 @@ func (gd *GracefulDegradation) IsAvailable() bool {
} }
// GetMetrics returns metrics about the graceful degradation mechanism // GetMetrics returns metrics about the graceful degradation mechanism
func (gd *GracefulDegradation) GetMetrics() map[string]any { func (gd *GracefulDegradation) GetMetrics() map[string]interface{} {
gd.mutex.RLock() gd.mutex.RLock()
degradedCount := len(gd.degradedServices) degradedCount := len(gd.degradedServices)
@@ -805,14 +806,14 @@ func (erm *ErrorRecoveryManager) ExecuteWithRecovery(ctx context.Context, servic
} }
// GetRecoveryMetrics returns metrics for all recovery mechanisms // GetRecoveryMetrics returns metrics for all recovery mechanisms
func (erm *ErrorRecoveryManager) GetRecoveryMetrics() map[string]any { func (erm *ErrorRecoveryManager) GetRecoveryMetrics() map[string]interface{} {
erm.mutex.RLock() erm.mutex.RLock()
defer erm.mutex.RUnlock() defer erm.mutex.RUnlock()
metrics := make(map[string]any) metrics := make(map[string]interface{})
// Circuit breaker metrics // Circuit breaker metrics
cbMetrics := make(map[string]any) cbMetrics := make(map[string]interface{})
for name, cb := range erm.circuitBreakers { for name, cb := range erm.circuitBreakers {
cbMetrics[name] = cb.GetMetrics() cbMetrics[name] = cb.GetMetrics()
} }
+11 -6
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"net" "net"
"slices"
"testing" "testing"
"time" "time"
) )
@@ -208,7 +207,7 @@ func TestGracefulDegradation(t *testing.T) {
}() }()
t.Run("Register fallback and health check", func(t *testing.T) { t.Run("Register fallback and health check", func(t *testing.T) {
gd.RegisterFallback("test-service", func() (any, error) { gd.RegisterFallback("test-service", func() (interface{}, error) {
return "fallback-result", nil return "fallback-result", nil
}) })
@@ -223,12 +222,12 @@ func TestGracefulDegradation(t *testing.T) {
}) })
t.Run("Execute with fallback on failure", func(t *testing.T) { t.Run("Execute with fallback on failure", func(t *testing.T) {
gd.RegisterFallback("failing-service", func() (any, error) { gd.RegisterFallback("failing-service", func() (interface{}, error) {
return "fallback-result", nil return "fallback-result", nil
}) })
// First call should fail and mark service as degraded // First call should fail and mark service as degraded
result, err := gd.ExecuteWithFallback("failing-service", func() (any, error) { result, err := gd.ExecuteWithFallback("failing-service", func() (interface{}, error) {
return nil, errors.New("service failure") return nil, errors.New("service failure")
}) })
if err != nil { if err != nil {
@@ -245,7 +244,7 @@ func TestGracefulDegradation(t *testing.T) {
}) })
t.Run("No fallback available", func(t *testing.T) { t.Run("No fallback available", func(t *testing.T) {
_, err := gd.ExecuteWithFallback("no-fallback-service", func() (any, error) { _, err := gd.ExecuteWithFallback("no-fallback-service", func() (interface{}, error) {
return nil, errors.New("service failure") return nil, errors.New("service failure")
}) })
@@ -256,7 +255,13 @@ func TestGracefulDegradation(t *testing.T) {
t.Run("Get degraded services", func(t *testing.T) { t.Run("Get degraded services", func(t *testing.T) {
degraded := gd.GetDegradedServices() degraded := gd.GetDegradedServices()
found := slices.Contains(degraded, "failing-service") found := false
for _, s := range degraded {
if s == "failing-service" {
found = true
break
}
}
if !found { if !found {
t.Error("Expected failing-service to be in degraded list") t.Error("Expected failing-service to be in degraded list")
} }
+11 -6
View File
@@ -9,7 +9,6 @@ import (
"math/big" "math/big"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -132,9 +131,9 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
}, },
} }
tOidc.extractClaimsFunc = func(token string) (map[string]any, error) { tOidc.extractClaimsFunc = func(token string) (map[string]interface{}, error) {
// Return mock claims // Return mock claims
return map[string]any{ return map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
}, nil }, nil
@@ -304,7 +303,13 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
scopeList := strings.Split(scope, " ") scopeList := strings.Split(scope, " ")
expectedScopes := []string{"openid", "profile", "email"} expectedScopes := []string{"openid", "profile", "email"}
for _, expectedScope := range expectedScopes { for _, expectedScope := range expectedScopes {
found := slices.Contains(scopeList, expectedScope) found := false
for _, s := range scopeList {
if s == expectedScope {
found = true
break
}
}
if !found { if !found {
t.Errorf("Expected scope '%s' not found in scope parameter: %s", expectedScope, scope) t.Errorf("Expected scope '%s' not found in scope parameter: %s", expectedScope, scope)
} }
@@ -380,7 +385,7 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
nbf := now.Unix() nbf := now.Unix()
// Create initial ID token // Create initial ID token
initialIDToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]any{ initialIDToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://accounts.google.com", "iss": "https://accounts.google.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -396,7 +401,7 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
} }
// Create refresh ID token // Create refresh ID token
refreshedIDToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]any{ refreshedIDToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://accounts.google.com", "iss": "https://accounts.google.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
+22 -5
View File
@@ -187,7 +187,7 @@ func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenRe
// Returns: // Returns:
// - A map representing the JSON claims extracted from the token payload. // - A map representing the JSON claims extracted from the token payload.
// - An error if the token format is invalid, decoding fails, or JSON unmarshaling fails. // - An error if the token format is invalid, decoding fails, or JSON unmarshaling fails.
func extractClaims(tokenString string) (map[string]any, error) { func extractClaims(tokenString string) (map[string]interface{}, error) {
parts := strings.Split(tokenString, ".") parts := strings.Split(tokenString, ".")
if len(parts) != 3 { if len(parts) != 3 {
return nil, fmt.Errorf("invalid token format") return nil, fmt.Errorf("invalid token format")
@@ -198,7 +198,7 @@ func extractClaims(tokenString string) (map[string]any, error) {
return nil, fmt.Errorf("failed to decode token payload: %w", err) return nil, fmt.Errorf("failed to decode token payload: %w", err)
} }
var claims map[string]any var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil { if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("failed to unmarshal claims: %w", err) return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
} }
@@ -236,7 +236,7 @@ func NewTokenCache() *TokenCache {
// - token: The raw token string (used as the key). // - token: The raw token string (used as the key).
// - claims: The map of claims associated with the token. // - claims: The map of claims associated with the token.
// - expiration: The duration for which the cache entry should be valid. // - expiration: The duration for which the cache entry should be valid.
func (tc *TokenCache) Set(token string, claims map[string]any, expiration time.Duration) { func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
token = "t-" + token token = "t-" + token
tc.cache.Set(token, claims, expiration) tc.cache.Set(token, claims, expiration)
} }
@@ -250,13 +250,13 @@ func (tc *TokenCache) Set(token string, claims map[string]any, expiration time.D
// Returns: // Returns:
// - The cached claims map if found and valid. // - The cached claims map if found and valid.
// - A boolean indicating whether the token was found in the cache (true if found, false otherwise). // - A boolean indicating whether the token was found in the cache (true if found, false otherwise).
func (tc *TokenCache) Get(token string) (map[string]any, bool) { func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
token = "t-" + token token = "t-" + token
value, found := tc.cache.Get(token) value, found := tc.cache.Get(token)
if !found { if !found {
return nil, false return nil, false
} }
claims, ok := value.(map[string]any) claims, ok := value.(map[string]interface{})
return claims, ok return claims, ok
} }
@@ -404,3 +404,20 @@ func BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI string) (strin
return u.String(), nil return u.String(), nil
} }
// deduplicateScopes removes duplicate strings from a slice while preserving order.
// The first occurrence of each scope is kept.
func deduplicateScopes(scopes []string) []string {
if len(scopes) == 0 {
return []string{}
}
seen := make(map[string]struct{})
result := []string{}
for _, scope := range scopes {
if _, ok := seen[scope]; !ok {
seen[scope] = struct{}{}
result = append(result, scope)
}
}
return result
}
+1 -1
View File
@@ -614,7 +614,7 @@ func (iv *InputValidator) SanitizeInput(input string, maxLength int) string {
} }
// ValidateBoundaryValues validates numeric boundary values // ValidateBoundaryValues validates numeric boundary values
func (iv *InputValidator) ValidateBoundaryValues(value any, min, max int64) ValidationResult { func (iv *InputValidator) ValidateBoundaryValues(value interface{}, min, max int64) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}} result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
var numValue int64 var numValue int64
+2 -2
View File
@@ -246,7 +246,7 @@ func TestValidateBoundaryValues(t *testing.T) {
} }
t.Run("Valid boundary values", func(t *testing.T) { t.Run("Valid boundary values", func(t *testing.T) {
validValues := []any{ validValues := []interface{}{
int(50), int(50),
int64(100), int64(100),
float64(75.5), float64(75.5),
@@ -261,7 +261,7 @@ func TestValidateBoundaryValues(t *testing.T) {
}) })
t.Run("Invalid boundary values", func(t *testing.T) { t.Run("Invalid boundary values", func(t *testing.T) {
invalidValues := []any{ invalidValues := []interface{}{
int(-1), int(-1),
int64(2000), int64(2000),
"not a number", "not a number",
+4 -4
View File
@@ -87,8 +87,8 @@ var ClockSkewTolerance = ClockSkewToleranceFuture
// JWT represents a JSON Web Token as defined in RFC 7519. // JWT represents a JSON Web Token as defined in RFC 7519.
type JWT struct { type JWT struct {
Header map[string]any Header map[string]interface{}
Claims map[string]any Claims map[string]interface{}
Token string Token string
Signature []byte Signature []byte
} }
@@ -276,13 +276,13 @@ func (j *JWT) Verify(issuerURL, clientID string, skipReplayCheck ...bool) error
// Returns: // Returns:
// - nil if the expected audience is found. // - nil if the expected audience is found.
// - An error if the claim type is invalid or the expected audience is not present. // - An error if the claim type is invalid or the expected audience is not present.
func verifyAudience(tokenAudience any, expectedAudience string) error { func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
switch aud := tokenAudience.(type) { switch aud := tokenAudience.(type) {
case string: case string:
if aud != expectedAudience { if aud != expectedAudience {
return fmt.Errorf("invalid audience") return fmt.Errorf("invalid audience")
} }
case []any: case []interface{}:
found := false found := false
for _, v := range aud { for _, v := range aud {
if str, ok := v.(string); ok && str == expectedAudience { if str, ok := v.(string); ok && str == expectedAudience {
+56 -24
View File
@@ -6,7 +6,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"maps"
"math" "math"
"net" "net"
"net/http" "net/http"
@@ -14,7 +13,6 @@ import (
"net/url" "net/url"
"os" "os"
"runtime" "runtime"
"slices"
"strings" "strings"
"sync" "sync"
"text/template" "text/template"
@@ -209,7 +207,7 @@ type TraefikOidc struct {
sessionManager *SessionManager sessionManager *SessionManager
tokenCleanupStopChan chan struct{} tokenCleanupStopChan chan struct{}
excludedURLs map[string]struct{} excludedURLs map[string]struct{}
extractClaimsFunc func(tokenString string) (map[string]any, error) extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string)
metadataCache *MetadataCache metadataCache *MetadataCache
allowedRolesAndGroups map[string]struct{} allowedRolesAndGroups map[string]struct{}
@@ -441,7 +439,7 @@ func (t *TraefikOidc) VerifyToken(token string) error {
// Parameters: // Parameters:
// - token: The raw token string (used as the cache key). // - token: The raw token string (used as the cache key).
// - claims: The map of claims extracted from the verified token. // - claims: The map of claims extracted from the verified token.
func (t *TraefikOidc) cacheVerifiedToken(token string, claims map[string]any) { func (t *TraefikOidc) cacheVerifiedToken(token string, claims map[string]interface{}) {
expClaim, ok := claims["exp"].(float64) expClaim, ok := claims["exp"].(float64)
if !ok { if !ok {
t.logger.Errorf("Failed to cache token: invalid 'exp' claim type") t.logger.Errorf("Failed to cache token: invalid 'exp' claim type")
@@ -616,6 +614,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
// Initialize logger // Initialize logger
logger := NewLogger(config.LogLevel) logger := NewLogger(config.LogLevel)
// Log the scopes received from Traefik to help diagnose duplication issues
// Ensure key meets minimum length requirement // Ensure key meets minimum length requirement
if len(config.SessionEncryptionKey) < minEncryptionKeyLength { if len(config.SessionEncryptionKey) < minEncryptionKeyLength {
if runtime.Compiler == "yaegi" { if runtime.Compiler == "yaegi" {
@@ -662,10 +661,19 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
enablePKCE: config.EnablePKCE, enablePKCE: config.EnablePKCE,
overrideScopes: config.OverrideScopes, overrideScopes: config.OverrideScopes,
scopes: func() []string { scopes: func() []string {
// Deduplicate user-provided scopes from the configuration.
userProvidedScopes := deduplicateScopes(config.Scopes)
if config.OverrideScopes { if config.OverrideScopes {
return append([]string(nil), config.Scopes...) // When overriding, only the explicitly user-provided scopes are used.
// Default scopes like "openid", "profile", "email" are NOT added.
return userProvidedScopes
} }
return mergeScopes([]string{"openid", "profile", "email"}, config.Scopes)
// When not overriding (overrideScopes is false), merge user-provided scopes
// with the system's default scopes.
defaultSystemScopes := []string{"openid", "profile", "email"}
return deduplicateScopes(mergeScopes(defaultSystemScopes, userProvidedScopes))
}(), }(),
limiter: rate.NewLimiter(rate.Every(time.Second), config.RateLimit), limiter: rate.NewLimiter(rate.Every(time.Second), config.RateLimit),
tokenCache: cacheManager.GetSharedTokenCache(), tokenCache: cacheManager.GetSharedTokenCache(),
@@ -698,7 +706,9 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
} }
// Add default excluded URLs // Add default excluded URLs
maps.Copy(t.excludedURLs, defaultExcludedURLs) for k, v := range defaultExcludedURLs {
t.excludedURLs[k] = v
}
t.tokenVerifier = t t.tokenVerifier = t
t.jwtVerifier = t t.jwtVerifier = t
@@ -723,7 +733,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
} }
startReplayCacheCleanup(pluginCtx, logger) startReplayCacheCleanup(pluginCtx, logger)
logger.Debugf("TraefikOidc.New: Final t.scopes initialized to: %v", t.scopes)
go t.initializeMetadata(config.ProviderURL) go t.initializeMetadata(config.ProviderURL)
return t, nil return t, nil
@@ -848,7 +858,7 @@ func discoverProviderMetadata(providerURL string, httpClient *http.Client, l *Lo
start := time.Now() start := time.Now()
var lastErr error var lastErr error
for attempt := range maxRetries { for attempt := 0; attempt < maxRetries; attempt++ {
if time.Since(start) > totalTimeout { if time.Since(start) > totalTimeout {
l.Errorf("Timeout exceeded while fetching provider metadata") l.Errorf("Timeout exceeded while fetching provider metadata")
return nil, fmt.Errorf("timeout exceeded while fetching provider metadata: %w", lastErr) return nil, fmt.Errorf("timeout exceeded while fetching provider metadata: %w", lastErr)
@@ -1048,7 +1058,7 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if idToken != "" { if idToken != "" {
jwt, err := parseJWT(idToken) jwt, err := parseJWT(idToken)
if err == nil { if err == nil {
// jwt.Claims is already map[string]any, no type assertion needed // jwt.Claims is already map[string]interface{}, no type assertion needed
claims := jwt.Claims claims := jwt.Claims
// STABILITY FIX: Safe type assertion with proper error handling // STABILITY FIX: Safe type assertion with proper error handling
if expClaim, ok := claims["exp"].(float64); ok { if expClaim, ok := claims["exp"].(float64); ok {
@@ -1205,7 +1215,7 @@ func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http
} 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
templateData := map[string]any{ templateData := map[string]interface{}{
"AccessToken": session.GetAccessToken(), "AccessToken": session.GetAccessToken(),
"IdToken": session.GetIDToken(), "IdToken": session.GetIDToken(),
"RefreshToken": session.GetRefreshToken(), "RefreshToken": session.GetRefreshToken(),
@@ -1680,25 +1690,47 @@ func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge stri
hasOfflineAccess := false hasOfflineAccess := false
if slices.Contains(scopes, "offline_access") { for _, scope := range scopes {
hasOfflineAccess = true if scope == "offline_access" {
hasOfflineAccess = true
break
}
} }
if !hasOfflineAccess { // For Azure AD, add offline_access scope if not overriding or if overriding with no user scopes
scopes = append(scopes, "offline_access") if !t.overrideScopes || (t.overrideScopes && len(t.scopes) == 0) {
t.logger.Debug("Azure AD provider detected, added offline_access scope for refresh tokens") if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
t.logger.Debugf("Azure AD provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", t.overrideScopes, len(t.scopes))
}
} else {
t.logger.Debugf("Azure AD provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(t.scopes))
} }
} else { } else {
// For other providers, use the standard offline_access scope // For other providers, use the standard offline_access scope
hasOfflineAccess := slices.Contains(scopes, "offline_access") // Only add offline_access if overrideScopes is false,
// or if overrideScopes is true AND no scopes were provided by the user (edge case, effectively defaults)
if !hasOfflineAccess { if !t.overrideScopes || (t.overrideScopes && len(t.scopes) == 0) {
scopes = append(scopes, "offline_access") hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
t.logger.Debugf("Standard provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", t.overrideScopes, len(t.scopes))
}
} else {
t.logger.Debugf("Standard provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(t.scopes))
} }
} }
if len(scopes) > 0 { if len(scopes) > 0 {
params.Set("scope", strings.Join(scopes, " ")) finalScopeString := strings.Join(scopes, " ")
params.Set("scope", finalScopeString)
t.logger.Debugf("TraefikOidc.buildAuthURL: Final scope string being sent to OIDC provider: %s", finalScopeString)
} }
// Use buildURLWithParams which handles potential relative authURL from metadata // Use buildURLWithParams which handles potential relative authURL from metadata
@@ -2214,7 +2246,7 @@ func (t *TraefikOidc) extractGroupsAndRoles(idToken string) ([]string, []string,
// Extract groups with type checking // Extract groups with type checking
if groupsClaim, exists := claims["groups"]; exists { if groupsClaim, exists := claims["groups"]; exists {
groupsSlice, ok := groupsClaim.([]any) groupsSlice, ok := groupsClaim.([]interface{})
if !ok { if !ok {
// Strictly expect an array // Strictly expect an array
return nil, nil, fmt.Errorf("groups claim is not an array") return nil, nil, fmt.Errorf("groups claim is not an array")
@@ -2232,7 +2264,7 @@ func (t *TraefikOidc) extractGroupsAndRoles(idToken string) ([]string, []string,
// Extract roles with type checking // Extract roles with type checking
if rolesClaim, exists := claims["roles"]; exists { if rolesClaim, exists := claims["roles"]; exists {
rolesSlice, ok := rolesClaim.([]any) rolesSlice, ok := rolesClaim.([]interface{})
if !ok { if !ok {
// Strictly expect an array // Strictly expect an array
return nil, nil, fmt.Errorf("roles claim is not an array") return nil, nil, fmt.Errorf("roles claim is not an array")
@@ -2316,7 +2348,7 @@ func (t *TraefikOidc) sendErrorResponse(rw http.ResponseWriter, req *http.Reques
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 - ensure this matches the expected response format in tests
json.NewEncoder(rw).Encode(map[string]any{ 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
"status_code": code, "status_code": code,
+259 -77
View File
@@ -74,7 +74,7 @@ func (ts *TestSuite) Setup() {
iat := now.Add(-2 * time.Minute).Unix() // Account for clock skew iat := now.Add(-2 * time.Minute).Unix() // Account for clock skew
nbf := now.Add(-2 * time.Minute).Unix() // Account for clock skew nbf := now.Add(-2 * time.Minute).Unix() // Account for clock skew
ts.token, err = createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ ts.token, err = createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -205,8 +205,8 @@ func (m *MockTokenExchanger) RevokeTokenWithProvider(token, tokenType string) er
} }
// Helper function to create a JWT token // Helper function to create a JWT token
func createTestJWT(privateKey *rsa.PrivateKey, alg, kid string, claims map[string]any) (string, error) { func createTestJWT(privateKey *rsa.PrivateKey, alg, kid string, claims map[string]interface{}) (string, error) {
header := map[string]any{ header := map[string]interface{}{
"alg": alg, "alg": alg,
"kid": kid, "kid": kid,
"typ": "JWT", "typ": "JWT",
@@ -333,7 +333,7 @@ func TestVerifyToken(t *testing.T) {
if tc.cacheToken { if tc.cacheToken {
// Use more realistic claims for cached token // Use more realistic claims for cached token
ts.tOidc.tokenCache.Set(tc.token, map[string]any{ ts.tOidc.tokenCache.Set(tc.token, map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"sub": "test-subject", "sub": "test-subject",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -376,7 +376,7 @@ func TestServeHTTP(t *testing.T) {
exp := time.Now().Add(-1 * time.Hour).Unix() // Expired 1 hour ago exp := time.Now().Add(-1 * time.Hour).Unix() // Expired 1 hour ago
iat := time.Now().Add(-2 * time.Hour).Unix() iat := time.Now().Add(-2 * time.Hour).Unix()
nbf := time.Now().Add(-2 * time.Hour).Unix() nbf := time.Now().Add(-2 * time.Hour).Unix()
expiredToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ expiredToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -395,7 +395,7 @@ func TestServeHTTP(t *testing.T) {
exp := time.Now().Add(1 * time.Hour).Unix() // Valid for 1 hour exp := time.Now().Add(1 * time.Hour).Unix() // Valid for 1 hour
iat := time.Now().Unix() iat := time.Now().Unix()
nbf := time.Now().Unix() nbf := time.Now().Unix()
newToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ newToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -410,7 +410,7 @@ func TestServeHTTP(t *testing.T) {
} }
tests := []struct { tests := []struct {
sessionValues map[any]any sessionValues map[interface{}]interface{}
setupSession func(*SessionData) setupSession func(*SessionData)
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)
assertSessionAfterRequest func(t *testing.T, rr *httptest.ResponseRecorder, req *http.Request, sessionManager *SessionManager) assertSessionAfterRequest func(t *testing.T, rr *httptest.ResponseRecorder, req *http.Request, sessionManager *SessionManager)
@@ -481,7 +481,7 @@ func TestServeHTTP(t *testing.T) {
session.SetAuthenticated(true) session.SetAuthenticated(true)
session.SetEmail("[email protected]") session.SetEmail("[email protected]")
// Generate a fresh valid token for this test case to avoid replay issues // Generate a fresh valid token for this test case to avoid replay issues
freshToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ freshToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(1 * time.Hour).Unix(), "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(1 * time.Hour).Unix(),
"iat": time.Now().Unix(), "nbf": time.Now().Unix(), "sub": "test-subject", "email": "[email protected]", "iat": time.Now().Unix(), "nbf": time.Now().Unix(), "sub": "test-subject", "email": "[email protected]",
"jti": generateRandomString(16), // Unique JTI "jti": generateRandomString(16), // Unique JTI
@@ -504,7 +504,7 @@ func TestServeHTTP(t *testing.T) {
session.SetAuthenticated(true) // Set flag initially, though isUserAuthenticated will override based on token session.SetAuthenticated(true) // Set flag initially, though isUserAuthenticated will override based on token
session.SetEmail("[email protected]") session.SetEmail("[email protected]")
// Create an expired token for this test // Create an expired token for this test
expiredToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ expiredToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(-1 * time.Hour).Unix(), "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(-1 * time.Hour).Unix(),
"iat": time.Now().Add(-2 * time.Hour).Unix(), "nbf": time.Now().Add(-2 * time.Hour).Unix(), "iat": time.Now().Add(-2 * time.Hour).Unix(), "nbf": time.Now().Add(-2 * time.Hour).Unix(),
"sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16), "sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16),
@@ -561,7 +561,7 @@ func TestServeHTTP(t *testing.T) {
session.SetAuthenticated(true) session.SetAuthenticated(true)
session.SetEmail("[email protected]") session.SetEmail("[email protected]")
// Generate a fresh valid token for this test case // Generate a fresh valid token for this test case
freshToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ freshToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(1 * time.Hour).Unix(), "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(1 * time.Hour).Unix(),
"iat": time.Now().Unix(), "nbf": time.Now().Unix(), "sub": "test-subject", "email": "[email protected]", "iat": time.Now().Unix(), "nbf": time.Now().Unix(), "sub": "test-subject", "email": "[email protected]",
"jti": generateRandomString(16), // Unique JTI "jti": generateRandomString(16), // Unique JTI
@@ -579,7 +579,7 @@ func TestServeHTTP(t *testing.T) {
session.SetAuthenticated(true) // Set flag initially session.SetAuthenticated(true) // Set flag initially
session.SetEmail("[email protected]") session.SetEmail("[email protected]")
// Create an expired token for this test // Create an expired token for this test
expiredToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ expiredToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(-1 * time.Hour).Unix(), "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(-1 * time.Hour).Unix(),
"iat": time.Now().Add(-2 * time.Hour).Unix(), "nbf": time.Now().Add(-2 * time.Hour).Unix(), "iat": time.Now().Add(-2 * time.Hour).Unix(), "nbf": time.Now().Add(-2 * time.Hour).Unix(),
"sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16), "sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16),
@@ -607,7 +607,7 @@ func TestServeHTTP(t *testing.T) {
session.SetAuthenticated(true) // Set flag initially session.SetAuthenticated(true) // Set flag initially
session.SetEmail("[email protected]") session.SetEmail("[email protected]")
// Create an expired token for this test // Create an expired token for this test
expiredToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ expiredToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(-1 * time.Hour).Unix(), "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(-1 * time.Hour).Unix(),
"iat": time.Now().Add(-2 * time.Hour).Unix(), "nbf": time.Now().Add(-2 * time.Hour).Unix(), "iat": time.Now().Add(-2 * time.Hour).Unix(), "nbf": time.Now().Add(-2 * time.Hour).Unix(),
"sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16), "sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16),
@@ -635,7 +635,7 @@ func TestServeHTTP(t *testing.T) {
exp := time.Now().Add(30 * time.Second).Unix() exp := time.Now().Add(30 * time.Second).Unix()
iat := time.Now().Add(-1 * time.Minute).Unix() iat := time.Now().Add(-1 * time.Minute).Unix()
nbf := time.Now().Add(-1 * time.Minute).Unix() nbf := time.Now().Add(-1 * time.Minute).Unix()
nearExpiryToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ nearExpiryToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": exp, "iat": iat, "nbf": nbf, "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": exp, "iat": iat, "nbf": nbf,
"sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16), "sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16),
}) })
@@ -666,7 +666,7 @@ func TestServeHTTP(t *testing.T) {
exp := time.Now().Add(10 * time.Minute).Unix() exp := time.Now().Add(10 * time.Minute).Unix()
iat := time.Now().Add(-1 * time.Minute).Unix() iat := time.Now().Add(-1 * time.Minute).Unix()
nbf := time.Now().Add(-1 * time.Minute).Unix() nbf := time.Now().Add(-1 * time.Minute).Unix()
validToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ validToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": exp, "iat": iat, "nbf": nbf, "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": exp, "iat": iat, "nbf": nbf,
"sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16), "sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16),
}) })
@@ -693,7 +693,7 @@ func TestServeHTTP(t *testing.T) {
session.SetAuthenticated(true) session.SetAuthenticated(true)
session.SetEmail("[email protected]") // Use disallowed domain session.SetEmail("[email protected]") // Use disallowed domain
// Generate a fresh valid token for this test case // Generate a fresh valid token for this test case
freshToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ freshToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(1 * time.Hour).Unix(), "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(1 * time.Hour).Unix(),
"iat": time.Now().Unix(), "nbf": time.Now().Unix(), "sub": "test-subject", "email": "[email protected]", // Match email "iat": time.Now().Unix(), "nbf": time.Now().Unix(), "sub": "test-subject", "email": "[email protected]", // Match email
"jti": generateRandomString(16), // Unique JTI "jti": generateRandomString(16), // Unique JTI
@@ -715,7 +715,7 @@ func TestServeHTTP(t *testing.T) {
session.SetAuthenticated(true) session.SetAuthenticated(true)
session.SetEmail("[email protected]") // Use disallowed domain session.SetEmail("[email protected]") // Use disallowed domain
// Generate a fresh valid token for this test case // Generate a fresh valid token for this test case
freshToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ freshToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(1 * time.Hour).Unix(), "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(1 * time.Hour).Unix(),
"iat": time.Now().Unix(), "nbf": time.Now().Unix(), "sub": "test-subject", "email": "[email protected]", // Match email "iat": time.Now().Unix(), "nbf": time.Now().Unix(), "sub": "test-subject", "email": "[email protected]", // Match email
"jti": generateRandomString(16), // Unique JTI "jti": generateRandomString(16), // Unique JTI
@@ -967,11 +967,11 @@ func TestJWTVerify_MissingClaims(t *testing.T) {
ts.Setup() ts.Setup()
jwt := &JWT{ jwt := &JWT{
Header: map[string]any{ Header: map[string]interface{}{
"alg": "RS256", "alg": "RS256",
"kid": "test-key-id", "kid": "test-key-id",
}, },
Claims: map[string]any{ Claims: map[string]interface{}{
// Missing 'iss', 'aud', 'exp', 'iat', 'sub' // Missing 'iss', 'aud', 'exp', 'iat', 'sub'
}, },
} }
@@ -990,7 +990,7 @@ func TestHandleCallback(t *testing.T) {
tests := []struct { tests := []struct {
exchangeCodeForToken func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) exchangeCodeForToken func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error)
extractClaimsFunc func(tokenString string) (map[string]any, error) extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
sessionSetupFunc func(*SessionData) sessionSetupFunc func(*SessionData)
name string name string
queryParams string queryParams string
@@ -1005,8 +1005,8 @@ func TestHandleCallback(t *testing.T) {
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
}, nil }, nil
}, },
extractClaimsFunc: func(tokenString string) (map[string]any, error) { extractClaimsFunc: func(tokenString string) (map[string]interface{}, error) {
return map[string]any{ return map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"nonce": "test-nonce", "nonce": "test-nonce",
}, nil }, nil
@@ -1060,7 +1060,7 @@ func TestHandleCallback(t *testing.T) {
exp := now.Add(1 * time.Hour).Unix() exp := now.Add(1 * time.Hour).Unix()
iat := now.Unix() iat := now.Unix()
nbf := now.Unix() nbf := now.Unix()
disallowedToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ disallowedToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -1102,8 +1102,8 @@ func TestHandleCallback(t *testing.T) {
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
}, nil }, nil
}, },
extractClaimsFunc: func(tokenString string) (map[string]any, error) { extractClaimsFunc: func(tokenString string) (map[string]interface{}, error) {
return map[string]any{ return map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"nonce": "test-nonce", "nonce": "test-nonce",
}, nil }, nil
@@ -1123,8 +1123,8 @@ func TestHandleCallback(t *testing.T) {
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
}, nil }, nil
}, },
extractClaimsFunc: func(tokenString string) (map[string]any, error) { extractClaimsFunc: func(tokenString string) (map[string]interface{}, error) {
return map[string]any{ return map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"nonce": "invalid-nonce", "nonce": "invalid-nonce",
}, nil }, nil
@@ -1144,8 +1144,8 @@ func TestHandleCallback(t *testing.T) {
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
}, nil }, nil
}, },
extractClaimsFunc: func(tokenString string) (map[string]any, error) { extractClaimsFunc: func(tokenString string) (map[string]interface{}, error) {
return map[string]any{ return map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
// Missing nonce // Missing nonce
}, nil }, nil
@@ -1344,7 +1344,7 @@ func TestOIDCHandler(t *testing.T) {
tests := []struct { tests := []struct {
exchangeCodeForToken func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) exchangeCodeForToken func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error)
extractClaimsFunc func(tokenString string) (map[string]any, error) extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
sessionSetupFunc func(session *sessions.Session) sessionSetupFunc func(session *sessions.Session)
name string name string
queryParams string queryParams string
@@ -1368,9 +1368,9 @@ func TestOIDCHandler(t *testing.T) {
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
}, nil }, nil
}, },
extractClaimsFunc: func(tokenString string) (map[string]any, error) { extractClaimsFunc: func(tokenString string) (map[string]interface{}, error) {
// Simulate extraction of claims with invalid nonce // Simulate extraction of claims with invalid nonce
return map[string]any{ return map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"nonce": "invalid-nonce", "nonce": "invalid-nonce",
}, nil }, nil
@@ -1392,9 +1392,9 @@ func TestOIDCHandler(t *testing.T) {
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
}, nil }, nil
}, },
extractClaimsFunc: func(tokenString string) (map[string]any, error) { extractClaimsFunc: func(tokenString string) (map[string]interface{}, error) {
// Simulate extraction of claims without nonce // Simulate extraction of claims without nonce
return map[string]any{ return map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
}, nil }, nil
}, },
@@ -1415,9 +1415,9 @@ func TestOIDCHandler(t *testing.T) {
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
}, nil }, nil
}, },
extractClaimsFunc: func(tokenString string) (map[string]any, error) { extractClaimsFunc: func(tokenString string) (map[string]interface{}, error) {
// Simulate extraction of claims // Simulate extraction of claims
return map[string]any{ return map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"nonce": "test-nonce", "nonce": "test-nonce",
}, nil }, nil
@@ -1439,9 +1439,9 @@ func TestOIDCHandler(t *testing.T) {
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
}, nil }, nil
}, },
extractClaimsFunc: func(tokenString string) (map[string]any, error) { extractClaimsFunc: func(tokenString string) (map[string]interface{}, error) {
// Simulate extraction of claims with mismatched nonce // Simulate extraction of claims with mismatched nonce
return map[string]any{ return map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"nonce": "invalid-nonce", "nonce": "invalid-nonce",
}, nil }, nil
@@ -1471,7 +1471,7 @@ func TestOIDCHandler(t *testing.T) {
if tc.cacheToken { if tc.cacheToken {
// Cache the token with dummy claims // Cache the token with dummy claims
ts.tOidc.tokenCache.Set(ts.token, map[string]any{ ts.tOidc.tokenCache.Set(ts.token, map[string]interface{}{
"empty": "claim", "empty": "claim",
}, 60) }, 60)
} }
@@ -1732,7 +1732,7 @@ func TestRevokeToken(t *testing.T) {
ts.Setup() ts.Setup()
token := "test.token.with.claims" token := "test.token.with.claims"
claims := map[string]any{ claims := map[string]interface{}{
"exp": float64(time.Now().Add(time.Hour).Unix()), "exp": float64(time.Now().Add(time.Hour).Unix()),
} }
@@ -1833,7 +1833,7 @@ func TestHandleExpiredToken(t *testing.T) {
setupSession: func(session *SessionData) { setupSession: func(session *SessionData) {
session.SetAuthenticated(true) session.SetAuthenticated(true)
// Create an expired token for this test // Create an expired token for this test
expiredToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ expiredToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(-1 * time.Hour).Unix(), "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(-1 * time.Hour).Unix(),
"iat": time.Now().Add(-2 * time.Hour).Unix(), "nbf": time.Now().Add(-2 * time.Hour).Unix(), "iat": time.Now().Add(-2 * time.Hour).Unix(), "nbf": time.Now().Add(-2 * time.Hour).Unix(),
"sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16), "sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16),
@@ -1848,7 +1848,7 @@ func TestHandleExpiredToken(t *testing.T) {
setupSession: func(session *SessionData) { setupSession: func(session *SessionData) {
session.SetAuthenticated(true) session.SetAuthenticated(true)
// Create an expired token for this test // Create an expired token for this test
expiredToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ expiredToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(-1 * time.Hour).Unix(), "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": time.Now().Add(-1 * time.Hour).Unix(),
"iat": time.Now().Add(-2 * time.Hour).Unix(), "nbf": time.Now().Add(-2 * time.Hour).Unix(), "iat": time.Now().Add(-2 * time.Hour).Unix(), "nbf": time.Now().Add(-2 * time.Hour).Unix(),
"sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16), "sub": "test-subject", "email": "[email protected]", "jti": generateRandomString(16),
@@ -1933,16 +1933,16 @@ func TestExtractGroupsAndRoles(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
claims map[string]any claims map[string]interface{}
expectGroups []string expectGroups []string
expectRoles []string expectRoles []string
expectError bool expectError bool
}{ }{
{ {
name: "Valid groups and roles", name: "Valid groups and roles",
claims: map[string]any{ claims: map[string]interface{}{
"groups": []any{"group1", "group2"}, "groups": []interface{}{"group1", "group2"},
"roles": []any{"role1", "role2"}, "roles": []interface{}{"role1", "role2"},
}, },
expectGroups: []string{"group1", "group2"}, expectGroups: []string{"group1", "group2"},
expectRoles: []string{"role1", "role2"}, expectRoles: []string{"role1", "role2"},
@@ -1950,9 +1950,9 @@ func TestExtractGroupsAndRoles(t *testing.T) {
}, },
{ {
name: "Empty groups and roles", name: "Empty groups and roles",
claims: map[string]any{ claims: map[string]interface{}{
"groups": []any{}, "groups": []interface{}{},
"roles": []any{}, "roles": []interface{}{},
}, },
expectGroups: []string{}, expectGroups: []string{},
expectRoles: []string{}, expectRoles: []string{},
@@ -1960,9 +1960,9 @@ func TestExtractGroupsAndRoles(t *testing.T) {
}, },
{ {
name: "Invalid groups format", name: "Invalid groups format",
claims: map[string]any{ claims: map[string]interface{}{
"groups": "not-an-array", "groups": "not-an-array",
"roles": []any{"role1"}, "roles": []interface{}{"role1"},
}, },
expectError: true, expectError: true,
}, },
@@ -2105,7 +2105,7 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
tests := []struct { tests := []struct {
allowedRolesAndGroups map[string]struct{} allowedRolesAndGroups map[string]struct{}
claims map[string]any claims map[string]interface{}
setupSession func(*SessionData) setupSession func(*SessionData)
expectedHeaders map[string]string expectedHeaders map[string]string
name string name string
@@ -2116,15 +2116,15 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
allowedRolesAndGroups: map[string]struct{}{ allowedRolesAndGroups: map[string]struct{}{
"admin": {}, "admin": {},
}, },
claims: map[string]any{ claims: map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
"iat": iat, "iat": iat,
"nbf": nbf, "nbf": nbf,
"sub": "test-subject", "sub": "test-subject",
"roles": []any{"admin", "user"}, "roles": []interface{}{"admin", "user"},
"groups": []any{"group1"}, "groups": []interface{}{"group1"},
"jti": generateRandomString(16), "jti": generateRandomString(16),
}, },
setupSession: func(session *SessionData) { setupSession: func(session *SessionData) {
@@ -2142,15 +2142,15 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
allowedRolesAndGroups: map[string]struct{}{ allowedRolesAndGroups: map[string]struct{}{
"allowed-group": {}, "allowed-group": {},
}, },
claims: map[string]any{ claims: map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
"iat": iat, "iat": iat,
"nbf": nbf, "nbf": nbf,
"sub": "test-subject", "sub": "test-subject",
"roles": []any{"user"}, "roles": []interface{}{"user"},
"groups": []any{"allowed-group"}, "groups": []interface{}{"allowed-group"},
"jti": generateRandomString(16), "jti": generateRandomString(16),
}, },
setupSession: func(session *SessionData) { setupSession: func(session *SessionData) {
@@ -2169,15 +2169,15 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
"admin": {}, "admin": {},
"allowed-group": {}, "allowed-group": {},
}, },
claims: map[string]any{ claims: map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
"iat": iat, "iat": iat,
"nbf": nbf, "nbf": nbf,
"sub": "test-subject", "sub": "test-subject",
"roles": []any{"user"}, "roles": []interface{}{"user"},
"groups": []any{"regular-group"}, "groups": []interface{}{"regular-group"},
"jti": generateRandomString(16), "jti": generateRandomString(16),
}, },
setupSession: func(session *SessionData) { setupSession: func(session *SessionData) {
@@ -2189,15 +2189,15 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
{ {
name: "No role/group restrictions", name: "No role/group restrictions",
allowedRolesAndGroups: map[string]struct{}{}, allowedRolesAndGroups: map[string]struct{}{},
claims: map[string]any{ claims: map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
"iat": iat, "iat": iat,
"nbf": nbf, "nbf": nbf,
"sub": "test-subject", "sub": "test-subject",
"roles": []any{"user"}, "roles": []interface{}{"user"},
"groups": []any{"regular-group"}, "groups": []interface{}{"regular-group"},
"jti": generateRandomString(16), "jti": generateRandomString(16),
}, },
setupSession: func(session *SessionData) { setupSession: func(session *SessionData) {
@@ -2213,7 +2213,7 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
{ {
name: "Claims without roles and groups", name: "Claims without roles and groups",
allowedRolesAndGroups: map[string]struct{}{}, allowedRolesAndGroups: map[string]struct{}{},
claims: map[string]any{ claims: map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -2861,7 +2861,7 @@ func TestJWTVerifyWithSkipReplayCheck(t *testing.T) {
iat := now.Unix() iat := now.Unix()
nbf := now.Unix() nbf := now.Unix()
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -2954,7 +2954,7 @@ func TestJWTVerifyBackwardCompatibility(t *testing.T) {
iat := now.Unix() iat := now.Unix()
nbf := now.Unix() nbf := now.Unix()
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -3007,7 +3007,7 @@ func TestTokenReplayDetectionFalsePositiveFix(t *testing.T) {
iat := now.Unix() iat := now.Unix()
nbf := now.Unix() nbf := now.Unix()
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -3080,7 +3080,7 @@ func TestAuthenticationFlowReplayDetection(t *testing.T) {
iat := now.Unix() iat := now.Unix()
nbf := now.Unix() nbf := now.Unix()
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -3155,7 +3155,7 @@ func TestActualReplayAttackDetection(t *testing.T) {
iat := now.Unix() iat := now.Unix()
nbf := now.Unix() nbf := now.Unix()
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -3241,7 +3241,7 @@ func TestConcurrentTokenValidation(t *testing.T) {
jti := generateRandomString(16) jti := generateRandomString(16)
jtis = append(jtis, jti) jtis = append(jtis, jti)
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -3322,7 +3322,7 @@ func TestJTIBlacklistBehavior(t *testing.T) {
iat := now.Unix() iat := now.Unix()
nbf := now.Unix() nbf := now.Unix()
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -3422,7 +3422,7 @@ func TestSessionBasedTokenRevalidation(t *testing.T) {
iat := now.Unix() iat := now.Unix()
nbf := now.Unix() nbf := now.Unix()
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -3495,7 +3495,7 @@ func TestEdgeCasesWithDifferentTokenTypes(t *testing.T) {
nbf := now.Unix() nbf := now.Unix()
tests := []struct { tests := []struct {
claims map[string]any claims map[string]interface{}
name string name string
tokenType string tokenType string
expectError bool expectError bool
@@ -3503,7 +3503,7 @@ func TestEdgeCasesWithDifferentTokenTypes(t *testing.T) {
{ {
name: "ID Token with JTI", name: "ID Token with JTI",
tokenType: "id_token", tokenType: "id_token",
claims: map[string]any{ claims: map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -3520,7 +3520,7 @@ func TestEdgeCasesWithDifferentTokenTypes(t *testing.T) {
{ {
name: "Access Token with JTI", name: "Access Token with JTI",
tokenType: "access_token", tokenType: "access_token",
claims: map[string]any{ claims: map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -3536,7 +3536,7 @@ func TestEdgeCasesWithDifferentTokenTypes(t *testing.T) {
{ {
name: "Token without JTI", name: "Token without JTI",
tokenType: "no_jti", tokenType: "no_jti",
claims: map[string]any{ claims: map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": exp, "exp": exp,
@@ -3998,9 +3998,12 @@ func TestBuildAuthURLWithMergedScopes(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Configure the test instance with specific scopes // Configure the test instance with specific scopes
tOidc := ts.tOidc tOidc := ts.tOidc
tOidc.scopes = tc.scopes tOidc.scopes = tc.scopes // These scopes are already deduplicated by New()
tOidc.authURL = "https://auth.example.com/oauth/authorize" tOidc.authURL = "https://auth.example.com/oauth/authorize"
tOidc.issuerURL = "https://auth.example.com" tOidc.issuerURL = "https://auth.example.com"
// Reset overrideScopes for each test case, as it's part of tOidc state
// Default to false, specific tests will set it.
tOidc.overrideScopes = false
// Build auth URL // Build auth URL
result := tOidc.buildAuthURL("https://app.example.com/callback", "test-state", "test-nonce", "") result := tOidc.buildAuthURL("https://app.example.com/callback", "test-state", "test-nonce", "")
@@ -4019,3 +4022,182 @@ func TestBuildAuthURLWithMergedScopes(t *testing.T) {
}) })
} }
} }
// TestBuildAuthURL_OverrideScopes_And_OfflineAccess tests the offline_access logic in buildAuthURL
// considering the overrideScopes flag.
func TestBuildAuthURL_OverrideScopes_And_OfflineAccess(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup() // Sets up ts.tOidc
tests := []struct {
name string
initialScopes []string // Scopes as they would be in tOidc.scopes (after New processing)
overrideScopes bool
isGoogle bool // To test Google-specific handling
isAzure bool // To test Azure-specific handling
expectedParams map[string]string
expectedScope string // The final scope string expected in the URL
}{
{
name: "Override false, no user scopes, non-Google/Azure",
initialScopes: []string{"openid", "profile", "email"}, // Defaults from New() when config.Scopes is empty
overrideScopes: false,
expectedScope: "openid profile email offline_access",
},
{
name: "Override false, user scopes without offline_access, non-Google/Azure",
initialScopes: []string{"openid", "profile", "email", "custom1"}, // Merged and deduplicated by New()
overrideScopes: false,
expectedScope: "openid profile email custom1 offline_access",
},
{
name: "Override false, user scopes with offline_access, non-Google/Azure",
initialScopes: []string{"openid", "profile", "email", "offline_access", "custom1"},
overrideScopes: false,
expectedScope: "openid profile email offline_access custom1", // Order might vary based on merge, but offline_access present
},
{
name: "Override true, user scopes without offline_access, non-Google/Azure",
initialScopes: []string{"custom1", "custom2"}, // Directly from config.Scopes, deduplicated
overrideScopes: true,
expectedScope: "custom1 custom2", // offline_access NOT added
},
{
name: "Override true, user scopes with offline_access, non-Google/Azure",
initialScopes: []string{"custom1", "offline_access", "custom2"},
overrideScopes: true,
expectedScope: "custom1 offline_access custom2", // User explicitly included it
},
{
name: "Override true, no user scopes (edge case), non-Google/Azure",
initialScopes: []string{}, // config.Scopes was empty
overrideScopes: true,
// In this edge case, buildAuthURL's logic `(t.overrideScopes && len(t.scopes) == 0)`
// will lead to offline_access being added, as it behaves like defaults.
expectedScope: "offline_access",
},
// Google Provider Tests (access_type=offline, prompt=consent)
{
name: "Google, Override false, no user scopes",
initialScopes: []string{"openid", "profile", "email"},
overrideScopes: false,
isGoogle: true,
expectedParams: map[string]string{"access_type": "offline", "prompt": "consent"},
expectedScope: "openid profile email", // No offline_access scope for Google
},
{
name: "Google, Override true, user scopes",
initialScopes: []string{"custom1", "custom2"},
overrideScopes: true,
isGoogle: true,
expectedParams: map[string]string{"access_type": "offline", "prompt": "consent"},
expectedScope: "custom1 custom2", // No offline_access scope for Google
},
// Azure Provider Tests (response_mode=query, offline_access scope added if not present by user)
{
name: "Azure, Override false, no user scopes",
initialScopes: []string{"openid", "profile", "email"},
overrideScopes: false,
isAzure: true,
expectedParams: map[string]string{"response_mode": "query"},
expectedScope: "openid profile email offline_access",
},
{
name: "Azure, Override true, user scopes without offline_access",
initialScopes: []string{"custom1", "custom2"},
overrideScopes: true,
isAzure: true,
expectedParams: map[string]string{"response_mode": "query"},
expectedScope: "custom1 custom2", // offline_access NOT added by default when override is true
},
{
name: "Azure, Override true, user scopes with offline_access",
initialScopes: []string{"custom1", "offline_access"},
overrideScopes: true,
isAzure: true,
expectedParams: map[string]string{"response_mode": "query"},
expectedScope: "custom1 offline_access",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tOidc := ts.tOidc
tOidc.scopes = tc.initialScopes // Set the scopes as if they came from New()
tOidc.overrideScopes = tc.overrideScopes
// Adjust issuerURL for provider-specific tests
originalIssuerURL := tOidc.issuerURL
if tc.isGoogle {
tOidc.issuerURL = "https://accounts.google.com"
} else if tc.isAzure {
tOidc.issuerURL = "https://login.microsoftonline.com/common"
} else {
tOidc.issuerURL = "https://generic-provider.com" // Non-Google/Azure
}
authURLString := tOidc.buildAuthURL("http://localhost/callback", "state123", "nonce123", "challenge123")
parsedAuthURL, err := url.Parse(authURLString)
if err != nil {
t.Fatalf("Failed to parse auth URL: %v", err)
}
query := parsedAuthURL.Query()
actualScope := query.Get("scope")
if actualScope != tc.expectedScope {
t.Errorf("Expected scope string %q, got %q", tc.expectedScope, actualScope)
}
if tc.expectedParams != nil {
for k, v := range tc.expectedParams {
if query.Get(k) != v {
t.Errorf("Expected param %s=%s, got %s", k, v, query.Get(k))
}
}
}
// Restore original issuerURL for next test
tOidc.issuerURL = originalIssuerURL
})
}
}
// TestBuildAuthURL_SpecificUserCase tests the buildAuthURL function with the specific user-reported scenario.
func TestBuildAuthURL_SpecificUserCase(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup() // Basic setup for tOidc
// Configure the TraefikOidc instance for the specific scenario
tOidc := ts.tOidc
tOidc.scopes = []string{"email", "test3"} // This is what t.scopes should be after New()
tOidc.overrideScopes = true
tOidc.issuerURL = "https://generic-provider.com" // Non-Google/Azure
tOidc.authURL = "https://generic-provider.com/auth" // Dummy auth URL
tOidc.clientID = "test-client-id"
// Expected scope string in the URL
expectedScopeString := "email test3"
// Call buildAuthURL
authURLString := tOidc.buildAuthURL("http://localhost/callback", "test-state", "test-nonce", "")
// Parse the resulting URL
parsedAuthURL, err := url.Parse(authURLString)
if err != nil {
t.Fatalf("Failed to parse generated auth URL %q: %v", authURLString, err)
}
// Get the 'scope' query parameter
actualScopeString := parsedAuthURL.Query().Get("scope")
// Assert that the scope string is as expected
if actualScopeString != expectedScopeString {
t.Errorf("Expected scope parameter to be %q, but got %q. Full URL: %s",
expectedScopeString, actualScopeString, authURLString)
}
// Additionally, ensure 'offline_access' was not added
if strings.Contains(actualScopeString, "offline_access") {
t.Errorf("Scope parameter %q should not contain 'offline_access' when overrideScopes is true and it's not in tOidc.scopes", actualScopeString)
}
}
+21 -21
View File
@@ -22,8 +22,8 @@ func TestConcurrentTokenVerification(t *testing.T) {
// Create multiple valid tokens to avoid replay detection // Create multiple valid tokens to avoid replay detection
tokens := make([]string, 10) tokens := make([]string, 10)
for i := range 10 { for i := 0; i < 10; i++ {
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -71,11 +71,11 @@ func TestConcurrentTokenVerification(t *testing.T) {
var errorCount int64 var errorCount int64
errors := make(chan error, numGoroutines*verificationsPerGoroutine) errors := make(chan error, numGoroutines*verificationsPerGoroutine)
for i := range numGoroutines { for i := 0; i < numGoroutines; i++ {
wg.Add(1) wg.Add(1)
go func(goroutineID int) { go func(goroutineID int) {
defer wg.Done() defer wg.Done()
for j := range verificationsPerGoroutine { for j := 0; j < verificationsPerGoroutine; j++ {
tokenIndex := (goroutineID*verificationsPerGoroutine + j) % len(tokens) tokenIndex := (goroutineID*verificationsPerGoroutine + j) % len(tokens)
err := tOidc.VerifyToken(tokens[tokenIndex]) err := tOidc.VerifyToken(tokens[tokenIndex])
if err != nil { if err != nil {
@@ -144,8 +144,8 @@ func TestCacheMemoryExhaustion(t *testing.T) {
const numTokens = 500 const numTokens = 500
tokens := make([]string, numTokens) tokens := make([]string, numTokens)
for i := range numTokens { for i := 0; i < numTokens; i++ {
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -161,7 +161,7 @@ func TestCacheMemoryExhaustion(t *testing.T) {
tokens[i] = token tokens[i] = token
// Add to cache // Add to cache
claims := map[string]any{ claims := map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -210,7 +210,7 @@ func TestSessionConcurrencyProtection(t *testing.T) {
var successCount int64 var successCount int64
var errorCount int64 var errorCount int64
for i := range numGoroutines { for i := 0; i < numGoroutines; i++ {
wg.Add(1) wg.Add(1)
go func(goroutineID int) { go func(goroutineID int) {
defer wg.Done() defer wg.Done()
@@ -218,7 +218,7 @@ func TestSessionConcurrencyProtection(t *testing.T) {
// Each goroutine gets its own request and session // Each goroutine gets its own request and session
req := httptest.NewRequest("GET", "/test", nil) req := httptest.NewRequest("GET", "/test", nil)
for j := range operationsPerGoroutine { for j := 0; j < operationsPerGoroutine; j++ {
// Get a fresh session for each operation // Get a fresh session for each operation
s, err := sessionManager.GetSession(req) s, err := sessionManager.GetSession(req)
if err != nil { if err != nil {
@@ -276,11 +276,11 @@ func TestParallelCacheOperations(t *testing.T) {
var deleteCount int64 var deleteCount int64
// Start multiple goroutines performing cache operations // Start multiple goroutines performing cache operations
for i := range numGoroutines { for i := 0; i < numGoroutines; i++ {
wg.Add(1) wg.Add(1)
go func(goroutineID int) { go func(goroutineID int) {
defer wg.Done() defer wg.Done()
for j := range operationsPerGoroutine { for j := 0; j < operationsPerGoroutine; j++ {
key := fmt.Sprintf("key-%d-%d", goroutineID, j) key := fmt.Sprintf("key-%d-%d", goroutineID, j)
value := fmt.Sprintf("value-%d-%d", goroutineID, j) value := fmt.Sprintf("value-%d-%d", goroutineID, j)
@@ -377,7 +377,7 @@ func TestOversizedTokenHandling(t *testing.T) {
// Create an oversized token with large claims // Create an oversized token with large claims
largeClaim := strings.Repeat("x", 10000) // 10KB claim largeClaim := strings.Repeat("x", 10000) // 10KB claim
oversizedClaims := map[string]any{ oversizedClaims := map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -411,7 +411,7 @@ func TestOversizedTokenHandling(t *testing.T) {
// Test extremely long token (beyond reasonable limits) // Test extremely long token (beyond reasonable limits)
extremelyLongClaim := strings.Repeat("y", 100000) // 100KB claim extremelyLongClaim := strings.Repeat("y", 100000) // 100KB claim
extremeClaims := map[string]any{ extremeClaims := map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -528,7 +528,7 @@ func TestMaliciousInputValidation(t *testing.T) {
} }
// Verify the system is still functional after malicious input // Verify the system is still functional after malicious input
validToken, createErr := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ validToken, createErr := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -609,7 +609,7 @@ func TestResourceLimits(t *testing.T) {
defer cache.Close() defer cache.Close()
// Try to overwhelm the cache // Try to overwhelm the cache
for i := range 1000 { for i := 0; i < 1000; i++ {
key := fmt.Sprintf("key-%d", i) key := fmt.Sprintf("key-%d", i)
value := fmt.Sprintf("value-%d", i) value := fmt.Sprintf("value-%d", i)
cache.Set(key, value, time.Minute) cache.Set(key, value, time.Minute)
@@ -627,7 +627,7 @@ func TestResourceLimits(t *testing.T) {
denied := 0 denied := 0
// Make many requests quickly // Make many requests quickly
for range 100 { for i := 0; i < 100; i++ {
if limiter.Allow() { if limiter.Allow() {
allowed++ allowed++
} else { } else {
@@ -655,7 +655,7 @@ func TestErrorRecoveryPatterns(t *testing.T) {
ts.tOidc.tokenCache.cache.Set("corrupted", "invalid-data", time.Hour) ts.tOidc.tokenCache.cache.Set("corrupted", "invalid-data", time.Hour)
// System should handle corrupted cache gracefully // System should handle corrupted cache gracefully
validToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ validToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -681,7 +681,7 @@ func TestErrorRecoveryPatterns(t *testing.T) {
ts.tOidc.tokenBlacklist.Set("corrupted-entry", "invalid-data", time.Hour) ts.tOidc.tokenBlacklist.Set("corrupted-entry", "invalid-data", time.Hour)
// System should still function // System should still function
validToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ validToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -713,8 +713,8 @@ func TestPerformanceUnderLoad(t *testing.T) {
// Create multiple valid tokens // Create multiple valid tokens
const numTokens = 100 const numTokens = 100
tokens := make([]string, numTokens) tokens := make([]string, numTokens)
for i := range numTokens { for i := 0; i < numTokens; i++ {
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -757,7 +757,7 @@ func TestPerformanceUnderLoad(t *testing.T) {
const iterations = 1000 const iterations = 1000
start := time.Now() start := time.Now()
for i := range iterations { for i := 0; i < iterations; i++ {
tokenIndex := i % numTokens tokenIndex := i % numTokens
err := tOidc.VerifyToken(tokens[tokenIndex]) err := tOidc.VerifyToken(tokens[tokenIndex])
if err != nil { if err != nil {
+300 -62
View File
@@ -1,6 +1,7 @@
package traefikoidc package traefikoidc
import ( import (
"net/url"
"reflect" "reflect"
"testing" "testing"
) )
@@ -54,71 +55,308 @@ func TestMergeScopes(t *testing.T) {
} }
} }
func TestDeduplicateScopes(t *testing.T) {
testCases := []struct {
name string
inputScopes []string
expectedScopes []string
}{
{
name: "No duplicates",
inputScopes: []string{"openid", "profile", "email"},
expectedScopes: []string{"openid", "profile", "email"},
},
{
name: "Simple duplicates",
inputScopes: []string{"openid", "profile", "openid", "email"},
expectedScopes: []string{"openid", "profile", "email"},
},
{
name: "Multiple duplicates",
inputScopes: []string{"scope1", "scope2", "scope1", "scope2", "scope1"},
expectedScopes: []string{"scope1", "scope2"},
},
{
name: "Empty input",
inputScopes: []string{},
expectedScopes: []string{},
},
{
name: "Nil input",
inputScopes: nil,
expectedScopes: []string{},
},
{
name: "Single element",
inputScopes: []string{"openid"},
expectedScopes: []string{"openid"},
},
{
name: "All duplicates",
inputScopes: []string{"test", "test", "test"},
expectedScopes: []string{"test"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := deduplicateScopes(tc.inputScopes)
if !reflect.DeepEqual(result, tc.expectedScopes) {
t.Errorf("Expected %v, got %v", tc.expectedScopes, result)
}
})
}
}
func TestScopesConfiguration(t *testing.T) { func TestScopesConfiguration(t *testing.T) {
defaultScopes := []string{"openid", "profile", "email"} defaultScopes := []string{"openid", "profile", "email"}
userScopes := []string{"roles", "custom_scope"}
t.Run("Default Append Behavior", func(t *testing.T) { testCases := []struct {
// Create config with user scopes but overrideScopes=false name string
config := &Config{ configScopes []string // Scopes from Traefik config
Scopes: userScopes, overrideScopes bool
OverrideScopes: false, expectedResult []string
} }{
{
name: "Default Append Behavior - No user scopes",
configScopes: []string{},
overrideScopes: false,
expectedResult: []string{"openid", "profile", "email"},
},
{
name: "Default Append Behavior - With user scopes",
configScopes: []string{"roles", "custom_scope"},
overrideScopes: false,
expectedResult: []string{"openid", "profile", "email", "roles", "custom_scope"},
},
{
name: "Default Append Behavior - With duplicate user scopes",
configScopes: []string{"roles", "custom_scope", "roles"},
overrideScopes: false,
expectedResult: []string{"openid", "profile", "email", "roles", "custom_scope"},
},
{
name: "Default Append Behavior - User scopes overlap with defaults",
configScopes: []string{"openid", "roles", "profile"},
overrideScopes: false,
expectedResult: []string{"openid", "profile", "email", "roles"},
},
{
name: "Override Behavior - With user scopes",
configScopes: []string{"roles", "custom_scope"},
overrideScopes: true,
expectedResult: []string{"roles", "custom_scope"},
},
{
name: "Override Behavior - With duplicate user scopes",
configScopes: []string{"roles", "custom_scope", "roles"},
overrideScopes: true,
expectedResult: []string{"roles", "custom_scope"},
},
{
name: "Override Behavior - Empty user scopes",
configScopes: []string{},
overrideScopes: true,
expectedResult: []string{},
},
{
name: "Override Behavior - Nil user scopes",
configScopes: nil,
overrideScopes: true,
expectedResult: []string{}, // Deduplicate will handle nil as empty
},
{
name: "Override Behavior - Single user scope",
configScopes: []string{"email"},
overrideScopes: true,
expectedResult: []string{"email"},
},
}
// Simulate middleware initialization for _, tc := range testCases {
var result []string t.Run(tc.name, func(t *testing.T) {
if config.OverrideScopes { // Simulate the logic within TraefikOidc.New for setting t.scopes
result = append([]string(nil), config.Scopes...) var result []string
} else { uniqueConfigScopes := deduplicateScopes(tc.configScopes)
result = mergeScopes(defaultScopes, config.Scopes) if tc.overrideScopes {
} result = uniqueConfigScopes
} else {
result = mergeScopes(defaultScopes, uniqueConfigScopes)
}
// Expect defaultScopes + userScopes with deduplication if !reflect.DeepEqual(result, tc.expectedResult) {
expectedScopes := []string{"openid", "profile", "email", "roles", "custom_scope"} t.Errorf("Expected scopes %v, got %v", tc.expectedResult, result)
if !reflect.DeepEqual(result, expectedScopes) { }
t.Errorf("Expected %v, got %v", expectedScopes, result) })
} }
}) }
t.Run("Override Behavior", func(t *testing.T) { func TestBuildAuthURLScopeHandling(t *testing.T) {
// Create config with user scopes and overrideScopes=true ts := &TestSuite{t: t}
config := &Config{ ts.Setup() // Basic setup for TraefikOidc instance
Scopes: userScopes,
OverrideScopes: true, // Default scopes expected if not overridden and no user scopes provided
} defaultInitialScopes := []string{"openid", "profile", "email"}
// Simulate middleware initialization testCases := []struct {
var result []string name string
if config.OverrideScopes { configScopes []string // Scopes from Traefik config
result = append([]string(nil), config.Scopes...) overrideScopes bool
} else { isGoogle bool
result = mergeScopes(defaultScopes, config.Scopes) isAzure bool
} expectedScopeString string // Expected final scope string in the auth URL
expectedParams map[string]string
// Expect only userScopes }{
if !reflect.DeepEqual(result, userScopes) { {
t.Errorf("Expected %v, got %v", userScopes, result) name: "Deduplication: Default append, duplicate in user scopes",
} configScopes: []string{"openid", "custom", "profile", "custom"},
}) overrideScopes: false,
expectedScopeString: "openid profile email custom offline_access",
t.Run("Empty Scopes with Override", func(t *testing.T) { },
// Create config with empty scopes and overrideScopes=true {
config := &Config{ name: "Deduplication: Override, duplicate in user scopes",
Scopes: []string{}, configScopes: []string{"openid", "custom", "profile", "custom"},
OverrideScopes: true, overrideScopes: true,
} expectedScopeString: "openid custom profile", // offline_access not added
},
// Simulate middleware initialization {
var result []string name: "Override True: No automatic offline_access",
if config.OverrideScopes { configScopes: []string{"scope1", "scope2"},
result = append([]string(nil), config.Scopes...) overrideScopes: true,
} else { expectedScopeString: "scope1 scope2",
result = mergeScopes(defaultScopes, config.Scopes) },
} {
name: "Override True: User includes offline_access",
// Expect empty scopes - check length instead of DeepEqual configScopes: []string{"scope1", "offline_access", "scope2"},
if len(result) != 0 { overrideScopes: true,
t.Errorf("Expected empty slice, got %v with length %d", result, len(result)) expectedScopeString: "scope1 offline_access scope2",
} },
}) {
name: "Override False: Automatic offline_access added",
configScopes: []string{"scope1", "scope2"},
overrideScopes: false,
expectedScopeString: "openid profile email scope1 scope2 offline_access",
},
{
name: "Override False: User includes offline_access (deduplicated)",
configScopes: []string{"scope1", "offline_access", "scope2"},
overrideScopes: false,
expectedScopeString: "openid profile email scope1 offline_access scope2",
},
{
name: "Integration: Duplicate scopes in config, override true",
configScopes: []string{"scope1", "scope1", "scope2"},
overrideScopes: true,
expectedScopeString: "scope1 scope2",
},
{
name: "Integration: No auto offline_access with override true",
configScopes: []string{"scope1", "scope2"},
overrideScopes: true,
expectedScopeString: "scope1 scope2",
},
{
name: "Integration: Duplicates and no auto offline_access with override true",
configScopes: []string{"scope1", "scope1", "scope2"},
overrideScopes: true,
expectedScopeString: "scope1 scope2",
},
{
name: "Integration: Google provider, override false, no user scopes",
configScopes: []string{},
overrideScopes: false,
isGoogle: true,
expectedScopeString: "openid profile email", // Google uses access_type=offline param
expectedParams: map[string]string{"access_type": "offline", "prompt": "consent"},
},
{
name: "Integration: Google provider, override true, user scopes",
configScopes: []string{"custom1", "custom2"},
overrideScopes: true,
isGoogle: true,
expectedScopeString: "custom1 custom2", // Google uses access_type=offline param
expectedParams: map[string]string{"access_type": "offline", "prompt": "consent"},
},
{
name: "Integration: Azure provider, override false, no user scopes",
configScopes: []string{},
overrideScopes: false,
isAzure: true,
expectedScopeString: "openid profile email offline_access", // Azure adds offline_access scope
expectedParams: map[string]string{"response_mode": "query"},
},
{
name: "Integration: Azure provider, override true, user scopes without offline_access",
configScopes: []string{"custom1", "custom2"},
overrideScopes: true,
isAzure: true,
expectedScopeString: "custom1 custom2", // Azure respects override
expectedParams: map[string]string{"response_mode": "query"},
},
{
name: "Integration: Azure provider, override true, user scopes with offline_access",
configScopes: []string{"custom1", "offline_access"},
overrideScopes: true,
isAzure: true,
expectedScopeString: "custom1 offline_access", // Azure respects override
expectedParams: map[string]string{"response_mode": "query"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Simulate the TraefikOidc instance's scope initialization
var initializedScopes []string
uniqueConfigScopes := deduplicateScopes(tc.configScopes)
if tc.overrideScopes {
initializedScopes = uniqueConfigScopes
} else {
initializedScopes = mergeScopes(defaultInitialScopes, uniqueConfigScopes)
}
// Create a new TraefikOidc instance for this test case
// to ensure proper isolation of 'scopes' and 'overrideScopes' fields.
// We use parts of the TestSuite's tOidc for common setup like logger, clientID etc.
// but override the scope-related fields.
testOidc := &TraefikOidc{
clientID: ts.tOidc.clientID,
logger: ts.tOidc.logger,
scopes: initializedScopes, // Use scopes processed as New() would
overrideScopes: tc.overrideScopes,
// Set other necessary fields for buildAuthURL to function
authURL: "https://provider.com/auth", // Dummy authURL
issuerURL: "https://provider.com", // Dummy issuerURL
httpClient: ts.tOidc.httpClient, // Reuse from TestSuite
}
originalIssuerURL := testOidc.issuerURL
if tc.isGoogle {
testOidc.issuerURL = "https://accounts.google.com"
} else if tc.isAzure {
testOidc.issuerURL = "https://login.microsoftonline.com/common"
}
authURLString := testOidc.buildAuthURL("http://localhost/callback", "state", "nonce", "challenge")
parsedURL, err := url.Parse(authURLString)
if err != nil {
t.Fatalf("Failed to parse auth URL: %v", err)
}
query := parsedURL.Query()
actualScopeString := query.Get("scope")
if actualScopeString != tc.expectedScopeString {
t.Errorf("Expected scope string %q, got %q", tc.expectedScopeString, actualScopeString)
}
if tc.expectedParams != nil {
for k, v := range tc.expectedParams {
if query.Get(k) != v {
t.Errorf("Expected param %s=%s, got %s", k, v, query.Get(k))
}
}
}
testOidc.issuerURL = originalIssuerURL // Restore
})
}
} }
+33 -22
View File
@@ -11,7 +11,6 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -26,7 +25,7 @@ func TestJWTAlgorithmConfusionAttack(t *testing.T) {
ts.Setup() ts.Setup()
// Create a standard JWT with RS256 algorithm // Create a standard JWT with RS256 algorithm
validRS256JWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ validRS256JWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -52,7 +51,7 @@ func TestJWTAlgorithmConfusionAttack(t *testing.T) {
} }
// Parse header // Parse header
var header map[string]any var header map[string]interface{}
if err := json.Unmarshal(headerBytes, &header); err != nil { if err := json.Unmarshal(headerBytes, &header); err != nil {
t.Fatalf("Failed to unmarshal header: %v", err) t.Fatalf("Failed to unmarshal header: %v", err)
} }
@@ -91,7 +90,7 @@ func TestJWTNoneAlgorithmAttack(t *testing.T) {
ts.Setup() ts.Setup()
// Create a standard JWT // Create a standard JWT
validJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ validJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -117,7 +116,7 @@ func TestJWTNoneAlgorithmAttack(t *testing.T) {
} }
// Parse header // Parse header
var header map[string]any var header map[string]interface{}
if err := json.Unmarshal(headerBytes, &header); err != nil { if err := json.Unmarshal(headerBytes, &header); err != nil {
t.Fatalf("Failed to unmarshal header: %v", err) t.Fatalf("Failed to unmarshal header: %v", err)
} }
@@ -155,7 +154,7 @@ func TestJWTTokenTampering(t *testing.T) {
ts.Setup() ts.Setup()
// Create a standard JWT // Create a standard JWT
validJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ validJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -181,7 +180,7 @@ func TestJWTTokenTampering(t *testing.T) {
} }
// Parse claims // Parse claims
var claims map[string]any var claims map[string]interface{}
if err := json.Unmarshal(claimsBytes, &claims); err != nil { if err := json.Unmarshal(claimsBytes, &claims); err != nil {
t.Fatalf("Failed to unmarshal claims: %v", err) t.Fatalf("Failed to unmarshal claims: %v", err)
} }
@@ -220,7 +219,7 @@ func TestJWTExpiredToken(t *testing.T) {
ts.Setup() ts.Setup()
// Create a JWT that is already expired // Create a JWT that is already expired
expiredJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ expiredJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(-1 * time.Hour).Unix()), // Expired 1 hour ago "exp": float64(time.Now().Add(-1 * time.Hour).Unix()), // Expired 1 hour ago
@@ -253,7 +252,7 @@ func TestJWTFutureToken(t *testing.T) {
ts.Setup() ts.Setup()
// Create a JWT with a future issuance time // Create a JWT with a future issuance time
futureJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ futureJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(2 * time.Hour).Unix()), "exp": float64(time.Now().Add(2 * time.Hour).Unix()),
@@ -316,7 +315,7 @@ func TestJWTReplayAttack(t *testing.T) {
fixedJTI := "fixed-test-jti-for-replay-" + generateRandomString(8) fixedJTI := "fixed-test-jti-for-replay-" + generateRandomString(8)
// Create a JWT with the fixed JTI // Create a JWT with the fixed JTI
replayJWT, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]any{ replayJWT, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -423,7 +422,7 @@ func TestMissingClaims(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Create standard claims // Create standard claims
claims := map[string]any{ claims := map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -599,7 +598,13 @@ func TestSessionFixationAttack(t *testing.T) {
// - The response is unauthorized (401), OR // - The response is unauthorized (401), OR
// - The token verification failed // - The token verification failed
expectedCodes := []int{http.StatusFound, http.StatusUnauthorized, http.StatusForbidden} expectedCodes := []int{http.StatusFound, http.StatusUnauthorized, http.StatusForbidden}
codeFound := slices.Contains(expectedCodes, victimResp.Code) codeFound := false
for _, code := range expectedCodes {
if victimResp.Code == code {
codeFound = true
break
}
}
if !codeFound { if !codeFound {
t.Errorf("Expected status code to be one of %v, but got %d", expectedCodes, victimResp.Code) t.Errorf("Expected status code to be one of %v, but got %d", expectedCodes, victimResp.Code)
@@ -824,7 +829,7 @@ func TestTokenBlacklisting(t *testing.T) {
} }
// Create a valid JWT // Create a valid JWT
validJWT, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]any{ validJWT, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -914,7 +919,7 @@ func TestDifferentSigningAlgorithms(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Define standard claims with unique JTI for each test // Define standard claims with unique JTI for each test
standardClaims := map[string]any{ standardClaims := map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -1017,9 +1022,9 @@ func TestDifferentSigningAlgorithms(t *testing.T) {
} }
// createTestJWTWithECKey creates a JWT signed with an EC private key // createTestJWTWithECKey creates a JWT signed with an EC private key
func createTestJWTWithECKey(privateKey *ecdsa.PrivateKey, alg, kid string, claims map[string]any) (string, error) { func createTestJWTWithECKey(privateKey *ecdsa.PrivateKey, alg, kid string, claims map[string]interface{}) (string, error) {
// Create the header // Create the header
header := map[string]any{ header := map[string]interface{}{
"alg": alg, "alg": alg,
"typ": "JWT", "typ": "JWT",
"kid": kid, "kid": kid,
@@ -1276,7 +1281,7 @@ func TestRateLimiting(t *testing.T) {
tOidc.tokenVerifier = tOidc tOidc.tokenVerifier = tOidc
// Create a valid JWT token // Create a valid JWT token
validJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ validJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -1296,7 +1301,7 @@ func TestRateLimiting(t *testing.T) {
} }
// Second request should succeed // Second request should succeed
validJWT2, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ validJWT2, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -1315,7 +1320,7 @@ func TestRateLimiting(t *testing.T) {
} }
// Third request should be rate limited // Third request should be rate limited
validJWT3, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ validJWT3, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -1399,7 +1404,13 @@ func TestAuthorizationHeaderBypass(t *testing.T) {
// Verify that the response is a redirect to authentication (302) or unauthorized (401) // Verify that the response is a redirect to authentication (302) or unauthorized (401)
expectedCodes := []int{http.StatusFound, http.StatusUnauthorized} expectedCodes := []int{http.StatusFound, http.StatusUnauthorized}
codeFound := slices.Contains(expectedCodes, resp.Code) codeFound := false
for _, code := range expectedCodes {
if resp.Code == code {
codeFound = true
break
}
}
if !codeFound { if !codeFound {
t.Errorf("Expected status code to be one of %v, but got %d", expectedCodes, resp.Code) t.Errorf("Expected status code to be one of %v, but got %d", expectedCodes, resp.Code)
@@ -1412,7 +1423,7 @@ func TestEmptyAudience(t *testing.T) {
ts.Setup() ts.Setup()
// Create a JWT with empty audience // Create a JWT with empty audience
emptyAudJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ emptyAudJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "", // Empty audience "aud": "", // Empty audience
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
@@ -1445,7 +1456,7 @@ func TestEmptyIssuer(t *testing.T) {
ts.Setup() ts.Setup()
// Create a JWT with empty issuer // Create a JWT with empty issuer
emptyIssJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ emptyIssJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "", // Empty issuer "iss": "", // Empty issuer
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), "exp": float64(time.Now().Add(1 * time.Hour).Unix()),
+19 -19
View File
@@ -55,14 +55,14 @@ func (t SecurityEventType) IPFailureType() string {
// SecurityEvent represents a security-related event that should be logged and monitored // SecurityEvent represents a security-related event that should be logged and monitored
type SecurityEvent struct { type SecurityEvent struct {
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
Details map[string]any `json:"details,omitempty"` Details map[string]interface{} `json:"details,omitempty"`
Type string `json:"type"` Type string `json:"type"`
Severity string `json:"severity"` Severity string `json:"severity"`
ClientIP string `json:"client_ip"` ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent"` UserAgent string `json:"user_agent"`
RequestPath string `json:"request_path"` RequestPath string `json:"request_path"`
Message string `json:"message"` Message string `json:"message"`
} }
// SecurityMonitor tracks security events and suspicious activity patterns // SecurityMonitor tracks security events and suspicious activity patterns
@@ -168,7 +168,7 @@ func (sm *SecurityMonitor) RecordSecurityEvent(
eventType SecurityEventType, eventType SecurityEventType,
clientIP, userAgent, requestPath string, clientIP, userAgent, requestPath string,
message string, message string,
details map[string]any, details map[string]interface{},
trackIPFailure bool) { trackIPFailure bool) {
// Create event with default values for the event type // Create event with default values for the event type
@@ -193,9 +193,9 @@ func (sm *SecurityMonitor) RecordSecurityEvent(
} }
// RecordAuthenticationFailure records an authentication failure event // RecordAuthenticationFailure records an authentication failure event
func (sm *SecurityMonitor) RecordAuthenticationFailure(clientIP, userAgent, requestPath, reason string, details map[string]any) { func (sm *SecurityMonitor) RecordAuthenticationFailure(clientIP, userAgent, requestPath, reason string, details map[string]interface{}) {
if details == nil { if details == nil {
details = make(map[string]any) details = make(map[string]interface{})
} }
details["reason"] = reason details["reason"] = reason
@@ -212,7 +212,7 @@ func (sm *SecurityMonitor) RecordAuthenticationFailure(clientIP, userAgent, requ
// RecordTokenValidationFailure records a token validation failure // RecordTokenValidationFailure records a token validation failure
func (sm *SecurityMonitor) RecordTokenValidationFailure(clientIP, userAgent, requestPath, reason string, tokenPrefix string) { func (sm *SecurityMonitor) RecordTokenValidationFailure(clientIP, userAgent, requestPath, reason string, tokenPrefix string) {
details := map[string]any{ details := map[string]interface{}{
"reason": reason, "reason": reason,
} }
if tokenPrefix != "" { if tokenPrefix != "" {
@@ -232,7 +232,7 @@ func (sm *SecurityMonitor) RecordTokenValidationFailure(clientIP, userAgent, req
// RecordRateLimitHit records when rate limiting is triggered // RecordRateLimitHit records when rate limiting is triggered
func (sm *SecurityMonitor) RecordRateLimitHit(clientIP, userAgent, requestPath string) { func (sm *SecurityMonitor) RecordRateLimitHit(clientIP, userAgent, requestPath string) {
details := map[string]any{ details := map[string]interface{}{
"limit_type": "token_verification", "limit_type": "token_verification",
} }
@@ -248,9 +248,9 @@ func (sm *SecurityMonitor) RecordRateLimitHit(clientIP, userAgent, requestPath s
} }
// RecordSuspiciousActivity records suspicious activity that doesn't fit other categories // RecordSuspiciousActivity records suspicious activity that doesn't fit other categories
func (sm *SecurityMonitor) RecordSuspiciousActivity(clientIP, userAgent, requestPath, activityType, description string, details map[string]any) { func (sm *SecurityMonitor) RecordSuspiciousActivity(clientIP, userAgent, requestPath, activityType, description string, details map[string]interface{}) {
if details == nil { if details == nil {
details = make(map[string]any) details = make(map[string]interface{})
} }
details["activity_type"] = activityType details["activity_type"] = activityType
@@ -302,7 +302,7 @@ func (sm *SecurityMonitor) recordIPFailure(clientIP, failureType string) {
Timestamp: time.Now(), Timestamp: time.Now(),
ClientIP: clientIP, ClientIP: clientIP,
Message: fmt.Sprintf("IP blocked due to %d failures in %d minutes", tracker.FailureCount, sm.config.FailureWindowMinutes), Message: fmt.Sprintf("IP blocked due to %d failures in %d minutes", tracker.FailureCount, sm.config.FailureWindowMinutes),
Details: map[string]any{ Details: map[string]interface{}{
"failure_count": tracker.FailureCount, "failure_count": tracker.FailureCount,
"failure_types": tracker.FailureTypes, "failure_types": tracker.FailureTypes,
"blocked_until": tracker.BlockedUntil, "blocked_until": tracker.BlockedUntil,
@@ -355,7 +355,7 @@ func (sm *SecurityMonitor) processSecurityEvent(event SecurityEvent) {
Severity: "high", Severity: "high",
Timestamp: time.Now(), Timestamp: time.Now(),
Message: fmt.Sprintf("Suspicious pattern detected: %s", pattern), Message: fmt.Sprintf("Suspicious pattern detected: %s", pattern),
Details: map[string]any{ Details: map[string]interface{}{
"pattern_type": pattern, "pattern_type": pattern,
"trigger_event": event, "trigger_event": event,
}, },
@@ -389,8 +389,8 @@ func (sm *SecurityMonitor) AddEventHandler(handler SecurityEventHandler) {
// GetSecurityMetrics returns minimal security metrics // GetSecurityMetrics returns minimal security metrics
// This is kept for API compatibility but doesn't collect actual metrics // This is kept for API compatibility but doesn't collect actual metrics
func (sm *SecurityMonitor) GetSecurityMetrics() map[string]any { func (sm *SecurityMonitor) GetSecurityMetrics() map[string]interface{} {
return map[string]any{ return map[string]interface{}{
"tracked_ips": 0, "tracked_ips": 0,
} }
} }
+18 -7
View File
@@ -2,7 +2,6 @@ package traefikoidc
import ( import (
"net/http/httptest" "net/http/httptest"
"slices"
"strconv" "strconv"
"testing" "testing"
"time" "time"
@@ -53,7 +52,7 @@ func TestSecurityMonitor(t *testing.T) {
}) })
t.Run("Suspicious activity", func(t *testing.T) { t.Run("Suspicious activity", func(t *testing.T) {
details := map[string]any{"pattern": "unusual"} details := map[string]interface{}{"pattern": "unusual"}
// Just verify the method doesn't panic // Just verify the method doesn't panic
monitor.RecordSuspiciousActivity("192.168.1.5", "test-agent", "/admin", "unusual pattern", "high frequency requests", details) monitor.RecordSuspiciousActivity("192.168.1.5", "test-agent", "/admin", "unusual pattern", "high frequency requests", details)
}) })
@@ -64,7 +63,7 @@ func TestSuspiciousPatternDetector(t *testing.T) {
t.Run("Add events and detect patterns", func(t *testing.T) { t.Run("Add events and detect patterns", func(t *testing.T) {
// Add multiple events from same IP // Add multiple events from same IP
for range 10 { for i := 0; i < 10; i++ {
event := SecurityEvent{ event := SecurityEvent{
Type: "authentication_failure", Type: "authentication_failure",
ClientIP: "192.168.1.100", ClientIP: "192.168.1.100",
@@ -75,7 +74,13 @@ func TestSuspiciousPatternDetector(t *testing.T) {
patterns := detector.DetectSuspiciousPatterns() patterns := detector.DetectSuspiciousPatterns()
found := slices.Contains(patterns, "rapid_failures_from_ip_192.168.1.100") found := false
for _, p := range patterns {
if p == "rapid_failures_from_ip_192.168.1.100" {
found = true
break
}
}
if !found { if !found {
t.Error("Expected to detect rapid failure pattern") t.Error("Expected to detect rapid failure pattern")
} }
@@ -83,7 +88,7 @@ func TestSuspiciousPatternDetector(t *testing.T) {
t.Run("Detect distributed attack pattern", func(t *testing.T) { t.Run("Detect distributed attack pattern", func(t *testing.T) {
// Add failures from many different IPs // Add failures from many different IPs
for i := range 25 { for i := 0; i < 25; i++ {
event := SecurityEvent{ event := SecurityEvent{
Type: "authentication_failure", Type: "authentication_failure",
ClientIP: "192.168.1." + strconv.Itoa(100+i), ClientIP: "192.168.1." + strconv.Itoa(100+i),
@@ -94,7 +99,13 @@ func TestSuspiciousPatternDetector(t *testing.T) {
patterns := detector.DetectSuspiciousPatterns() patterns := detector.DetectSuspiciousPatterns()
found := slices.Contains(patterns, "distributed_attack_pattern") found := false
for _, p := range patterns {
if p == "distributed_attack_pattern" {
found = true
break
}
}
if !found { if !found {
t.Error("Expected to detect distributed attack pattern") t.Error("Expected to detect distributed attack pattern")
} }
@@ -266,7 +277,7 @@ func TestSecurityEventTypes(t *testing.T) {
monitor.RecordTokenValidationFailure("192.168.1.200", "test-agent", "/api", "expired token", "abc123") monitor.RecordTokenValidationFailure("192.168.1.200", "test-agent", "/api", "expired token", "abc123")
monitor.RecordRateLimitHit("192.168.1.200", "test-agent", "/api") monitor.RecordRateLimitHit("192.168.1.200", "test-agent", "/api")
details := map[string]any{"pattern": "test"} details := map[string]interface{}{"pattern": "test"}
monitor.RecordSuspiciousActivity("192.168.1.200", "test-agent", "/admin", "unusual pattern", "multiple failed logins", details) monitor.RecordSuspiciousActivity("192.168.1.200", "test-agent", "/admin", "unusual pattern", "multiple failed logins", details)
// Just verify GetSecurityMetrics doesn't panic // Just verify GetSecurityMetrics doesn't panic
+11 -11
View File
@@ -238,7 +238,7 @@ func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) (*
logger: logger, logger: logger,
} }
sm.sessionPool.New = func() any { sm.sessionPool.New = func() interface{} {
sd := &SessionData{ sd := &SessionData{
manager: sm, manager: sm,
accessTokenChunks: make(map[int]*sessions.Session), accessTokenChunks: make(map[int]*sessions.Session),
@@ -699,7 +699,7 @@ func (sd *SessionData) SetAuthenticated(value bool) error {
} }
maxRetries := 5 maxRetries := 5
for retry := range maxRetries { for retry := 0; retry < maxRetries; retry++ {
if sd.mainSession.ID != id { if sd.mainSession.ID != id {
break break
} }
@@ -869,7 +869,7 @@ func (sd *SessionData) getAccessTokenUnsafe() string {
totalSize := 0 totalSize := 0
// Sequential chunk validation with gap detection // Sequential chunk validation with gap detection
for i := range expectedChunkCount { for i := 0; i < expectedChunkCount; i++ {
session, ok := sd.accessTokenChunks[i] session, ok := sd.accessTokenChunks[i]
if !ok { if !ok {
sd.manager.logger.Errorf("CRITICAL: Access token chunk %d missing - gap in sequence detected", i) sd.manager.logger.Errorf("CRITICAL: Access token chunk %d missing - gap in sequence detected", i)
@@ -1205,7 +1205,7 @@ func (sd *SessionData) GetRefreshToken() string {
totalSize := 0 totalSize := 0
// Sequential chunk validation with gap detection // Sequential chunk validation with gap detection
for i := range expectedChunkCount { for i := 0; i < expectedChunkCount; i++ {
session, ok := sd.refreshTokenChunks[i] session, ok := sd.refreshTokenChunks[i]
if !ok { if !ok {
sd.manager.logger.Errorf("CRITICAL: Refresh token chunk %d missing - gap in sequence detected", i) sd.manager.logger.Errorf("CRITICAL: Refresh token chunk %d missing - gap in sequence detected", i)
@@ -1460,7 +1460,7 @@ func (sd *SessionData) expireAccessTokenChunksEnhanced(w http.ResponseWriter) {
const maxChunkSearchLimit = 50 // Limit search to prevent infinite loops from corrupted state const maxChunkSearchLimit = 50 // Limit search to prevent infinite loops from corrupted state
orphanedChunks := 0 orphanedChunks := 0
for i := range maxChunkSearchLimit { for i := 0; i < maxChunkSearchLimit; i++ {
sessionName := fmt.Sprintf("%s_%d", accessTokenCookie, i) sessionName := fmt.Sprintf("%s_%d", accessTokenCookie, i)
session, err := sd.manager.store.Get(sd.request, sessionName) session, err := sd.manager.store.Get(sd.request, sessionName)
if err != nil { if err != nil {
@@ -1490,7 +1490,7 @@ func (sd *SessionData) expireAccessTokenChunksEnhanced(w http.ResponseWriter) {
// Expire the chunk regardless of orphan status // Expire the chunk regardless of orphan status
session.Options.MaxAge = -1 session.Options.MaxAge = -1
session.Values = make(map[any]any) session.Values = make(map[interface{}]interface{})
if w != nil { if w != nil {
if err := session.Save(sd.request, w); err != nil { if err := session.Save(sd.request, w); err != nil {
sd.manager.logger.Errorf("failed to save expired access token chunk %d: %v", i, err) sd.manager.logger.Errorf("failed to save expired access token chunk %d: %v", i, err)
@@ -1513,7 +1513,7 @@ func (sd *SessionData) expireRefreshTokenChunksEnhanced(w http.ResponseWriter) {
const maxChunkSearchLimit = 50 // Limit search to prevent infinite loops from corrupted state const maxChunkSearchLimit = 50 // Limit search to prevent infinite loops from corrupted state
orphanedChunks := 0 orphanedChunks := 0
for i := range maxChunkSearchLimit { for i := 0; i < maxChunkSearchLimit; i++ {
sessionName := fmt.Sprintf("%s_%d", refreshTokenCookie, i) sessionName := fmt.Sprintf("%s_%d", refreshTokenCookie, i)
session, err := sd.manager.store.Get(sd.request, sessionName) session, err := sd.manager.store.Get(sd.request, sessionName)
if err != nil { if err != nil {
@@ -1543,7 +1543,7 @@ func (sd *SessionData) expireRefreshTokenChunksEnhanced(w http.ResponseWriter) {
// Expire the chunk regardless of orphan status // Expire the chunk regardless of orphan status
session.Options.MaxAge = -1 session.Options.MaxAge = -1
session.Values = make(map[any]any) session.Values = make(map[interface{}]interface{})
if w != nil { if w != nil {
if err := session.Save(sd.request, w); err != nil { if err := session.Save(sd.request, w); err != nil {
sd.manager.logger.Errorf("failed to save expired refresh token chunk %d: %v", i, err) sd.manager.logger.Errorf("failed to save expired refresh token chunk %d: %v", i, err)
@@ -1566,7 +1566,7 @@ func (sd *SessionData) expireIDTokenChunksEnhanced(w http.ResponseWriter) {
const maxChunkSearchLimit = 50 // Limit search to prevent infinite loops from corrupted state const maxChunkSearchLimit = 50 // Limit search to prevent infinite loops from corrupted state
orphanedChunks := 0 orphanedChunks := 0
for i := range maxChunkSearchLimit { for i := 0; i < maxChunkSearchLimit; i++ {
sessionName := fmt.Sprintf("%s_%d", idTokenCookie, i) sessionName := fmt.Sprintf("%s_%d", idTokenCookie, i)
session, err := sd.manager.store.Get(sd.request, sessionName) session, err := sd.manager.store.Get(sd.request, sessionName)
if err != nil { if err != nil {
@@ -1596,7 +1596,7 @@ func (sd *SessionData) expireIDTokenChunksEnhanced(w http.ResponseWriter) {
// Expire the chunk regardless of orphan status // Expire the chunk regardless of orphan status
session.Options.MaxAge = -1 session.Options.MaxAge = -1
session.Values = make(map[any]any) session.Values = make(map[interface{}]interface{})
if w != nil { if w != nil {
if err := session.Save(sd.request, w); err != nil { if err := session.Save(sd.request, w); err != nil {
sd.manager.logger.Errorf("failed to save expired ID token chunk %d: %v", i, err) sd.manager.logger.Errorf("failed to save expired ID token chunk %d: %v", i, err)
@@ -1896,7 +1896,7 @@ func (sd *SessionData) getIDTokenUnsafe() string {
totalSize := 0 totalSize := 0
// Sequential chunk validation with gap detection // Sequential chunk validation with gap detection
for i := range expectedChunkCount { for i := 0; i < expectedChunkCount; i++ {
session, ok := sd.idTokenChunks[i] session, ok := sd.idTokenChunks[i]
if !ok { if !ok {
sd.manager.logger.Errorf("CRITICAL: ID token chunk %d missing - gap in sequence detected", i) sd.manager.logger.Errorf("CRITICAL: ID token chunk %d missing - gap in sequence detected", i)
+5 -5
View File
@@ -148,7 +148,7 @@ func getPooledObjects(sm *SessionManager) int {
var objects []*SessionData var objects []*SessionData
maxAttempts := 100 // Safety limit to prevent infinite loops maxAttempts := 100 // Safety limit to prevent infinite loops
for range maxAttempts { for i := 0; i < maxAttempts; i++ {
obj := sm.sessionPool.Get() obj := sm.sessionPool.Get()
if obj == nil { if obj == nil {
break break
@@ -195,7 +195,7 @@ func TestSessionObjectTracking(t *testing.T) {
} }
// Create and discard 5 sessions // Create and discard 5 sessions
for range 5 { for i := 0; i < 5; i++ {
session, err := sm.GetSession(req) session, err := sm.GetSession(req)
if err != nil { if err != nil {
t.Fatalf("GetSession failed: %v", err) t.Fatalf("GetSession failed: %v", err)
@@ -609,11 +609,11 @@ func TestConcurrentTokenOperations(t *testing.T) {
// Test concurrent access and refresh token operations // Test concurrent access and refresh token operations
done := make(chan bool, numGoroutines) done := make(chan bool, numGoroutines)
for i := range numGoroutines { for i := 0; i < numGoroutines; i++ {
go func(id int) { go func(id int) {
defer func() { done <- true }() defer func() { done <- true }()
for j := range numOperations { for j := 0; j < numOperations; j++ {
// Create unique tokens for each goroutine/operation // Create unique tokens for each goroutine/operation
accessToken := ValidAccessToken accessToken := ValidAccessToken
refreshToken := fmt.Sprintf("refresh_token_%d_%d", id, j) refreshToken := fmt.Sprintf("refresh_token_%d_%d", id, j)
@@ -637,7 +637,7 @@ func TestConcurrentTokenOperations(t *testing.T) {
} }
// Wait for all goroutines to complete // Wait for all goroutines to complete
for range numGoroutines { for i := 0; i < numGoroutines; i++ {
<-done <-done
} }
} }
+6 -6
View File
@@ -414,7 +414,7 @@ func NewLogger(logLevel string) *Logger {
// Parameters: // Parameters:
// - format: The format string (as in fmt.Printf). // - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string. // - args: The arguments for the format string.
func (l *Logger) Info(format string, args ...any) { func (l *Logger) Info(format string, args ...interface{}) {
l.logInfo.Printf(format, args...) l.logInfo.Printf(format, args...)
} }
@@ -424,7 +424,7 @@ func (l *Logger) Info(format string, args ...any) {
// Parameters: // Parameters:
// - format: The format string (as in fmt.Printf). // - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string. // - args: The arguments for the format string.
func (l *Logger) Debug(format string, args ...any) { func (l *Logger) Debug(format string, args ...interface{}) {
l.logDebug.Printf(format, args...) l.logDebug.Printf(format, args...)
} }
@@ -434,7 +434,7 @@ func (l *Logger) Debug(format string, args ...any) {
// Parameters: // Parameters:
// - format: The format string (as in fmt.Printf). // - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string. // - args: The arguments for the format string.
func (l *Logger) Error(format string, args ...any) { func (l *Logger) Error(format string, args ...interface{}) {
l.logError.Printf(format, args...) l.logError.Printf(format, args...)
} }
@@ -445,7 +445,7 @@ func (l *Logger) Error(format string, args ...any) {
// Parameters: // Parameters:
// - format: The format string (as in fmt.Printf). // - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string. // - args: The arguments for the format string.
func (l *Logger) Infof(format string, args ...any) { func (l *Logger) Infof(format string, args ...interface{}) {
l.logInfo.Printf(format, args...) l.logInfo.Printf(format, args...)
} }
@@ -456,7 +456,7 @@ func (l *Logger) Infof(format string, args ...any) {
// Parameters: // Parameters:
// - format: The format string (as in fmt.Printf). // - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string. // - args: The arguments for the format string.
func (l *Logger) Debugf(format string, args ...any) { func (l *Logger) Debugf(format string, args ...interface{}) {
l.logDebug.Printf(format, args...) l.logDebug.Printf(format, args...)
} }
@@ -467,7 +467,7 @@ func (l *Logger) Debugf(format string, args ...any) {
// Parameters: // Parameters:
// - format: The format string (as in fmt.Printf). // - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string. // - args: The arguments for the format string.
func (l *Logger) Errorf(format string, args ...any) { func (l *Logger) Errorf(format string, args ...interface{}) {
l.logError.Printf(format, args...) l.logError.Printf(format, args...)
} }
+75 -75
View File
@@ -11,15 +11,15 @@ func TestTemplateExecution(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
templateText string templateText string
data map[string]any data map[string]interface{}
expectedValue string expectedValue string
expectError bool expectError bool
}{ }{
{ {
name: "String Claim", name: "String Claim",
templateText: "{{.Claims.email}}", templateText: "{{.Claims.email}}",
data: map[string]any{ data: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
}, },
}, },
@@ -29,8 +29,8 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "Number Claim", name: "Number Claim",
templateText: "{{.Claims.age}}", templateText: "{{.Claims.age}}",
data: map[string]any{ data: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"age": 30, "age": 30,
}, },
}, },
@@ -40,8 +40,8 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "Boolean Claim", name: "Boolean Claim",
templateText: "{{.Claims.admin}}", templateText: "{{.Claims.admin}}",
data: map[string]any{ data: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"admin": true, "admin": true,
}, },
}, },
@@ -51,8 +51,8 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "Array Claim", name: "Array Claim",
templateText: "{{index .Claims.roles 0}}", templateText: "{{index .Claims.roles 0}}",
data: map[string]any{ data: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"roles": []string{"admin", "user"}, "roles": []string{"admin", "user"},
}, },
}, },
@@ -62,9 +62,9 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "Nested Object Claim", name: "Nested Object Claim",
templateText: "{{.Claims.user.name}}", templateText: "{{.Claims.user.name}}",
data: map[string]any{ data: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"user": map[string]any{ "user": map[string]interface{}{
"name": "John Doe", "name": "John Doe",
}, },
}, },
@@ -75,7 +75,7 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "Access Token", name: "Access Token",
templateText: "Bearer {{.AccessToken}}", templateText: "Bearer {{.AccessToken}}",
data: map[string]any{ data: map[string]interface{}{
"AccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U", "AccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
}, },
expectedValue: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U", expectedValue: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
@@ -84,7 +84,7 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "ID Token", name: "ID Token",
templateText: "{{.IdToken}}", templateText: "{{.IdToken}}",
data: map[string]any{ data: map[string]interface{}{
"IdToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U", "IdToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
}, },
expectedValue: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U", expectedValue: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
@@ -93,7 +93,7 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "Refresh Token", name: "Refresh Token",
templateText: "{{.RefreshToken}}", templateText: "{{.RefreshToken}}",
data: map[string]any{ data: map[string]interface{}{
"RefreshToken": "refresh-token-value", "RefreshToken": "refresh-token-value",
}, },
expectedValue: "refresh-token-value", expectedValue: "refresh-token-value",
@@ -102,8 +102,8 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "Conditional Template", name: "Conditional Template",
templateText: "{{if .Claims.admin}}Admin User{{else}}Regular User{{end}}", templateText: "{{if .Claims.admin}}Admin User{{else}}Regular User{{end}}",
data: map[string]any{ data: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"admin": true, "admin": true,
}, },
}, },
@@ -113,8 +113,8 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "Multiple Claims", name: "Multiple Claims",
templateText: "{{.Claims.firstName}} {{.Claims.lastName}} <{{.Claims.email}}>", templateText: "{{.Claims.firstName}} {{.Claims.lastName}} <{{.Claims.email}}>",
data: map[string]any{ data: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"firstName": "John", "firstName": "John",
"lastName": "Doe", "lastName": "Doe",
"email": "[email protected]", "email": "[email protected]",
@@ -126,8 +126,8 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "Missing Claim", name: "Missing Claim",
templateText: "{{.Claims.missing}}", templateText: "{{.Claims.missing}}",
data: map[string]any{ data: map[string]interface{}{
"Claims": map[string]any{}, "Claims": map[string]interface{}{},
}, },
expectedValue: "<no value>", expectedValue: "<no value>",
expectError: false, // Go templates don't error on missing values expectError: false, // Go templates don't error on missing values
@@ -135,8 +135,8 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "Invalid Template Syntax", name: "Invalid Template Syntax",
templateText: "{{.Claims.email", templateText: "{{.Claims.email",
data: map[string]any{ data: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
}, },
}, },
@@ -146,8 +146,8 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "Custom Claims", name: "Custom Claims",
templateText: "Role: {{.Claims.role}}, Department: {{.Claims.department}}", templateText: "Role: {{.Claims.role}}, Department: {{.Claims.department}}",
data: map[string]any{ data: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"role": "admin", "role": "admin",
"department": "engineering", "department": "engineering",
@@ -159,10 +159,10 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "Nested Custom Claims", name: "Nested Custom Claims",
templateText: "Org: {{.Claims.metadata.organization}}, Team: {{.Claims.metadata.team}}", templateText: "Org: {{.Claims.metadata.organization}}, Team: {{.Claims.metadata.team}}",
data: map[string]any{ data: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"metadata": map[string]any{ "metadata": map[string]interface{}{
"organization": "company-name", "organization": "company-name",
"team": "platform", "team": "platform",
}, },
@@ -174,8 +174,8 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "Email Claims", name: "Email Claims",
templateText: "Email: {{.Claims.email}}, Verified: {{.Claims.email_verified}}", templateText: "Email: {{.Claims.email}}, Verified: {{.Claims.email_verified}}",
data: map[string]any{ data: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"email_verified": true, "email_verified": true,
}, },
@@ -186,8 +186,8 @@ func TestTemplateExecution(t *testing.T) {
{ {
name: "User Identity Claims", name: "User Identity Claims",
templateText: "Name: {{.Claims.name}}, Subject: {{.Claims.sub}}, Username: {{.Claims.preferred_username}}", templateText: "Name: {{.Claims.name}}, Subject: {{.Claims.sub}}, Username: {{.Claims.preferred_username}}",
data: map[string]any{ data: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"name": "John Doe", "name": "John Doe",
"sub": "user123", "sub": "user123",
"preferred_username": "johndoe", "preferred_username": "johndoe",
@@ -233,16 +233,16 @@ func TestTemplateExecutionContext(t *testing.T) {
mapTests := []struct { mapTests := []struct {
name string name string
templateText string templateText string
data map[string]any data map[string]interface{}
expectedValue string expectedValue string
}{ }{
{ {
name: "Access and ID token distinction with map", name: "Access and ID token distinction with map",
templateText: "Access: {{.AccessToken}} ID: {{.IdToken}}", templateText: "Access: {{.AccessToken}} ID: {{.IdToken}}",
data: map[string]any{ data: map[string]interface{}{
"AccessToken": "access-token-value", "AccessToken": "access-token-value",
"IdToken": "id-token-value", "IdToken": "id-token-value",
"Claims": map[string]any{}, "Claims": map[string]interface{}{},
"RefreshToken": "refresh-token-value", "RefreshToken": "refresh-token-value",
}, },
expectedValue: "Access: access-token-value ID: id-token-value", expectedValue: "Access: access-token-value ID: id-token-value",
@@ -250,10 +250,10 @@ func TestTemplateExecutionContext(t *testing.T) {
{ {
name: "Combining tokens and claims with map", name: "Combining tokens and claims with map",
templateText: "User: {{.Claims.sub}} Token: {{.AccessToken}}", templateText: "User: {{.Claims.sub}} Token: {{.AccessToken}}",
data: map[string]any{ data: map[string]interface{}{
"AccessToken": "access-token", "AccessToken": "access-token",
"IdToken": "id-token", "IdToken": "id-token",
"Claims": map[string]any{ "Claims": map[string]interface{}{
"sub": "user123", "sub": "user123",
}, },
"RefreshToken": "refresh-token", "RefreshToken": "refresh-token",
@@ -263,17 +263,17 @@ func TestTemplateExecutionContext(t *testing.T) {
{ {
name: "Authorization header with Bearer token", name: "Authorization header with Bearer token",
templateText: "Bearer {{.AccessToken}}", templateText: "Bearer {{.AccessToken}}",
data: map[string]any{ data: map[string]interface{}{
"AccessToken": "jwt-access-token", "AccessToken": "jwt-access-token",
"IdToken": "id-token", "IdToken": "id-token",
"Claims": map[string]any{}, "Claims": map[string]interface{}{},
}, },
expectedValue: "Bearer jwt-access-token", expectedValue: "Bearer jwt-access-token",
}, },
{ {
name: "Boolean template data with AccessToken", name: "Boolean template data with AccessToken",
templateText: "Bearer {{.AccessToken}}", templateText: "Bearer {{.AccessToken}}",
data: map[string]any{ data: map[string]interface{}{
"AccessToken": true, // Test boolean values to ensure they render correctly "AccessToken": true, // Test boolean values to ensure they render correctly
}, },
expectedValue: "Bearer true", expectedValue: "Bearer true",
@@ -281,10 +281,10 @@ func TestTemplateExecutionContext(t *testing.T) {
{ {
name: "Custom non-standard claims in ID token", name: "Custom non-standard claims in ID token",
templateText: "X-User-Role: {{.Claims.role}}, X-User-Permissions: {{.Claims.permissions}}", templateText: "X-User-Role: {{.Claims.role}}, X-User-Permissions: {{.Claims.permissions}}",
data: map[string]any{ data: map[string]interface{}{
"AccessToken": "access-token-value", "AccessToken": "access-token-value",
"IdToken": "id-token-value", "IdToken": "id-token-value",
"Claims": map[string]any{ "Claims": map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"role": "admin", "role": "admin",
"permissions": "read:all,write:own", "permissions": "read:all,write:own",
@@ -295,11 +295,11 @@ func TestTemplateExecutionContext(t *testing.T) {
{ {
name: "Deeply nested custom claims", name: "Deeply nested custom claims",
templateText: "X-Organization: {{.Claims.app_metadata.organization.name}}, X-Team: {{.Claims.app_metadata.team}}", templateText: "X-Organization: {{.Claims.app_metadata.organization.name}}, X-Team: {{.Claims.app_metadata.team}}",
data: map[string]any{ data: map[string]interface{}{
"AccessToken": "access-token-value", "AccessToken": "access-token-value",
"Claims": map[string]any{ "Claims": map[string]interface{}{
"app_metadata": map[string]any{ "app_metadata": map[string]interface{}{
"organization": map[string]any{ "organization": map[string]interface{}{
"name": "acme-corp", "name": "acme-corp",
"id": "org-123", "id": "org-123",
}, },
@@ -312,10 +312,10 @@ func TestTemplateExecutionContext(t *testing.T) {
{ {
name: "Email in claims", name: "Email in claims",
templateText: "X-User-Email: {{.Claims.email}}, X-Email-Verified: {{.Claims.email_verified}}", templateText: "X-User-Email: {{.Claims.email}}, X-Email-Verified: {{.Claims.email_verified}}",
data: map[string]any{ data: map[string]interface{}{
"AccessToken": "access-token-value", "AccessToken": "access-token-value",
"IdToken": "id-token-value", "IdToken": "id-token-value",
"Claims": map[string]any{ "Claims": map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"email_verified": true, "email_verified": true,
}, },
@@ -325,10 +325,10 @@ func TestTemplateExecutionContext(t *testing.T) {
{ {
name: "User info from claims", name: "User info from claims",
templateText: "X-User-ID: {{.Claims.sub}}, X-User-Name: {{.Claims.name}}, X-Username: {{.Claims.preferred_username}}", templateText: "X-User-ID: {{.Claims.sub}}, X-User-Name: {{.Claims.name}}, X-Username: {{.Claims.preferred_username}}",
data: map[string]any{ data: map[string]interface{}{
"AccessToken": "access-token-value", "AccessToken": "access-token-value",
"IdToken": "id-token-value", "IdToken": "id-token-value",
"Claims": map[string]any{ "Claims": map[string]interface{}{
"sub": "user123456", "sub": "user123456",
"name": "Jane Doe", "name": "Jane Doe",
"preferred_username": "jane.doe", "preferred_username": "jane.doe",
@@ -361,7 +361,7 @@ func TestTemplateExecutionContext(t *testing.T) {
// For backward compatibility, also test the original struct-based implementation // For backward compatibility, also test the original struct-based implementation
type templateData struct { type templateData struct {
Claims map[string]any Claims map[string]interface{}
AccessToken string AccessToken string
IdToken string IdToken string
RefreshToken string RefreshToken string
@@ -380,7 +380,7 @@ func TestTemplateExecutionContext(t *testing.T) {
data: templateData{ data: templateData{
AccessToken: "access-token-value", AccessToken: "access-token-value",
IdToken: "id-token-value", // Now these should be distinct values IdToken: "id-token-value", // Now these should be distinct values
Claims: map[string]any{}, Claims: map[string]interface{}{},
}, },
expectedValue: "Access: access-token-value ID: id-token-value", expectedValue: "Access: access-token-value ID: id-token-value",
}, },
@@ -390,7 +390,7 @@ func TestTemplateExecutionContext(t *testing.T) {
data: templateData{ data: templateData{
AccessToken: "access-token", AccessToken: "access-token",
IdToken: "access-token", IdToken: "access-token",
Claims: map[string]any{ Claims: map[string]interface{}{
"sub": "user123", "sub": "user123",
}, },
}, },
@@ -402,7 +402,7 @@ func TestTemplateExecutionContext(t *testing.T) {
data: templateData{ data: templateData{
AccessToken: "access-token", AccessToken: "access-token",
IdToken: "id-token", IdToken: "id-token",
Claims: map[string]any{ Claims: map[string]interface{}{
"sub": "user123", "sub": "user123",
"custom_field": "custom-value", "custom_field": "custom-value",
"group": "admins", "group": "admins",
@@ -416,7 +416,7 @@ func TestTemplateExecutionContext(t *testing.T) {
data: templateData{ data: templateData{
AccessToken: "access-token", AccessToken: "access-token",
IdToken: "id-token", IdToken: "id-token",
Claims: map[string]any{ Claims: map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"name": "John Smith", "name": "John Smith",
}, },
@@ -454,14 +454,14 @@ func TestRegressionBooleanAccessToken(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
templateText string templateText string
dataContext any dataContext interface{}
expectedValue string expectedValue string
expectError bool // Added to skip the test that demonstrates the error expectError bool // Added to skip the test that demonstrates the error
}{ }{
{ {
name: "Map with boolean as root", name: "Map with boolean as root",
templateText: "{{.AccessToken}}", templateText: "{{.AccessToken}}",
dataContext: map[string]any{"AccessToken": "token-value"}, dataContext: map[string]interface{}{"AccessToken": "token-value"},
expectedValue: "token-value", expectedValue: "token-value",
expectError: false, expectError: false,
}, },
@@ -475,17 +475,17 @@ func TestRegressionBooleanAccessToken(t *testing.T) {
{ {
name: "Bearer with map context", name: "Bearer with map context",
templateText: "Bearer {{.AccessToken}}", templateText: "Bearer {{.AccessToken}}",
dataContext: map[string]any{"AccessToken": "token-value"}, dataContext: map[string]interface{}{"AccessToken": "token-value"},
expectedValue: "Bearer token-value", expectedValue: "Bearer token-value",
expectError: false, expectError: false,
}, },
{ {
name: "Complex nesting with authorization", name: "Complex nesting with authorization",
templateText: "Authorization: Bearer {{.AccessToken}}", templateText: "Authorization: Bearer {{.AccessToken}}",
dataContext: map[string]any{ dataContext: map[string]interface{}{
"AccessToken": "jwt-token-123", "AccessToken": "jwt-token-123",
"something": true, "something": true,
"anotherField": map[string]any{ "anotherField": map[string]interface{}{
"nested": "value", "nested": "value",
}, },
}, },
@@ -495,13 +495,13 @@ func TestRegressionBooleanAccessToken(t *testing.T) {
{ {
name: "Custom claims access", name: "Custom claims access",
templateText: "X-User-Role: {{.Claims.role}}, X-User-Groups: {{.Claims.groups}}", templateText: "X-User-Role: {{.Claims.role}}, X-User-Groups: {{.Claims.groups}}",
dataContext: map[string]any{ dataContext: map[string]interface{}{
"AccessToken": "jwt-token-xyz", "AccessToken": "jwt-token-xyz",
"Claims": map[string]any{ "Claims": map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"role": "admin", "role": "admin",
"groups": "group1,group2,group3", "groups": "group1,group2,group3",
"custom_data": map[string]any{ "custom_data": map[string]interface{}{
"organization": "company-name", "organization": "company-name",
"department": "engineering", "department": "engineering",
}, },
@@ -513,9 +513,9 @@ func TestRegressionBooleanAccessToken(t *testing.T) {
{ {
name: "Nested custom claims access", name: "Nested custom claims access",
templateText: "X-Organization: {{.Claims.custom_data.organization}}, X-Department: {{.Claims.custom_data.department}}", templateText: "X-Organization: {{.Claims.custom_data.organization}}, X-Department: {{.Claims.custom_data.department}}",
dataContext: map[string]any{ dataContext: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"custom_data": map[string]any{ "custom_data": map[string]interface{}{
"organization": "company-name", "organization": "company-name",
"department": "engineering", "department": "engineering",
}, },
@@ -527,8 +527,8 @@ func TestRegressionBooleanAccessToken(t *testing.T) {
{ {
name: "Azure AD specific claims", name: "Azure AD specific claims",
templateText: "X-TenantID: {{.Claims.tid}}, X-Roles: {{.Claims.roles}}", templateText: "X-TenantID: {{.Claims.tid}}, X-Roles: {{.Claims.roles}}",
dataContext: map[string]any{ dataContext: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"tid": "tenant-id-12345", "tid": "tenant-id-12345",
"roles": "User,Admin,Developer", "roles": "User,Admin,Developer",
}, },
@@ -539,10 +539,10 @@ func TestRegressionBooleanAccessToken(t *testing.T) {
{ {
name: "Auth0 specific claims", name: "Auth0 specific claims",
templateText: "X-Permissions: {{.Claims.permissions}}, X-AppMetadata: {{.Claims.app_metadata.plan}}", templateText: "X-Permissions: {{.Claims.permissions}}, X-AppMetadata: {{.Claims.app_metadata.plan}}",
dataContext: map[string]any{ dataContext: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"permissions": "read:products,write:orders", "permissions": "read:products,write:orders",
"app_metadata": map[string]any{ "app_metadata": map[string]interface{}{
"plan": "premium", "plan": "premium",
"status": "active", "status": "active",
"trial_ended": false, "trial_ended": false,
@@ -555,8 +555,8 @@ func TestRegressionBooleanAccessToken(t *testing.T) {
{ {
name: "Standard claims with email", name: "Standard claims with email",
templateText: "X-Email: {{.Claims.email}}, X-Name: {{.Claims.name}}, X-Subject: {{.Claims.sub}}", templateText: "X-Email: {{.Claims.email}}, X-Name: {{.Claims.name}}, X-Subject: {{.Claims.sub}}",
dataContext: map[string]any{ dataContext: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"name": "John Doe", "name": "John Doe",
"sub": "auth0|12345", "sub": "auth0|12345",
@@ -568,8 +568,8 @@ func TestRegressionBooleanAccessToken(t *testing.T) {
{ {
name: "Verified email claim", name: "Verified email claim",
templateText: "X-Email: {{.Claims.email}}, X-Email-Verified: {{.Claims.email_verified}}", templateText: "X-Email: {{.Claims.email}}, X-Email-Verified: {{.Claims.email_verified}}",
dataContext: map[string]any{ dataContext: map[string]interface{}{
"Claims": map[string]any{ "Claims": map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"email_verified": true, "email_verified": true,
}, },
+29 -23
View File
@@ -2,7 +2,6 @@ package traefikoidc
import ( import (
"errors" "errors"
"maps"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -21,7 +20,7 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
tests := []struct { tests := []struct {
sessionSetup func(*SessionData) sessionSetup func(*SessionData)
claims map[string]any claims map[string]interface{}
expectedHeaders map[string]string expectedHeaders map[string]string
interceptedHeaders map[string]string interceptedHeaders map[string]string
name string name string
@@ -32,7 +31,7 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
headers: []TemplatedHeader{ headers: []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"}, {Name: "X-User-Email", Value: "{{.Claims.email}}"},
}, },
claims: map[string]any{ claims: map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
}, },
expectedHeaders: map[string]string{ expectedHeaders: map[string]string{
@@ -46,7 +45,7 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
{Name: "X-User-ID", Value: "{{.Claims.sub}}"}, {Name: "X-User-ID", Value: "{{.Claims.sub}}"},
{Name: "X-User-Name", Value: "{{.Claims.given_name}} {{.Claims.family_name}}"}, {Name: "X-User-Name", Value: "{{.Claims.given_name}} {{.Claims.family_name}}"},
}, },
claims: map[string]any{ claims: map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"sub": "user123", "sub": "user123",
"given_name": "John", "given_name": "John",
@@ -95,7 +94,7 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
headers: []TemplatedHeader{ headers: []TemplatedHeader{
{Name: "X-User-Role", Value: "{{.Claims.role}}"}, {Name: "X-User-Role", Value: "{{.Claims.role}}"},
}, },
claims: map[string]any{ claims: map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
// role claim is missing // role claim is missing
}, },
@@ -108,7 +107,7 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
headers: []TemplatedHeader{ headers: []TemplatedHeader{
{Name: "X-User-Admin", Value: "{{if .Claims.is_admin}}true{{else}}false{{end}}"}, {Name: "X-User-Admin", Value: "{{if .Claims.is_admin}}true{{else}}false{{end}}"},
}, },
claims: map[string]any{ claims: map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
"is_admin": true, "is_admin": true,
}, },
@@ -121,7 +120,7 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
headers: []TemplatedHeader{ headers: []TemplatedHeader{
{Name: "X-Auth-Info", Value: "User={{.Claims.email}}, Token={{.AccessToken}}"}, {Name: "X-Auth-Info", Value: "User={{.Claims.email}}, Token={{.AccessToken}}"},
}, },
claims: map[string]any{ claims: map[string]interface{}{
"email": "[email protected]", "email": "[email protected]",
}, },
expectedHeaders: map[string]string{ expectedHeaders: map[string]string{
@@ -134,7 +133,7 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
headers: []TemplatedHeader{ headers: []TemplatedHeader{
{Name: "X-User-AccessToken", Value: "{{.AccessToken}}"}, {Name: "X-User-AccessToken", Value: "{{.AccessToken}}"},
}, },
claims: map[string]any{ // For ID Token claims: map[string]interface{}{ // For ID Token
"email": "[email protected]", "email": "[email protected]",
"sub": "opaque_sub_for_id_token", "sub": "opaque_sub_for_id_token",
}, },
@@ -150,7 +149,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]any{ baseClaims := 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,7 +161,9 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
} }
// Add the test-specific claims // Add the test-specific claims
maps.Copy(baseClaims, tc.claims) for k, v := range tc.claims {
baseClaims[k] = v
}
token, err = createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", baseClaims) token, err = createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", baseClaims)
if err != nil { if err != nil {
@@ -266,7 +267,7 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
session.SetRefreshToken("test-refresh-token") session.SetRefreshToken("test-refresh-token")
if tc.name == "ID Token Header" || tc.name == "Both Token Types" { if tc.name == "ID Token Header" || tc.name == "Both Token Types" {
idTokenClaims := map[string]any{ idTokenClaims := map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000), "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000),
"iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject", "iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject",
"nonce": "test-nonce", "jti": generateRandomString(16), "type": "id_token", "nonce": "test-nonce", "jti": generateRandomString(16), "type": "id_token",
@@ -284,7 +285,7 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
t.Fatalf("Failed to create test ID JWT: %v", idErr) t.Fatalf("Failed to create test ID JWT: %v", idErr)
} }
accessTokenClaims := map[string]any{ accessTokenClaims := map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000), "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000),
"iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject", "iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject",
"jti": generateRandomString(16), "type": "access_token", "scope": "openid email profile", "jti": generateRandomString(16), "type": "access_token", "scope": "openid email profile",
@@ -315,13 +316,15 @@ func TestTemplatedHeadersIntegration(t *testing.T) {
tc.expectedHeaders["X-Access-Token"] = accessTokenForSession tc.expectedHeaders["X-Access-Token"] = accessTokenForSession
} }
} else if tc.name == "Opaque Access Token with AccessTokenField" { } else if tc.name == "Opaque Access Token with AccessTokenField" {
idTokenClaims := map[string]any{ idTokenClaims := map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000), "iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000),
"iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject", // Default sub "iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject", // Default sub
"nonce": "test-nonce", "jti": generateRandomString(16), "type": "id_token", "nonce": "test-nonce", "jti": generateRandomString(16), "type": "id_token",
} }
// Populate ID token claims from tc.claims // Populate ID token claims from tc.claims
maps.Copy(idTokenClaims, tc.claims) for k, v := range tc.claims {
idTokenClaims[k] = v
}
// Ensure email from tc.claims is used for the ID token // 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 session.SetEmail(tc.claims["email"].(string)) // Also set it directly for initial session state
@@ -423,7 +426,7 @@ func TestEdgeCaseTemplatedHeaders(t *testing.T) {
ts.Setup() ts.Setup()
tests := []struct { tests := []struct {
claims map[string]any claims map[string]interface{}
name string name string
headers []TemplatedHeader headers []TemplatedHeader
shouldExecuteCheck bool shouldExecuteCheck bool
@@ -444,8 +447,8 @@ func TestEdgeCaseTemplatedHeaders(t *testing.T) {
headers: []TemplatedHeader{ headers: []TemplatedHeader{
{Name: "X-Roles", Value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"}, {Name: "X-Roles", Value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"},
}, },
claims: map[string]any{ claims: map[string]interface{}{
"roles": []any{"admin", "user", "manager"}, "roles": []interface{}{"admin", "user", "manager"},
}, },
shouldExecuteCheck: true, shouldExecuteCheck: true,
}, },
@@ -454,7 +457,7 @@ func TestEdgeCaseTemplatedHeaders(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) {
// Create token with the test claims // Create token with the test claims
claims := map[string]any{ 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
@@ -466,7 +469,9 @@ func TestEdgeCaseTemplatedHeaders(t *testing.T) {
} }
// Add the test-specific claims // Add the test-specific claims
maps.Copy(claims, tc.claims) for k, v := range tc.claims {
claims[k] = v
}
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims) token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims)
if err != nil { if err != nil {
@@ -571,7 +576,8 @@ func TestEdgeCaseTemplatedHeaders(t *testing.T) {
// createLargeTemplate creates a template with many variable references // createLargeTemplate creates a template with many variable references
func createLargeTemplate(size int) string { func createLargeTemplate(size int) string {
template := "{{with .Claims}}" template := "{{with .Claims}}"
for i := range size { for i := 0; i < size; i++ {
if i > 0 { if i > 0 {
template += "," template += ","
} }
@@ -582,9 +588,9 @@ func createLargeTemplate(size int) string {
} }
// createLargeClaims creates a map with many claims for testing large templates // createLargeClaims creates a map with many claims for testing large templates
func createLargeClaims(size int) map[string]any { func createLargeClaims(size int) map[string]interface{} {
claims := make(map[string]any) claims := make(map[string]interface{})
for i := range size { for i := 0; i < size; i++ {
claims["email"] = "[email protected]" // Add email claim claims["email"] = "[email protected]" // Add email claim
key := "field" + string(rune('a'+i%26)) + string(rune('0'+i%10)) key := "field" + string(rune('a'+i%26)) + string(rune('0'+i%10))
claims[key] = "value" + string(rune('a'+i%26)) + string(rune('0'+i%10)) claims[key] = "value" + string(rune('a'+i%26)) + string(rune('0'+i%10))
+27 -18
View File
@@ -61,10 +61,13 @@ func (tt *TestTokens) CreateLargeValidJWT(targetSize int) string {
// Calculate required payload size // Calculate required payload size
usedSize := len(header) + len(signature) + 2 // account for dots usedSize := len(header) + len(signature) + 2 // account for dots
payloadSize := max(targetSize-usedSize, 50) payloadSize := targetSize - usedSize
if payloadSize < 50 {
payloadSize = 50
}
// Create a payload with realistic JWT claims // Create a payload with realistic JWT claims
claims := map[string]any{ claims := map[string]interface{}{
"sub": "user123", "sub": "user123",
"iss": "https://example.com", "iss": "https://example.com",
"aud": "client123", "aud": "client123",
@@ -72,12 +75,10 @@ func (tt *TestTokens) CreateLargeValidJWT(targetSize int) string {
"iat": 1000000000, "iat": 1000000000,
} }
// FIXED: Calculate data size safely dataSize := payloadSize - 100 // Account for other claims and base64 encoding
dataSize := max( if dataSize < 10 {
// Account for other claims and base64 encoding dataSize = 10 // Minimum data size
payloadSize-100, }
// Minimum data size
10)
claims["data"] = tt.generateRandomString(dataSize) claims["data"] = tt.generateRandomString(dataSize)
@@ -99,7 +100,7 @@ func (tt *TestTokens) CreateExpiredJWT() string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
// Create claims with expired timestamp // Create claims with expired timestamp
claims := map[string]any{ claims := map[string]interface{}{
"sub": "user123", "sub": "user123",
"iss": "https://example.com", "iss": "https://example.com",
"aud": "client123", "aud": "client123",
@@ -118,7 +119,7 @@ func (tt *TestTokens) CreateExpiredJWT() string {
func (tt *TestTokens) CreateUniqueValidJWT(id string) string { func (tt *TestTokens) CreateUniqueValidJWT(id string) string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
claims := map[string]any{ claims := map[string]interface{}{
"sub": "user_" + id, "sub": "user_" + id,
"iss": "https://example.com", "iss": "https://example.com",
"aud": "client123", "aud": "client123",
@@ -145,7 +146,7 @@ func (tt *TestTokens) CreateIncompressibleToken(targetSize int) string {
payloadSize := max(targetSize-usedSize, 100) payloadSize := max(targetSize-usedSize, 100)
// Generate multiple random fields to prevent compression // Generate multiple random fields to prevent compression
randomFields := make(map[string]any) randomFields := make(map[string]interface{})
randomFields["sub"] = "user123" randomFields["sub"] = "user123"
randomFields["iss"] = "https://example.com" randomFields["iss"] = "https://example.com"
randomFields["aud"] = "client123" randomFields["aud"] = "client123"
@@ -154,11 +155,12 @@ func (tt *TestTokens) CreateIncompressibleToken(targetSize int) string {
// Add many random fields with random data to prevent compression // Add many random fields with random data to prevent compression
remainingSize := payloadSize - 200 // Account for base64 encoding and other fields remainingSize := payloadSize - 200 // Account for base64 encoding and other fields
fieldCount := max( fieldCount := remainingSize / 100 // ~100 bytes per field
// ~100 bytes per field if fieldCount < 1 {
remainingSize/100, 1) fieldCount = 1
}
for i := range fieldCount { for i := 0; i < fieldCount; i++ {
// Generate truly random data for each field // Generate truly random data for each field
randomBytes := make([]byte, 50) randomBytes := make([]byte, 50)
rand.Read(randomBytes) rand.Read(randomBytes)
@@ -232,7 +234,7 @@ func (tt *TestTokens) generateRandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length) b := make([]byte, length)
for i := range b { for i := 0; i < length; i++ {
randomByte := make([]byte, 1) randomByte := make([]byte, 1)
rand.Read(randomByte) rand.Read(randomByte)
b[i] = charset[int(randomByte[0])%len(charset)] b[i] = charset[int(randomByte[0])%len(charset)]
@@ -296,7 +298,7 @@ func (ts *TestScenarios) CorruptionTest() CorruptionTestSet {
// ConcurrentTest returns unique tokens for concurrent testing // ConcurrentTest returns unique tokens for concurrent testing
func (ts *TestScenarios) ConcurrentTest(count int) []TokenSet { func (ts *TestScenarios) ConcurrentTest(count int) []TokenSet {
sets := make([]TokenSet, count) sets := make([]TokenSet, count)
for i := range count { for i := 0; i < count; i++ {
sets[i] = TokenSet{ sets[i] = TokenSet{
AccessToken: ts.tokens.CreateUniqueValidJWT(fmt.Sprintf("concurrent_%d", i)), AccessToken: ts.tokens.CreateUniqueValidJWT(fmt.Sprintf("concurrent_%d", i)),
IDToken: ts.tokens.CreateUniqueValidJWT(fmt.Sprintf("id_%d", i)), IDToken: ts.tokens.CreateUniqueValidJWT(fmt.Sprintf("id_%d", i)),
@@ -387,5 +389,12 @@ func AssertInvalidTokenRejection(t TestingInterface, session *SessionData, token
// TestingInterface provides the minimal interface needed for testing // TestingInterface provides the minimal interface needed for testing
type TestingInterface interface { type TestingInterface interface {
Errorf(format string, args ...any) Errorf(format string, args ...interface{})
}
func max(a, b int) int {
if a > b {
return a
}
return b
} }
+11 -6
View File
@@ -7,7 +7,6 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"net/http/httptest" "net/http/httptest"
"slices"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -251,8 +250,8 @@ func TestChunkReassemblyEdgeCases(t *testing.T) {
corruption: func(chunks map[int]*sessions.Session) { corruption: func(chunks map[int]*sessions.Session) {
// This test simulates having too many chunks (>50 limit) // This test simulates having too many chunks (>50 limit)
// We'll create a scenario by adding many fake chunks // We'll create a scenario by adding many fake chunks
for i := range 60 { for i := 0; i < 60; i++ {
fakeSession := &sessions.Session{Values: make(map[any]any)} fakeSession := &sessions.Session{Values: make(map[interface{}]interface{})}
fakeSession.Values["token_chunk"] = "fake_chunk_data" fakeSession.Values["token_chunk"] = "fake_chunk_data"
chunks[i] = fakeSession chunks[i] = fakeSession
} }
@@ -321,12 +320,12 @@ func TestRaceConditionProtection(t *testing.T) {
var wg sync.WaitGroup var wg sync.WaitGroup
errChan := make(chan error, numGoroutines*numOperations) errChan := make(chan error, numGoroutines*numOperations)
for i := range numGoroutines { for i := 0; i < numGoroutines; i++ {
wg.Add(1) wg.Add(1)
go func(goroutineID int) { go func(goroutineID int) {
defer wg.Done() defer wg.Done()
for j := range numOperations { for j := 0; j < numOperations; j++ {
tokenIndex := (goroutineID + j) % len(tokens) tokenIndex := (goroutineID + j) % len(tokens)
expectedToken := tokens[tokenIndex] expectedToken := tokens[tokenIndex]
@@ -345,7 +344,13 @@ func TestRaceConditionProtection(t *testing.T) {
// The retrieved token should be one of the valid tokens we set // The retrieved token should be one of the valid tokens we set
// (due to concurrent access, it might not be the exact one we just set) // (due to concurrent access, it might not be the exact one we just set)
isValidToken := slices.Contains(tokens, retrieved) isValidToken := false
for _, validToken := range tokens {
if retrieved == validToken {
isValidToken = true
break
}
}
if retrieved != "" && !isValidToken { if retrieved != "" && !isValidToken {
errChan <- fmt.Errorf("goroutine %d, op %d: retrieved unknown token: %q", errChan <- fmt.Errorf("goroutine %d, op %d: retrieved unknown token: %q",
+12 -9
View File
@@ -19,7 +19,7 @@ import (
func TestTokenTypeDistinction(t *testing.T) { func TestTokenTypeDistinction(t *testing.T) {
// Define test data where AccessToken and IdToken are deliberately different // Define test data where AccessToken and IdToken are deliberately different
type templateData struct { type templateData struct {
Claims map[string]any Claims map[string]interface{}
AccessToken string AccessToken string
IdToken string IdToken string
RefreshToken string RefreshToken string
@@ -29,7 +29,7 @@ func TestTokenTypeDistinction(t *testing.T) {
AccessToken: "test-access-token-abc123", AccessToken: "test-access-token-abc123",
IdToken: "test-id-token-xyz789", IdToken: "test-id-token-xyz789",
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
Claims: map[string]any{ Claims: map[string]interface{}{
"sub": "test-subject", "sub": "test-subject",
"email": "[email protected]", "email": "[email protected]",
}, },
@@ -91,7 +91,7 @@ func TestTokenTypeIntegration(t *testing.T) {
ts.Setup() ts.Setup()
// Create different tokens for ID and access tokens // Create different tokens for ID and access tokens
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(3000000000), "exp": float64(3000000000),
@@ -107,7 +107,7 @@ func TestTokenTypeIntegration(t *testing.T) {
t.Fatalf("Failed to create test ID JWT: %v", err) t.Fatalf("Failed to create test ID JWT: %v", err)
} }
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]any{ accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com", "iss": "https://test-issuer.com",
"aud": "test-client-id", "aud": "test-client-id",
"exp": float64(3000000000), "exp": float64(3000000000),
@@ -518,11 +518,11 @@ func TestConcurrentTokenOperationsWithCorruption(t *testing.T) {
errorChan := make(chan error, numGoroutines*numOperations) errorChan := make(chan error, numGoroutines*numOperations)
// Start concurrent operations // Start concurrent operations
for i := range numGoroutines { for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) { go func(goroutineID int) {
defer func() { done <- true }() defer func() { done <- true }()
for j := range numOperations { for j := 0; j < numOperations; j++ {
// Create a unique valid token for each operation // Create a unique valid token for each operation
token := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwib3AiOiIxMjMifQ.sig_%d_%d", token := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwib3AiOiIxMjMifQ.sig_%d_%d",
goroutineID, j) goroutineID, j)
@@ -563,7 +563,7 @@ func TestConcurrentTokenOperationsWithCorruption(t *testing.T) {
} }
// Wait for all goroutines to complete // Wait for all goroutines to complete
for range numGoroutines { for i := 0; i < numGoroutines; i++ {
<-done <-done
} }
close(errorChan) close(errorChan)
@@ -645,10 +645,13 @@ func createLargeValidJWT(targetSize int) string {
// Calculate required payload size // Calculate required payload size
usedSize := len(header) + len(signature) + 2 // account for dots usedSize := len(header) + len(signature) + 2 // account for dots
payloadSize := max(targetSize-usedSize, 50) payloadSize := targetSize - usedSize
if payloadSize < 50 {
payloadSize = 50
}
// Create a payload with realistic JWT claims // Create a payload with realistic JWT claims
claims := map[string]any{ claims := map[string]interface{}{
"sub": "user123", "sub": "user123",
"iss": "https://example.com", "iss": "https://example.com",
"aud": "client123", "aud": "client123",