Compare commits

..

2 Commits

124 changed files with 1170 additions and 54975 deletions
-5
View File
@@ -1,5 +0,0 @@
version: 2
secret:
ignored_paths:
- "*test.go"
-2
View File
@@ -1,2 +0,0 @@
docker/
.claude/
+22 -441
View File
@@ -4,447 +4,28 @@ type: middleware
import: github.com/lukaszraczylo/traefikoidc
summary: |
Middleware adding OpenID Connect (OIDC) authentication to Traefik routes.
This middleware replaces the need for forward-auth and oauth2-proxy when using Traefik as a reverse proxy.
It provides a complete OIDC authentication solution with features like domain restrictions,
role-based access control, token caching, and more.
The middleware has been tested with Auth0, Logto, Google, and other standard OIDC providers.
It includes special handling for Google's OAuth implementation to ensure compatibility.
It supports various authentication scenarios including:
- Basic authentication with customizable callback and logout URLs
- Email domain restrictions to limit access to specific organizations
- Role and group-based access control
- Public URLs that bypass authentication
- Rate limiting to prevent brute force attacks
- Custom post-logout redirect behavior
- Secure session management with encrypted cookies
- Automatic token validation and refresh
Middleware adding OIDC authentication to traefik routes. Does what it says on the tin.
Middleware has been tested with Auth0 and Logto. It should work with any OIDC provider.
testData:
# Required parameters
providerURL: https://accounts.google.com # Base URL of the OIDC provider
clientID: 1234567890.apps.googleusercontent.com # OAuth 2.0 client identifier
clientSecret: secret # OAuth 2.0 client secret
callbackURL: /oauth2/callback # Path where the OIDC provider will redirect after authentication
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long # Key used to encrypt session data (must be at least 32 bytes)
# Optional parameters with defaults
logoutURL: /oauth2/logout # Path for handling logout requests (if not provided, it will be set to callbackURL + "/logout")
postLogoutRedirectURI: /oidc/different-logout # URL to redirect to after logout (default: "/")
scopes: # Additional scopes to append to defaults ["openid", "profile", "email"]
- roles # Result: ["openid", "profile", "email", "roles"]
allowedUserDomains: # Restricts access to specific email domains (if not provided, relies on OIDC provider)
- company.com
- subsidiary.com
allowedUsers: # Restricts access to specific email addresses regardless of domain
- specific-user@company.com
- another-user@gmail.com
allowedRolesAndGroups: # Restricts access to users with specific roles or groups (if not provided, no role/group restrictions)
- guest-endpoints
- admin
- developer
forceHTTPS: false # Forces the use of HTTPS for all URLs (default: true for security)
logLevel: debug # Sets logging verbosity: debug, info, error (default: info)
rateLimit: 100 # Maximum number of requests per second (default: 100, minimum: 10)
excludedURLs: # Lists paths that bypass authentication
- /login # covers /login, /login/me, /login/reminder etc.
- /public
- /health
- /metrics
headers: # Custom headers to set with templated values from claims and tokens
# NOTE: If you encounter "can't evaluate field AccessToken in type bool" errors,
# you may need to escape the templates. See the headers section in configuration below.
- name: "X-User-Email"
value: "{{.Claims.email}}"
- name: "X-User-ID"
value: "{{.Claims.sub}}"
- name: "Authorization"
value: "Bearer {{.AccessToken}}"
- name: "X-User-Roles"
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
# Advanced parameters (usually discovered automatically from provider metadata)
revocationURL: https://accounts.google.com/revoke # Endpoint for revoking tokens
oidcEndSessionURL: https://accounts.google.com/logout # Provider's end session endpoint
enablePKCE: false # Enables PKCE (Proof Key for Code Exchange) for additional security
cookieDomain: "" # Explicit domain for session cookies (e.g., ".example.com" for multi-subdomain setups)
# --- 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:
providerURL:
type: string
description: |
The base URL of the OIDC provider. This is the issuer URL that will be used to discover
OIDC endpoints like authorization, token, and JWKS URIs.
Examples:
- https://accounts.google.com
- https://login.microsoftonline.com/tenant-id/v2.0
- https://your-auth0-domain.auth0.com
- https://your-logto-instance.com/oidc
required: true
clientID:
type: string
description: |
The OAuth 2.0 client identifier obtained from your OIDC provider.
This is the public identifier for your application.
required: true
clientSecret:
type: string
description: |
The OAuth 2.0 client secret obtained from your OIDC provider.
This should be kept confidential and not exposed in client-side code.
For Kubernetes deployments, you can use the secret reference format:
urn:k8s:secret:namespace:secret-name:key
required: true
callbackURL:
type: string
description: |
The path where the OIDC provider will redirect after authentication.
This must match one of the redirect URIs configured in your OIDC provider.
The full redirect URI will be constructed as:
[scheme]://[host][callbackURL]
Example: /oauth2/callback
required: true
sessionEncryptionKey:
type: string
description: |
Key used to encrypt session data stored in cookies.
Must be at least 32 bytes long for security.
Example: potato-secret-is-at-least-32-bytes-long
required: true
logoutURL:
type: string
description: |
The path for handling logout requests.
If not provided, it will be set to callbackURL + "/logout".
Example: /oauth2/logout
required: false
postLogoutRedirectURI:
type: string
description: |
The URL to redirect to after logout.
Default: "/"
Example: /logged-out-page
required: false
scopes:
type: array
description: |
Additional OAuth 2.0 scopes to append to the default scopes.
Default scopes are always included: ["openid", "profile", "email"]
User-provided scopes are appended to defaults with automatic deduplication.
For example, specifying ["roles", "custom_scope"] results in:
["openid", "profile", "email", "roles", "custom_scope"]
Include "roles" or similar scope if you need role/group information.
Note: For Google OAuth, the middleware automatically handles the
proper authentication parameters and does NOT require the "offline_access"
scope (which Google rejects as invalid). See documentation for details.
required: false
items:
type: string
logLevel:
type: string
description: |
Sets the logging verbosity.
Valid values: "debug", "info", "error"
Default: "info"
required: false
enum:
- debug
- info
- error
forceHTTPS:
type: boolean
description: |
Forces the use of HTTPS for all URLs.
This is recommended for security in production environments.
Default: true
required: false
rateLimit:
type: integer
description: |
Sets the maximum number of requests per second.
This helps prevent brute force attacks.
Default: 100
Minimum: 10
required: false
excludedURLs:
type: array
description: |
Lists paths that bypass authentication.
These paths will be accessible without OIDC authentication.
The middleware uses prefix matching, so "/public" will match
"/public", "/public/page", "/public-data", etc.
Examples: ["/health", "/metrics", "/public"]
required: false
items:
type: string
allowedUserDomains:
type: array
description: |
Restricts access to users with email addresses from specific domains.
If not provided, the middleware relies entirely on the OIDC provider
for authentication decisions.
Examples: ["company.com", "subsidiary.com"]
required: false
items:
type: string
allowedUsers:
type: array
description: |
Restricts access to specific email addresses.
If provided, only users with these exact email addresses will be allowed access,
in addition to any domain-level restrictions set by allowedUserDomains.
This provides fine-grained control over individual access and can be used
together with allowedUserDomains for flexible access control strategies.
Examples: ["user1@example.com", "admin@company.com"]
required: false
items:
type: string
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: secret
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
postLogoutRedirectURI: /oidc/different-logout # If not provided it will redirect to the "/" URL
scopes: # If not provided, default scopes will be used (openid, email, profile)
- openid
- email
- profile
allowedUserDomains: # If not provided - will rely entirely on the OIDC yes/no
- raczylo.com
allowedRolesAndGroups:
type: array
description: |
Restricts access to users with specific roles or groups.
If not provided, no role/group restrictions are applied.
The middleware checks both the "roles" and "groups" claims in the ID token.
Examples: ["admin", "developer"]
required: false
items:
type: string
revocationURL:
type: string
description: |
The endpoint for revoking tokens.
If not provided, it will be discovered from provider metadata.
Example: https://accounts.google.com/revoke
required: false
oidcEndSessionURL:
type: string
description: |
The provider's end session endpoint.
If not provided, it will be discovered from provider metadata.
Example: https://accounts.google.com/logout
required: false
enablePKCE:
type: boolean
description: |
Enables PKCE (Proof Key for Code Exchange) for the OAuth 2.0 authorization code flow.
PKCE adds an extra layer of security to protect against authorization code interception attacks.
Not all OIDC providers support PKCE, so this should only be enabled if your provider supports it.
If enabled, the middleware will generate and use a code verifier/challenge pair during authentication.
Default: false
required: false
cookieDomain:
type: string
description: |
Explicit domain for session cookies. This is important for multi-subdomain setups
and reverse proxy deployments to ensure consistent cookie handling.
When set, all session cookies will use this domain. When not set, the domain
is auto-detected from the request headers (X-Forwarded-Host or Host).
Use a leading dot for subdomain-wide cookies (e.g., ".example.com" allows
cookies to be shared between app.example.com, api.example.com, etc.).
Use a specific domain for host-only cookies (e.g., "app.example.com" restricts
cookies to that exact domain).
This setting is crucial to prevent authentication issues like "CSRF token missing
in session" errors that can occur when cookies are created with inconsistent domains.
Examples:
- ".example.com" - Allows all subdomains to share cookies
- "app.example.com" - Restricts cookies to this specific host
Default: "" (auto-detected from request headers)
required: false
headers:
type: array
description: |
Custom HTTP headers to set with templated values derived from OIDC claims and tokens.
Each header has a name and a value template that can access:
- {{.Claims.field}} - Access ID token claims (e.g., email, sub, name)
- {{.AccessToken}} - The raw access token string
- {{.IdToken}} - The raw ID token string
- {{.RefreshToken}} - The raw refresh token string
Templates support Go template syntax including conditionals and iteration.
Variable names are case-sensitive - use .Claims not .claims.
IMPORTANT: Template Escaping
If you encounter the error "can't evaluate field AccessToken in type bool" when
starting Traefik, this means Traefik is trying to evaluate the template expressions
before passing them to the plugin. To fix this, you need to escape the templates
using one of these methods:
1. Use YAML literal style (recommended):
headers:
- name: "Authorization"
value: |
Bearer {{.AccessToken}}
2. Use single quotes:
headers:
- name: "Authorization"
value: 'Bearer {{.AccessToken}}'
3. For inline double quotes, escape the braces:
headers:
- name: "Authorization"
value: "Bearer {{"{{.AccessToken}}"}}"
Examples:
- name: "X-User-Email", value: "{{.Claims.email}}"
- name: "Authorization", value: "Bearer {{.AccessToken}}"
- name: "X-User-Roles", value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
required: false
items:
type: object
properties:
name:
type: string
description: The HTTP header name to set
value:
type: string
description: Template string for the header value
- guest-endpoints
sessionEncryptionKey: potato-secret
forceHTTPS: false
logLevel: debug # debug, info, warn, error
rateLimit: 100 # Simple rate limiter to prevent brute force attacks
excludedURLs: # Determines the list of URLs which are NOT a subject to authentication
- /login # covers /login, /login/me, /login/reminder etc.
- /my-public-data
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Lukasz Raczylo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+129 -772
View File
@@ -1,514 +1,151 @@
# Traefik OIDC Middleware
## Traefik OIDC middleware
This middleware replaces the need for forward-auth and oauth2-proxy when using Traefik as a reverse proxy to support OpenID Connect (OIDC) authentication.
This middleware is supposed to replace the need for the forward-auth and oauth2-proxy when using traefik as a reverse proxy to support the OIDC authentication.
## Overview
Middleware has been tested with Auth0 and Logto.
The Traefik OIDC middleware provides a complete OIDC authentication solution with features like:
- Token validation and verification
- Session management
- Domain restrictions
- Role-based access control
- Token caching and blacklisting
- Rate limiting
- Excluded paths (public URLs)
### Traefik version compatibility
**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.
Code follows closely the current traefik helm chart versions. If plugin fails to load - it's time to update to the latest version of the traefik helm chart.
The middleware has been tested with Auth0, Logto, Google and other standard OIDC providers. It includes special handling for Google's OAuth implementation.
### Configuration options
## Traefik Version Compatibility
Middleware currently supports following scenarios:
This middleware follows closely the current Traefik helm chart versions. If the plugin fails to load, it's time to update to the latest version of the Traefik helm chart.
* Setting custom callback and logout URLs via `callbackURL` and `logoutURL`
* Allowing for access only from the listed domains if `allowedUserDomains` is set, otherwise it relies entirely on the OIDC provider
* Using excluded URLs which do **NOT** require the OIDC authentication
* Rate limiting requests to prevent the bruteforce attacks
## Installation
#### How to configure...
### As a Traefik Plugin
##### Keeping secrets secret
1. Enable the plugin in your Traefik static configuration:
This works ONLY in kubernetes environments. Don't forget to create secret traefik-middleware-oidc with fields ISSUER, CLIENT_ID and SECRET keys.
```yaml
# traefik.yml
experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v0.2.1 # Use the latest version
```
2. Configure the middleware in your dynamic configuration (see examples below).
### Local Development with Docker Compose
For local development or testing, you can use the provided Docker Compose setup:
```bash
cd docker
docker-compose up -d
```
This will start Traefik with the OIDC middleware and two test services.
## Configuration Options
The middleware supports the following configuration options:
### Required Parameters
| Parameter | Description | Example |
|-----------|-------------|---------|
| `providerURL` | The base URL of the OIDC provider | `https://accounts.google.com` |
| `clientID` | The OAuth 2.0 client identifier | `1234567890.apps.googleusercontent.com` |
| `clientSecret` | The OAuth 2.0 client secret | `your-client-secret` |
| `sessionEncryptionKey` | Key used to encrypt session data (must be at least 32 bytes long) | `potato-secret-is-at-least-32-bytes-long` |
| `callbackURL` | The path where the OIDC provider will redirect after authentication | `/oauth2/callback` |
### Optional Parameters
| Parameter | Description | Default | Example |
|-----------|-------------|---------|---------|
| `logoutURL` | The path for handling logout requests | `callbackURL + "/logout"` | `/oauth2/logout` |
| `postLogoutRedirectURI` | The URL to redirect to after logout | `/` | `/logged-out-page` |
| `scopes` | OAuth 2.0 scopes to use for authentication | `["openid", "profile", "email"]` (always included by default) | `["roles", "custom_scope"]` (appended to defaults) |
| `overrideScopes` | When true, replaces default scopes with provided scopes instead of appending | `false` | `true` (use only the scopes explicitly provided) |
| `logLevel` | Sets the logging verbosity | `info` | `debug`, `info`, `error` |
| `forceHTTPS` | Forces the use of HTTPS for all URLs | `true` | `true`, `false` |
| `rateLimit` | Sets the maximum number of requests per second | `100` | `500` |
| `excludedURLs` | Lists paths that bypass authentication | none | `["/health", "/metrics", "/public"]` |
| `allowedUserDomains` | Restricts access to specific email domains | none | `["company.com", "subsidiary.com"]` |
| `allowedUsers` | A list of specific email addresses that are allowed access | none | `["user1@example.com", "user2@another.org"]` |
| `allowedRolesAndGroups` | Restricts access to users with specific roles or groups | none | `["admin", "developer"]` |
| `revocationURL` | The endpoint for revoking tokens | auto-discovered | `https://accounts.google.com/revoke` |
| `oidcEndSessionURL` | The provider's end session endpoint | auto-discovered | `https://accounts.google.com/logout` |
| `enablePKCE` | Enables PKCE (Proof Key for Code Exchange) for authorization code flow | `false` | `true`, `false` |
| `refreshGracePeriodSeconds` | Seconds before token expiry to attempt proactive refresh | `60` | `120` |
| `cookieDomain` | Explicit domain for session cookies (important for multi-subdomain setups) | auto-detected | `.example.com`, `app.example.com` |
| `headers` | Custom HTTP headers with templates that can access OIDC claims and tokens | none | See "Templated Headers" section |
## Scope Configuration
### Scope Behavior
The middleware supports two modes for handling OAuth 2.0 scopes, controlled by the `overrideScopes` parameter:
#### Default Append Mode (`overrideScopes: false`)
By default, the middleware uses an **append** behavior for OAuth 2.0 scopes:
- **Default scopes** are always included: `["openid", "profile", "email"]`
- **User-provided scopes** are appended to the defaults with automatic deduplication
- The final scope list maintains the order: defaults first, then user scopes
#### Override Mode (`overrideScopes: true`)
When `overrideScopes` is set to `true`, the middleware uses **replacement** behavior:
- Default scopes are **not** automatically included
- Only the scopes explicitly provided in the `scopes` field are used
- You must include all required scopes explicitly, including `openid` if needed
### Examples:
**Default behavior (no custom scopes):**
```yaml
# No scopes field specified
# Result: ["openid", "profile", "email"]
```
**Default append behavior:**
```yaml
scopes:
- roles
- custom_scope
# Result: ["openid", "profile", "email", "roles", "custom_scope"]
```
**Overlapping scopes with append (automatic deduplication):**
```yaml
scopes:
- openid # Duplicate - will be deduplicated
- roles
- profile # Duplicate - will be deduplicated
- permissions
# Result: ["openid", "profile", "email", "roles", "permissions"]
```
**Using override mode:**
```yaml
overrideScopes: true
scopes:
- openid
- profile
- custom_scope
# Result: ["openid", "profile", "custom_scope"]
```
**Empty scopes list with default behavior:**
```yaml
scopes: []
# Result: ["openid", "profile", "email"]
```
**Empty scopes list with override mode:**
```yaml
overrideScopes: true
scopes: []
# Result: [] (Warning: empty scopes may cause authentication to fail)
```
The default append behavior ensures essential OIDC scopes are always present, while the override mode gives you complete control over the exact scopes requested from the provider.
## Usage Examples
### Basic Configuration
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-basic
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: your-client-secret
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
```
### With Excluded URLs (Public Access Paths)
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-with-open-urls
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: your-client-secret
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
excludedURLs:
- /login # covers /login, /login/me, /login/reminder etc.
- /public-data
- /health
- /metrics
```
### With Email Domain Restrictions
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-domain-restricted
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: your-client-secret
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
allowedUserDomains:
- company.com
- subsidiary.com
```
### With Specific User Access
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-specific-users
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: your-client-secret
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
allowedUsers:
- user1@example.com
- user2@another.org
```
### With Both Domain and Specific User Access
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-domain-and-users
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: your-client-secret
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
allowedUserDomains:
- company.com
allowedUsers:
- special-user@gmail.com
- contractor@external.org
```
When configuring access control:
- If only `allowedUsers` is set, only the specified email addresses will be granted access
- If only `allowedUserDomains` is set, only users with email addresses from those domains will be granted access
- If both are set, access is granted if the user's email is in `allowedUsers` OR their email's domain is in `allowedUserDomains`
- If neither is set, any authenticated user will be granted access
- Email matching is case-insensitive
### With Role-Based Access Control
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-rbac
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: your-client-secret
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
allowedRolesAndGroups:
- admin
- developer
```
### With Cookie Domain Configuration (Multi-Subdomain Setup)
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-multi-subdomain
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: your-client-secret
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
cookieDomain: .example.com # Allows cookies to be shared across all subdomains
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
```
**Important**: The `cookieDomain` parameter is crucial when running behind a reverse proxy or when your application serves multiple subdomains. Without it, cookies may be created with inconsistent domains, leading to authentication issues like "CSRF token missing in session" errors.
### With Custom Logging and Rate Limiting
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-custom-settings
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: your-client-secret
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
logLevel: debug # Options: debug, info, error (default: info)
rateLimit: 500 # Requests per second (default: 100)
forceHTTPS: false # Default is true for security
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
```
### With Custom Post-Logout Redirect
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-custom-logout
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: your-client-secret
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
postLogoutRedirectURI: /logged-out-page # Where to redirect after logout
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
```
### With Templated Headers
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-with-headers
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: your-client-secret
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
headers:
# Using double curly braces to escape template expressions
- name: "X-User-Email"
value: "{{{{.Claims.email}}}}"
- name: "X-User-ID"
value: "{{{{.Claims.sub}}}}"
- name: "Authorization"
value: "Bearer {{{{.AccessToken}}}}"
- name: "X-User-Roles"
value: "{{{{range $i, $e := .Claims.roles}}}}{{{{if $i}}}},{{{{end}}}}{{{{$e}}}}{{{{end}}}}"
- name: "X-Is-Admin"
value: "{{{{if eq .Claims.role \"admin\"}}}}true{{{{else}}}}false{{{{end}}}}"
```
### With PKCE Enabled
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-with-pkce
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: your-client-secret
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
enablePKCE: true # Enables PKCE for added security
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
```
### Google OIDC Configuration Example
This example shows a configuration specifically tailored for Google OIDC:
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-google
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: your-google-client-id.apps.googleusercontent.com # Replace with your Client ID
clientSecret: your-google-client-secret # Replace with your Client Secret
sessionEncryptionKey: your-secure-encryption-key-min-32-chars # Replace with your key
callbackURL: /oauth2/callback # Adjust if needed
logoutURL: /oauth2/logout # Optional: Adjust if needed
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
# Note: DO NOT manually add offline_access scope for Google
# The middleware automatically handles Google-specific requirements
refreshGracePeriodSeconds: 300 # Optional: Start refresh 5 min before expiry (default 60)
# Other optional parameters like allowedUserDomains, etc. can be added here
```
The middleware automatically detects Google as the provider and applies the necessary adjustments to ensure proper authentication and token refresh. See the [Google OAuth Fix](#google-oauth-compatibility-fix) section for details.
### Keeping Secrets Secret in Kubernetes
For Kubernetes environments, you can reference secrets instead of hardcoding sensitive values:
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-with-secrets
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: urn:k8s:secret:traefik-middleware-oidc:ISSUER
clientID: urn:k8s:secret:traefik-middleware-oidc:CLIENT_ID
clientSecret: urn:k8s:secret:traefik-middleware-oidc:SECRET
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
sessionEncryptionKey: vvv
callbackURL: /cool-oidc/callback
logoutURL: /cool-oidc/logout
postLogoutRedirectURI: /my-website/you-have-logged-out # Optional post logout URL redirection
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
- openid
- email
- profile
excludedURLs: # Determines the list of URLs which are NOT a subject to authentication
- /login # covers /login, /login/me, /login/reminder etc.
- /my-public-data
```
Don't forget to create the secret:
##### Excluded URLs with open access
```bash
kubectl create secret generic traefik-middleware-oidc \
--from-literal=ISSUER=https://accounts.google.com \
--from-literal=CLIENT_ID=1234567890.apps.googleusercontent.com \
--from-literal=SECRET=your-client-secret \
-n traefik
```
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-with-open-urls
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: xxx
clientID: yyy
clientSecret: zzz
sessionEncryptionKey: vvv
callbackURL: /cool-oidc/callback
logoutURL: /cool-oidc/logout
scopes:
- openid
- email
- profile
excludedURLs: # Determines the list of URLs which are NOT a subject to authentication
- /login # covers /login, /login/me, /login/reminder etc.
- /my-public-data
```
## Complete Docker Compose Example
Here's a complete example of using the middleware with Docker Compose:
##### Allowed email domains
Assuming that your OIDC provider allows anyone to log in, you may want to limit the access to people using emains in specific domain.
```
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-only-my-users
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: xxx
clientID: yyy
clientSecret: zzz
sessionEncryptionKey: vvv
callbackURL: /new-oidc/callback
logoutURL: /new-oidc/logout
scopes:
- openid
- email
- profile
allowedUserDomains:
- raczylo.com
```
##### Allowed groups and roles
In case of multiple roles / groups and access separation for various endpoints you will need to create multiple traefik middlewares.
Following example allows access for users who have additional role `guest-endpoints` assigned.
```
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-guest-endpoints
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: xxx
clientID: yyy
clientSecret: zzz
sessionEncryptionKey: vvv
callbackURL: /my-oidc/callback
logoutURL: /my-oidc/logout
scopes:
- openid
- email
- profile
- roles # This line queries the OIDC provider for roles
forceHTTPS: true
allowedRolesAndGroups:
- guest-endpoints # This line specifies the roles or groups allowed to access content
allowedUserDomains:
- raczylo.com
```
#### Docker compose example
`docker-compose.yaml`
```yaml
version: "3.7"
services:
traefik:
image: traefik:v3.2.1
image: traefik:v3.0.1
command:
- "--experimental.plugins.traefikoidc.modulename=github.com/lukaszraczylo/traefikoidc"
- "--experimental.plugins.traefikoidc.version=v0.2.1"
@@ -519,6 +156,7 @@ services:
labels:
- "traefik.http.routers.dash.rule=Host(`dash.localhost`)"
- "traefik.http.routers.dash.service=api@internal"
ports:
- "80:80"
@@ -541,7 +179,8 @@ services:
- traefik.http.routers.whoami.middlewares=my-plugin@file
```
`traefik-config/traefik.yml`:
`traefik-config/traefik.yaml`
```yaml
log:
level: INFO
@@ -570,7 +209,7 @@ providers:
filename: /etc/traefik/dynamic-configuration.yml
```
`traefik-config/dynamic-configuration.yml`:
`traefik-config/dynamic-configuration.yaml`
```yaml
http:
middlewares:
@@ -579,302 +218,20 @@ http:
traefikoidc:
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: your-client-secret
clientSecret: secret
callbackURL: /oauth2/callback
logoutURL: /oauth2/logout
postLogoutRedirectURI: /logged-out-page
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
scopes:
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
allowedUserDomains:
- company.com
allowedUsers:
- special-user@gmail.com
- contractor@external.org
allowedRolesAndGroups:
- admin
- developer
scopes: # If not provided, default scopes will be used (openid, email, profile)
- openid
- email
- profile
allowedUserDomains: # If not provided - will rely entirely on the OIDC yes/no
- raczylo.com
sessionEncryptionKey: potato-secret
forceHTTPS: false
logLevel: debug
rateLimit: 100
excludedURLs:
- /login
- /public
- /health
- /metrics
headers:
# Using YAML literal style to prevent Traefik from pre-evaluating templates
- name: "X-User-Email"
value: |
{{.Claims.email}}
- name: "X-User-ID"
value: |
{{.Claims.sub}}
- name: "Authorization"
value: |
Bearer {{.AccessToken}}
- name: "X-User-Roles"
value: |
{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}
logLevel: debug # debug, info, warn, error
rateLimit: 100 # Simple rate limiter to prevent brute force attacks
excludedURLs: # Determines the list of URLs which are NOT a subject to authentication
- /login # covers /login, /login/me, /login/reminder etc.
- /my-public-data
```
## Advanced Configuration
### Session Management
The middleware uses encrypted cookies to manage user sessions. The `sessionEncryptionKey` must be at least 32 bytes long and should be kept secret.
### PKCE Support
The middleware supports PKCE (Proof Key for Code Exchange), which is an extension to the authorization code flow to prevent authorization code interception attacks. When enabled via the `enablePKCE` option, the middleware will generate a code verifier for each authentication request and derive a code challenge from it. The code verifier is stored in the user's session and sent during the token exchange process.
PKCE is recommended when:
- Your OIDC provider supports it (most modern providers do)
- You need an additional layer of security for the authorization code flow
- You're concerned about potential authorization code interception attacks
Note that not all OIDC providers support PKCE, so check your provider's documentation before enabling this feature.
### Session Duration and Token Refresh
This middleware aims to provide long-lived user sessions, typically up to 24 hours, by utilizing OIDC refresh tokens.
**How it works:**
- When a user authenticates, the middleware requests an access token and, if available, a refresh token from the OIDC provider.
- The access token usually has a short lifespan (e.g., 1 hour).
- Before the access token expires (controlled by `refreshGracePeriodSeconds`), the middleware uses the refresh token to obtain a new access token from the provider without requiring the user to log in again.
- This process repeats, allowing the session to remain valid for as long as the refresh token is valid (often 24 hours or more, depending on the provider).
**Provider-Specific Considerations (e.g., Google):**
- Some providers, like Google, issue short-lived access tokens (e.g., 1 hour) and require specific configurations for long-term sessions.
- To enable session extension beyond the initial token expiry with Google and similar providers, the middleware automatically includes the `offline_access` scope in the authentication request. This scope is necessary to obtain a refresh token.
- For Google specifically, the middleware also adds the `prompt=consent` parameter to the initial authorization request. This ensures Google issues a refresh token, which is crucial for extending the session.
- If a refresh attempt fails (e.g., the refresh token is revoked or expired), the user will be required to re-authenticate. The middleware includes enhanced error handling and logging for these scenarios.
- Ensure your OIDC provider is configured to issue refresh tokens and allows their use for extending sessions. Check your provider's documentation for details on refresh token validity periods.
### Google OAuth Compatibility Fix
The middleware includes a specific fix for Google's OAuth implementation, which differs from the standard OIDC specification in how it handles refresh tokens:
- **Issue**: Google does not support the standard `offline_access` scope for requesting refresh tokens and instead requires special parameters.
- **Automatic Solution**: The middleware detects Google as the provider based on the issuer URL and:
- Uses `access_type=offline` query parameter instead of the `offline_access` scope
- Adds `prompt=consent` to ensure refresh tokens are consistently issued
- Properly handles token refresh with Google's implementation
You do not need any special configuration to use Google OAuth - just set `providerURL` to `https://accounts.google.com` and the middleware will automatically apply the proper parameters.
For detailed information on the Google OAuth fix, see the [dedicated documentation](docs/google-oauth-fix.md).
### Token Caching and Blacklisting
The middleware automatically caches validated tokens to improve performance and maintains a blacklist of revoked tokens.
### Templated Headers
The middleware supports setting custom HTTP headers with values templated from OIDC claims and tokens. This allows you to pass authentication information to downstream services in a flexible, customized format.
Templates can access the following variables:
- `{{.Claims.field}}` - Access individual claims from the ID token (e.g., `{{.Claims.email}}`, `{{.Claims.sub}}`)
- `{{.AccessToken}}` - The raw access token string
- `{{.IdToken}}` - The raw ID token string (same as AccessToken in most configurations)
- `{{.RefreshToken}}` - The raw refresh token string
**⚠️ Important: Template Escaping**
If you encounter the error `can't evaluate field AccessToken in type bool` when starting Traefik, this indicates that Traefik is attempting to evaluate the template expressions before passing them to the plugin. This is a known issue when using template syntax in Traefik plugin configurations.
**Solution:** You must escape the template expressions using double curly braces:
```yaml
headers:
- name: "Authorization"
value: "Bearer {{{{.AccessToken}}}}"
```
This is the only reliable method that works consistently. Here's why:
- **Double curly braces (`{{{{.AccessToken}}}}`)** ✅
- The YAML parser converts `{{{{``{{` and `}}}}``}}`
- Result: `Bearer {{.AccessToken}}` reaches the Go template engine correctly
- **Other methods (YAML literal style, single quotes) do NOT work** ❌
- These methods don't prevent Traefik's YAML parser from interpreting the curly braces
- The template syntax gets processed incorrectly before reaching the plugin
**Working example configuration:**
```yaml
headers:
- name: "X-User-Email"
value: "{{{{.Claims.email}}}}"
- name: "X-User-ID"
value: "{{{{.Claims.sub}}}}"
- name: "Authorization"
value: "Bearer {{{{.AccessToken}}}}"
- name: "X-User-Name"
value: "{{{{.Claims.given_name}}}} {{{{.Claims.family_name}}}}"
```
**Advanced template examples:**
Conditional logic:
```yaml
headers:
- name: "X-Is-Admin"
value: "{{{{if eq .Claims.role \"admin\"}}}}true{{{{else}}}}false{{{{end}}}}"
```
Array handling:
```yaml
headers:
- name: "X-User-Roles"
value: "{{{{range $i, $e := .Claims.roles}}}}{{{{if $i}}}},{{{{end}}}}{{{{$e}}}}{{{{end}}}}"
```
**Notes:**
- Variable names are case-sensitive (use `.Claims`, not `.claims`)
- Missing claims will result in `<no value>` in the header value
- The middleware validates templates during startup and logs errors for invalid templates
- Always use double curly braces (`{{{{` and `}}}}`) to escape template expressions in YAML configuration files
### Default Headers Set for Downstream Services
When a user is authenticated, the middleware sets the following headers for downstream services:
- `X-Forwarded-User`: The user's email address
- `X-User-Groups`: Comma-separated list of user groups (if available)
- `X-User-Roles`: Comma-separated list of user roles (if available)
- `X-Auth-Request-Redirect`: The original request URI
- `X-Auth-Request-User`: The user's email address
- `X-Auth-Request-Token`: The user's access token
### Security Headers
The middleware also sets the following security headers:
- `X-Frame-Options: DENY`
- `X-Content-Type-Options: nosniff`
- `X-XSS-Protection: 1; mode=block`
- `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
### Logging
Set the `logLevel` to `debug` to get more detailed logs:
```yaml
logLevel: debug
```
### Common Issues
1. **Token verification failed**: Check that your `providerURL` is correct and accessible.
2. **Session encryption key too short**: Ensure your `sessionEncryptionKey` is at least 32 bytes long.
3. **No matching public key found**: The JWKS endpoint might be unavailable or the token's key ID (kid) doesn't match any key in the JWKS.
4. **Access denied: Your email domain is not allowed**: The user's email domain is not in the `allowedUserDomains` list.
5. **Access denied: You do not have any of the allowed roles or groups**: The user doesn't have any of the roles or groups specified in `allowedRolesAndGroups`.
6. **"can't evaluate field AccessToken in type bool" error**: This error occurs when Traefik attempts to evaluate template expressions in the headers configuration before passing them to the plugin. To fix this:
- Use double curly braces to escape template expressions: `value: "Bearer {{{{.AccessToken}}}}"`
- This is the only reliable method that works with Traefik's YAML parsing
- See the [Templated Headers](#templated-headers) section for complete examples
7. **Google sessions expire after ~1 hour**: If using Google as the OIDC provider and sessions expire prematurely (around 1 hour instead of longer), ensure:
- Do NOT manually add the `offline_access` scope. Google rejects this scope as invalid.
- The middleware automatically applies the required Google parameters (`access_type=offline` and `prompt=consent`).
- Your Google Cloud OAuth consent screen is set to "External" and "Production" mode. "Testing" mode often limits refresh token validity.
- 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).
8. **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
Contributions are welcome! Please feel free to submit a Pull Request.
View File
-100
View File
@@ -1,100 +0,0 @@
package traefikoidc
import "time"
// BackgroundTask represents a managed recurring task that runs in the background.
// It provides a clean interface for starting and stopping periodic operations
// with proper lifecycle management and logging.
type BackgroundTask struct {
stopChan chan struct{}
taskFunc func()
logger *Logger
name string
interval time.Duration
}
// NewBackgroundTask creates a new background task with the specified parameters.
//
// Parameters:
// - name: Identifier for the task (used in logging).
// - interval: Duration between task executions.
// - taskFunc: The function to execute periodically.
// - logger: Logger instance for task lifecycle events.
//
// Returns:
// - A configured BackgroundTask ready to be started.
func NewBackgroundTask(name string, interval time.Duration, taskFunc func(), logger *Logger) *BackgroundTask {
return &BackgroundTask{
name: name,
interval: interval,
stopChan: make(chan struct{}),
taskFunc: taskFunc,
logger: logger,
}
}
// Start begins the background task execution in a separate goroutine.
// The task runs immediately upon start and then at the specified interval.
func (bt *BackgroundTask) Start() {
go bt.run()
}
// Stop gracefully terminates the background task by closing the stop channel.
// This method is safe to call multiple times.
func (bt *BackgroundTask) Stop() {
close(bt.stopChan)
}
// run is the main execution loop for the background task.
// It executes the task function immediately and then at regular intervals
// until the stop signal is received.
func (bt *BackgroundTask) run() {
ticker := time.NewTicker(bt.interval)
defer ticker.Stop()
// Only log startup if debug level is enabled
if bt.logger != nil {
bt.logger.Info("Starting background task: %s", bt.name)
}
// Run task immediately on startup
bt.taskFunc()
for {
select {
case <-ticker.C:
bt.taskFunc()
case <-bt.stopChan:
// Only log shutdown
if bt.logger != nil {
bt.logger.Info("Stopping background task: %s", bt.name)
}
return
}
}
}
// autoCleanupRoutine periodically calls the provided cleanup function.
// It starts a ticker with the given interval and executes the cleanup function
// on each tick. The routine stops gracefully when a signal is received on the
// stop channel. This is typically used for background cleanup tasks like
// expiring cache entries.
//
// Parameters:
// - interval: The time duration between cleanup calls.
// - stop: A channel used to signal the routine to stop. Receiving any value will terminate the loop.
// - cleanup: The function to call periodically for cleanup tasks.
//
// Deprecated: Use BackgroundTask instead.
func autoCleanupRoutine(interval time.Duration, stop <-chan struct{}, cleanup func()) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
cleanup()
case <-stop:
return
}
}
}
-22
View File
@@ -1,22 +0,0 @@
package traefikoidc
import (
"sync/atomic"
"testing"
"time"
)
func TestAutoCleanupRoutine(t *testing.T) {
var counter int32
cleanupFunc := func() {
atomic.AddInt32(&counter, 1)
}
stop := make(chan struct{})
go autoCleanupRoutine(50*time.Millisecond, stop, cleanupFunc)
time.Sleep(250 * time.Millisecond)
close(stop)
if atomic.LoadInt32(&counter) < 3 {
t.Errorf("Expected cleanup to be called at least 3 times, got %d", counter)
}
}
-181
View File
@@ -1,181 +0,0 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAzureOAuthCallbackScenario tests the exact scenario from issue #53
// This test ensures that cookies set during OAuth initiation are available
// during the callback from Azure AD
func TestAzureOAuthCallbackScenario(t *testing.T) {
t.Run("Azure_OAuth_Complete_Flow", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Step 1: User visits https://app.example.com/protected
// Traefik receives this as http://internal/protected with headers
initReq := httptest.NewRequest("GET", "http://internal/protected", nil)
initReq.Header.Set("X-Forwarded-Proto", "https")
initReq.Header.Set("X-Forwarded-Host", "app.example.com")
initReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
initReq.Host = "internal" // The actual host Traefik sees
// Get session and prepare for OAuth
session, err := sessionManager.GetSession(initReq)
require.NoError(t, err)
// Set OAuth flow data
csrfToken := "azure-csrf-state-token"
nonce := "azure-nonce-value"
codeVerifier := "pkce-code-verifier"
session.SetCSRF(csrfToken)
session.SetNonce(nonce)
session.SetCodeVerifier(codeVerifier)
session.SetIncomingPath("/protected")
session.MarkDirty()
// Save session
rec := httptest.NewRecorder()
err = session.Save(initReq, rec)
require.NoError(t, err)
// Examine the cookies that would be sent to the browser
cookies := rec.Result().Cookies()
require.NotEmpty(t, cookies, "Cookies must be set for OAuth flow")
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie, "Main session cookie must be set")
// Verify cookie attributes for Azure OAuth
assert.True(t, mainCookie.Secure, "Cookie MUST be Secure for HTTPS")
assert.Equal(t, http.SameSiteLaxMode, mainCookie.SameSite,
"MUST be Lax to allow Azure callback from different domain")
assert.Equal(t, "app.example.com", mainCookie.Domain,
"Domain must match X-Forwarded-Host for browser to send it back")
assert.Equal(t, "/", mainCookie.Path, "Path must be root")
assert.True(t, mainCookie.HttpOnly, "HttpOnly for security")
// Step 2: User is redirected to Azure AD login
// Azure AD redirects back to https://app.example.com/oidc/callback?code=xxx&state=xxx
// Traefik receives this as http://internal/oidc/callback with headers
callbackReq := httptest.NewRequest("GET",
"http://internal/oidc/callback?code=AzureAuthCode&state="+csrfToken, nil)
callbackReq.Header.Set("X-Forwarded-Proto", "https")
callbackReq.Header.Set("X-Forwarded-Host", "app.example.com")
callbackReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
callbackReq.Host = "internal"
// Browser sends cookies because:
// 1. Request is to https://app.example.com (matches cookie domain)
// 2. Cookie has Secure flag and request is HTTPS
// 3. Cookie has SameSite=Lax which allows top-level navigation from Azure
for _, cookie := range cookies {
callbackReq.AddCookie(cookie)
}
// Get session in callback
callbackSession, err := sessionManager.GetSession(callbackReq)
require.NoError(t, err)
// Verify session data is available - THIS WAS FAILING IN ISSUE #53
retrievedCSRF := callbackSession.GetCSRF()
assert.Equal(t, csrfToken, retrievedCSRF,
"CSRF token MUST be available in callback (was missing in issue #53)")
retrievedNonce := callbackSession.GetNonce()
assert.Equal(t, nonce, retrievedNonce,
"Nonce MUST be available for security validation")
retrievedCodeVerifier := callbackSession.GetCodeVerifier()
assert.Equal(t, codeVerifier, retrievedCodeVerifier,
"PKCE verifier MUST be available for token exchange")
retrievedPath := callbackSession.GetIncomingPath()
assert.Equal(t, "/protected", retrievedPath,
"Original path MUST be available for post-auth redirect")
})
t.Run("Cookie_Not_Sent_With_Wrong_Domain", func(t *testing.T) {
// This test verifies that cookies with wrong domain won't be sent
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Initial request sets cookie for app.example.com
initReq := httptest.NewRequest("GET", "http://internal/protected", nil)
initReq.Header.Set("X-Forwarded-Proto", "https")
initReq.Header.Set("X-Forwarded-Host", "app.example.com")
initReq.Header.Set("User-Agent", "Mozilla/5.0")
session, err := sessionManager.GetSession(initReq)
require.NoError(t, err)
session.SetCSRF("test-csrf")
rec := httptest.NewRecorder()
err = session.Save(initReq, rec)
require.NoError(t, err)
// Callback comes to different domain
callbackReq := httptest.NewRequest("GET", "http://internal/oidc/callback", nil)
callbackReq.Header.Set("X-Forwarded-Proto", "https")
callbackReq.Header.Set("X-Forwarded-Host", "different.example.com") // Different domain!
callbackReq.Header.Set("User-Agent", "Mozilla/5.0")
// Browser wouldn't send cookies because domain doesn't match
// So we simulate that by not adding cookies
callbackSession, err := sessionManager.GetSession(callbackReq)
require.NoError(t, err)
// Session should be empty
assert.Empty(t, callbackSession.GetCSRF(),
"CSRF should be empty when cookies aren't sent due to domain mismatch")
})
t.Run("SameSite_Strict_Would_Break_OAuth", func(t *testing.T) {
// This test demonstrates why we can't use SameSite=Strict for OAuth
// With Strict, cookies wouldn't be sent when redirecting from Azure to our app
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// If we had SameSite=Strict (which we don't anymore), the browser would:
// 1. Set cookie when user visits app.example.com
// 2. NOT send cookie when Azure redirects back to app.example.com/callback
// This is because the request originates from login.microsoftonline.com
// Our fix ensures we use SameSite=Lax which allows top-level navigation
req := httptest.NewRequest("GET", "http://internal/test", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "app.example.com")
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
session.SetCSRF("test")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
cookies := rec.Result().Cookies()
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite,
"Must use Lax, not Strict, for OAuth to work")
break
}
}
})
}
-371
View File
@@ -1,371 +0,0 @@
package traefikoidc
import (
"net/http/httptest"
"strings"
"testing"
"time"
"golang.org/x/time/rate"
)
// mockTraefikOidc extends TraefikOidc to override JWT verification for testing
type mockTraefikOidc struct {
*TraefikOidc
}
// Override VerifyToken to avoid JWKS lookup in tests
func (m *mockTraefikOidc) VerifyToken(token string) error {
// Cache test claims to avoid "claims not found" errors
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "test@example.com",
}
m.tokenCache.Set(token, testClaims, time.Hour)
return nil // Always succeed for testing
}
// Override VerifyJWTSignatureAndClaims to avoid JWKS lookup in tests
func (m *mockTraefikOidc) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error {
// Cache test claims to avoid "claims not found" errors
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "test@example.com",
}
m.tokenCache.Set(token, testClaims, time.Hour)
return nil // Always succeed for testing
}
func TestAzureOIDCRegression(t *testing.T) {
// Create a mocked TraefikOidc instance configured for Azure AD
mockLogger := NewLogger("debug")
// Configure for Azure AD provider
baseOidc := &TraefikOidc{
issuerURL: "https://login.microsoftonline.com/tenant-id/v2.0",
authURL: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/authorize",
tokenURL: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token",
jwksURL: "https://login.microsoftonline.com/tenant-id/discovery/v2.0/keys",
clientID: "test-client-id",
clientSecret: "test-client-secret",
scopes: []string{"openid", "profile", "email"},
refreshGracePeriod: 60 * time.Second,
limiter: rate.NewLimiter(rate.Every(time.Second), 100), // Add rate limiter
logger: mockLogger,
httpClient: createDefaultHTTPClient(), // Add HTTP client
jwkCache: &JWKCache{}, // Add JWK cache
tokenCache: NewTokenCache(),
tokenBlacklist: NewCache(),
allowedUserDomains: make(map[string]struct{}),
allowedUsers: make(map[string]struct{}),
allowedRolesAndGroups: make(map[string]struct{}),
excludedURLs: make(map[string]struct{}),
extractClaimsFunc: extractClaims,
}
// Create the mock wrapper
tOidc := &mockTraefikOidc{TraefikOidc: baseOidc}
// Initialize session manager
sessionManager, _ := NewSessionManager("test-encryption-key-32-bytes-long", false, "", mockLogger)
tOidc.sessionManager = sessionManager
// Mock the JWT verification to avoid JWKS lookup issues
tOidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
// For test tokens, always return success and cache claims
if strings.HasPrefix(token, "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0") {
// Cache test claims for JWT tokens
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "test@example.com",
}
tOidc.tokenCache.Set(token, testClaims, time.Hour)
return nil
}
// For opaque tokens (non-JWT format), return success
if !strings.Contains(token, ".") || strings.Count(token, ".") != 2 {
return nil
}
// For JWT tokens, cache basic claims to avoid cache lookup issues
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "test@example.com",
}
tOidc.tokenCache.Set(token, testClaims, time.Hour)
return nil // Always succeed for test purposes
},
}
// Mock JWT verifier to avoid JWKS lookup
tOidc.jwtVerifier = &mockJWTVerifier{
verifyFunc: func(jwt *JWT, token string) error {
// Also cache claims here to ensure they're available
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "test@example.com",
}
tOidc.tokenCache.Set(token, testClaims, time.Hour)
return nil // Always succeed
},
}
t.Run("Azure provider detection works correctly", func(t *testing.T) {
if !tOidc.isAzureProvider() {
t.Error("Azure provider should be detected for Azure AD issuer URL")
}
if tOidc.isGoogleProvider() {
t.Error("Google provider should not be detected for Azure AD issuer URL")
}
})
t.Run("Azure auth URL includes correct parameters", func(t *testing.T) {
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Check that response_mode=query was added for Azure
if !strings.Contains(authURL, "response_mode=query") {
t.Errorf("response_mode=query not added to Azure auth URL: %s", authURL)
}
// Verify offline_access scope is included for Azure providers
if !strings.Contains(authURL, "offline_access") {
t.Errorf("offline_access scope not included in Azure auth URL: %s", authURL)
}
// Verify Azure doesn't get Google-specific parameters
if strings.Contains(authURL, "access_type=offline") {
t.Errorf("access_type=offline incorrectly added to Azure auth URL: %s", authURL)
}
if strings.Contains(authURL, "prompt=consent") {
t.Errorf("prompt=consent incorrectly added to Azure auth URL: %s", authURL)
}
})
t.Run("Azure access token validation takes priority", func(t *testing.T) {
// Create a request and session
req := httptest.NewRequest("GET", "/protected", nil)
session, _ := tOidc.sessionManager.GetSession(req)
// Set up session with Azure-style tokens
session.SetAuthenticated(true)
session.SetEmail("user@example.com")
// Use standardized test tokens with valid future expiration dates
accessToken := ValidAccessToken // This token expires in 2065
session.SetAccessToken(accessToken)
// Create an expired ID token using a mock JWT with past expiration
idTokenClaims := map[string]interface{}{
"iss": "https://login.microsoftonline.com/tenant-id/v2.0",
"aud": "test-client-id",
"exp": time.Now().Add(-1 * time.Hour).Unix(), // Expired
"iat": time.Now().Add(-2 * time.Hour).Unix(),
"sub": "user123",
"email": "user@example.com",
}
idToken, _ := createAzureMockJWT(idTokenClaims)
session.SetIDToken(idToken)
// Mock the token verification to simulate Azure behavior
originalTokenVerifier := tOidc.tokenVerifier
tOidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
if token == accessToken {
// Access token validation succeeds - cache claims
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "test@example.com",
}
tOidc.tokenCache.Set(token, testClaims, time.Hour)
return nil
}
if token == idToken {
// ID token validation fails (expired) - don't cache
return newMockError("token has expired")
}
return newMockError("token validation failed")
},
}
defer func() { tOidc.tokenVerifier = originalTokenVerifier }()
// Test Azure-specific validation
authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session)
// Azure should prioritize access token, so even with expired ID token,
// user should still be authenticated since access token is valid
if !authenticated {
t.Error("Azure user should be authenticated when access token is valid, even if ID token is expired")
}
if expired {
t.Error("Azure session should not be marked as expired when access token is valid")
}
// May need refresh if we want to get a fresh ID token
if !needsRefresh {
t.Log("Azure session may not need immediate refresh if access token is still valid")
}
})
t.Run("Azure handles opaque access tokens gracefully", func(t *testing.T) {
// Create a request and session
req := httptest.NewRequest("GET", "/protected", nil)
session, _ := tOidc.sessionManager.GetSession(req)
// Set up session with JWT access token (not opaque for this test)
session.SetAuthenticated(true)
session.SetEmail("user@example.com")
session.SetAccessToken(ValidAccessToken) // This is actually a JWT token
// Use a valid ID token from test tokens
session.SetIDToken(ValidIDToken) // This token expires in 2065
// Mock the token verification
originalTokenVerifier := tOidc.tokenVerifier
tOidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
if token == ValidIDToken {
// ID token is valid - cache claims
testClaims := map[string]interface{}{
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "test@example.com",
}
tOidc.tokenCache.Set(token, testClaims, time.Hour)
return nil
}
return newMockError("token validation failed")
},
}
defer func() { tOidc.tokenVerifier = originalTokenVerifier }()
// Test Azure-specific validation with opaque token
authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session)
// Azure should handle opaque access tokens gracefully
if !authenticated {
t.Error("Azure user should be authenticated with opaque access token")
}
if expired {
t.Error("Azure session should not be expired with valid tokens")
}
if needsRefresh {
t.Log("Azure session with opaque token may signal refresh to get JWT tokens")
}
})
t.Run("Azure CSRF handling during token validation failures", func(t *testing.T) {
// Create a request and session
req := httptest.NewRequest("GET", "/protected", nil)
rw := httptest.NewRecorder()
session, _ := tOidc.sessionManager.GetSession(req)
// Set up session with CSRF token (simulating ongoing auth flow)
session.SetCSRF("test-csrf-token-123")
session.SetNonce("test-nonce-456")
session.SetAuthenticated(false) // Not yet authenticated
// Save session to simulate real scenario
session.Save(req, rw)
// Mock token verification to always fail (simulating Azure token issues)
originalTokenVerifier := tOidc.tokenVerifier
tOidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
return newMockError("azure token validation failed")
},
}
defer func() { tOidc.tokenVerifier = originalTokenVerifier }()
// Test that CSRF is preserved during Azure validation failures
authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session)
// Should not be authenticated due to validation failure
if authenticated {
t.Error("Should not be authenticated when token validation fails")
}
// Should be marked as expired since no tokens work
if !expired && !needsRefresh {
t.Error("Should be marked as needing refresh or expired when validation fails")
}
// Verify CSRF token is still preserved in session
if session.GetCSRF() != "test-csrf-token-123" {
t.Error("CSRF token should be preserved during Azure token validation failures")
}
if session.GetNonce() != "test-nonce-456" {
t.Error("Nonce should be preserved during Azure token validation failures")
}
})
}
// createAzureMockJWT creates a basic JWT token for testing purposes
func createAzureMockJWT(claims map[string]interface{}) (string, error) {
// For testing purposes, create a JWT with expired claims when needed
// Use the test tokens infrastructure for most cases, but allow expired tokens for specific tests
testTokens := NewTestTokens()
// Check if this is meant to be an expired token
if exp, ok := claims["exp"].(int64); ok && exp < time.Now().Unix() {
return testTokens.CreateExpiredJWT(), nil
}
// Otherwise return a valid token
return ValidIDToken, nil
}
// Mock error type for testing
type mockError struct {
message string
}
func (e *mockError) Error() string {
return e.message
}
func newMockError(message string) error {
return &mockError{message: message}
}
// Mock token verifier for testing
type mockTokenVerifier struct {
verifyFunc func(token string) error
}
func (m *mockTokenVerifier) VerifyToken(token string) error {
if m.verifyFunc != nil {
return m.verifyFunc(token)
}
return nil
}
// Mock JWT verifier for testing
type mockJWTVerifier struct {
verifyFunc func(jwt *JWT, token string) error
}
func (m *mockJWTVerifier) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error {
if m.verifyFunc != nil {
return m.verifyFunc(jwt, token)
}
return nil
}
+21 -181
View File
@@ -1,229 +1,69 @@
package traefikoidc
import (
"container/list"
"sync"
"time"
)
// CacheItem represents an item stored in the cache with its associated metadata.
// CacheItem represents an item in the cache
type CacheItem struct {
// Value is the cached data of any type.
Value interface{}
// ExpiresAt is the timestamp when this item should be considered expired.
Value interface{}
ExpiresAt time.Time
}
// lruEntry represents an entry in the LRU list.
type lruEntry struct {
key string
}
// Cache provides a thread-safe in-memory caching mechanism with expiration support.
// It implements an LRU (Least Recently Used) eviction policy using a doubly-linked list for efficiency.
// Cache is a simple in-memory cache
type Cache struct {
items map[string]CacheItem
order *list.List
elems map[string]*list.Element
cleanupTask *BackgroundTask
logger *Logger
maxSize int
autoCleanupInterval time.Duration
mutex sync.RWMutex
items map[string]CacheItem
mutex sync.RWMutex
}
// DefaultMaxSize is the default maximum number of items in the cache.
const DefaultMaxSize = 500
// NewCache creates a new empty cache instance with default settings.
// It initializes the internal maps and list and sets the default maximum size.
// NewCache creates a new Cache
func NewCache() *Cache {
return NewCacheWithLogger(nil)
return &Cache{
items: make(map[string]CacheItem),
}
}
// NewCacheWithLogger creates a new cache with a specified logger
func NewCacheWithLogger(logger *Logger) *Cache {
if logger == nil {
logger = newNoOpLogger()
}
c := &Cache{
items: make(map[string]CacheItem, DefaultMaxSize),
order: list.New(),
elems: make(map[string]*list.Element, DefaultMaxSize),
maxSize: DefaultMaxSize,
autoCleanupInterval: 5 * time.Minute,
logger: logger,
}
c.startAutoCleanup()
return c
}
// Set adds or updates an item in the cache with the specified key, value, and expiration duration.
// If the key already exists, its value and expiration time are updated, and it's moved
// to the most recently used position in the LRU list.
// If the key does not exist and the cache is full, the least recently used item is evicted
// before adding the new item.
// The expiration duration is relative to the time Set is called.
// Set adds an item to the cache
func (c *Cache) Set(key string, value interface{}, expiration time.Duration) {
c.mutex.Lock()
defer c.mutex.Unlock()
now := time.Now()
expTime := now.Add(expiration)
// Update existing item.
if _, exists := c.items[key]; exists {
c.items[key] = CacheItem{
Value: value,
ExpiresAt: expTime,
}
if elem, ok := c.elems[key]; ok {
c.order.MoveToBack(elem)
}
return
}
// Evict oldest item if cache is full.
if len(c.items) >= c.maxSize {
c.evictOldest()
}
// Add new item.
c.items[key] = CacheItem{
Value: value,
ExpiresAt: expTime,
ExpiresAt: time.Now().Add(expiration),
}
elem := c.order.PushBack(lruEntry{key: key})
c.elems[key] = elem
}
// Get retrieves an item from the cache by its key.
// If the item exists and has not expired, its value and true are returned.
// 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
// expired item is removed from the cache.
// Get retrieves an item from the cache
func (c *Cache) Get(key string) (interface{}, bool) {
c.mutex.Lock()
defer c.mutex.Unlock()
item, exists := c.items[key]
if !exists {
c.mutex.RLock()
defer c.mutex.RUnlock()
item, found := c.items[key]
if !found {
return nil, false
}
// Check for expiration.
if time.Now().After(item.ExpiresAt) {
c.removeItem(key)
delete(c.items, key)
return nil, false
}
// Move item to the back (most recently used).
if elem, ok := c.elems[key]; ok {
c.order.MoveToBack(elem)
}
return item.Value, true
}
// Delete removes an item from the cache by its key.
// If the key exists, the corresponding item is removed from the cache storage
// and the LRU list.
// Delete removes an item from the cache
func (c *Cache) Delete(key string) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.removeItem(key)
delete(c.items, key)
}
// Cleanup iterates through the cache and removes all items that have expired.
// An item is considered expired if the current time is after its ExpiresAt timestamp.
// This method is called automatically by the auto-cleanup goroutine, but can also
// be called manually.
// Cleanup removes expired items from the cache
func (c *Cache) Cleanup() {
c.mutex.Lock()
defer c.mutex.Unlock()
now := time.Now()
for key, item := range c.items {
// Remove items that are expired
if now.After(item.ExpiresAt) {
c.removeItem(key)
delete(c.items, key)
}
}
}
// evictOldest removes the least recently used (oldest) item from the cache.
// It first attempts to find and remove an expired item from the front of the LRU list.
// If no expired items are found at the front, it removes the absolute oldest item (front of the list).
// This method is called internally by Set when the cache reaches its maximum size.
// Note: This function assumes the write lock is already held.
func (c *Cache) evictOldest() {
now := time.Now()
elem := c.order.Front()
// First try to find an expired item from the front
for elem != nil {
entry := elem.Value.(lruEntry)
if item, exists := c.items[entry.key]; exists {
if now.After(item.ExpiresAt) {
c.removeItem(entry.key)
return
}
}
elem = elem.Next()
}
// If no expired items found, remove the oldest item
if elem = c.order.Front(); elem != nil {
entry := elem.Value.(lruEntry)
c.removeItem(entry.key)
}
}
// SetMaxSize changes the maximum number of items the cache can hold.
// If the new size is smaller than the current number of items in the cache,
// oldest items will be evicted until the cache size is within the new limit.
func (c *Cache) SetMaxSize(size int) {
if size <= 0 {
return // Invalid size, ignore
}
c.mutex.Lock()
defer c.mutex.Unlock()
c.maxSize = size
// If cache exceeds the new max size, evict oldest items
for len(c.items) > c.maxSize {
c.evictOldest()
}
}
// removeItem removes an item specified by the key from the cache's internal storage (items map)
// and its corresponding entry from the LRU list (order list and elems map).
// Note: This function assumes the write lock is already held.
func (c *Cache) removeItem(key string) {
delete(c.items, key)
if elem, ok := c.elems[key]; ok {
c.order.Remove(elem)
delete(c.elems, key)
}
}
// startAutoCleanup starts the background task that automatically calls the Cleanup method
// at the interval specified by c.autoCleanupInterval.
func (c *Cache) startAutoCleanup() {
c.cleanupTask = NewBackgroundTask("cache-cleanup", c.autoCleanupInterval, c.Cleanup, c.logger)
c.cleanupTask.Start()
}
// Close stops the automatic cleanup task associated with this cache instance.
// It should be called when the cache is no longer needed to prevent resource leaks.
func (c *Cache) Close() {
if c.cleanupTask != nil {
c.cleanupTask.Stop()
c.cleanupTask = nil
}
}
-338
View File
@@ -1,338 +0,0 @@
package traefikoidc
import (
"sync"
"time"
)
// MaxKeyLength defines the maximum allowed length for cache keys
// to prevent memory exhaustion from excessively long keys.
const MaxKeyLength = 256
// OptimizedCacheEntry represents a single cache entry with embedded LRU linked list pointers.
// This design eliminates the need for separate data structures (list.List and map[string]*list.Element)
// and reduces memory overhead by approximately 66% compared to traditional implementations.
type OptimizedCacheEntry struct {
Value interface{}
ExpiresAt time.Time
Key string
// Embedded doubly-linked list pointers for LRU ordering
prev, next *OptimizedCacheEntry
}
// OptimizedCache provides a memory-efficient, thread-safe cache with LRU eviction policy.
// It uses a single map with entries containing embedded doubly-linked list pointers,
// eliminating the memory overhead of maintaining separate data structures.
// The cache supports both item count and memory size limits.
type OptimizedCache struct {
items map[string]*OptimizedCacheEntry
head, tail *OptimizedCacheEntry // LRU sentinel nodes
cleanupTask *BackgroundTask
logger *Logger
maxSize int
maxMemoryBytes int64 // Memory budget limit
currentMemoryBytes int64 // Current estimated memory usage
autoCleanupInterval time.Duration
mutex sync.RWMutex
}
// NewOptimizedCache creates a new memory-efficient cache with default settings.
// It uses the default maximum size and no memory limit.
func NewOptimizedCache() *OptimizedCache {
return NewOptimizedCacheWithConfig(DefaultMaxSize, 0, nil)
}
// NewOptimizedCacheWithConfig creates a cache with specified configuration.
//
// Parameters:
// - maxSize: Maximum number of items in the cache.
// - maxMemoryMB: Maximum memory usage in megabytes (0 for default 64MB).
// - logger: Logger instance for debug output (nil for no-op logger).
//
// Returns:
// - A new OptimizedCache instance.
func NewOptimizedCacheWithConfig(maxSize int, maxMemoryMB int, logger *Logger) *OptimizedCache {
if logger == nil {
logger = newNoOpLogger()
}
// Create sentinel nodes for the doubly-linked list
head := &OptimizedCacheEntry{}
tail := &OptimizedCacheEntry{}
head.next = tail
tail.prev = head
maxMemoryBytes := int64(maxMemoryMB) * 1024 * 1024 // Convert MB to bytes
if maxMemoryBytes == 0 {
maxMemoryBytes = 64 * 1024 * 1024 // Default 64MB
}
c := &OptimizedCache{
items: make(map[string]*OptimizedCacheEntry, maxSize),
head: head,
tail: tail,
maxSize: maxSize,
maxMemoryBytes: maxMemoryBytes,
autoCleanupInterval: 5 * time.Minute,
logger: logger,
}
c.startAutoCleanup()
return c
}
// Set adds or updates an item in the cache with the specified expiration.
// It validates key length and enforces both item count and memory limits.
// When limits are exceeded, the least recently used items are evicted.
//
// Parameters:
// - key: The cache key (must be <= MaxKeyLength).
// - value: The value to cache.
// - expiration: Time until the item expires.
func (c *OptimizedCache) Set(key string, value interface{}, expiration time.Duration) {
// Validate key length to prevent memory bloat
if len(key) > MaxKeyLength {
c.logger.Debugf("Cache key too long (%d > %d), ignoring", len(key), MaxKeyLength)
return
}
c.mutex.Lock()
defer c.mutex.Unlock()
now := time.Now()
expTime := now.Add(expiration)
// Update existing item
if entry, exists := c.items[key]; exists {
oldSize := c.estimateEntrySize(entry)
entry.Value = value
entry.ExpiresAt = expTime
newSize := c.estimateEntrySize(entry)
c.currentMemoryBytes += newSize - oldSize
c.moveToTail(entry)
return
}
// Create new entry
entry := &OptimizedCacheEntry{
Value: value,
ExpiresAt: expTime,
Key: key,
}
entrySize := c.estimateEntrySize(entry)
// Check memory budget and evict if necessary
for (c.currentMemoryBytes+entrySize > c.maxMemoryBytes || len(c.items) >= c.maxSize) && len(c.items) > 0 {
if !c.evictOldest() {
break // No more items to evict
}
}
// Add new entry
c.items[key] = entry
c.currentMemoryBytes += entrySize
c.addToTail(entry)
}
// Get retrieves an item from the cache with memory-efficient access tracking
func (c *OptimizedCache) Get(key string) (interface{}, bool) {
c.mutex.Lock()
defer c.mutex.Unlock()
entry, exists := c.items[key]
if !exists {
return nil, false
}
// Check for expiration
if time.Now().After(entry.ExpiresAt) {
c.removeEntry(entry)
return nil, false
}
// Move to tail (most recently used)
c.moveToTail(entry)
return entry.Value, true
}
// Delete removes an item from the cache
func (c *OptimizedCache) Delete(key string) {
c.mutex.Lock()
defer c.mutex.Unlock()
if entry, exists := c.items[key]; exists {
c.removeEntry(entry)
}
}
// Cleanup removes expired items and performs memory optimization
func (c *OptimizedCache) Cleanup() {
c.mutex.Lock()
defer c.mutex.Unlock()
now := time.Now()
toRemove := make([]*OptimizedCacheEntry, 0, len(c.items)/10) // Pre-allocate for efficiency
// Collect expired entries (start from head - oldest items)
for entry := c.head.next; entry != c.tail; entry = entry.next {
if now.After(entry.ExpiresAt) {
toRemove = append(toRemove, entry)
}
}
// Remove expired entries
for _, entry := range toRemove {
c.removeEntry(entry)
}
// Perform memory pressure eviction if needed
for c.currentMemoryBytes > c.maxMemoryBytes && len(c.items) > 0 {
if !c.evictOldest() {
break
}
}
}
// evictOldest removes the least recently used item
// Returns false if no items to evict
func (c *OptimizedCache) evictOldest() bool {
if c.head.next == c.tail {
return false // Empty cache
}
oldest := c.head.next
c.removeEntry(oldest)
return true
}
// removeEntry removes an entry from both the map and linked list
func (c *OptimizedCache) removeEntry(entry *OptimizedCacheEntry) {
// Remove from map
delete(c.items, entry.Key)
// Update memory usage
c.currentMemoryBytes -= c.estimateEntrySize(entry)
// Remove from linked list
entry.prev.next = entry.next
entry.next.prev = entry.prev
// Clear references to help GC
entry.prev = nil
entry.next = nil
entry.Value = nil
}
// addToTail adds an entry to the tail (most recently used position)
func (c *OptimizedCache) addToTail(entry *OptimizedCacheEntry) {
entry.prev = c.tail.prev
entry.next = c.tail
c.tail.prev.next = entry
c.tail.prev = entry
}
// moveToTail moves an existing entry to the tail (mark as most recently used)
func (c *OptimizedCache) moveToTail(entry *OptimizedCacheEntry) {
// Remove from current position
entry.prev.next = entry.next
entry.next.prev = entry.prev
// Add to tail
c.addToTail(entry)
}
// estimateEntrySize estimates the memory usage of a cache entry
// Uses conservative estimates since unsafe.Sizeof is not allowed in Yaegi
func (c *OptimizedCache) estimateEntrySize(entry *OptimizedCacheEntry) int64 {
// Conservative estimate for OptimizedCacheEntry struct overhead
// (3 pointers + time.Time + string) ≈ 80 bytes on 64-bit systems
size := int64(80) + int64(len(entry.Key))
// Estimate value size based on type
if entry.Value != nil {
switch v := entry.Value.(type) {
case string:
size += int64(len(v))
case []byte:
size += int64(len(v))
case map[string]interface{}:
// Rough estimate for map overhead + keys + values
size += int64(len(v)) * 64 // 64 bytes per entry estimate
for key, val := range v {
size += int64(len(key))
// Estimate value size
switch val := val.(type) {
case string:
size += int64(len(val))
case []byte:
size += int64(len(val))
default:
size += 32 // Default estimate for other types
}
}
case []string:
for _, s := range v {
size += int64(len(s)) + 16 // 16 bytes slice overhead per string
}
default:
// Generic estimate for unknown types
size += 64
}
}
return size
}
// SetMaxSize changes the maximum number of items the cache can hold
func (c *OptimizedCache) SetMaxSize(size int) {
if size <= 0 {
return
}
c.mutex.Lock()
defer c.mutex.Unlock()
c.maxSize = size
// Evict excess items if necessary
for len(c.items) > c.maxSize && len(c.items) > 0 {
if !c.evictOldest() {
break
}
}
}
// SetMaxMemory sets the maximum memory budget in MB
func (c *OptimizedCache) SetMaxMemory(maxMemoryMB int) {
if maxMemoryMB <= 0 {
return
}
c.mutex.Lock()
defer c.mutex.Unlock()
c.maxMemoryBytes = int64(maxMemoryMB) * 1024 * 1024
// Evict items if over memory budget
for c.currentMemoryBytes > c.maxMemoryBytes && len(c.items) > 0 {
if !c.evictOldest() {
break
}
}
}
// startAutoCleanup starts the background cleanup task
func (c *OptimizedCache) startAutoCleanup() {
c.cleanupTask = NewBackgroundTask("optimized-cache-cleanup", c.autoCleanupInterval, c.Cleanup, c.logger)
c.cleanupTask.Start()
}
// Close stops the automatic cleanup task
func (c *OptimizedCache) Close() {
if c.cleanupTask != nil {
c.cleanupTask.Stop()
c.cleanupTask = nil
}
}
-78
View File
@@ -1,78 +0,0 @@
package traefikoidc
import (
"testing"
"time"
)
func TestCache_Cleanup(t *testing.T) {
c := NewCache()
// Add some items with different expiration times
now := time.Now()
pastTime := now.Add(-1 * time.Hour) // Already expired
futureTime := now.Add(1 * time.Hour) // Not expired
// Create test items
c.items["expired"] = CacheItem{
Value: "expired-value",
ExpiresAt: pastTime,
}
c.items["valid"] = CacheItem{
Value: "valid-value",
ExpiresAt: futureTime,
}
// Store original elements in the order list to match items
c.elems["expired"] = c.order.PushBack(lruEntry{key: "expired"})
c.elems["valid"] = c.order.PushBack(lruEntry{key: "valid"})
// Call cleanup, which should only remove expired items
c.Cleanup()
// Check that only the expired item was removed
if _, exists := c.items["expired"]; exists {
t.Error("Expired item was not removed by Cleanup()")
}
if _, exists := c.items["valid"]; !exists {
t.Error("Valid item was incorrectly removed by Cleanup()")
}
}
func TestCache_SetMaxSize(t *testing.T) {
c := NewCache()
// Set a lower max size
originalMaxSize := c.maxSize
newMaxSize := 3
// Add more items than the new max size
for i := 0; i < originalMaxSize; i++ {
key := "key" + string(rune('A'+i))
c.Set(key, i, 1*time.Hour)
}
// Verify items were added
if len(c.items) != originalMaxSize {
t.Errorf("Expected %d items before SetMaxSize, got %d", originalMaxSize, len(c.items))
}
// Change the max size to a smaller value
c.SetMaxSize(newMaxSize)
// Check that the cache was reduced to the new max size
if len(c.items) > newMaxSize {
t.Errorf("Cache size %d exceeds new max size %d after SetMaxSize", len(c.items), newMaxSize)
}
if c.maxSize != newMaxSize {
t.Errorf("Cache maxSize not updated, expected %d, got %d", newMaxSize, c.maxSize)
}
// Check that the oldest items were evicted (should keep "keyC", "keyD", "keyE", etc.)
if _, exists := c.items["keyA"]; exists {
t.Error("Expected oldest item 'keyA' to be evicted, but it still exists")
}
}
-218
View File
@@ -1,218 +0,0 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestCookieCleanupBehavior tests the cookie cleanup function's behavior
func TestCookieCleanupBehavior(t *testing.T) {
logger := NewLogger("debug")
// Create session manager with a specific domain
sm, err := NewSessionManager("test-encryption-key-32-characters", false, "app.example.com", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create request from app.example.com
req := httptest.NewRequest("GET", "http://app.example.com/test", nil)
// Add some OIDC cookies (browsers don't send domain info)
cookies := []*http.Cookie{
{
Name: "_oidc_raczylo_m",
Value: "test-value-1",
},
{
Name: "_oidc_raczylo_a",
Value: "test-value-2",
},
{
Name: "access_token_chunk_0",
Value: "chunk-value",
},
}
for _, cookie := range cookies {
req.AddCookie(cookie)
}
// Create response recorder to capture Set-Cookie headers
rr := httptest.NewRecorder()
// Run cleanup - it should attempt to delete cookies with various domains
sm.CleanupOldCookies(rr, req)
// Check Set-Cookie headers
setCookies := rr.Result().Cookies()
// The cleanup should have attempted to delete cookies with various domain variations
// We should see deletion attempts for:
// - app.example.com
// - .app.example.com
// - example.com
// - .example.com
var deletionAttempts int
domainsAttempted := make(map[string]bool)
for _, cookie := range setCookies {
if cookie.MaxAge == -1 {
deletionAttempts++
domainsAttempted[cookie.Domain] = true
}
}
// We should see deletion attempts for various domain variations
// Note: The exact number depends on the implementation, but we should see multiple attempts
if deletionAttempts == 0 {
t.Error("Expected cleanup to attempt cookie deletions, but none were found")
}
// Log the domains attempted for debugging
t.Logf("Deletion attempts: %d", deletionAttempts)
for domain := range domainsAttempted {
t.Logf("Attempted deletion for domain: %q", domain)
}
}
// TestConfiguredDomainPersistence tests that configured domain is consistently used
func TestConfiguredDomainPersistence(t *testing.T) {
logger := NewLogger("debug")
// Create session manager with explicit domain configuration
configuredDomain := ".example.com"
sm, err := NewSessionManager("test-encryption-key-32-characters", false, configuredDomain, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create requests from different subdomains
tests := []struct {
requestHost string
name string
}{
{
name: "Request from main domain",
requestHost: "example.com",
},
{
name: "Request from subdomain",
requestHost: "app.example.com",
},
{
name: "Request from nested subdomain",
requestHost: "api.app.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "http://"+tt.requestHost+"/test", nil)
rr := httptest.NewRecorder()
// Get session and set a value
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
session.SetNonce("test-nonce")
// Save session
err = session.Save(req, rr)
if err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Check that all cookies use the configured domain
cookies := rr.Result().Cookies()
for _, cookie := range cookies {
if strings.HasPrefix(cookie.Name, "_oidc_") {
// The domain should match the configured domain (with possible normalization)
expectedDomains := []string{configuredDomain, strings.TrimPrefix(configuredDomain, ".")}
domainMatches := false
for _, expected := range expectedDomains {
if cookie.Domain == expected || cookie.Domain == "" {
domainMatches = true
break
}
}
if !domainMatches {
t.Errorf("Cookie %s has unexpected domain %q, expected one of %v",
cookie.Name, cookie.Domain, expectedDomains)
}
}
}
})
}
}
// TestDomainMigration simulates migrating from no configured domain to explicit domain
func TestDomainMigration(t *testing.T) {
logger := NewLogger("debug")
// Step 1: Create session without configured domain (auto-detection)
sm1, err := NewSessionManager("test-encryption-key-32-characters", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req1 := httptest.NewRequest("GET", "http://app.example.com/test", nil)
rr1 := httptest.NewRecorder()
session1, err := sm1.GetSession(req1)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
session1.SetNonce("test-nonce")
session1.Save(req1, rr1)
session1.ReturnToPool()
// The cookies will have auto-detected domain
oldCookies := rr1.Result().Cookies()
t.Logf("Old cookies count: %d", len(oldCookies))
// Step 2: Create new session manager with explicit domain configuration
sm2, err := NewSessionManager("test-encryption-key-32-characters", false, ".example.com", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Step 3: Make request with old cookies
req2 := httptest.NewRequest("GET", "http://app.example.com/test", nil)
// Add the old cookies to the new request
for _, cookie := range oldCookies {
// Simulate browser behavior - don't include domain in Cookie header
simpleCookie := &http.Cookie{
Name: cookie.Name,
Value: cookie.Value,
}
req2.AddCookie(simpleCookie)
}
rr2 := httptest.NewRecorder()
// Run cleanup - should attempt to delete old cookies
sm2.CleanupOldCookies(rr2, req2)
// Check that deletion cookies were sent
newCookies := rr2.Result().Cookies()
deletionCount := 0
for _, cookie := range newCookies {
if cookie.MaxAge == -1 {
deletionCount++
}
}
if deletionCount == 0 {
t.Error("Expected cleanup to send deletion cookies during migration, but none were found")
}
t.Logf("Sent %d deletion cookies during migration", deletionCount)
}
-205
View File
@@ -1,205 +0,0 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestCookieDomainConfiguration tests that the cookie domain configuration is properly applied
func TestCookieDomainConfiguration(t *testing.T) {
tests := []struct {
configDomain string
requestHost string
forwardedHost string
expectedDomain string
name string
}{
{
name: "Configured domain takes precedence",
configDomain: ".example.com",
requestHost: "app.example.com",
expectedDomain: ".example.com",
},
{
name: "Auto-detection when no domain configured",
configDomain: "",
requestHost: "app.example.com",
expectedDomain: "app.example.com",
},
{
name: "X-Forwarded-Host used for auto-detection",
configDomain: "",
requestHost: "internal.local",
forwardedHost: "public.example.com",
expectedDomain: "public.example.com",
},
{
name: "No domain for localhost",
configDomain: "",
requestHost: "localhost:8080",
expectedDomain: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create session manager with configured domain
logger := NewLogger("debug")
sm, err := NewSessionManager("test-encryption-key-32-characters", false, tt.configDomain, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create request
req := httptest.NewRequest("GET", "http://"+tt.requestHost+"/test", nil)
if tt.forwardedHost != "" {
req.Header.Set("X-Forwarded-Host", tt.forwardedHost)
}
// Create a dummy response writer to test getCookieOptions behavior
// We'll examine the session options domain instead
options := &http.Cookie{
Domain: sm.cookieDomain,
}
// If no configured domain, simulate auto-detection
if sm.cookieDomain == "" && req != nil {
host := req.Host
if forwardedHost := req.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
}
if host != "" && !strings.Contains(host, "localhost") && !strings.Contains(host, "127.0.0.1") {
if colonIndex := strings.Index(host, ":"); colonIndex != -1 {
host = host[:colonIndex]
}
options.Domain = host
}
}
// Check domain
if options.Domain != tt.expectedDomain {
t.Errorf("Expected domain %q, got %q", tt.expectedDomain, options.Domain)
}
})
}
}
// TestCookieDomainConsistency tests that all session cookies use the same domain
func TestCookieDomainConsistency(t *testing.T) {
logger := NewLogger("debug")
// Test with configured domain
sm, err := NewSessionManager("test-encryption-key-32-characters", false, ".example.com", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://app.example.com/test", nil)
rr := httptest.NewRecorder()
// Get session and set some values
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
// Set various session values including ID token
session.SetNonce("test-nonce")
session.SetAccessToken("test-access-token")
session.SetRefreshToken("test-refresh-token")
// Set a valid JWT-like ID token (needs 2 dots)
session.SetIDToken("header.payload.signature")
// Save session
err = session.Save(req, rr)
if err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Check all cookies have the same domain
cookies := rr.Result().Cookies()
var seenDomain string
for _, cookie := range cookies {
// Only check our OIDC cookies
if strings.HasPrefix(cookie.Name, "_oidc_") ||
strings.HasPrefix(cookie.Name, "access_token_chunk_") ||
strings.HasPrefix(cookie.Name, "refresh_token_chunk_") {
// Normalize domain for comparison (handle leading dot and empty domain)
normalizedDomain := cookie.Domain
if normalizedDomain == "" {
// Empty domain means host-only cookie, should match configured domain
normalizedDomain = "example.com"
} else if strings.HasPrefix(normalizedDomain, ".") {
normalizedDomain = normalizedDomain[1:]
}
if seenDomain == "" {
seenDomain = normalizedDomain
} else if normalizedDomain != seenDomain {
t.Errorf("Inconsistent cookie domains: %q vs %q for cookie %s",
seenDomain, normalizedDomain, cookie.Name)
}
// Verify it matches configured domain (browsers may normalize by removing leading dot)
// Empty domain is also acceptable for host-only cookies
if cookie.Domain != "" && cookie.Domain != ".example.com" && cookie.Domain != "example.com" {
t.Errorf("Cookie %s has domain %q, expected %q, %q, or empty",
cookie.Name, cookie.Domain, ".example.com", "example.com")
}
}
}
}
// TestCookieDomainWithReverseProxy simulates a reverse proxy scenario
func TestCookieDomainWithReverseProxy(t *testing.T) {
logger := NewLogger("debug")
// No configured domain, should auto-detect from X-Forwarded-Host
sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Simulate reverse proxy request
req := httptest.NewRequest("GET", "http://internal.local:8080/test", nil)
req.Header.Set("X-Forwarded-Host", "public.example.com")
req.Header.Set("X-Forwarded-Proto", "https")
// Test the domain configuration
// Since getCookieOptions is private, we'll check the configured domain directly
options := &http.Cookie{
Domain: sm.cookieDomain,
}
// If no configured domain, simulate auto-detection
if sm.cookieDomain == "" && req != nil {
host := req.Host
if forwardedHost := req.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
}
if host != "" && !strings.Contains(host, "localhost") && !strings.Contains(host, "127.0.0.1") {
if colonIndex := strings.Index(host, ":"); colonIndex != -1 {
host = host[:colonIndex]
}
options.Domain = host
}
}
// Check secure flag based on X-Forwarded-Proto
isSecure := req.Header.Get("X-Forwarded-Proto") == "https" || req.TLS != nil
options.Secure = isSecure // Note: forceHTTPS is private so we can't access it in test
// Should use the forwarded host
if options.Domain != "public.example.com" {
t.Errorf("Expected domain from X-Forwarded-Host %q, got %q",
"public.example.com", options.Domain)
}
// Should be secure due to X-Forwarded-Proto
if !options.Secure {
t.Error("Expected Secure flag to be true with X-Forwarded-Proto: https")
}
}
-476
View File
@@ -1,476 +0,0 @@
package traefikoidc
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestCSRFTokenSessionManagement tests the session management changes that fix the login loop
func TestCSRFTokenSessionManagement(t *testing.T) {
// Test that CSRF tokens persist through the authentication flow
t.Run("CSRF_Token_Persists_After_Selective_Clear", func(t *testing.T) {
// Create a session manager
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Create initial request
req := httptest.NewRequest("GET", "http://example.com/test", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Set initial values
csrfToken := "critical-csrf-token"
session.SetCSRF(csrfToken)
session.SetNonce("test-nonce")
session.SetAuthenticated(true)
session.SetEmail("user@example.com")
session.SetAccessToken("old-access-token")
session.SetRefreshToken("old-refresh-token")
session.SetIDToken("old-id-token")
// Save session
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Get cookies
cookies := rec.Result().Cookies()
// Create new request with cookies (simulating redirect back)
req2 := httptest.NewRequest("GET", "http://example.com/test2", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
// Get session again
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
// Verify all values are there
assert.Equal(t, csrfToken, session2.GetCSRF())
assert.Equal(t, "test-nonce", session2.GetNonce())
assert.True(t, session2.GetAuthenticated())
// Now perform selective clearing (as done in the fix)
session2.SetAuthenticated(false)
session2.SetEmail("")
session2.SetAccessToken("")
session2.SetRefreshToken("")
session2.SetIDToken("")
// Clear OIDC flow values from previous attempts
session2.SetNonce("")
session2.SetCodeVerifier("")
// CRITICAL: CSRF token should still be there
assert.Equal(t, csrfToken, session2.GetCSRF(), "CSRF token must persist after selective clearing")
// Save again
rec2 := httptest.NewRecorder()
err = session2.Save(req2, rec2)
require.NoError(t, err)
// Verify CSRF token persists in new session
req3 := httptest.NewRequest("GET", "http://example.com/callback", nil)
for _, cookie := range rec2.Result().Cookies() {
req3.AddCookie(cookie)
}
session3, err := sessionManager.GetSession(req3)
require.NoError(t, err)
assert.Equal(t, csrfToken, session3.GetCSRF(), "CSRF token must persist across saves")
})
// Test that marking session as dirty forces save
t.Run("Mark_Dirty_Forces_Session_Save", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
req := httptest.NewRequest("GET", "http://example.com/test", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Set CSRF token
csrfToken := "test-csrf-token"
session.SetCSRF(csrfToken)
// Mark as dirty explicitly
session.MarkDirty()
// Save should work even if no apparent changes
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Verify cookie was set
cookies := rec.Result().Cookies()
assert.NotEmpty(t, cookies, "Cookies should be set after save")
// Find main session cookie
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie, "Main session cookie should be set")
})
// Test Azure-specific session handling
t.Run("Azure_Session_Cookie_Configuration", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Simulate Azure callback scenario
req := httptest.NewRequest("GET", "http://example.com/oidc/callback?code=test&state=test-csrf", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Set values as would happen in auth flow
session.SetCSRF("test-csrf")
session.SetNonce("test-nonce")
// Save with proper cookie settings
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Check cookie attributes
cookies := rec.Result().Cookies()
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
// Azure requires SameSite=Lax for cross-site redirects
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite, "SameSite should be Lax for Azure compatibility")
assert.Equal(t, "/", cookie.Path, "Path should be root")
assert.True(t, cookie.HttpOnly, "Cookie should be HttpOnly")
// In production, Secure would be true, but false in test
}
}
})
// Test session continuity through auth flow
t.Run("Session_Continuity_Through_Auth_Flow", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Step 1: Initial request
req1 := httptest.NewRequest("GET", "http://example.com/protected", nil)
session1, err := sessionManager.GetSession(req1)
require.NoError(t, err)
// Simulate auth initiation
csrfToken := "auth-flow-csrf-token"
nonce := "auth-flow-nonce"
session1.SetCSRF(csrfToken)
session1.SetNonce(nonce)
session1.SetIncomingPath("/protected")
// Force save
session1.MarkDirty()
rec1 := httptest.NewRecorder()
err = session1.Save(req1, rec1)
require.NoError(t, err)
cookies := rec1.Result().Cookies()
require.NotEmpty(t, cookies)
// Step 2: Callback request with same cookies
req2 := httptest.NewRequest("GET", "http://example.com/oidc/callback?code=test&state="+csrfToken, nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
// Verify session continuity
assert.Equal(t, csrfToken, session2.GetCSRF(), "CSRF token should be maintained")
assert.Equal(t, nonce, session2.GetNonce(), "Nonce should be maintained")
assert.Equal(t, "/protected", session2.GetIncomingPath(), "Incoming path should be maintained")
})
// Test large token handling doesn't affect CSRF
t.Run("Large_Tokens_Dont_Affect_CSRF", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
req := httptest.NewRequest("GET", "http://example.com/test", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Set CSRF first
csrfToken := "important-csrf"
session.SetCSRF(csrfToken)
// Add large tokens that might cause chunking
largeToken := generateMockJWT(5000)
session.SetIDToken(largeToken)
session.SetAccessToken(largeToken)
// Save
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Count cookies
cookies := rec.Result().Cookies()
mainFound := false
chunkCount := 0
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainFound = true
}
if strings.Contains(cookie.Name, "_oidc_raczylo_") && strings.Contains(cookie.Name, "_") {
chunkCount++
}
}
assert.True(t, mainFound, "Main session cookie must exist")
t.Logf("Total chunks created: %d", chunkCount)
// Verify CSRF is still accessible
req2 := httptest.NewRequest("GET", "http://example.com/test2", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
assert.Equal(t, csrfToken, session2.GetCSRF(), "CSRF must be preserved with large tokens")
})
}
// TestAuthFlowWithoutExternalDependencies tests the auth flow without external dependencies
func TestAuthFlowWithoutExternalDependencies(t *testing.T) {
plugin := CreateConfig()
plugin.ProviderURL = "https://login.microsoftonline.com/test-tenant/v2.0"
plugin.ClientID = "test-client-id"
plugin.ClientSecret = "test-client-secret"
plugin.CallbackURL = "http://example.com/oidc/callback"
plugin.SessionEncryptionKey = "test-encryption-key-32-characters"
plugin.LogLevel = "debug"
// Variables removed as they're not used in this test
// We can't fully initialize TraefikOidc without network access,
// but we can test the session management directly
sessionManager, err := NewSessionManager(plugin.SessionEncryptionKey, plugin.ForceHTTPS, "", NewLogger(plugin.LogLevel))
require.NoError(t, err)
t.Run("Session_Created_On_Protected_Request", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/protected", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Session should be new
assert.False(t, session.GetAuthenticated())
// Set auth flow values
session.SetCSRF("test-csrf-token")
session.SetNonce("test-nonce")
session.SetIncomingPath("/protected")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Should have set cookies
cookies := rec.Result().Cookies()
assert.NotEmpty(t, cookies)
})
}
// TestRegressionLoginLoop specifically tests the fix for issue #53
func TestRegressionLoginLoop(t *testing.T) {
// This test verifies that the specific changes made to fix the login loop work correctly
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Simulate the exact flow that was causing the login loop
t.Run("Fix_Session_Clear_Timing", func(t *testing.T) {
// Initial request
req := httptest.NewRequest("GET", "http://example.com/protected", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Set initial session data
session.SetAuthenticated(true)
session.SetEmail("old@example.com")
session.SetAccessToken("old-token")
session.SetCSRF("existing-csrf")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
cookies := rec.Result().Cookies()
// New request with existing session (user hits protected resource again)
req2 := httptest.NewRequest("GET", "http://example.com/protected", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
// OLD BEHAVIOR: session.Clear() would have been called here, losing CSRF
// NEW BEHAVIOR: Selective clearing
session2.SetAuthenticated(false)
session2.SetEmail("")
session2.SetAccessToken("")
session2.SetRefreshToken("")
session2.SetIDToken("")
session2.SetNonce("")
session2.SetCodeVerifier("")
// CSRF should still exist
existingCSRF := session2.GetCSRF()
assert.Equal(t, "existing-csrf", existingCSRF, "CSRF should persist through selective clear")
// Set new auth flow values
newCSRF := "new-csrf-for-auth"
session2.SetCSRF(newCSRF)
session2.SetNonce("new-nonce")
// Force save
session2.MarkDirty()
rec2 := httptest.NewRecorder()
err = session2.Save(req2, rec2)
require.NoError(t, err)
// Simulate callback
cookies2 := rec2.Result().Cookies()
req3 := httptest.NewRequest("GET", "http://example.com/oidc/callback?code=test&state="+newCSRF, nil)
for _, cookie := range cookies2 {
req3.AddCookie(cookie)
}
session3, err := sessionManager.GetSession(req3)
require.NoError(t, err)
// CSRF should match
assert.Equal(t, newCSRF, session3.GetCSRF(), "CSRF token should be available in callback")
})
t.Run("Fix_Force_Session_Save", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/test", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Set CSRF but don't change authenticated status
session.SetCSRF("important-csrf")
// Without MarkDirty(), the session might not save if the session manager
// doesn't detect the change. The fix ensures we call MarkDirty()
session.MarkDirty()
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Verify cookie was actually set
cookies := rec.Result().Cookies()
found := false
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
found = true
assert.NotEmpty(t, cookie.Value, "Cookie should have value")
}
}
assert.True(t, found, "Main session cookie must be set after MarkDirty")
})
}
// TestCSRFValidationTiming tests timing-sensitive CSRF validation scenarios
func TestCSRFValidationTiming(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
t.Run("Rapid_Redirect_Maintains_CSRF", func(t *testing.T) {
// Simulate rapid redirect (no delay between auth init and callback)
req1 := httptest.NewRequest("GET", "http://example.com/auth", nil)
session1, err := sessionManager.GetSession(req1)
require.NoError(t, err)
csrfToken := "rapid-redirect-csrf"
session1.SetCSRF(csrfToken)
session1.MarkDirty()
rec1 := httptest.NewRecorder()
err = session1.Save(req1, rec1)
require.NoError(t, err)
// Immediate callback (no delay)
cookies := rec1.Result().Cookies()
req2 := httptest.NewRequest("GET", "http://example.com/callback", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
assert.Equal(t, csrfToken, session2.GetCSRF())
})
t.Run("Delayed_Redirect_Maintains_CSRF", func(t *testing.T) {
// Simulate delayed redirect (user takes time at provider)
req1 := httptest.NewRequest("GET", "http://example.com/auth", nil)
session1, err := sessionManager.GetSession(req1)
require.NoError(t, err)
csrfToken := "delayed-redirect-csrf"
session1.SetCSRF(csrfToken)
session1.MarkDirty()
rec1 := httptest.NewRecorder()
err = session1.Save(req1, rec1)
require.NoError(t, err)
// Simulate delay
time.Sleep(500 * time.Millisecond)
// Callback after delay
cookies := rec1.Result().Cookies()
req2 := httptest.NewRequest("GET", "http://example.com/callback", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
assert.Equal(t, csrfToken, session2.GetCSRF(), "CSRF should persist even with delay")
})
}
// Helper function to generate a mock JWT of specified size
func generateMockJWT(targetSize int) string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
signature := "signature"
// Calculate payload size needed
overhead := len(header) + len(signature) + 2 // 2 dots
payloadSize := targetSize - overhead
// Create payload with padding
payload := map[string]interface{}{
"sub": "1234567890",
"name": "Test User",
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
"padding": strings.Repeat("x", payloadSize-100), // Leave room for JSON structure
}
payloadJSON, _ := json.Marshal(payload)
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
return header + "." + payloadB64 + "." + signature
}
-163
View File
@@ -1,163 +0,0 @@
# Google OAuth Integration Fix
## Problem Overview
The Traefik OIDC plugin encountered an authentication issue when using Google as an OAuth provider. Authentication would fail with the following error:
```
Some requested scopes were invalid. {valid=[openid, https://www.googleapis.com/auth/userinfo.email, https://www.googleapis.com/auth/userinfo.profile], invalid=[offline_access]}
```
This occurred because Google's OAuth implementation differs from the standard OIDC specification in how it handles refresh tokens and offline access.
## Technical Details of the Issue
### Standard OIDC Provider Behavior
Most OpenID Connect (OIDC) providers follow the standard specification, where:
- To obtain a refresh token, clients include the `offline_access` scope in their authorization request
- This allows authenticated sessions to persist beyond the initial access token expiration
### Google's Non-Standard Approach
Google's OAuth implementation deviates from the standard by:
1. Not supporting the `offline_access` scope, instead rejecting it as an invalid scope
2. Requiring the `access_type=offline` query parameter for requesting refresh tokens
3. Needing the `prompt=consent` parameter to consistently issue refresh tokens (especially for repeat authentications)
This difference caused the plugin to fail when configured for Google OAuth, as it was using a standard approach that didn't work with Google's implementation.
## Solution Implementation
The fix involved modifying the authentication flow to specifically handle Google providers:
1. **Google Provider Detection**: Added code to detect if the OIDC provider is Google based on the issuer URL:
```go
// Check if we're dealing with a Google OIDC provider
isGoogleProvider := strings.Contains(t.issuerURL, "google") ||
strings.Contains(t.issuerURL, "accounts.google.com")
```
2. **Provider-Specific Auth URL Building**: Modified the `buildAuthURL` function to handle Google and non-Google providers differently:
```go
// Handle offline access differently for Google vs other providers
if isGoogleProvider {
// For Google, use access_type=offline parameter instead of offline_access scope
params.Set("access_type", "offline")
t.logger.Debug("Google OIDC provider detected, added access_type=offline for refresh tokens")
// Add prompt=consent for Google to ensure refresh token is issued
params.Set("prompt", "consent")
t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
} else {
// For non-Google providers, use the offline_access scope
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
}
}
```
3. **Token Refresh Enhancement**: Improved the token refresh logic to better handle Google's behavior, particularly when refresh tokens aren't returned in refresh responses (as Google often uses the same refresh token for multiple requests).
## Why This Approach Works
This solution aligns with Google's OAuth 2.0 documentation which specifies:
1. **Access Type Parameter**: Google's [OAuth 2.0 documentation](https://developers.google.com/identity/protocols/oauth2/web-server#offline) states that to request a refresh token, applications must include `access_type=offline` in the authorization request.
2. **Prompt Parameter**: The [`prompt=consent`](https://developers.google.com/identity/protocols/oauth2/web-server#forceapprovalprompt) parameter forces the consent screen to appear, ensuring a refresh token is issued even if the user has previously granted access.
3. **Scope Validation**: Google strictly validates scopes and rejects non-standard ones like `offline_access`, instead relying on the `access_type` parameter to indicate whether a refresh token should be issued.
By adapting to these Google-specific requirements, the OIDC plugin can now seamlessly work with both standard OIDC providers and Google's OAuth implementation.
## Testing and Verification
Comprehensive tests were implemented to verify the solution:
1. **Provider Detection Test**: Ensures the code correctly identifies Google providers and applies the appropriate parameters.
2. **Auth URL Parameter Tests**: Verifies that:
- For Google providers: `access_type=offline` and `prompt=consent` are included; `offline_access` scope is NOT included
- For non-Google providers: `offline_access` scope IS included; `access_type` parameter is NOT added
3. **Token Refresh Tests**: Validates that Google's token refresh process works correctly, including the preservation of refresh tokens when Google doesn't return a new one.
4. **Integration Test**: Tests the complete authentication flow with a mocked Google provider to ensure all components work together seamlessly.
Sample test case (simplified):
```go
t.Run("Google provider detection adds required parameters", func(t *testing.T) {
// Test buildAuthURL to ensure it adds access_type=offline and prompt=consent for Google
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Check that access_type=offline was added (not offline_access scope for Google)
if !strings.Contains(authURL, "access_type=offline") {
t.Errorf("access_type=offline not added to Google auth URL: %s", authURL)
}
// Verify offline_access scope is NOT included for Google providers
if strings.Contains(authURL, "offline_access") {
t.Errorf("offline_access scope incorrectly added to Google auth URL: %s", authURL)
}
// Check that prompt=consent was added
if !strings.Contains(authURL, "prompt=consent") {
t.Errorf("prompt=consent not added to Google auth URL: %s", authURL)
}
})
```
## Usage Guidance for Developers
When configuring the Traefik OIDC middleware for Google:
1. **Provider URL**: Use `https://accounts.google.com` as the `providerURL` value
2. **Client Configuration**: Create OAuth 2.0 credentials in the Google Cloud Console:
- Configure the authorized redirect URI to match your `callbackURL` setting
- Ensure your OAuth consent screen is properly configured (especially if you want long-lived refresh tokens)
3. **Configuration Example**:
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-google
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: your-google-client-id.apps.googleusercontent.com
clientSecret: your-google-client-secret
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
callbackURL: /oauth2/callback
scopes:
- openid
- email
- profile
# Note: DO NOT manually add offline_access scope for Google
# The middleware handles this automatically and correctly
```
4. **Troubleshooting**: If sessions still expire prematurely with Google (typically after 1 hour):
- Ensure your Google Cloud OAuth consent screen is set to "External" and "Production" mode (not "Testing" mode, which limits refresh token validity)
- Review your application logs with `logLevel: debug` to check for refresh token errors
- Verify you're using a version of the middleware that includes this fix
## Conclusion
This fix ensures that the Traefik OIDC plugin works seamlessly with Google's OAuth implementation without requiring users to make provider-specific configuration changes. The middleware now intelligently adapts to the provider's requirements, making it more robust and user-friendly while maintaining compatibility with the standard OIDC specification for other providers.
-863
View File
@@ -1,863 +0,0 @@
package traefikoidc
import (
"context"
"fmt"
"math"
"math/rand/v2"
"net"
"sync"
"sync/atomic"
"time"
)
// ErrorRecoveryMechanism defines the common interface for all error recovery strategies
// including circuit breakers, retry logic, and rate limiters. Implementations provide
// resilience patterns to handle transient failures and protect downstream services.
type ErrorRecoveryMechanism interface {
// ExecuteWithContext executes a function with error recovery
ExecuteWithContext(ctx context.Context, fn func() error) error
// GetMetrics returns metrics about the error recovery mechanism
GetMetrics() map[string]interface{}
// Reset resets the state of the error recovery mechanism
Reset()
// IsAvailable returns whether the mechanism is available for use
IsAvailable() bool
}
// BaseRecoveryMechanism provides common functionality shared by all error recovery
// implementations. It tracks metrics, manages state, and provides base logging
// capabilities for derived recovery mechanisms.
type BaseRecoveryMechanism struct {
startTime time.Time
lastFailureTime time.Time
lastSuccessTime time.Time
logger *Logger
name string
totalRequests int64
totalFailures int64
totalSuccesses int64
mutex sync.RWMutex
}
// NewBaseRecoveryMechanism creates a new base recovery mechanism with the specified name.
//
// Parameters:
// - name: Identifier for the recovery mechanism.
// - logger: Logger instance for recording events.
//
// Returns:
// - A configured BaseRecoveryMechanism instance.
func NewBaseRecoveryMechanism(name string, logger *Logger) *BaseRecoveryMechanism {
if logger == nil {
logger = newNoOpLogger()
}
return &BaseRecoveryMechanism{
name: name,
logger: logger,
startTime: time.Now(),
}
}
// RecordRequest increments the total request counter.
// This method is thread-safe using atomic operations.
func (b *BaseRecoveryMechanism) RecordRequest() {
atomic.AddInt64(&b.totalRequests, 1)
}
// RecordSuccess records a successful operation by incrementing the success counter
// and updating the last success timestamp. This method is thread-safe.
func (b *BaseRecoveryMechanism) RecordSuccess() {
atomic.AddInt64(&b.totalSuccesses, 1)
b.mutex.Lock()
defer b.mutex.Unlock()
b.lastSuccessTime = time.Now()
}
// RecordFailure records a failed operation by incrementing the failure counter
// and updating the last failure timestamp. This method is thread-safe.
func (b *BaseRecoveryMechanism) RecordFailure() {
atomic.AddInt64(&b.totalFailures, 1)
b.mutex.Lock()
defer b.mutex.Unlock()
b.lastFailureTime = time.Now()
}
// GetBaseMetrics returns metrics common to all recovery mechanisms including
// request counts, success/failure rates, and timing information.
func (b *BaseRecoveryMechanism) GetBaseMetrics() map[string]interface{} {
b.mutex.RLock()
defer b.mutex.RUnlock()
metrics := map[string]interface{}{
"total_requests": atomic.LoadInt64(&b.totalRequests),
"total_failures": atomic.LoadInt64(&b.totalFailures),
"total_successes": atomic.LoadInt64(&b.totalSuccesses),
"uptime_seconds": time.Since(b.startTime).Seconds(),
"name": b.name,
}
if !b.lastFailureTime.IsZero() {
metrics["last_failure_time"] = b.lastFailureTime.Format(time.RFC3339)
metrics["seconds_since_last_failure"] = time.Since(b.lastFailureTime).Seconds()
}
if !b.lastSuccessTime.IsZero() {
metrics["last_success_time"] = b.lastSuccessTime.Format(time.RFC3339)
metrics["seconds_since_last_success"] = time.Since(b.lastSuccessTime).Seconds()
}
// Calculate success rate
if metrics["total_requests"].(int64) > 0 {
successRate := float64(metrics["total_successes"].(int64)) / float64(metrics["total_requests"].(int64))
metrics["success_rate"] = successRate
} else {
metrics["success_rate"] = 1.0 // Default to 100% if no requests
}
return metrics
}
// LogInfo logs an informational message
func (b *BaseRecoveryMechanism) LogInfo(format string, args ...interface{}) {
if b.logger != nil {
b.logger.Infof("%s: "+format, append([]interface{}{b.name}, args...)...)
}
}
// LogError logs an error message
func (b *BaseRecoveryMechanism) LogError(format string, args ...interface{}) {
if b.logger != nil {
b.logger.Errorf("%s: "+format, append([]interface{}{b.name}, args...)...)
}
}
// LogDebug logs a debug message
func (b *BaseRecoveryMechanism) LogDebug(format string, args ...interface{}) {
if b.logger != nil {
b.logger.Debugf("%s: "+format, append([]interface{}{b.name}, args...)...)
}
}
// CircuitBreakerState represents the current state of a circuit breaker
type CircuitBreakerState int
const (
// CircuitBreakerClosed - normal operation, requests are allowed
CircuitBreakerClosed CircuitBreakerState = iota
// CircuitBreakerOpen - circuit is open, requests are rejected
CircuitBreakerOpen
// CircuitBreakerHalfOpen - testing if service has recovered
CircuitBreakerHalfOpen
)
// CircuitBreaker implements the circuit breaker pattern for external service calls
type CircuitBreaker struct {
*BaseRecoveryMechanism
maxFailures int
timeout time.Duration
resetTimeout time.Duration
state CircuitBreakerState
failures int64
}
// CircuitBreakerConfig holds configuration for circuit breakers
type CircuitBreakerConfig struct {
MaxFailures int `json:"max_failures"`
Timeout time.Duration `json:"timeout"`
ResetTimeout time.Duration `json:"reset_timeout"`
}
// DefaultCircuitBreakerConfig returns default circuit breaker configuration
func DefaultCircuitBreakerConfig() CircuitBreakerConfig {
return CircuitBreakerConfig{
MaxFailures: 2, // Reduced from 5 to open circuit faster
Timeout: 60 * time.Second, // Increased from 30s to reduce retry frequency
ResetTimeout: 30 * time.Second, // Increased from 10s to wait longer before retrying
}
}
// NewCircuitBreaker creates a new circuit breaker with the given configuration
func NewCircuitBreaker(config CircuitBreakerConfig, logger *Logger) *CircuitBreaker {
return &CircuitBreaker{
BaseRecoveryMechanism: NewBaseRecoveryMechanism("circuit-breaker", logger),
maxFailures: config.MaxFailures,
timeout: config.Timeout,
resetTimeout: config.ResetTimeout,
state: CircuitBreakerClosed,
}
}
// ExecuteWithContext implements the ErrorRecoveryMechanism interface
func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() error) error {
cb.RecordRequest()
// Check if circuit breaker allows the request
if !cb.allowRequest() {
return fmt.Errorf("circuit breaker is open")
}
// Execute the function
err := fn()
// Record the result
if err != nil {
cb.recordFailure()
cb.RecordFailure()
return err
}
cb.recordSuccess()
cb.RecordSuccess()
return nil
}
// Execute is the original method for backward compatibility
func (cb *CircuitBreaker) Execute(fn func() error) error {
return cb.ExecuteWithContext(context.Background(), fn)
}
// allowRequest checks if the circuit breaker allows the request
func (cb *CircuitBreaker) allowRequest() bool {
cb.mutex.Lock()
defer cb.mutex.Unlock()
now := time.Now()
switch cb.state {
case CircuitBreakerClosed:
return true
case CircuitBreakerOpen:
// Check if timeout has passed
if now.Sub(cb.lastFailureTime) > cb.timeout {
cb.state = CircuitBreakerHalfOpen
cb.logger.Infof("Circuit breaker transitioning to half-open state")
return true
}
return false
case CircuitBreakerHalfOpen:
// Allow limited requests in half-open state
return true
default:
return false
}
}
// recordFailure records a failure and potentially opens the circuit
func (cb *CircuitBreaker) recordFailure() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
cb.failures++
switch cb.state {
case CircuitBreakerClosed:
if cb.failures >= int64(cb.maxFailures) {
cb.state = CircuitBreakerOpen
cb.LogError("Circuit breaker opened after %d failures", cb.failures)
}
case CircuitBreakerHalfOpen:
// Go back to open state on any failure in half-open
cb.state = CircuitBreakerOpen
cb.LogError("Circuit breaker returned to open state after failure in half-open")
}
}
// recordSuccess records a success and potentially closes the circuit
func (cb *CircuitBreaker) recordSuccess() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
switch cb.state {
case CircuitBreakerHalfOpen:
// Reset failures and close circuit on success in half-open
cb.failures = 0
cb.state = CircuitBreakerClosed
cb.LogInfo("Circuit breaker closed after successful request in half-open state")
case CircuitBreakerClosed:
// Reset failure count on success
cb.failures = 0
}
}
// GetState returns the current state of the circuit breaker
func (cb *CircuitBreaker) GetState() CircuitBreakerState {
cb.mutex.RLock()
defer cb.mutex.RUnlock()
return cb.state
}
// Reset resets the circuit breaker to its initial state
func (cb *CircuitBreaker) Reset() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
cb.state = CircuitBreakerClosed
atomic.StoreInt64(&cb.failures, 0)
cb.LogInfo("Circuit breaker has been reset")
}
// IsAvailable returns whether the circuit breaker is allowing requests
func (cb *CircuitBreaker) IsAvailable() bool {
return cb.allowRequest()
}
// GetMetrics returns metrics about the circuit breaker
func (cb *CircuitBreaker) GetMetrics() map[string]interface{} {
cb.mutex.RLock()
state := cb.state
failures := cb.failures
cb.mutex.RUnlock()
metrics := cb.GetBaseMetrics()
// Add circuit breaker specific metrics
stateStr := "unknown"
switch state {
case CircuitBreakerClosed:
stateStr = "closed"
case CircuitBreakerOpen:
stateStr = "open"
case CircuitBreakerHalfOpen:
stateStr = "half-open"
}
metrics["state"] = stateStr
metrics["max_failures"] = cb.maxFailures
metrics["current_failures"] = failures
metrics["timeout_ms"] = cb.timeout.Milliseconds()
metrics["reset_timeout_ms"] = cb.resetTimeout.Milliseconds()
return metrics
}
// RetryConfig holds configuration for retry mechanisms
type RetryConfig struct {
RetryableErrors []string `json:"retryable_errors"`
MaxAttempts int `json:"max_attempts"`
InitialDelay time.Duration `json:"initial_delay"`
MaxDelay time.Duration `json:"max_delay"`
BackoffFactor float64 `json:"backoff_factor"`
EnableJitter bool `json:"enable_jitter"`
}
// DefaultRetryConfig returns default retry configuration
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
MaxAttempts: 3,
InitialDelay: 100 * time.Millisecond,
MaxDelay: 5 * time.Second,
BackoffFactor: 2.0,
EnableJitter: true,
RetryableErrors: []string{
"connection refused",
"timeout",
"temporary failure",
"network unreachable",
},
}
}
// RetryExecutor implements retry logic with exponential backoff
type RetryExecutor struct {
*BaseRecoveryMechanism
config RetryConfig
}
// NewRetryExecutor creates a new retry executor
func NewRetryExecutor(config RetryConfig, logger *Logger) *RetryExecutor {
return &RetryExecutor{
BaseRecoveryMechanism: NewBaseRecoveryMechanism("retry-executor", logger),
config: config,
}
}
// ExecuteWithContext implements the ErrorRecoveryMechanism interface
func (re *RetryExecutor) ExecuteWithContext(ctx context.Context, fn func() error) error {
re.RecordRequest()
var lastErr error
for attempt := 1; attempt <= re.config.MaxAttempts; attempt++ {
// Execute the function
err := fn()
if err == nil {
if attempt > 1 {
re.LogInfo("Operation succeeded after %d attempts", attempt)
}
re.RecordSuccess()
return nil
}
lastErr = err
// Check if error is retryable
if !re.isRetryableError(err) {
// Only log non-retryable errors once
re.RecordFailure()
return err
}
// Don't wait after the last attempt
if attempt == re.config.MaxAttempts {
re.RecordFailure()
break
}
// Calculate delay with exponential backoff
delay := re.calculateDelay(attempt)
// Only log on first retry and then every 3rd attempt to reduce spam
if attempt == 1 || attempt%3 == 0 {
re.LogDebug("Retrying operation after %v (attempt %d/%d): %v",
delay, attempt, re.config.MaxAttempts, err)
}
// Wait with context cancellation support
select {
case <-ctx.Done():
re.RecordFailure()
return ctx.Err()
case <-time.After(delay):
// Continue to next attempt
}
}
finalErr := fmt.Errorf("operation failed after %d attempts: %w", re.config.MaxAttempts, lastErr)
return finalErr
}
// Execute runs the given function with retry logic (for backward compatibility)
func (re *RetryExecutor) Execute(ctx context.Context, fn func() error) error {
return re.ExecuteWithContext(ctx, fn)
}
// isRetryableError checks if an error should trigger a retry
func (re *RetryExecutor) isRetryableError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
// Check against configured retryable errors
for _, retryableErr := range re.config.RetryableErrors {
if contains(errStr, retryableErr) {
return true
}
}
// Check for common network errors using modern Go error handling
if netErr, ok := err.(net.Error); ok {
// Use Timeout() method which is still valid
if netErr.Timeout() {
return true
}
// Check for specific temporary error patterns instead of deprecated Temporary()
errStr := netErr.Error()
temporaryPatterns := []string{
"connection refused",
"connection reset",
"network is unreachable",
"no route to host",
"temporary failure",
"try again",
"resource temporarily unavailable",
}
for _, pattern := range temporaryPatterns {
if contains(errStr, pattern) {
return true
}
}
}
// Check for HTTP status codes that are retryable
if httpErr, ok := err.(*HTTPError); ok {
return httpErr.StatusCode >= 500 || httpErr.StatusCode == 429
}
return false
}
// calculateDelay calculates the delay for the next retry attempt
func (re *RetryExecutor) calculateDelay(attempt int) time.Duration {
// Calculate exponential backoff
delay := float64(re.config.InitialDelay) * math.Pow(re.config.BackoffFactor, float64(attempt-1))
// Apply maximum delay limit
if delay > float64(re.config.MaxDelay) {
delay = float64(re.config.MaxDelay)
}
// Add jitter to prevent thundering herd
if re.config.EnableJitter {
jitter := delay * 0.1 * (2.0*rand.Float64() - 1.0) // ±10% jitter
delay += jitter
}
return time.Duration(delay)
}
// Reset resets the retry executor state
func (re *RetryExecutor) Reset() {
// Nothing to reset for RetryExecutor
re.LogDebug("Retry executor reset")
}
// IsAvailable always returns true for RetryExecutor
func (re *RetryExecutor) IsAvailable() bool {
return true
}
// GetMetrics returns metrics about the retry executor
func (re *RetryExecutor) GetMetrics() map[string]interface{} {
metrics := re.GetBaseMetrics()
// Add retry executor specific metrics
metrics["max_attempts"] = re.config.MaxAttempts
metrics["initial_delay_ms"] = re.config.InitialDelay.Milliseconds()
metrics["max_delay_ms"] = re.config.MaxDelay.Milliseconds()
metrics["backoff_factor"] = re.config.BackoffFactor
metrics["enable_jitter"] = re.config.EnableJitter
metrics["retryable_errors"] = re.config.RetryableErrors
return metrics
}
// HTTPError represents an HTTP error with status code
type HTTPError struct {
Message string
StatusCode int
}
// Error implements the error interface
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message)
}
// GracefulDegradation implements graceful degradation patterns
type GracefulDegradation struct {
*BaseRecoveryMechanism
fallbacks map[string]func() (interface{}, error)
healthChecks map[string]func() bool
degradedServices map[string]time.Time
config GracefulDegradationConfig
mutex sync.RWMutex
}
// GracefulDegradationConfig holds configuration for graceful degradation
type GracefulDegradationConfig struct {
HealthCheckInterval time.Duration `json:"health_check_interval"`
RecoveryTimeout time.Duration `json:"recovery_timeout"`
EnableFallbacks bool `json:"enable_fallbacks"`
}
// DefaultGracefulDegradationConfig returns default configuration
func DefaultGracefulDegradationConfig() GracefulDegradationConfig {
return GracefulDegradationConfig{
HealthCheckInterval: 30 * time.Second,
RecoveryTimeout: 5 * time.Minute,
EnableFallbacks: true,
}
}
// NewGracefulDegradation creates a new graceful degradation manager
func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *GracefulDegradation {
gd := &GracefulDegradation{
BaseRecoveryMechanism: NewBaseRecoveryMechanism("graceful-degradation", logger),
fallbacks: make(map[string]func() (interface{}, error)),
healthChecks: make(map[string]func() bool),
degradedServices: make(map[string]time.Time),
config: config,
}
// Start health check routine
go gd.startHealthCheckRoutine()
return gd
}
// RegisterFallback registers a fallback function for a service
func (gd *GracefulDegradation) RegisterFallback(serviceName string, fallback func() (interface{}, error)) {
gd.mutex.Lock()
defer gd.mutex.Unlock()
gd.fallbacks[serviceName] = fallback
}
// RegisterHealthCheck registers a health check function for a service
func (gd *GracefulDegradation) RegisterHealthCheck(serviceName string, healthCheck func() bool) {
gd.mutex.Lock()
defer gd.mutex.Unlock()
gd.healthChecks[serviceName] = healthCheck
}
// ExecuteWithContext implements the ErrorRecoveryMechanism interface
func (gd *GracefulDegradation) ExecuteWithContext(ctx context.Context, fn func() error) error {
gd.RecordRequest()
// Execute with a simple wrapper
_, err := gd.ExecuteWithFallback("default", func() (interface{}, error) {
return nil, fn()
})
if err != nil {
gd.RecordFailure()
} else {
gd.RecordSuccess()
}
return err
}
// ExecuteWithFallback executes a function with fallback support
func (gd *GracefulDegradation) ExecuteWithFallback(serviceName string, primary func() (interface{}, error)) (interface{}, error) {
// Check if service is degraded
if gd.isServiceDegraded(serviceName) {
gd.LogInfo("Service %s is degraded, using fallback", serviceName)
return gd.executeFallback(serviceName)
}
// Try primary function
result, err := primary()
if err != nil {
// Mark service as degraded
gd.markServiceDegraded(serviceName)
gd.LogError("Service %s failed: %v", serviceName, err)
// Try fallback if available
if gd.config.EnableFallbacks {
gd.LogInfo("Using fallback for service %s", serviceName)
return gd.executeFallback(serviceName)
}
return nil, err
}
return result, nil
}
// isServiceDegraded checks if a service is currently degraded
func (gd *GracefulDegradation) isServiceDegraded(serviceName string) bool {
gd.mutex.RLock()
defer gd.mutex.RUnlock()
degradedTime, exists := gd.degradedServices[serviceName]
if !exists {
return false
}
// Check if recovery timeout has passed
if time.Since(degradedTime) > gd.config.RecoveryTimeout {
delete(gd.degradedServices, serviceName)
return false
}
return true
}
// markServiceDegraded marks a service as degraded
func (gd *GracefulDegradation) markServiceDegraded(serviceName string) {
gd.mutex.Lock()
defer gd.mutex.Unlock()
if _, exists := gd.degradedServices[serviceName]; !exists {
gd.LogError("Service %s marked as degraded", serviceName)
}
gd.degradedServices[serviceName] = time.Now()
}
// executeFallback executes the fallback function for a service
func (gd *GracefulDegradation) executeFallback(serviceName string) (interface{}, error) {
gd.mutex.RLock()
fallback, exists := gd.fallbacks[serviceName]
gd.mutex.RUnlock()
if !exists {
return nil, fmt.Errorf("no fallback available for service %s", serviceName)
}
gd.LogInfo("Executing fallback for degraded service %s", serviceName)
return fallback()
}
// startHealthCheckRoutine starts the background health check routine
func (gd *GracefulDegradation) startHealthCheckRoutine() {
healthCheckTask := NewBackgroundTask(
"graceful-degradation-health-check",
gd.config.HealthCheckInterval,
gd.performHealthChecks,
gd.BaseRecoveryMechanism.logger,
)
healthCheckTask.Start()
}
// performHealthChecks runs health checks for all registered services
func (gd *GracefulDegradation) performHealthChecks() {
gd.mutex.RLock()
healthChecks := make(map[string]func() bool)
for k, v := range gd.healthChecks {
healthChecks[k] = v
}
gd.mutex.RUnlock()
for serviceName, healthCheck := range healthChecks {
if healthCheck() {
// Service is healthy, remove from degraded list
gd.mutex.Lock()
if _, wasDegraded := gd.degradedServices[serviceName]; wasDegraded {
delete(gd.degradedServices, serviceName)
gd.logger.Infof("Service %s recovered from degraded state", serviceName)
}
gd.mutex.Unlock()
} else {
// Service is unhealthy, mark as degraded
gd.markServiceDegraded(serviceName)
}
}
}
// GetDegradedServices returns a list of currently degraded services
func (gd *GracefulDegradation) GetDegradedServices() []string {
gd.mutex.RLock()
defer gd.mutex.RUnlock()
var degraded []string
for serviceName := range gd.degradedServices {
degraded = append(degraded, serviceName)
}
return degraded
}
// Reset resets the state of all degraded services
func (gd *GracefulDegradation) Reset() {
gd.mutex.Lock()
defer gd.mutex.Unlock()
// Clear degraded services
gd.degradedServices = make(map[string]time.Time)
gd.LogInfo("Graceful degradation state has been reset")
}
// IsAvailable returns whether the mechanism is available for use
func (gd *GracefulDegradation) IsAvailable() bool {
return true
}
// GetMetrics returns metrics about the graceful degradation mechanism
func (gd *GracefulDegradation) GetMetrics() map[string]interface{} {
gd.mutex.RLock()
degradedCount := len(gd.degradedServices)
// Get the names of degraded services
degradedServices := make([]string, 0, degradedCount)
for service := range gd.degradedServices {
degradedServices = append(degradedServices, service)
}
// Get total count of registered fallbacks and health checks
fallbackCount := len(gd.fallbacks)
healthCheckCount := len(gd.healthChecks)
gd.mutex.RUnlock()
// Get base metrics
metrics := gd.GetBaseMetrics()
// Add graceful degradation specific metrics
metrics["degraded_services_count"] = degradedCount
metrics["degraded_services"] = degradedServices
metrics["registered_fallbacks_count"] = fallbackCount
metrics["registered_health_checks_count"] = healthCheckCount
metrics["health_check_interval_seconds"] = gd.config.HealthCheckInterval.Seconds()
metrics["recovery_timeout_seconds"] = gd.config.RecoveryTimeout.Seconds()
metrics["fallbacks_enabled"] = gd.config.EnableFallbacks
return metrics
}
// ErrorRecoveryManager coordinates all error recovery mechanisms
type ErrorRecoveryManager struct {
circuitBreakers map[string]*CircuitBreaker
retryExecutor *RetryExecutor
gracefulDegradation *GracefulDegradation
logger *Logger
mutex sync.RWMutex
}
// NewErrorRecoveryManager creates a new error recovery manager
func NewErrorRecoveryManager(logger *Logger) *ErrorRecoveryManager {
return &ErrorRecoveryManager{
circuitBreakers: make(map[string]*CircuitBreaker),
retryExecutor: NewRetryExecutor(DefaultRetryConfig(), logger),
gracefulDegradation: NewGracefulDegradation(DefaultGracefulDegradationConfig(), logger),
logger: logger,
}
}
// GetCircuitBreaker gets or creates a circuit breaker for a service
func (erm *ErrorRecoveryManager) GetCircuitBreaker(serviceName string) *CircuitBreaker {
erm.mutex.Lock()
defer erm.mutex.Unlock()
if cb, exists := erm.circuitBreakers[serviceName]; exists {
return cb
}
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), erm.logger)
erm.circuitBreakers[serviceName] = cb
return cb
}
// ExecuteWithRecovery executes a function with full error recovery support
func (erm *ErrorRecoveryManager) ExecuteWithRecovery(ctx context.Context, serviceName string, fn func() error) error {
cb := erm.GetCircuitBreaker(serviceName)
return erm.retryExecutor.Execute(ctx, func() error {
return cb.Execute(fn)
})
}
// GetRecoveryMetrics returns metrics for all recovery mechanisms
func (erm *ErrorRecoveryManager) GetRecoveryMetrics() map[string]interface{} {
erm.mutex.RLock()
defer erm.mutex.RUnlock()
metrics := make(map[string]interface{})
// Circuit breaker metrics
cbMetrics := make(map[string]interface{})
for name, cb := range erm.circuitBreakers {
cbMetrics[name] = cb.GetMetrics()
}
metrics["circuit_breakers"] = cbMetrics
// Degraded services
metrics["degraded_services"] = erm.gracefulDegradation.GetDegradedServices()
return metrics
}
// Helper function to check if a string contains a substring (case-insensitive)
func contains(s, substr string) bool {
return len(s) >= len(substr) &&
(s == substr ||
(len(s) > len(substr) &&
(s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
containsSubstring(s, substr))))
}
func containsSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
-360
View File
@@ -1,360 +0,0 @@
package traefikoidc
import (
"context"
"errors"
"net"
"testing"
"time"
)
func TestCircuitBreaker(t *testing.T) {
logger := NewLogger("debug")
config := DefaultCircuitBreakerConfig()
config.MaxFailures = 2
config.Timeout = 100 * time.Millisecond
cb := NewCircuitBreaker(config, logger)
t.Run("Successful execution", func(t *testing.T) {
err := cb.Execute(func() error {
return nil
})
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
})
t.Run("Circuit opens after max failures", func(t *testing.T) {
// Trigger failures to open circuit
for i := 0; i < config.MaxFailures; i++ {
cb.Execute(func() error {
return errors.New("test error")
})
}
if cb.GetState() != CircuitBreakerOpen {
t.Errorf("Expected circuit to be open, got %v", cb.GetState())
}
// Should reject requests when open
err := cb.Execute(func() error {
return nil
})
if err == nil || err.Error() != "circuit breaker is open" {
t.Errorf("Expected circuit breaker open error, got %v", err)
}
})
t.Run("Circuit transitions to half-open after timeout", func(t *testing.T) {
// Wait for timeout
time.Sleep(config.Timeout + 10*time.Millisecond)
// Next request should transition to half-open
cb.Execute(func() error {
return nil
})
if cb.GetState() != CircuitBreakerClosed {
t.Errorf("Expected circuit to be closed after successful request, got %v", cb.GetState())
}
})
t.Run("Get metrics", func(t *testing.T) {
metrics := cb.GetMetrics()
if metrics["state"] == nil {
t.Error("Expected metrics to contain state")
}
if metrics["total_requests"] == nil {
t.Error("Expected metrics to contain total_requests")
}
})
}
func TestRetryExecutor(t *testing.T) {
logger := NewLogger("debug")
config := DefaultRetryConfig()
config.MaxAttempts = 3
config.InitialDelay = 10 * time.Millisecond
re := NewRetryExecutor(config, logger)
t.Run("Successful execution on first attempt", func(t *testing.T) {
attempts := 0
err := re.Execute(context.Background(), func() error {
attempts++
return nil
})
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if attempts != 1 {
t.Errorf("Expected 1 attempt, got %d", attempts)
}
})
t.Run("Retry on retryable error", func(t *testing.T) {
attempts := 0
err := re.Execute(context.Background(), func() error {
attempts++
if attempts < 2 {
return errors.New("connection refused")
}
return nil
})
if err != nil {
t.Errorf("Expected no error after retry, got %v", err)
}
if attempts != 2 {
t.Errorf("Expected 2 attempts, got %d", attempts)
}
})
t.Run("No retry on non-retryable error", func(t *testing.T) {
attempts := 0
err := re.Execute(context.Background(), func() error {
attempts++
return errors.New("non-retryable error")
})
if err == nil {
t.Error("Expected error to be returned")
}
if attempts != 1 {
t.Errorf("Expected 1 attempt, got %d", attempts)
}
})
t.Run("Max attempts reached", func(t *testing.T) {
attempts := 0
err := re.Execute(context.Background(), func() error {
attempts++
return errors.New("timeout")
})
if err == nil {
t.Error("Expected error after max attempts")
}
if attempts != config.MaxAttempts {
t.Errorf("Expected %d attempts, got %d", config.MaxAttempts, attempts)
}
})
t.Run("Context cancellation", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
err := re.Execute(ctx, func() error {
return errors.New("timeout")
})
if err != context.Canceled {
t.Errorf("Expected context canceled error, got %v", err)
}
})
t.Run("Network error handling", func(t *testing.T) {
// Test timeout error
timeoutErr := &net.OpError{Op: "dial", Err: errors.New("timeout")}
if !re.isRetryableError(timeoutErr) {
t.Error("Expected timeout error to be retryable")
}
// Test connection refused
connErr := errors.New("connection refused")
if !re.isRetryableError(connErr) {
t.Error("Expected connection refused to be retryable")
}
})
t.Run("HTTP error handling", func(t *testing.T) {
// Test 500 error (retryable)
httpErr500 := &HTTPError{StatusCode: 500, Message: "Internal Server Error"}
if !re.isRetryableError(httpErr500) {
t.Error("Expected 500 error to be retryable")
}
// Test 429 error (retryable)
httpErr429 := &HTTPError{StatusCode: 429, Message: "Too Many Requests"}
if !re.isRetryableError(httpErr429) {
t.Error("Expected 429 error to be retryable")
}
// Test 400 error (not retryable)
httpErr400 := &HTTPError{StatusCode: 400, Message: "Bad Request"}
if re.isRetryableError(httpErr400) {
t.Error("Expected 400 error to not be retryable")
}
})
}
func TestGracefulDegradation(t *testing.T) {
logger := NewLogger("debug")
config := DefaultGracefulDegradationConfig()
config.HealthCheckInterval = 50 * time.Millisecond
config.RecoveryTimeout = 100 * time.Millisecond
gd := NewGracefulDegradation(config, logger)
defer func() {
// Clean up goroutine
time.Sleep(100 * time.Millisecond)
}()
t.Run("Register fallback and health check", func(t *testing.T) {
gd.RegisterFallback("test-service", func() (interface{}, error) {
return "fallback-result", nil
})
gd.RegisterHealthCheck("test-service", func() bool {
return true
})
// Should not be degraded initially
if gd.isServiceDegraded("test-service") {
t.Error("Service should not be degraded initially")
}
})
t.Run("Execute with fallback on failure", func(t *testing.T) {
gd.RegisterFallback("failing-service", func() (interface{}, error) {
return "fallback-result", nil
})
// First call should fail and mark service as degraded
result, err := gd.ExecuteWithFallback("failing-service", func() (interface{}, error) {
return nil, errors.New("service failure")
})
if err != nil {
t.Errorf("Expected fallback to succeed, got error: %v", err)
}
if result != "fallback-result" {
t.Errorf("Expected fallback result, got %v", result)
}
// Service should now be degraded
if !gd.isServiceDegraded("failing-service") {
t.Error("Service should be marked as degraded")
}
})
t.Run("No fallback available", func(t *testing.T) {
_, err := gd.ExecuteWithFallback("no-fallback-service", func() (interface{}, error) {
return nil, errors.New("service failure")
})
if err == nil {
t.Error("Expected error when no fallback available")
}
})
t.Run("Get degraded services", func(t *testing.T) {
degraded := gd.GetDegradedServices()
found := false
for _, s := range degraded {
if s == "failing-service" {
found = true
break
}
}
if !found {
t.Error("Expected failing-service to be in degraded list")
}
})
t.Run("Service recovery after timeout", func(t *testing.T) {
// Wait for recovery timeout
time.Sleep(config.RecoveryTimeout + 20*time.Millisecond)
// Service should no longer be degraded
if gd.isServiceDegraded("failing-service") {
t.Error("Service should have recovered after timeout")
}
})
}
func TestErrorRecoveryManager(t *testing.T) {
logger := NewLogger("debug")
erm := NewErrorRecoveryManager(logger)
t.Run("Get circuit breaker", func(t *testing.T) {
cb1 := erm.GetCircuitBreaker("service1")
cb2 := erm.GetCircuitBreaker("service1")
// Should return the same instance
if cb1 != cb2 {
t.Error("Expected same circuit breaker instance for same service")
}
cb3 := erm.GetCircuitBreaker("service2")
if cb1 == cb3 {
t.Error("Expected different circuit breaker instances for different services")
}
})
t.Run("Execute with recovery", func(t *testing.T) {
attempts := 0
err := erm.ExecuteWithRecovery(context.Background(), "test-service", func() error {
attempts++
if attempts < 2 {
return errors.New("temporary failure")
}
return nil
})
if err != nil {
t.Errorf("Expected recovery to succeed, got %v", err)
}
if attempts < 2 {
t.Errorf("Expected at least 2 attempts, got %d", attempts)
}
})
t.Run("Get recovery metrics", func(t *testing.T) {
metrics := erm.GetRecoveryMetrics()
if metrics["circuit_breakers"] == nil {
t.Error("Expected circuit_breakers in metrics")
}
if metrics["degraded_services"] == nil {
t.Error("Expected degraded_services in metrics")
}
})
}
func TestHTTPError(t *testing.T) {
err := &HTTPError{StatusCode: 500, Message: "Internal Server Error"}
expected := "HTTP 500: Internal Server Error"
if err.Error() != expected {
t.Errorf("Expected %q, got %q", expected, err.Error())
}
}
// Mock network error for testing
type mockNetError struct {
timeout bool
temp bool
}
func (e *mockNetError) Error() string { return "mock network error" }
func (e *mockNetError) Timeout() bool { return e.timeout }
func (e *mockNetError) Temporary() bool { return e.temp }
func TestNetworkErrorHandling(t *testing.T) {
logger := NewLogger("debug")
config := DefaultRetryConfig()
re := NewRetryExecutor(config, logger)
t.Run("Timeout error is retryable", func(t *testing.T) {
err := &mockNetError{timeout: true}
if !re.isRetryableError(err) {
t.Error("Expected timeout error to be retryable")
}
})
t.Run("Non-timeout network error with retryable pattern", func(t *testing.T) {
err := &mockNetError{timeout: false}
// This should not be retryable since it doesn't match patterns and isn't timeout
if re.isRetryableError(err) {
t.Error("Expected non-timeout network error without pattern to not be retryable")
}
})
}
-517
View File
@@ -1,517 +0,0 @@
package traefikoidc
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestExcludedURLsConfiguration(t *testing.T) {
tests := []struct {
name string
excludedURLs []string
expectError bool
errorContains string
}{
{
name: "valid excluded URLs",
excludedURLs: []string{"/health", "/metrics", "/public"},
expectError: false,
},
{
name: "empty excluded URLs list",
excludedURLs: []string{},
expectError: false,
},
{
name: "URL without leading slash",
excludedURLs: []string{"health"},
expectError: true,
errorContains: "excluded URL must start with /",
},
{
name: "URL with path traversal",
excludedURLs: []string{"/../../etc/passwd"},
expectError: true,
errorContains: "must not contain path traversal",
},
{
name: "URL with wildcards",
excludedURLs: []string{"/api/*"},
expectError: true,
errorContains: "must not contain wildcards",
},
{
name: "multiple valid URLs",
excludedURLs: []string{"/login", "/logout", "/api/public", "/static/assets"},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := createTestConfig()
config.ExcludedURLs = tt.excludedURLs
err := config.Validate()
if tt.expectError {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errorContains)
} else {
assert.NoError(t, err)
}
})
}
}
func TestExcludedURLsMatching(t *testing.T) {
tests := []struct {
name string
excludedURLs []string
requestPath string
shouldMatch bool
}{
{
name: "exact match",
excludedURLs: []string{"/health"},
requestPath: "/health",
shouldMatch: true,
},
{
name: "prefix match",
excludedURLs: []string{"/api/public"},
requestPath: "/api/public/users",
shouldMatch: true,
},
{
name: "no match",
excludedURLs: []string{"/health"},
requestPath: "/api/private",
shouldMatch: false,
},
{
name: "multiple URLs with match",
excludedURLs: []string{"/health", "/metrics", "/api/public"},
requestPath: "/api/public/data",
shouldMatch: true,
},
{
name: "case sensitive matching",
excludedURLs: []string{"/Health"},
requestPath: "/health",
shouldMatch: false,
},
{
name: "trailing slash difference",
excludedURLs: []string{"/api"},
requestPath: "/api/",
shouldMatch: true,
},
{
name: "nested path match",
excludedURLs: []string{"/static"},
requestPath: "/static/css/main.css",
shouldMatch: true,
},
{
name: "partial path no match",
excludedURLs: []string{"/api/public"},
requestPath: "/api",
shouldMatch: false,
},
{
name: "empty excluded URLs list",
excludedURLs: []string{},
requestPath: "/anything",
shouldMatch: false,
},
{
name: "root path exclusion",
excludedURLs: []string{"/"},
requestPath: "/anything",
shouldMatch: true, // Everything starts with /
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := createTestConfig()
config.ExcludedURLs = tt.excludedURLs
oidc, _ := setupTestOIDCMiddleware(t, config)
result := oidc.determineExcludedURL(tt.requestPath)
assert.Equal(t, tt.shouldMatch, result)
})
}
}
func TestExcludedURLsBypassesAuthentication(t *testing.T) {
// Track if next handler was called
nextHandlerCalled := false
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextHandlerCalled = true
w.WriteHeader(http.StatusOK)
w.Write([]byte("public content"))
})
tests := []struct {
name string
excludedURLs []string
requestPath string
expectNextHandler bool
expectAuthRedirect bool
}{
{
name: "excluded URL bypasses auth",
excludedURLs: []string{"/public"},
requestPath: "/public/data",
expectNextHandler: true,
expectAuthRedirect: false,
},
{
name: "non-excluded URL requires auth",
excludedURLs: []string{"/public"},
requestPath: "/private/data",
expectNextHandler: false,
expectAuthRedirect: true,
},
{
name: "health check bypass",
excludedURLs: []string{"/health", "/readiness"},
requestPath: "/health",
expectNextHandler: true,
expectAuthRedirect: false,
},
{
name: "metrics endpoint bypass",
excludedURLs: []string{"/metrics"},
requestPath: "/metrics",
expectNextHandler: true,
expectAuthRedirect: false,
},
{
name: "login page bypass",
excludedURLs: []string{"/login"},
requestPath: "/login",
expectNextHandler: true,
expectAuthRedirect: false,
},
{
name: "nested public path",
excludedURLs: []string{"/api/v1/public"},
requestPath: "/api/v1/public/docs",
expectNextHandler: true,
expectAuthRedirect: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset state
nextHandlerCalled = false
config := createTestConfig()
config.ExcludedURLs = tt.excludedURLs
oidc, server := setupTestOIDCMiddleware(t, config)
defer server.Close()
oidc.next = nextHandler
req := httptest.NewRequest("GET", tt.requestPath, nil)
req.Host = "test.example.com" // Set a proper host header
rec := httptest.NewRecorder()
oidc.ServeHTTP(rec, req)
assert.Equal(t, tt.expectNextHandler, nextHandlerCalled)
if tt.expectAuthRedirect {
assert.Equal(t, http.StatusFound, rec.Code)
location := rec.Header().Get("Location")
// Check that it redirects to the test provider
assert.Contains(t, location, "https://test-provider.example.com/auth")
} else {
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "public content", rec.Body.String())
}
})
}
}
func TestDefaultExcludedURLs(t *testing.T) {
// Test that default excluded URLs (like /favicon) work correctly
config := createTestConfig()
// Don't set any ExcludedURLs to test defaults
oidc, _ := setupTestOIDCMiddleware(t, config)
// Check if /favicon is excluded by default
assert.True(t, oidc.determineExcludedURL("/favicon"))
assert.True(t, oidc.determineExcludedURL("/favicon.ico"))
// Other paths should not be excluded
assert.False(t, oidc.determineExcludedURL("/api"))
assert.False(t, oidc.determineExcludedURL("/"))
}
func TestExcludedURLsWithAuthentication(t *testing.T) {
// Test that excluded URLs work correctly when user is already authenticated
nextHandlerCalled := false
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextHandlerCalled = true
w.WriteHeader(http.StatusOK)
})
config := createTestConfig()
config.ExcludedURLs = []string{"/public", "/health"}
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.next = nextHandler
// Mock the token verifier to avoid JWKS lookup
oidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
// Always return success for test tokens
claims, err := extractClaims(token)
if err != nil {
return err
}
// Cache the claims for the token
oidc.tokenCache.Set(token, claims, time.Hour)
return nil
},
}
// Create authenticated session
session := createTestSession()
session.SetAuthenticated(true)
session.SetAccessToken("valid-token-longer-than-20-chars")
session.SetIDToken(createMockJWT(t, "test-user", "test@example.com"))
session.SetEmail("test@example.com")
tests := []struct {
name string
requestPath string
expectNextHandler bool
}{
{
name: "excluded URL with auth session",
requestPath: "/public",
expectNextHandler: true,
},
{
name: "non-excluded URL with auth session",
requestPath: "/private",
expectNextHandler: true, // Should pass through because authenticated
},
{
name: "health check with auth session",
requestPath: "/health",
expectNextHandler: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
nextHandlerCalled = false
req := httptest.NewRequest("GET", tt.requestPath, nil)
rec := httptest.NewRecorder()
// Inject session into request
injectSessionIntoRequest(t, req, session)
oidc.ServeHTTP(rec, req)
assert.Equal(t, tt.expectNextHandler, nextHandlerCalled)
assert.Equal(t, http.StatusOK, rec.Code)
})
}
}
func TestExcludedURLsEdgeCases(t *testing.T) {
tests := []struct {
name string
excludedURLs []string
requestPath string
description string
shouldMatch bool
}{
{
name: "query parameters ignored",
excludedURLs: []string{"/api/public"},
requestPath: "/api/public?secret=123",
description: "Query parameters should be ignored in matching",
shouldMatch: true,
},
{
name: "fragment ignored",
excludedURLs: []string{"/docs"},
requestPath: "/docs#section1",
description: "URL fragments should be ignored in matching",
shouldMatch: true,
},
{
name: "double slashes normalized",
excludedURLs: []string{"/api/public"},
requestPath: "//api/public",
description: "Double slashes should be handled",
shouldMatch: false, // Path normalization depends on implementation
},
{
name: "encoded URLs",
excludedURLs: []string{"/api/public"},
requestPath: "/api%2Fpublic",
description: "URL encoding should be handled",
shouldMatch: false, // Encoded slash is different
},
{
name: "very long excluded path",
excludedURLs: []string{"/this/is/a/very/long/path/that/should/still/work"},
requestPath: "/this/is/a/very/long/path/that/should/still/work/and/more",
description: "Long paths should work correctly",
shouldMatch: true,
},
{
name: "similar but different paths",
excludedURLs: []string{"/api/v1"},
requestPath: "/api/v2",
description: "Similar paths should not match",
shouldMatch: false,
},
{
name: "empty path",
excludedURLs: []string{"/api"},
requestPath: "",
description: "Empty path should not match",
shouldMatch: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := createTestConfig()
config.ExcludedURLs = tt.excludedURLs
oidc, _ := setupTestOIDCMiddleware(t, config)
result := oidc.determineExcludedURL(tt.requestPath)
assert.Equal(t, tt.shouldMatch, result, tt.description)
})
}
}
func TestExcludedURLsPerformance(t *testing.T) {
// Test performance with many excluded URLs
excludedURLs := make([]string, 100)
for i := 0; i < 100; i++ {
excludedURLs[i] = fmt.Sprintf("/excluded/path/%d", i)
}
config := createTestConfig()
config.ExcludedURLs = excludedURLs
oidc, _ := setupTestOIDCMiddleware(t, config)
// Suppress debug logs for performance test
oldLogger := oidc.logger
oidc.logger = newNoOpLogger()
defer func() { oidc.logger = oldLogger }()
// Test that matching is still fast with many URLs
start := time.Now()
for i := 0; i < 1000; i++ {
oidc.determineExcludedURL("/excluded/path/50/subpath")
}
elapsed := time.Since(start)
// Should complete 1000 checks in under 100ms (lenient for slower systems and CI)
assert.Less(t, elapsed.Milliseconds(), int64(100), "URL matching should be fast")
}
func TestExcludedURLsIntegration(t *testing.T) {
// Integration test simulating real-world usage
publicContent := "This is public content"
privateContent := "This is private content"
publicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/public") {
w.Write([]byte(publicContent))
} else {
w.Write([]byte(privateContent))
}
})
config := createTestConfig()
config.ExcludedURLs = []string{
"/health",
"/api/public",
"/login",
"/static",
}
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.next = publicHandler
// Test various scenarios
scenarios := []struct {
path string
expectStatus int
expectContent string
expectRedirect bool
}{
{
path: "/health",
expectStatus: http.StatusOK,
expectContent: privateContent,
expectRedirect: false,
},
{
path: "/api/public/users",
expectStatus: http.StatusOK,
expectContent: publicContent,
expectRedirect: false,
},
{
path: "/api/private/admin",
expectStatus: http.StatusFound,
expectContent: "",
expectRedirect: true,
},
{
path: "/static/css/main.css",
expectStatus: http.StatusOK,
expectContent: privateContent,
expectRedirect: false,
},
{
path: "/login?redirect=/dashboard",
expectStatus: http.StatusOK,
expectContent: privateContent,
expectRedirect: false,
},
}
for _, scenario := range scenarios {
t.Run("request to "+scenario.path, func(t *testing.T) {
req := httptest.NewRequest("GET", scenario.path, nil)
rec := httptest.NewRecorder()
oidc.ServeHTTP(rec, req)
assert.Equal(t, scenario.expectStatus, rec.Code)
if scenario.expectRedirect {
assert.Contains(t, rec.Header().Get("Location"), "https://test-provider.example.com")
} else {
assert.Equal(t, scenario.expectContent, rec.Body.String())
}
})
}
}
+1 -7
View File
@@ -7,13 +7,7 @@ toolchain go1.23.1
require (
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.3.0
github.com/stretchr/testify v1.10.0
golang.org/x/time v0.7.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require github.com/gorilla/securecookie v1.1.2 // indirect
-10
View File
@@ -1,5 +1,3 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -8,13 +6,5 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-600
View File
@@ -1,600 +0,0 @@
package traefikoidc
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"fmt"
"math/big"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"golang.org/x/time/rate"
)
// MockJWTVerifier implements the JWTVerifier interface for testing
type MockJWTVerifier struct {
VerifyJWTFunc func(jwt *JWT, token string) error
}
func (m *MockJWTVerifier) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error {
if m.VerifyJWTFunc != nil {
return m.VerifyJWTFunc(jwt, token)
}
return nil
}
func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
// Create a mocked TraefikOidc instance that simulates Google provider behavior
mockLogger := NewLogger("debug")
// Create a test instance with a Google-like issuer URL
tOidc := &TraefikOidc{
issuerURL: "https://accounts.google.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
logger: mockLogger,
scopes: []string{"openid", "profile", "email"},
refreshGracePeriod: 60,
}
// Create a session manager
sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, "", mockLogger)
tOidc.sessionManager = sessionManager
t.Run("Google provider detection adds required parameters", func(t *testing.T) {
// Test buildAuthURL to ensure it adds access_type=offline and prompt=consent for Google
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Check that access_type=offline was added (not offline_access scope for Google)
if !strings.Contains(authURL, "access_type=offline") {
t.Errorf("access_type=offline not added to Google auth URL: %s", authURL)
}
// Verify offline_access scope is NOT included for Google providers
if strings.Contains(authURL, "offline_access") {
t.Errorf("offline_access scope incorrectly added to Google auth URL: %s", authURL)
}
// Check that prompt=consent was added
if !strings.Contains(authURL, "prompt=consent") {
t.Errorf("prompt=consent not added to Google auth URL: %s", authURL)
}
})
t.Run("Non-Google provider doesn't add Google-specific params", func(t *testing.T) {
// Create a test instance with a non-Google issuer URL
nonGoogleOidc := &TraefikOidc{
issuerURL: "https://auth.example.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
logger: mockLogger,
scopes: []string{"openid", "profile", "email"},
}
// Test buildAuthURL without Google-specific parameters
authURL := nonGoogleOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Check that prompt=consent is not automatically added
if strings.Contains(authURL, "prompt=consent") {
t.Errorf("prompt=consent added to non-Google auth URL: %s", authURL)
}
})
t.Run("Session refresh with Google provider", func(t *testing.T) {
// Create a request and response recorder
req := httptest.NewRequest("GET", "/test", nil)
rw := httptest.NewRecorder()
// Create a session and set a refresh token
session, _ := sessionManager.GetSession(req)
session.SetAuthenticated(true)
session.SetEmail("test@example.com")
session.SetAccessToken(ValidAccessToken)
session.SetRefreshToken("valid-refresh-token")
// Create a mock token exchanger that simulates Google's behavior
mockTokenExchanger := &MockTokenExchanger{
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
// Check that the refresh token is passed correctly
if refreshToken != "valid-refresh-token" {
t.Errorf("Incorrect refresh token passed: %s", refreshToken)
return nil, fmt.Errorf("invalid token")
}
// Use standardized test tokens instead of ad-hoc strings
testTokens := NewTestTokens()
googleTokens := testTokens.GetGoogleTokenSet()
// Return a simulated Google token response with a new access token
// but without a new refresh token (Google doesn't always return a new refresh token)
return &TokenResponse{
IDToken: googleTokens.IDToken,
AccessToken: googleTokens.AccessToken,
RefreshToken: "", // Google often doesn't return a new refresh token
ExpiresIn: 3600,
}, nil
},
}
// Set the mock token exchanger
tOidc.tokenExchanger = mockTokenExchanger
// Create a struct that implements the TokenVerifier interface
tOidc.tokenVerifier = &MockTokenVerifier{
VerifyFunc: func(token string) error {
return nil
},
}
tOidc.extractClaimsFunc = func(token string) (map[string]interface{}, error) {
// Return mock claims
return map[string]interface{}{
"email": "test@example.com",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
}, nil
}
// Attempt to refresh the token
refreshed := tOidc.refreshToken(rw, req, session)
// Verify the refresh was successful
if !refreshed {
t.Error("Token refresh failed for Google provider")
}
// Check that we kept the original refresh token since Google didn't provide a new one
if session.GetRefreshToken() != "valid-refresh-token" {
t.Errorf("Original refresh token not preserved: got %s, expected 'valid-refresh-token'",
session.GetRefreshToken())
}
// Use the same test tokens for validation
testTokens := NewTestTokens()
expectedTokens := testTokens.GetGoogleTokenSet()
// Check that the tokens were updated correctly
if session.GetIDToken() != expectedTokens.IDToken {
t.Errorf("ID token not updated: got %s, expected %s",
session.GetIDToken(), expectedTokens.IDToken)
}
if session.GetAccessToken() != expectedTokens.AccessToken {
t.Errorf("Access token not updated: got %s, expected %s",
session.GetAccessToken(), expectedTokens.AccessToken)
}
})
// Test that our fix specifically addresses the reported Google error
t.Run("Google provider handles offline access correctly", func(t *testing.T) {
// Build the auth URL with Google provider detection
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Parse the URL to examine its parameters
parsedURL, err := url.Parse(authURL)
if err != nil {
t.Fatalf("Failed to parse auth URL: %v", err)
}
params := parsedURL.Query()
// Verify that access_type=offline is set (Google's way of requesting refresh tokens)
if params.Get("access_type") != "offline" {
t.Errorf("access_type=offline not set in Google auth URL")
}
// Verify that the scope parameter doesn't contain offline_access
// (which Google reports as invalid: {invalid=[offline_access]})
scope := params.Get("scope")
if strings.Contains(scope, "offline_access") {
t.Errorf("offline_access incorrectly included in scope for Google provider: %s", scope)
}
// Verify that the necessary scopes are still included
for _, requiredScope := range []string{"openid", "profile", "email"} {
if !strings.Contains(scope, requiredScope) {
t.Errorf("Required scope '%s' missing from auth URL", requiredScope)
}
}
})
// Enhanced test for verifying non-Google provider includes offline_access scope
t.Run("Non-Google provider includes offline_access scope", func(t *testing.T) {
// Create a test instance with a non-Google issuer URL
nonGoogleOidc := &TraefikOidc{
issuerURL: "https://auth.example.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
logger: mockLogger,
scopes: []string{"openid", "profile", "email"},
}
// Test buildAuthURL for a non-Google provider
authURL := nonGoogleOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Parse the URL to examine its parameters
parsedURL, err := url.Parse(authURL)
if err != nil {
t.Fatalf("Failed to parse auth URL: %v", err)
}
params := parsedURL.Query()
// Verify that access_type=offline is NOT set for non-Google providers
if params.Get("access_type") == "offline" {
t.Errorf("access_type=offline incorrectly added to non-Google auth URL")
}
// Verify that offline_access scope IS included for non-Google providers
scope := params.Get("scope")
if !strings.Contains(scope, "offline_access") {
t.Errorf("offline_access scope missing from non-Google auth URL scope: %s", scope)
}
// Verify that the necessary scopes are still included
for _, requiredScope := range []string{"openid", "profile", "email"} {
if !strings.Contains(scope, requiredScope) {
t.Errorf("Required scope '%s' missing from non-Google auth URL", requiredScope)
}
}
})
// Additional test for complete URL construction for Google provider
t.Run("Complete Google auth URL construction", func(t *testing.T) {
// Build the auth URL with additional parameters
redirectURL := "https://example.com/callback"
state := "state123"
nonce := "nonce123"
codeChallenge := "code_challenge_value" // For PKCE
// Enable PKCE for this test
tOidc.enablePKCE = true
// Build auth URL
authURL := tOidc.buildAuthURL(redirectURL, state, nonce, codeChallenge)
// Parse the URL to examine its structure and parameters
parsedURL, err := url.Parse(authURL)
if err != nil {
t.Fatalf("Failed to parse auth URL: %v", err)
}
// Verify the base URL
expectedBaseURL := "https://accounts.google.com/o/oauth2/v2/auth"
if !strings.HasPrefix(authURL, expectedBaseURL) && !strings.Contains(authURL, "accounts.google.com") {
t.Errorf("Auth URL doesn't start with expected Google OAuth endpoint: %s", authURL)
}
// Check all required parameters
params := parsedURL.Query()
expectedParams := map[string]string{
"client_id": "test-client-id",
"response_type": "code",
"redirect_uri": redirectURL,
"state": state,
"nonce": nonce,
"access_type": "offline",
"prompt": "consent",
}
// Also check PKCE parameters if enabled
if tOidc.enablePKCE {
expectedParams["code_challenge"] = codeChallenge
expectedParams["code_challenge_method"] = "S256"
}
for key, expectedValue := range expectedParams {
if value := params.Get(key); value != expectedValue {
t.Errorf("Parameter %s has incorrect value. Expected: %s, Got: %s",
key, expectedValue, value)
}
}
// Verify scope parameter separately due to it being space-separated values
scope := params.Get("scope")
if scope == "" {
t.Error("Scope parameter missing from Google auth URL")
}
// Check that all required scopes are present
scopeList := strings.Split(scope, " ")
expectedScopes := []string{"openid", "profile", "email"}
for _, expectedScope := range expectedScopes {
found := false
for _, s := range scopeList {
if s == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in scope parameter: %s", expectedScope, scope)
}
}
// Verify offline_access is NOT in the scope list
for _, actualScope := range scopeList {
if actualScope == "offline_access" {
t.Errorf("offline_access scope incorrectly included in Google auth URL: %s", scope)
}
}
})
// Integration test with mocked Google provider
t.Run("Integration test with mocked Google provider", func(t *testing.T) {
// Generate an RSA key for signing the test JWTs
rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
// Create JWK for the RSA public key
jwk := JWK{
Kty: "RSA",
Kid: "test-key-id",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(rsaPrivateKey.PublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(bigIntToBytes(big.NewInt(int64(rsaPrivateKey.PublicKey.E)))),
}
jwks := &JWKSet{
Keys: []JWK{jwk},
}
// Create a mock JWK cache
mockJWKCache := &MockJWKCache{
JWKS: jwks,
Err: nil,
}
// Create a complete test instance with all required fields
mockLogger := NewLogger("debug")
googleTOidc := &TraefikOidc{
issuerURL: "https://accounts.google.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
logger: mockLogger,
scopes: []string{"openid", "profile", "email"},
refreshGracePeriod: 60,
tokenCache: NewTokenCache(), // Initialize tokenCache
tokenBlacklist: NewCache(), // Initialize tokenBlacklist
enablePKCE: false,
limiter: rate.NewLimiter(rate.Inf, 0), // No rate limiting for tests
jwkCache: mockJWKCache,
jwksURL: "https://accounts.google.com/jwks",
}
// Create a session manager
sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, "", mockLogger)
googleTOidc.sessionManager = sessionManager
// Create a mock token verifier
mockTokenVerifier := &MockTokenVerifier{
VerifyFunc: func(token string) error {
return nil // Always verify successfully for this test
},
}
googleTOidc.tokenVerifier = mockTokenVerifier
// Create JWT tokens for the test
now := time.Now()
exp := now.Add(1 * time.Hour).Unix()
iat := now.Unix()
nbf := now.Unix()
// Create initial ID token
initialIDToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://accounts.google.com",
"aud": "test-client-id",
"exp": exp,
"iat": iat,
"nbf": nbf,
"sub": "test-subject",
"email": "user@example.com",
"nonce": "nonce123", // For initial authentication verification
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create test ID token: %v", err)
}
// Create refresh ID token
refreshedIDToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://accounts.google.com",
"aud": "test-client-id",
"exp": exp,
"iat": iat,
"nbf": nbf,
"sub": "test-subject",
"email": "user@example.com",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create refreshed test ID token: %v", err)
}
// Set up token verifier with mock
googleTOidc.tokenVerifier = &MockTokenVerifier{
VerifyFunc: func(token string) error {
return nil // Always verify successfully for this test
},
}
// Set up JWT verifier with mock
googleTOidc.jwtVerifier = &MockJWTVerifier{
VerifyJWTFunc: func(jwt *JWT, token string) error {
return nil // Always verify successfully for this test
},
}
// Create a mock token exchanger that simulates Google's OAuth behavior
mockTokenExchanger := &MockTokenExchanger{
ExchangeCodeFunc: func(ctx context.Context, grantType, codeOrToken, redirectURL, codeVerifier string) (*TokenResponse, error) {
// Verify the correct parameters are passed
if grantType != "authorization_code" {
t.Errorf("Expected grant_type=authorization_code, got %s", grantType)
}
if codeOrToken != "test_auth_code" {
t.Errorf("Expected code=test_auth_code, got %s", codeOrToken)
}
if redirectURL != "https://example.com/callback" {
t.Errorf("Expected redirect_uri=https://example.com/callback, got %s", redirectURL)
}
// Return a successful token response with a proper JWT
return &TokenResponse{
IDToken: initialIDToken,
AccessToken: initialIDToken, // Use a valid JWT as the access token too
RefreshToken: "google_refresh_token",
ExpiresIn: 3600,
}, nil
},
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
// Verify the correct refresh token is passed
if refreshToken != "google_refresh_token" {
t.Errorf("Expected refresh_token=google_refresh_token, got %s", refreshToken)
}
// Return a successful refresh response with a proper JWT
return &TokenResponse{
IDToken: refreshedIDToken,
AccessToken: refreshedIDToken, // Use a valid JWT as the access token
RefreshToken: "", // Google doesn't always return a new refresh token
ExpiresIn: 3600,
}, nil
},
}
googleTOidc.tokenExchanger = mockTokenExchanger
// Use the real extractClaimsFunc to parse the proper JWT tokens
googleTOidc.extractClaimsFunc = extractClaims
// 1. Test building the authorization URL
authURL := googleTOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Verify Google-specific parameters
if !strings.Contains(authURL, "access_type=offline") {
t.Errorf("Google auth URL missing access_type=offline: %s", authURL)
}
if !strings.Contains(authURL, "prompt=consent") {
t.Errorf("Google auth URL missing prompt=consent: %s", authURL)
}
if strings.Contains(authURL, "offline_access") {
t.Errorf("Google auth URL incorrectly includes offline_access scope: %s", authURL)
}
// 2. Test handling the callback and token exchange
// Create a request and response recorder for the callback
req := httptest.NewRequest("GET", "/callback?code=test_auth_code&state=state123", nil)
rw := httptest.NewRecorder()
// Create a session and set the necessary values
session, _ := googleTOidc.sessionManager.GetSession(req)
session.SetCSRF("state123") // Must match the state parameter
session.SetNonce("nonce123")
// Save the session to the request
if err := session.Save(req, rw); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Get cookies from the response and add them to a new request
cookies := rw.Result().Cookies()
callbackReq := httptest.NewRequest("GET", "/callback?code=test_auth_code&state=state123", nil)
for _, cookie := range cookies {
callbackReq.AddCookie(cookie)
}
callbackRw := httptest.NewRecorder()
// Handle the callback
googleTOidc.handleCallback(callbackRw, callbackReq, "https://example.com/callback")
// Verify the response is a redirect (302 Found)
if callbackRw.Code != 302 {
t.Errorf("Expected 302 redirect, got %d", callbackRw.Code)
}
// Create a new request to get the updated session
newReq := httptest.NewRequest("GET", "/", nil)
for _, cookie := range callbackRw.Result().Cookies() {
newReq.AddCookie(cookie)
}
// Get the updated session
newSession, err := googleTOidc.sessionManager.GetSession(newReq)
if err != nil {
t.Fatalf("Failed to get session after callback: %v", err)
}
// Verify the session contains the expected values
if !newSession.GetAuthenticated() {
t.Error("Session not marked as authenticated after callback")
}
if newSession.GetEmail() != "user@example.com" {
t.Errorf("Session email incorrect: got %s, expected user@example.com",
newSession.GetEmail())
}
// Check for non-empty access token that can be parsed as JWT
accessToken := newSession.GetAccessToken()
if accessToken == "" {
t.Error("Session access token is empty")
} else {
claims, err := extractClaims(accessToken)
if err != nil {
t.Errorf("Failed to parse access token as JWT: %v", err)
} else if email, ok := claims["email"].(string); !ok || email != "user@example.com" {
t.Errorf("Access token JWT doesn't contain expected email claim")
}
}
// Check refresh token
if newSession.GetRefreshToken() != "google_refresh_token" {
t.Errorf("Session refresh token incorrect: got %s, expected google_refresh_token",
newSession.GetRefreshToken())
}
// 3. Test token refresh
refreshReq := httptest.NewRequest("GET", "/", nil)
for _, cookie := range callbackRw.Result().Cookies() {
refreshReq.AddCookie(cookie)
}
refreshRw := httptest.NewRecorder()
// Get the session for refresh
refreshSession, _ := googleTOidc.sessionManager.GetSession(refreshReq)
// Refresh the token
refreshed := googleTOidc.refreshToken(refreshRw, refreshReq, refreshSession)
// Verify refresh was successful
if !refreshed {
t.Error("Token refresh failed")
}
// Verify the session data after refresh
// Check for non-empty refreshed access token that can be parsed as JWT
refreshedAccessToken := refreshSession.GetAccessToken()
if refreshedAccessToken == "" {
t.Error("Session access token is empty after refresh")
} else {
claims, err := extractClaims(refreshedAccessToken)
if err != nil {
t.Errorf("Failed to parse refreshed access token as JWT: %v", err)
} else if email, ok := claims["email"].(string); !ok || email != "user@example.com" {
t.Errorf("Refreshed access token JWT doesn't contain expected email claim")
}
}
// Since Google didn't return a new refresh token, the original should be preserved
if refreshSession.GetRefreshToken() != "google_refresh_token" {
t.Errorf("Original refresh token not preserved: got %s, expected google_refresh_token",
refreshSession.GetRefreshToken())
}
})
}
// No need to redefine MockTokenExchanger - it's already defined in main_test.go
+260 -251
View File
@@ -3,26 +3,21 @@ package traefikoidc
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/sessions"
)
// generateNonce creates a cryptographically secure random string suitable for use as an OIDC nonce.
// The nonce is used during the authentication flow to mitigate replay attacks by associating
// the ID token with the specific authentication request.
// It generates 32 random bytes and encodes them using base64 URL encoding.
//
// Returns:
// - A base64 URL encoded random string (nonce).
// - An error if the random byte generation fails.
// generateNonce generates a random nonce
func generateNonce() (string, error) {
nonceBytes := make([]byte, 32)
_, err := rand.Read(nonceBytes)
@@ -32,70 +27,8 @@ func generateNonce() (string, error) {
return base64.URLEncoding.EncodeToString(nonceBytes), nil
}
// generateCodeVerifier creates a cryptographically secure random string suitable for use as a PKCE code verifier.
// According to RFC 7636, the verifier should be a high-entropy string between 43 and 128 characters long.
// This function generates 32 random bytes, resulting in a 43-character base64 URL encoded string.
//
// Returns:
// - A base64 URL encoded random string (code verifier).
// - An error if the random byte generation fails.
func generateCodeVerifier() (string, error) {
// Using 32 bytes (256 bits) will produce a 43 character base64url string
verifierBytes := make([]byte, 32)
_, err := rand.Read(verifierBytes)
if err != nil {
return "", fmt.Errorf("could not generate code verifier: %w", err)
}
return base64.RawURLEncoding.EncodeToString(verifierBytes), nil
}
// deriveCodeChallenge computes the PKCE code challenge from a given code verifier.
// It uses the S256 challenge method (SHA-256 hash followed by base64 URL encoding)
// as defined in RFC 7636.
//
// Parameters:
// - codeVerifier: The high-entropy string generated by generateCodeVerifier.
//
// Returns:
// - The base64 URL encoded SHA-256 hash of the code verifier (code challenge).
func deriveCodeChallenge(codeVerifier string) string {
// Calculate SHA-256 hash of the code verifier
hasher := sha256.New()
hasher.Write([]byte(codeVerifier))
hash := hasher.Sum(nil)
// Base64url encode the hash to get the code challenge
return base64.RawURLEncoding.EncodeToString(hash)
}
// TokenResponse represents the response from the OIDC token endpoint.
// It contains the various tokens and metadata returned after successful
// code exchange or token refresh operations.
type TokenResponse struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// exchangeTokens performs the OAuth 2.0 token exchange with the OIDC provider's token endpoint.
// It handles both the "authorization_code" grant type (exchanging an authorization code for tokens)
// and the "refresh_token" grant type (using a refresh token to obtain new tokens).
// It includes necessary parameters like client credentials and handles PKCE verification if applicable.
// The function follows redirects and handles potential errors during the exchange.
//
// Parameters:
// - ctx: The context for the outgoing HTTP request.
// - grantType: The OAuth 2.0 grant type ("authorization_code" or "refresh_token").
// - codeOrToken: The authorization code (for "authorization_code" grant) or the refresh token (for "refresh_token" grant).
// - redirectURL: The redirect URI that was used in the initial authorization request (required for "authorization_code" grant).
// - codeVerifier: The PKCE code verifier (required for "authorization_code" grant if PKCE was used).
//
// Returns:
// - A TokenResponse containing the obtained tokens (ID, access, refresh).
// - An error if the token exchange fails (e.g., network error, provider error, invalid grant).
func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
// exchangeTokens exchanges a code or refresh token for tokens
func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType, codeOrToken, redirectURL string) (*TokenResponse, error) {
data := url.Values{
"grant_type": {grantType},
"client_id": {t.clientID},
@@ -105,51 +38,24 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
if grantType == "authorization_code" {
data.Set("code", codeOrToken)
data.Set("redirect_uri", redirectURL)
// Add code_verifier if PKCE is being used
if codeVerifier != "" {
data.Set("code_verifier", codeVerifier)
}
} else if grantType == "refresh_token" {
data.Set("refresh_token", codeOrToken)
}
client := t.tokenHTTPClient
if client == nil {
jar, _ := cookiejar.New(nil)
client = &http.Client{
Transport: t.httpClient.Transport,
Timeout: t.httpClient.Timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 50 {
return fmt.Errorf("stopped after 50 redirects")
}
return nil
},
Jar: jar,
}
}
req, err := http.NewRequestWithContext(ctx, "POST", t.tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to exchange tokens: %w", err)
}
defer func() {
// Always drain the body before closing to ensure connection can be reused
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Limit body read to prevent memory issues
limitReader := io.LimitReader(resp.Body, 1024*10) // 10KB limit
bodyBytes, _ := io.ReadAll(limitReader)
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
@@ -161,38 +67,185 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
return &tokenResponse, nil
}
// getNewTokenWithRefreshToken uses a refresh token to obtain a new set of tokens (ID, access, refresh)
// from the OIDC provider's token endpoint. It wraps the exchangeTokens function with the
// "refresh_token" grant type.
//
// Parameters:
// - refreshToken: The refresh token previously obtained during authentication or a prior refresh.
//
// Returns:
// - A TokenResponse containing the newly obtained tokens.
// - An error if the refresh operation fails.
// TokenResponse represents the response from the token endpoint
type TokenResponse struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
// getNewTokenWithRefreshToken refreshes the token using the refresh token
func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) {
ctx := context.Background()
tokenResponse, err := t.exchangeTokens(ctx, "refresh_token", refreshToken, "", "")
tokenResponse, err := t.exchangeTokens(ctx, "refresh_token", refreshToken, "")
if err != nil {
return nil, fmt.Errorf("failed to refresh token: %w", err)
}
t.logger.Debugf("Token response: %+v", tokenResponse)
return tokenResponse, nil
}
// extractClaims decodes the payload (claims set) part of a JWT string.
// It splits the JWT into its three parts, base64 URL decodes the second part (payload),
// and unmarshals the resulting JSON into a map.
// Note: This function does *not* validate the token's signature or claims.
//
// Parameters:
// - tokenString: The raw JWT string.
//
// Returns:
// - 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.
// handleExpiredToken handles the case when a token has expired
func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Request, session *sessions.Session, redirectURL string) {
// Clear the existing session
session.Options.MaxAge = -1
for k := range session.Values {
delete(session.Values, k)
}
// Set new values
session.Values["csrf"] = uuid.New().String()
session.Values["incoming_path"] = req.URL.Path
session.Values["nonce"], _ = generateNonce()
session.Options = defaultSessionOptions
// Save the session before initiating authentication
if err := session.Save(req, rw); err != nil {
t.logger.Errorf("Failed to save session: %v", err)
http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
return
}
// Initiate a new authentication flow
t.initiateAuthenticationFunc(rw, req, session, redirectURL)
}
// handleCallback handles the callback from the OIDC provider
func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request, redirectURL string) {
session, err := t.store.Get(req, cookieName)
if err != nil {
t.logger.Errorf("Session error: %v", err)
http.Error(rw, "Session error", http.StatusInternalServerError)
return
}
t.logger.Debugf("Handling callback, URL: %s", req.URL.String())
// Check for errors in the query parameters
if req.URL.Query().Get("error") != "" {
errorDescription := req.URL.Query().Get("error_description")
t.logger.Errorf("Authentication error: %s - %s", req.URL.Query().Get("error"), errorDescription)
http.Error(rw, fmt.Sprintf("Authentication error: %s", errorDescription), http.StatusBadRequest)
return
}
// Validate the state parameter matches the session's CSRF token
state := req.URL.Query().Get("state")
if state == "" {
t.logger.Error("No state in callback")
http.Error(rw, "State parameter missing in callback", http.StatusBadRequest)
return
}
csrfToken, ok := session.Values["csrf"].(string)
if !ok || csrfToken == "" {
t.logger.Error("CSRF token missing in session")
http.Error(rw, "CSRF token missing", http.StatusBadRequest)
return
}
if state != csrfToken {
t.logger.Error("State parameter does not match CSRF token in session")
http.Error(rw, "Invalid state parameter", http.StatusBadRequest)
return
}
// Proceed to exchange the code for tokens
code := req.URL.Query().Get("code")
if code == "" {
t.logger.Error("No code in callback")
http.Error(rw, "No code in callback", http.StatusBadRequest)
return
}
tokenResponse, err := t.exchangeCodeForTokenFunc(code, redirectURL)
if err != nil {
t.logger.Errorf("Failed to exchange code for token: %v", err)
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return
}
// Extract id_token
idToken := tokenResponse.IDToken
if idToken == "" {
t.logger.Error("No id_token in token response")
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return
}
// Verify the id_token
if err := t.verifyToken(idToken); err != nil {
t.logger.Errorf("Failed to verify id_token: %v", err)
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return
}
// Extract claims from id_token
claims, err := t.extractClaimsFunc(idToken)
if err != nil {
t.logger.Errorf("Failed to extract claims: %v", err)
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return
}
// Verify the nonce claim matches the one stored in session
nonceClaim, ok := claims["nonce"].(string)
if !ok || nonceClaim == "" {
t.logger.Error("Nonce claim missing in id_token")
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return
}
sessionNonce, ok := session.Values["nonce"].(string)
if !ok || sessionNonce == "" {
t.logger.Error("Nonce not found in session")
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return
}
if nonceClaim != sessionNonce {
t.logger.Error("Nonce claim does not match session nonce")
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
return
}
// Get the email from claims
email, _ := claims["email"].(string)
if email == "" || !t.isAllowedDomain(email) {
t.logger.Errorf("Invalid or disallowed email: %s", email)
http.Error(rw, "Authentication failed: Invalid or disallowed email", http.StatusForbidden)
return
}
// Store tokens and authentication status in session
session.Values["authenticated"] = true
session.Values["email"] = email
session.Values["id_token"] = idToken
session.Values["refresh_token"] = tokenResponse.RefreshToken
session.Options = defaultSessionOptions
// Remove CSRF and nonce from session
delete(session.Values, "csrf")
delete(session.Values, "nonce")
if err := session.Save(req, rw); err != nil {
t.logger.Errorf("Failed to save session: %v", err)
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
return
}
t.logger.Debugf("Authentication successful. User email: %s", email)
// Redirect to the original requested path or default to root
redirectPath := "/"
if path, ok := session.Values["incoming_path"].(string); ok && path != t.redirURLPath {
t.logger.Debugf("Redirecting to incoming path from original request: %s", path)
redirectPath = path
}
http.Redirect(rw, req, redirectPath, http.StatusFound)
}
// extractClaims extracts claims from a JWT token
func extractClaims(tokenString string) (map[string]interface{}, error) {
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
@@ -212,53 +265,65 @@ func extractClaims(tokenString string) (map[string]interface{}, error) {
return claims, nil
}
// TokenCache provides a caching mechanism for validated tokens.
// It stores token claims to avoid repeated validation of the
// same token, improving performance for frequently used tokens.
// TokenCache provides a specialized cache for validated JWT tokens.
// It wraps the generic Cache with token-specific prefixing to avoid
// key collisions and provides a clean interface for token caching operations.
// TokenBlacklist maintains a blacklist of tokens
type TokenBlacklist struct {
blacklist map[string]time.Time
mutex sync.RWMutex
}
// NewTokenBlacklist creates a new TokenBlacklist
func NewTokenBlacklist() *TokenBlacklist {
return &TokenBlacklist{
blacklist: make(map[string]time.Time),
}
}
// Add adds a token to the blacklist
func (tb *TokenBlacklist) Add(tokenID string, expiration time.Time) {
tb.mutex.Lock()
defer tb.mutex.Unlock()
tb.blacklist[tokenID] = expiration
}
// IsBlacklisted checks if a token is blacklisted
func (tb *TokenBlacklist) IsBlacklisted(tokenID string) bool {
tb.mutex.RLock()
defer tb.mutex.RUnlock()
expiration, exists := tb.blacklist[tokenID]
return exists && time.Now().Before(expiration)
}
// Cleanup removes expired tokens from the blacklist
func (tb *TokenBlacklist) Cleanup() {
tb.mutex.Lock()
defer tb.mutex.Unlock()
now := time.Now()
for tokenID, expiration := range tb.blacklist {
if now.After(expiration) {
delete(tb.blacklist, tokenID)
}
}
}
// TokenCache caches tokens
type TokenCache struct {
cache *Cache
}
const (
defaultTokenCacheMaxSize = 1000
defaultTokenCacheCleanupInterval = 2 * time.Minute
)
// NewTokenCache creates and initializes a new TokenCache.
// NewTokenCache creates a new TokenCache
func NewTokenCache() *TokenCache {
cache := NewCache()
cache.SetMaxSize(defaultTokenCacheMaxSize)
return &TokenCache{
cache: cache,
cache: NewCache(),
}
}
// Set stores the claims associated with a specific token string in the cache.
// It prefixes the token string to avoid potential collisions with other cache types
// and sets the provided expiration duration.
//
// Parameters:
// - token: The raw token string (used as the key).
// - claims: The map of claims associated with the token.
// - expiration: The duration for which the cache entry should be valid.
// Set sets a token in the cache
func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
token = "t-" + token
tc.cache.Set(token, claims, expiration)
}
// Get retrieves the cached claims for a given token string.
// It prefixes the token string before querying the underlying cache.
//
// Parameters:
// - token: The raw token string to look up.
//
// Returns:
// - The cached claims map if found and valid.
// - A boolean indicating whether the token was found in the cache (true if found, false otherwise).
// Get retrieves a token from the cache
func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
token = "t-" + token
value, found := tc.cache.Get(token)
@@ -269,63 +334,28 @@ func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
return claims, ok
}
// Delete removes the cached entry for a specific token string.
// It prefixes the token string before calling the underlying cache's Delete method.
//
// Parameters:
// - token: The raw token string to remove from the cache.
// Delete removes a token from the cache
func (tc *TokenCache) Delete(token string) {
token = "t-" + token
tc.cache.Delete(token)
}
// Cleanup triggers the cleanup process for the underlying generic cache,
// removing expired token entries.
// Cleanup cleans up expired tokens from the cache
func (tc *TokenCache) Cleanup() {
tc.cache.Cleanup()
}
// Close stops the cleanup goroutine in the underlying cache.
func (tc *TokenCache) Close() {
tc.cache.Close()
}
// exchangeCodeForToken is a convenience function that wraps exchangeTokens specifically
// for the "authorization_code" grant type. It handles the conditional inclusion of the
// PKCE code verifier based on the middleware's configuration (t.enablePKCE).
//
// Parameters:
// - code: The authorization code received from the OIDC provider.
// - redirectURL: The redirect URI used in the initial authorization request.
// - codeVerifier: The PKCE code verifier stored in the session (if PKCE is enabled).
//
// Returns:
// - A TokenResponse containing the obtained tokens.
// - An error if the code exchange fails.
func (t *TraefikOidc) exchangeCodeForToken(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
// exchangeCodeForToken exchanges the authorization code for tokens
func (t *TraefikOidc) exchangeCodeForToken(code string, redirectURL string) (*TokenResponse, error) {
ctx := context.Background()
effectiveCodeVerifier := ""
if t.enablePKCE && codeVerifier != "" {
effectiveCodeVerifier = codeVerifier
}
tokenResponse, err := t.exchangeTokens(ctx, "authorization_code", code, redirectURL, effectiveCodeVerifier)
tokenResponse, err := t.exchangeTokens(ctx, "authorization_code", code, redirectURL)
if err != nil {
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
}
return tokenResponse, nil
}
// createStringMap converts a slice of strings into a map[string]struct{} (a set).
// This is useful for creating efficient lookups (O(1) average time complexity)
// for checking the presence of items like allowed domains, roles, or groups.
//
// Parameters:
// - keys: A slice of strings to be added to the set.
//
// Returns:
// - A map where the keys are the strings from the input slice and the values are empty structs.
// createStringMap creates a map from a slice of strings
func createStringMap(keys []string) map[string]struct{} {
result := make(map[string]struct{})
for _, key := range keys {
@@ -334,70 +364,65 @@ func createStringMap(keys []string) map[string]struct{} {
return result
}
// handleLogout processes requests to the configured logout path.
// It performs the following steps:
// 1. Retrieves the current user session.
// 2. Gets the access token (ID token hint) from the session.
// 3. Clears all authentication-related data from the session cookies.
// 4. Determines the final post-logout redirect URI.
// 5. If an OIDC end_session_endpoint is configured and an ID token hint is available,
// it builds the OIDC logout URL and redirects the user agent to the provider for logout.
// 6. Otherwise, it redirects the user agent directly to the post-logout redirect URI.
//
// It handles potential errors during session retrieval or clearing.
// handleLogout handles the logout request
func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
session, err := t.sessionManager.GetSession(req)
session, err := t.store.Get(req, cookieName)
if err != nil {
t.logger.Errorf("Error getting session: %v", err)
http.Error(rw, "Session error", http.StatusInternalServerError)
return
}
idToken := session.GetIDToken()
// Get the id_token before clearing the session
idToken, _ := session.Values["id_token"].(string)
if err := session.Clear(req, rw); err != nil {
t.logger.Errorf("Error clearing session: %v", err)
// Clear and expire the session
session.Values = make(map[interface{}]interface{})
session.Options.MaxAge = -1
if err := session.Save(req, rw); err != nil {
t.logger.Errorf("Error saving session: %v", err)
http.Error(rw, "Session error", http.StatusInternalServerError)
return
}
// Get the base URL for redirects
host := t.determineHost(req)
scheme := t.determineScheme(req)
baseURL := fmt.Sprintf("%s://%s", scheme, host)
postLogoutRedirectURI := t.postLogoutRedirectURI
if postLogoutRedirectURI == "" {
postLogoutRedirectURI = fmt.Sprintf("%s/", baseURL)
} else if !strings.HasPrefix(postLogoutRedirectURI, "http") {
postLogoutRedirectURI = fmt.Sprintf("%s%s", baseURL, postLogoutRedirectURI)
// Determine post logout redirect URI
var postLogoutRedirectURI string
if t.postLogoutRedirectURI != "" {
// Use explicitly configured postLogoutRedirectURI
if strings.HasPrefix(t.postLogoutRedirectURI, "http://") || strings.HasPrefix(t.postLogoutRedirectURI, "https://") {
postLogoutRedirectURI = t.postLogoutRedirectURI
} else {
postLogoutRedirectURI = fmt.Sprintf("%s%s", baseURL, t.postLogoutRedirectURI)
}
} else {
postLogoutRedirectURI = fmt.Sprintf("%s%s", baseURL, "/")
}
t.logger.Debugf("Using post logout redirect URI: %s", postLogoutRedirectURI)
// If we have an end session endpoint and an ID token, use OIDC end session
if t.endSessionURL != "" && idToken != "" {
logoutURL, err := BuildLogoutURL(t.endSessionURL, idToken, postLogoutRedirectURI)
if err != nil {
t.logger.Errorf("Failed to build logout URL: %v", err)
http.Error(rw, "Logout error", http.StatusInternalServerError)
handleError(rw, fmt.Sprintf("Failed to build logout URL: %v", err), http.StatusInternalServerError, t.logger)
return
}
t.logger.Debugf("Redirecting to end session URL: %s", logoutURL)
http.Redirect(rw, req, logoutURL, http.StatusFound)
return
}
// If no end session endpoint or no ID token, just redirect to the post logout URI
t.logger.Debugf("Redirecting to post logout URI: %s", postLogoutRedirectURI)
http.Redirect(rw, req, postLogoutRedirectURI, http.StatusFound)
}
// BuildLogoutURL constructs the URL for redirecting the user agent to the OIDC provider's
// end_session_endpoint, including the required id_token_hint and optional
// post_logout_redirect_uri parameters as query arguments.
//
// Parameters:
// - endSessionURL: The URL of the OIDC provider's end session endpoint.
// - idToken: The ID token previously issued to the user (used as id_token_hint).
// - postLogoutRedirectURI: The optional URI where the provider should redirect the user agent after logout.
//
// Returns:
// - The fully constructed logout URL string.
// - An error if the provided endSessionURL is invalid.
// BuildLogoutURL constructs the OIDC end session URL
func BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI string) (string, error) {
u, err := url.Parse(endSessionURL)
if err != nil {
@@ -407,26 +432,10 @@ func BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI string) (strin
q := u.Query()
q.Set("id_token_hint", idToken)
if postLogoutRedirectURI != "" {
// Ensure postLogoutRedirectURI is properly URL encoded
q.Set("post_logout_redirect_uri", postLogoutRedirectURI)
}
u.RawQuery = q.Encode()
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
}
-669
View File
@@ -1,669 +0,0 @@
package traefikoidc
import (
"fmt"
"net/url"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// InputValidator provides comprehensive input validation and sanitization
// to protect against common security vulnerabilities including SQL injection,
// XSS, path traversal, and other injection attacks. It validates and sanitizes
// various input types used in OIDC authentication flows.
type InputValidator struct {
usernameRegex *regexp.Regexp
tokenRegex *regexp.Regexp
logger *Logger
urlRegex *regexp.Regexp
emailRegex *regexp.Regexp
sqlInjectionPatterns []string
pathTraversalPatterns []string
xssPatterns []string
maxUsernameLength int
maxURLLength int
maxTokenLength int
maxEmailLength int
maxClaimLength int
maxHeaderLength int
}
// ValidationResult encapsulates the outcome of input validation.
// It includes the sanitized value, detected security risks, validation
// errors and warnings, and an overall validity status.
type ValidationResult struct {
SanitizedValue string `json:"sanitized_value,omitempty"`
SecurityRisk string `json:"security_risk,omitempty"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
IsValid bool `json:"is_valid"`
}
// InputValidationConfig defines the configuration parameters for input validation.
// It specifies maximum lengths for various input types and controls whether
// strict validation mode is enabled.
type InputValidationConfig struct {
MaxTokenLength int `json:"max_token_length"`
MaxURLLength int `json:"max_url_length"`
MaxHeaderLength int `json:"max_header_length"`
MaxClaimLength int `json:"max_claim_length"`
MaxEmailLength int `json:"max_email_length"`
MaxUsernameLength int `json:"max_username_length"`
StrictMode bool `json:"strict_mode"`
}
// DefaultInputValidationConfig returns a secure default configuration
// for input validation with reasonable limits based on industry standards
// and security best practices.
func DefaultInputValidationConfig() InputValidationConfig {
return InputValidationConfig{
MaxTokenLength: 50000, // 50KB for tokens
MaxURLLength: 2048, // Standard URL length limit
MaxHeaderLength: 8192, // 8KB for headers
MaxClaimLength: 1024, // 1KB for individual claims
MaxEmailLength: 254, // RFC 5321 limit
MaxUsernameLength: 64, // Reasonable username limit
StrictMode: true, // Enable strict validation by default
}
}
// NewInputValidator creates a new input validator with the specified configuration.
// It compiles all necessary regex patterns and initializes security pattern lists.
//
// Parameters:
// - config: Validation configuration with size limits and mode settings.
// - logger: Logger instance for recording validation events.
//
// Returns:
// - A configured InputValidator instance.
// - An error if regex compilation fails.
func NewInputValidator(config InputValidationConfig, logger *Logger) (*InputValidator, error) {
// Compile regex patterns
emailRegex, err := regexp.Compile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
if err != nil {
return nil, fmt.Errorf("failed to compile email regex: %w", err)
}
urlRegex, err := regexp.Compile(`^https?://[a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,})?(?::[0-9]+)?(?:/[^\s]*)?$`)
if err != nil {
return nil, fmt.Errorf("failed to compile URL regex: %w", err)
}
tokenRegex, err := regexp.Compile(`^[A-Za-z0-9._-]+$`)
if err != nil {
return nil, fmt.Errorf("failed to compile token regex: %w", err)
}
usernameRegex, err := regexp.Compile(`^[a-zA-Z0-9._-]+$`)
if err != nil {
return nil, fmt.Errorf("failed to compile username regex: %w", err)
}
return &InputValidator{
maxTokenLength: config.MaxTokenLength,
maxURLLength: config.MaxURLLength,
maxHeaderLength: config.MaxHeaderLength,
maxClaimLength: config.MaxClaimLength,
maxEmailLength: config.MaxEmailLength,
maxUsernameLength: config.MaxUsernameLength,
emailRegex: emailRegex,
urlRegex: urlRegex,
tokenRegex: tokenRegex,
usernameRegex: usernameRegex,
sqlInjectionPatterns: []string{
"'", "\"", ";", "--", "/*", "*/", "xp_", "sp_",
"union", "select", "insert", "update", "delete", "drop",
"create", "alter", "exec", "execute", "script",
},
xssPatterns: []string{
"<script", "</script>", "javascript:", "vbscript:",
"onload=", "onerror=", "onclick=", "onmouseover=",
"<iframe", "<object", "<embed", "<link", "<meta",
},
pathTraversalPatterns: []string{
"../", "..\\", "%2e%2e%2f", "%2e%2e%5c",
"..%2f", "..%5c", "%252e%252e%252f",
},
logger: logger,
}, nil
}
// ValidateToken validates JWT tokens and similar token strings
func (iv *InputValidator) ValidateToken(token string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty token
if token == "" {
result.IsValid = false
result.Errors = append(result.Errors, "token cannot be empty")
return result
}
// Check length limits
if len(token) > iv.maxTokenLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("token length %d exceeds maximum %d", len(token), iv.maxTokenLength))
return result
}
// Check for minimum reasonable length
if len(token) < 10 {
result.IsValid = false
result.Errors = append(result.Errors, "token is too short to be valid")
return result
}
// Check for valid JWT structure (3 parts separated by dots)
parts := strings.Split(token, ".")
if len(parts) != 3 {
result.IsValid = false
result.Errors = append(result.Errors, "token does not have valid JWT structure (expected 3 parts)")
return result
}
// Validate each part is base64url encoded
for i, part := range parts {
if !iv.isValidBase64URL(part) {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("token part %d is not valid base64url", i+1))
return result
}
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(token); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Check for null bytes and control characters
if iv.containsNullBytes(token) {
result.IsValid = false
result.Errors = append(result.Errors, "token contains null bytes")
return result
}
if iv.containsControlCharacters(token) {
result.IsValid = false
result.Errors = append(result.Errors, "token contains control characters")
return result
}
// Validate UTF-8 encoding
if !utf8.ValidString(token) {
result.IsValid = false
result.Errors = append(result.Errors, "token contains invalid UTF-8 sequences")
return result
}
result.SanitizedValue = token
return result
}
// ValidateEmail validates email addresses
func (iv *InputValidator) ValidateEmail(email string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty email
if email == "" {
result.IsValid = false
result.Errors = append(result.Errors, "email cannot be empty")
return result
}
// Check length limits
if len(email) > iv.maxEmailLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("email length %d exceeds maximum %d", len(email), iv.maxEmailLength))
return result
}
// Sanitize email (trim whitespace, convert to lowercase)
sanitized := strings.TrimSpace(strings.ToLower(email))
// Check regex pattern
if !iv.emailRegex.MatchString(sanitized) {
result.IsValid = false
result.Errors = append(result.Errors, "email format is invalid")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Additional email-specific validations
parts := strings.Split(sanitized, "@")
if len(parts) != 2 {
result.IsValid = false
result.Errors = append(result.Errors, "email must contain exactly one @ symbol")
return result
}
localPart, domain := parts[0], parts[1]
// Validate local part
if len(localPart) == 0 || len(localPart) > 64 {
result.IsValid = false
result.Errors = append(result.Errors, "email local part length is invalid")
return result
}
// Validate domain
if len(domain) == 0 || len(domain) > 253 {
result.IsValid = false
result.Errors = append(result.Errors, "email domain length is invalid")
return result
}
// Check for consecutive dots
if strings.Contains(sanitized, "..") {
result.IsValid = false
result.Errors = append(result.Errors, "email contains consecutive dots")
return result
}
result.SanitizedValue = sanitized
return result
}
// ValidateURL validates URLs
func (iv *InputValidator) ValidateURL(urlStr string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty URL
if urlStr == "" {
result.IsValid = false
result.Errors = append(result.Errors, "URL cannot be empty")
return result
}
// Check length limits
if len(urlStr) > iv.maxURLLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("URL length %d exceeds maximum %d", len(urlStr), iv.maxURLLength))
return result
}
// Sanitize URL (trim whitespace)
sanitized := strings.TrimSpace(urlStr)
// Parse URL
parsedURL, err := url.Parse(sanitized)
if err != nil {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("URL parsing failed: %v", err))
return result
}
// Check scheme
if parsedURL.Scheme != "https" && parsedURL.Scheme != "http" {
result.IsValid = false
result.Errors = append(result.Errors, "URL scheme must be http or https")
return result
}
// Prefer HTTPS
if parsedURL.Scheme == "http" {
result.Warnings = append(result.Warnings, "HTTP URLs are less secure than HTTPS")
}
// Check host
if parsedURL.Host == "" {
result.IsValid = false
result.Errors = append(result.Errors, "URL must have a valid host")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Check for path traversal attempts
if iv.containsPathTraversal(sanitized) {
result.IsValid = false
result.Errors = append(result.Errors, "URL contains path traversal patterns")
return result
}
result.SanitizedValue = sanitized
return result
}
// ValidateUsername validates usernames
func (iv *InputValidator) ValidateUsername(username string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty username
if username == "" {
result.IsValid = false
result.Errors = append(result.Errors, "username cannot be empty")
return result
}
// Check length limits
if len(username) > iv.maxUsernameLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("username length %d exceeds maximum %d", len(username), iv.maxUsernameLength))
return result
}
// Check minimum length
if len(username) < 2 {
result.IsValid = false
result.Errors = append(result.Errors, "username must be at least 2 characters long")
return result
}
// Sanitize username (trim whitespace)
sanitized := strings.TrimSpace(username)
// Check regex pattern
if !iv.usernameRegex.MatchString(sanitized) {
result.IsValid = false
result.Errors = append(result.Errors, "username contains invalid characters (only letters, numbers, dots, underscores, and hyphens allowed)")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
result.SanitizedValue = sanitized
return result
}
// ValidateClaim validates individual JWT claims
func (iv *InputValidator) ValidateClaim(claimName, claimValue string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check claim name
if claimName == "" {
result.IsValid = false
result.Errors = append(result.Errors, "claim name cannot be empty")
return result
}
// Check claim value length
if len(claimValue) > iv.maxClaimLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("claim value length %d exceeds maximum %d", len(claimValue), iv.maxClaimLength))
return result
}
// Check for null bytes and control characters
if iv.containsNullBytes(claimValue) {
result.IsValid = false
result.Errors = append(result.Errors, "claim value contains null bytes")
return result
}
if iv.containsControlCharacters(claimValue) {
result.Warnings = append(result.Warnings, "claim value contains control characters")
}
// Validate UTF-8 encoding
if !utf8.ValidString(claimValue) {
result.IsValid = false
result.Errors = append(result.Errors, "claim value contains invalid UTF-8 sequences")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(claimValue); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Specific validations based on claim name
switch claimName {
case "email":
emailResult := iv.ValidateEmail(claimValue)
if !emailResult.IsValid {
result.IsValid = false
result.Errors = append(result.Errors, emailResult.Errors...)
}
result.Warnings = append(result.Warnings, emailResult.Warnings...)
result.SanitizedValue = emailResult.SanitizedValue
case "iss", "aud":
urlResult := iv.ValidateURL(claimValue)
if !urlResult.IsValid {
// For issuer/audience, we're more lenient - just warn
result.Warnings = append(result.Warnings, fmt.Sprintf("%s claim is not a valid URL: %v", claimName, urlResult.Errors))
}
result.SanitizedValue = claimValue
case "preferred_username", "username":
usernameResult := iv.ValidateUsername(claimValue)
if !usernameResult.IsValid {
result.IsValid = false
result.Errors = append(result.Errors, usernameResult.Errors...)
}
result.Warnings = append(result.Warnings, usernameResult.Warnings...)
result.SanitizedValue = usernameResult.SanitizedValue
default:
// Generic string validation
result.SanitizedValue = strings.TrimSpace(claimValue)
}
return result
}
// ValidateHeader validates HTTP header values
func (iv *InputValidator) ValidateHeader(headerName, headerValue string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check header name
if headerName == "" {
result.IsValid = false
result.Errors = append(result.Errors, "header name cannot be empty")
return result
}
// Check for control characters in header name (including CRLF)
if iv.containsControlCharacters(headerName) {
result.IsValid = false
result.Errors = append(result.Errors, "header name contains control characters")
return result
}
// Check for CRLF injection in header name
if strings.Contains(headerName, "\r") || strings.Contains(headerName, "\n") {
result.IsValid = false
result.Errors = append(result.Errors, "header name contains CRLF characters (potential header injection)")
return result
}
// Check header value length
if len(headerValue) > iv.maxHeaderLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("header value length %d exceeds maximum %d", len(headerValue), iv.maxHeaderLength))
return result
}
// Check for null bytes and control characters (except allowed ones)
if iv.containsNullBytes(headerValue) {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains null bytes")
return result
}
// Check for CRLF injection
if strings.Contains(headerValue, "\r") || strings.Contains(headerValue, "\n") {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains CRLF characters (potential header injection)")
return result
}
// Validate UTF-8 encoding
if !utf8.ValidString(headerValue) {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains invalid UTF-8 sequences")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(headerValue); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
result.SanitizedValue = strings.TrimSpace(headerValue)
return result
}
// isValidBase64URL checks if a string is valid base64url encoding
func (iv *InputValidator) isValidBase64URL(s string) bool {
// Base64url uses A-Z, a-z, 0-9, -, _ and no padding
for _, r := range s {
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_') {
return false
}
}
return true
}
// containsNullBytes checks if a string contains null bytes
func (iv *InputValidator) containsNullBytes(s string) bool {
return strings.Contains(s, "\x00")
}
// containsControlCharacters checks if a string contains control characters
func (iv *InputValidator) containsControlCharacters(s string) bool {
for _, r := range s {
if unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r' {
return true
}
}
return false
}
// containsPathTraversal checks for path traversal patterns
func (iv *InputValidator) containsPathTraversal(s string) bool {
lowerS := strings.ToLower(s)
for _, pattern := range iv.pathTraversalPatterns {
if strings.Contains(lowerS, pattern) {
return true
}
}
return false
}
// detectSecurityRisk detects potential security risks in input
func (iv *InputValidator) detectSecurityRisk(input string) string {
lowerInput := strings.ToLower(input)
// Check for SQL injection patterns
for _, pattern := range iv.sqlInjectionPatterns {
if strings.Contains(lowerInput, pattern) {
return "sql_injection"
}
}
// Check for XSS patterns
for _, pattern := range iv.xssPatterns {
if strings.Contains(lowerInput, pattern) {
return "xss"
}
}
// Check for path traversal
if iv.containsPathTraversal(input) {
return "path_traversal"
}
// Check for excessive length (potential DoS)
if len(input) > 10000 {
return "excessive_length"
}
// Check for suspicious character patterns
if iv.containsNullBytes(input) {
return "null_bytes"
}
// Check for binary data patterns
nonPrintableCount := 0
for _, r := range input {
if !unicode.IsPrint(r) && !unicode.IsSpace(r) {
nonPrintableCount++
}
}
if nonPrintableCount > len(input)/10 { // More than 10% non-printable
return "binary_data"
}
return ""
}
// SanitizeInput provides general input sanitization
func (iv *InputValidator) SanitizeInput(input string, maxLength int) string {
// Trim whitespace
sanitized := strings.TrimSpace(input)
// Truncate if too long
if len(sanitized) > maxLength {
sanitized = sanitized[:maxLength]
}
// Remove null bytes
sanitized = strings.ReplaceAll(sanitized, "\x00", "")
// Remove other control characters except tab, newline, carriage return
var result strings.Builder
for _, r := range sanitized {
if !unicode.IsControl(r) || r == '\t' || r == '\n' || r == '\r' {
result.WriteRune(r)
}
}
return result.String()
}
// ValidateBoundaryValues validates numeric boundary values
func (iv *InputValidator) ValidateBoundaryValues(value interface{}, min, max int64) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
var numValue int64
switch v := value.(type) {
case int:
numValue = int64(v)
case int32:
numValue = int64(v)
case int64:
numValue = v
case float64:
numValue = int64(v)
if float64(numValue) != v {
result.Warnings = append(result.Warnings, "floating point value truncated to integer")
}
default:
result.IsValid = false
result.Errors = append(result.Errors, "value is not a numeric type")
return result
}
if numValue < min {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("value %d is below minimum %d", numValue, min))
}
if numValue > max {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("value %d exceeds maximum %d", numValue, max))
}
return result
}
-421
View File
@@ -1,421 +0,0 @@
package traefikoidc
import (
"strings"
"testing"
)
func TestInputValidator(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("Valid token validation", func(t *testing.T) {
validToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8jO19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHs3UjpMC6M6FNqI2J-I2NxrragtnDxGxdJUvDERDQVHzeNlVQiuqWDEeO_O-0KptafbfyuGqfQxH_6dp2_MeFpAc"
result := validator.ValidateToken(validToken)
if !result.IsValid {
t.Errorf("Expected valid token to pass validation, got errors: %v", result.Errors)
}
})
t.Run("Invalid token validation", func(t *testing.T) {
invalidTokens := []string{
"", // Empty token
"invalid.token", // Invalid format
"a.b", // Too few parts
"a.b.c.d", // Too many parts
}
for _, token := range invalidTokens {
result := validator.ValidateToken(token)
if result.IsValid {
t.Errorf("Expected invalid token '%s' to fail validation", token)
}
}
})
t.Run("Valid email validation", func(t *testing.T) {
validEmails := []string{
"user@example.com",
"test.email@domain.co.uk",
"user123@test-domain.org",
}
for _, email := range validEmails {
result := validator.ValidateEmail(email)
if !result.IsValid {
t.Errorf("Expected valid email '%s' to pass validation, got errors: %v", email, result.Errors)
}
}
})
t.Run("Invalid email validation", func(t *testing.T) {
invalidEmails := []string{
"", // Empty
"invalid", // No @ symbol
"@domain.com", // No local part
"user@", // No domain
"user@domain", // No TLD
"user..double@domain.com", // Double dots
}
for _, email := range invalidEmails {
result := validator.ValidateEmail(email)
if result.IsValid {
t.Errorf("Expected invalid email '%s' to fail validation", email)
}
}
})
t.Run("Valid URL validation", func(t *testing.T) {
validURLs := []string{
"https://example.com",
"https://sub.domain.com/path",
"https://localhost:8080/callback",
}
for _, url := range validURLs {
result := validator.ValidateURL(url)
if !result.IsValid {
t.Errorf("Expected valid URL '%s' to pass validation, got errors: %v", url, result.Errors)
}
}
})
t.Run("Invalid URL validation", func(t *testing.T) {
invalidURLs := []string{
"", // Empty
"not-a-url", // Invalid format
"ftp://example.com", // Wrong scheme
"https://", // No host
}
for _, url := range invalidURLs {
result := validator.ValidateURL(url)
if result.IsValid {
t.Errorf("Expected invalid URL '%s' to fail validation", url)
}
}
})
t.Run("Valid username validation", func(t *testing.T) {
validUsernames := []string{
"user123",
"test_user",
"user-name",
}
for _, username := range validUsernames {
result := validator.ValidateUsername(username)
if !result.IsValid {
t.Errorf("Expected valid username '%s' to pass validation, got errors: %v", username, result.Errors)
}
}
})
t.Run("Invalid username validation", func(t *testing.T) {
invalidUsernames := []string{
"", // Empty
"a", // Too short
strings.Repeat("a", 100), // Too long
"user name", // Spaces
}
for _, username := range invalidUsernames {
result := validator.ValidateUsername(username)
if result.IsValid {
t.Errorf("Expected invalid username '%s' to fail validation", username)
}
}
})
t.Run("Valid claim validation", func(t *testing.T) {
validClaims := map[string]string{
"sub": "user123",
"email": "user@example.com",
"name": "John Doe",
}
for key, value := range validClaims {
result := validator.ValidateClaim(key, value)
if !result.IsValid {
t.Errorf("Expected valid claim '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors)
}
}
})
t.Run("Invalid claim validation", func(t *testing.T) {
invalidClaims := map[string]string{
"": "value", // Empty key
"long_key": strings.Repeat("a", 10000), // Too long value
}
for key, value := range invalidClaims {
result := validator.ValidateClaim(key, value)
if result.IsValid {
t.Errorf("Expected invalid claim '%s'='%s' to fail validation", key, value)
}
}
})
t.Run("Valid header validation", func(t *testing.T) {
validHeaders := map[string]string{
"Authorization": "Bearer token123",
"Content-Type": "application/json",
"X-Custom": "custom-value",
}
for key, value := range validHeaders {
result := validator.ValidateHeader(key, value)
if !result.IsValid {
t.Errorf("Expected valid header '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors)
}
}
})
t.Run("Invalid header validation", func(t *testing.T) {
invalidHeaders := map[string]string{
"": "value", // Empty key
"Invalid\nKey": "value", // Control characters in key
"key": "value\r\n", // Control characters in value
}
for key, value := range invalidHeaders {
result := validator.ValidateHeader(key, value)
if result.IsValid {
t.Errorf("Expected invalid header '%s'='%s' to fail validation", key, value)
}
}
})
}
func TestSanitizeInput(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
tests := []struct {
name string
input string
expected string
maxLen int
}{
{
name: "Normal text",
input: "Hello World",
maxLen: 100,
expected: "Hello World",
},
{
name: "Control characters",
input: "text\x00with\x01control\x02chars",
maxLen: 100,
expected: "textwithcontrolchars",
},
{
name: "Truncation",
input: "very long text",
maxLen: 5,
expected: "very ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.SanitizeInput(tt.input, tt.maxLen)
if result != tt.expected {
t.Errorf("Expected sanitized input '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestValidateBoundaryValues(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("Valid boundary values", func(t *testing.T) {
validValues := []interface{}{
int(50),
int64(100),
float64(75.5),
}
for _, value := range validValues {
result := validator.ValidateBoundaryValues(value, 1, 1000)
if !result.IsValid {
t.Errorf("Expected valid boundary value %v to pass validation, got errors: %v", value, result.Errors)
}
}
})
t.Run("Invalid boundary values", func(t *testing.T) {
invalidValues := []interface{}{
int(-1),
int64(2000),
"not a number",
}
for _, value := range invalidValues {
result := validator.ValidateBoundaryValues(value, 1, 1000)
if result.IsValid {
t.Errorf("Expected invalid boundary value %v to fail validation", value)
}
}
})
}
func TestDefaultInputValidationConfig(t *testing.T) {
config := DefaultInputValidationConfig()
if config.MaxTokenLength <= 0 {
t.Error("Expected positive MaxTokenLength")
}
if config.MaxEmailLength <= 0 {
t.Error("Expected positive MaxEmailLength")
}
if config.MaxUsernameLength <= 0 {
t.Error("Expected positive MaxUsernameLength")
}
if config.MaxClaimLength <= 0 {
t.Error("Expected positive MaxClaimLength")
}
if config.MaxHeaderLength <= 0 {
t.Error("Expected positive MaxHeaderLength")
}
if !config.StrictMode {
t.Error("Expected StrictMode to be true by default")
}
}
func TestInputValidationHelpers(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("isValidBase64URL", func(t *testing.T) {
validBase64URL := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
if !validator.isValidBase64URL(validBase64URL) {
t.Error("Expected valid base64url to be recognized")
}
invalidBase64URL := "invalid+base64/with+padding="
if validator.isValidBase64URL(invalidBase64URL) {
t.Error("Expected invalid base64url to be rejected")
}
})
t.Run("containsNullBytes", func(t *testing.T) {
withNull := "text\x00with\x00null"
if !validator.containsNullBytes(withNull) {
t.Error("Expected string with null bytes to be detected")
}
withoutNull := "normal text"
if validator.containsNullBytes(withoutNull) {
t.Error("Expected string without null bytes to pass")
}
})
t.Run("containsControlCharacters", func(t *testing.T) {
withControl := "text\x01with\x02control"
if !validator.containsControlCharacters(withControl) {
t.Error("Expected string with control characters to be detected")
}
withoutControl := "normal text"
if validator.containsControlCharacters(withoutControl) {
t.Error("Expected string without control characters to pass")
}
})
t.Run("containsPathTraversal", func(t *testing.T) {
withTraversal := "../../../etc/passwd"
if !validator.containsPathTraversal(withTraversal) {
t.Error("Expected path traversal to be detected")
}
normalPath := "/normal/path"
if validator.containsPathTraversal(normalPath) {
t.Error("Expected normal path to pass")
}
})
t.Run("detectSecurityRisk", func(t *testing.T) {
riskyInputs := []string{
"<script>alert('xss')</script>",
"'; DROP TABLE users; --",
"javascript:alert('xss')",
}
for _, input := range riskyInputs {
if validator.detectSecurityRisk(input) == "" {
t.Errorf("Expected security risk to be detected in: %s", input)
}
}
safeInput := "normal safe text"
if validator.detectSecurityRisk(safeInput) != "" {
t.Error("Expected safe input to pass security check")
}
})
}
func TestInputValidationEdgeCases(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("Empty inputs", func(t *testing.T) {
// Most validations should reject empty inputs
if result := validator.ValidateToken(""); result.IsValid {
t.Error("Expected empty token to be rejected")
}
if result := validator.ValidateEmail(""); result.IsValid {
t.Error("Expected empty email to be rejected")
}
if result := validator.ValidateURL(""); result.IsValid {
t.Error("Expected empty URL to be rejected")
}
if result := validator.ValidateUsername(""); result.IsValid {
t.Error("Expected empty username to be rejected")
}
})
t.Run("Very long inputs", func(t *testing.T) {
longString := strings.Repeat("a", 10000)
if result := validator.ValidateEmail(longString + "@domain.com"); result.IsValid {
t.Error("Expected very long email to be rejected")
}
if result := validator.ValidateUsername(longString); result.IsValid {
t.Error("Expected very long username to be rejected")
}
})
t.Run("Unicode handling", func(t *testing.T) {
unicodeEmail := "用户@example.com"
// Should handle unicode gracefully
validator.ValidateEmail(unicodeEmail) // Don't fail on unicode
unicodeUsername := "用户名"
validator.ValidateUsername(unicodeUsername) // Don't fail on unicode
})
}
-127
View File
@@ -1,127 +0,0 @@
package providers
import (
"net/url"
"strings"
"time"
)
// Adapter facilitates communication between the legacy TraefikOIDC struct and the new provider system.
type Adapter struct {
provider OIDCProvider
legacySettings LegacySettings
tokenVerifier TokenVerifier
tokenCache TokenCache
}
// LegacySettings provides the adapter with access to the original configuration values.
type LegacySettings interface {
GetIssuerURL() string
GetAuthURL() string
GetScopes() []string
IsPKCEEnabled() bool
GetClientID() string
GetRefreshGracePeriod() time.Duration
IsOverrideScopes() bool
}
// NewAdapter creates a new adapter for a given provider and legacy settings.
func NewAdapter(provider OIDCProvider, settings LegacySettings, tokenVerifier TokenVerifier, tokenCache TokenCache) *Adapter {
return &Adapter{
provider: provider,
legacySettings: settings,
tokenVerifier: tokenVerifier,
tokenCache: tokenCache,
}
}
// BuildAuthURL constructs the authentication URL using the adapted provider.
func (a *Adapter) BuildAuthURL(redirectURL, state, nonce, codeChallenge string) string {
params := url.Values{}
params.Set("client_id", a.legacySettings.GetClientID())
params.Set("response_type", "code")
params.Set("redirect_uri", redirectURL)
params.Set("state", state)
params.Set("nonce", nonce)
if a.legacySettings.IsPKCEEnabled() && codeChallenge != "" {
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
}
scopes := a.legacySettings.GetScopes()
// When overrideScopes is true, use exactly the scopes provided without modification
if a.legacySettings.IsOverrideScopes() {
// Use scopes as-is, don't let provider add anything
finalParams := params
finalParams.Set("scope", strings.Join(scopes, " "))
// For provider-specific parameters, we still need to check the provider type
switch a.provider.GetType() {
case ProviderTypeGoogle:
// Google-specific parameters
finalParams.Set("access_type", "offline")
finalParams.Set("prompt", "consent")
case ProviderTypeAzure:
// Azure-specific parameters
finalParams.Set("response_mode", "query")
}
return a.buildURLWithParams(a.legacySettings.GetAuthURL(), finalParams)
}
// When overrideScopes is false, let the provider add necessary scopes
authParams, err := a.provider.BuildAuthParams(params, scopes)
if err != nil {
// Log the error appropriately
return ""
}
finalParams := authParams.URLValues
finalParams.Set("scope", strings.Join(authParams.Scopes, " "))
// Build the full URL with params
return a.buildURLWithParams(a.legacySettings.GetAuthURL(), finalParams)
}
// buildURLWithParams takes a base URL and query parameters and constructs a full URL string.
// If the baseURL is relative (doesn't start with http/https), it prepends the scheme and host
// from the configured issuerURL.
func (a *Adapter) buildURLWithParams(baseURL string, params url.Values) string {
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
// Relative URL - resolve against issuer URL
issuerURLParsed, err := url.Parse(a.legacySettings.GetIssuerURL())
if err != nil {
return ""
}
baseURLParsed, err := url.Parse(baseURL)
if err != nil {
return ""
}
resolvedURL := issuerURLParsed.ResolveReference(baseURLParsed)
resolvedURL.RawQuery = params.Encode()
return resolvedURL.String()
}
// Absolute URL
u, err := url.Parse(baseURL)
if err != nil {
return ""
}
u.RawQuery = params.Encode()
return u.String()
}
// ValidateTokens validates tokens using the adapted provider.
func (a *Adapter) ValidateTokens(session Session) (*ValidationResult, error) {
return a.provider.ValidateTokens(session, a.tokenVerifier, a.tokenCache, a.legacySettings.GetRefreshGracePeriod())
}
// GetType returns the underlying provider's type.
func (a *Adapter) GetType() ProviderType {
return a.provider.GetType()
}
-111
View File
@@ -1,111 +0,0 @@
package providers
import (
"net/url"
"strings"
"time"
)
// AzureProvider encapsulates Azure AD-specific OIDC logic.
type AzureProvider struct {
*BaseProvider
}
// NewAzureProvider creates a new instance of the AzureProvider.
func NewAzureProvider() *AzureProvider {
return &AzureProvider{
BaseProvider: NewBaseProvider(),
}
}
// GetType returns the provider's type.
func (p *AzureProvider) GetType() ProviderType {
return ProviderTypeAzure
}
// GetCapabilities returns the specific capabilities of the Azure provider.
func (p *AzureProvider) GetCapabilities() ProviderCapabilities {
return ProviderCapabilities{
SupportsRefreshTokens: true,
RequiresOfflineAccessScope: true,
PreferredTokenValidation: "access", // Azure AD prefers access token validation
}
}
// BuildAuthParams configures Azure-specific authentication parameters.
func (p *AzureProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) {
baseParams.Set("response_mode", "query")
// Ensure "offline_access" scope is present for refresh tokens
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
}
return &AuthParams{
URLValues: baseParams,
Scopes: scopes,
}, nil
}
// ValidateTokens overrides the default token validation to implement Azure-specific logic.
// Azure may use access tokens for validation, and this method ensures that behavior is preserved.
func (p *AzureProvider) ValidateTokens(session Session, verifier TokenVerifier, tokenCache TokenCache, refreshGracePeriod time.Duration) (*ValidationResult, error) {
if !session.GetAuthenticated() {
if session.GetRefreshToken() != "" {
return &ValidationResult{NeedsRefresh: true}, nil
}
return &ValidationResult{IsExpired: true}, nil
}
accessToken := session.GetAccessToken()
idToken := session.GetIDToken()
if accessToken != "" {
if strings.Count(accessToken, ".") == 2 {
if err := verifier.VerifyToken(accessToken); err != nil {
if idToken != "" {
return p.ValidateTokenExpiry(session, idToken, tokenCache, refreshGracePeriod)
}
if session.GetRefreshToken() != "" {
return &ValidationResult{NeedsRefresh: true}, nil
}
return &ValidationResult{IsExpired: true}, nil
}
return p.ValidateTokenExpiry(session, accessToken, tokenCache, refreshGracePeriod)
}
if idToken != "" {
return p.ValidateTokenExpiry(session, idToken, tokenCache, refreshGracePeriod)
}
return &ValidationResult{Authenticated: true}, nil
}
if idToken != "" {
if err := verifier.VerifyToken(idToken); err != nil {
if session.GetRefreshToken() != "" {
return &ValidationResult{NeedsRefresh: true}, nil
}
return &ValidationResult{IsExpired: true}, nil
}
return p.ValidateTokenExpiry(session, idToken, tokenCache, refreshGracePeriod)
}
if session.GetRefreshToken() != "" {
return &ValidationResult{NeedsRefresh: true}, nil
}
return &ValidationResult{IsExpired: true}, nil
}
// ValidateConfig validates Azure-specific configuration requirements.
// Azure requires specific tenant configuration and scope handling.
func (p *AzureProvider) ValidateConfig() error {
// Azure provider validation - ensure we have the necessary configuration
// In a real implementation, this might check for tenant ID, proper issuer URL format, etc.
return p.BaseProvider.ValidateConfig()
}
-141
View File
@@ -1,141 +0,0 @@
package providers
import (
"net/url"
"strings"
"time"
)
// BaseProvider provides a common foundation for OIDC provider implementations.
// It can be embedded in specific provider structs to share common logic.
type BaseProvider struct {
// Common configuration or dependencies can be added here.
}
// GetType returns the default provider type, which is Generic.
// This should be overridden by specific provider implementations.
func (p *BaseProvider) GetType() ProviderType {
return ProviderTypeGeneric
}
// GetCapabilities returns a default set of capabilities for a generic OIDC provider.
// This can be overridden by specific providers to declare their unique features.
func (p *BaseProvider) GetCapabilities() ProviderCapabilities {
return ProviderCapabilities{
SupportsRefreshTokens: true,
RequiresOfflineAccessScope: true,
PreferredTokenValidation: "id",
}
}
// ValidateTokens provides a default token validation implementation.
// This method can be extended or replaced by specific providers.
func (p *BaseProvider) ValidateTokens(session Session, verifier TokenVerifier, tokenCache TokenCache, refreshGracePeriod time.Duration) (*ValidationResult, error) {
if !session.GetAuthenticated() {
if session.GetRefreshToken() != "" {
return &ValidationResult{NeedsRefresh: true}, nil
}
return &ValidationResult{}, nil
}
accessToken := session.GetAccessToken()
if accessToken == "" {
if session.GetRefreshToken() != "" {
return &ValidationResult{NeedsRefresh: true}, nil
}
return &ValidationResult{IsExpired: true}, nil
}
idToken := session.GetIDToken()
if idToken == "" {
if session.GetRefreshToken() != "" {
return &ValidationResult{Authenticated: true, NeedsRefresh: true}, nil
}
return &ValidationResult{Authenticated: true}, nil
}
if err := verifier.VerifyToken(idToken); err != nil {
if strings.Contains(err.Error(), "token has expired") {
if session.GetRefreshToken() != "" {
return &ValidationResult{NeedsRefresh: true}, nil
}
return &ValidationResult{IsExpired: true}, nil
}
if session.GetRefreshToken() != "" {
return &ValidationResult{NeedsRefresh: true}, nil
}
return &ValidationResult{IsExpired: true}, nil
}
return p.ValidateTokenExpiry(session, idToken, tokenCache, refreshGracePeriod)
}
// ValidateTokenExpiry provides common token expiry validation logic that can be used by all providers.
// This method is now exported so provider implementations can reuse this logic without duplication.
func (p *BaseProvider) ValidateTokenExpiry(session Session, token string, tokenCache TokenCache, refreshGracePeriod time.Duration) (*ValidationResult, error) {
cachedClaims, found := tokenCache.Get(token)
if !found {
if session.GetRefreshToken() != "" {
return &ValidationResult{NeedsRefresh: true}, nil
}
return &ValidationResult{IsExpired: true}, nil
}
expClaim, ok := cachedClaims["exp"].(float64)
if !ok {
if session.GetRefreshToken() != "" {
return &ValidationResult{NeedsRefresh: true}, nil
}
return &ValidationResult{IsExpired: true}, nil
}
expTime := time.Unix(int64(expClaim), 0)
if expTime.Before(time.Now().Add(refreshGracePeriod)) {
if session.GetRefreshToken() != "" {
return &ValidationResult{Authenticated: true, NeedsRefresh: true}, nil
}
return &ValidationResult{Authenticated: true}, nil
}
return &ValidationResult{Authenticated: true}, nil
}
// BuildAuthParams provides a default implementation for building authorization parameters.
// It includes the "offline_access" scope by default.
func (p *BaseProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) {
// Ensure offline_access is included if not already present
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
}
return &AuthParams{
URLValues: baseParams,
Scopes: scopes,
}, nil
}
// HandleTokenRefresh provides a default implementation for token refresh handling.
// By default, it does nothing and assumes the standard token response is sufficient.
func (p *BaseProvider) HandleTokenRefresh(tokenData *TokenResult) error {
// No provider-specific refresh handling by default.
return nil
}
// ValidateConfig provides a default implementation for configuration validation.
// By default, it assumes the configuration is valid.
func (p *BaseProvider) ValidateConfig() error {
// No provider-specific config validation by default.
return nil
}
// NewBaseProvider creates a new BaseProvider.
func NewBaseProvider() *BaseProvider {
return &BaseProvider{}
}
-118
View File
@@ -1,118 +0,0 @@
package providers
import (
"fmt"
"net/url"
"strings"
)
// ProviderFactory encapsulates the logic for creating and configuring OIDC providers.
type ProviderFactory struct {
registry *ProviderRegistry
}
// NewProviderFactory creates a new factory with a pre-configured registry.
func NewProviderFactory() *ProviderFactory {
registry := NewProviderRegistry()
// Register all available providers
registry.RegisterProvider(NewGenericProvider())
registry.RegisterProvider(NewGoogleProvider())
registry.RegisterProvider(NewAzureProvider())
return &ProviderFactory{
registry: registry,
}
}
// CreateProvider creates and returns the appropriate provider for the given issuer URL.
// It automatically detects the provider type and returns a configured instance.
func (f *ProviderFactory) CreateProvider(issuerURL string) (OIDCProvider, error) {
if issuerURL == "" {
return nil, fmt.Errorf("issuer URL cannot be empty")
}
// Validate URL format
if _, err := url.Parse(issuerURL); err != nil {
return nil, fmt.Errorf("invalid issuer URL format: %w", err)
}
provider := f.registry.DetectProvider(issuerURL)
if provider == nil {
return nil, fmt.Errorf("unable to detect provider for issuer URL: %s", issuerURL)
}
// Validate the provider configuration if it implements config validation
if err := provider.ValidateConfig(); err != nil {
return nil, fmt.Errorf("provider configuration validation failed: %w", err)
}
return provider, nil
}
// CreateProviderByType creates a provider instance for a specific provider type.
// This is useful when you want to force a specific provider type regardless of URL.
func (f *ProviderFactory) CreateProviderByType(providerType ProviderType) (OIDCProvider, error) {
var provider OIDCProvider
switch providerType {
case ProviderTypeGeneric:
provider = NewGenericProvider()
case ProviderTypeGoogle:
provider = NewGoogleProvider()
case ProviderTypeAzure:
provider = NewAzureProvider()
default:
return nil, fmt.Errorf("unsupported provider type: %d", providerType)
}
if err := provider.ValidateConfig(); err != nil {
return nil, fmt.Errorf("provider configuration validation failed: %w", err)
}
return provider, nil
}
// GetSupportedProviders returns a list of all supported provider types and their detection patterns.
func (f *ProviderFactory) GetSupportedProviders() map[ProviderType][]string {
return map[ProviderType][]string{
ProviderTypeGeneric: {"*"}, // Generic supports any issuer
ProviderTypeGoogle: {"accounts.google.com"},
ProviderTypeAzure: {"login.microsoftonline.com", "sts.windows.net"},
}
}
// DetectProviderType returns the provider type that would be used for a given issuer URL.
// This is useful for diagnostic purposes or UI display.
func (f *ProviderFactory) DetectProviderType(issuerURL string) (ProviderType, error) {
provider, err := f.CreateProvider(issuerURL)
if err != nil {
return ProviderTypeGeneric, err
}
return provider.GetType(), nil
}
// IsProviderSupported checks if a given issuer URL is supported by any registered provider.
func (f *ProviderFactory) IsProviderSupported(issuerURL string) bool {
if issuerURL == "" {
return false
}
normalizedURL, err := url.Parse(issuerURL)
if err != nil {
return false
}
host := strings.ToLower(normalizedURL.Host)
supportedProviders := f.GetSupportedProviders()
for _, patterns := range supportedProviders {
for _, pattern := range patterns {
if pattern == "*" || strings.Contains(host, strings.ToLower(pattern)) {
return true
}
}
}
return false
}
-18
View File
@@ -1,18 +0,0 @@
package providers
// GenericProvider encapsulates standard OIDC logic for any compliant provider.
type GenericProvider struct {
*BaseProvider
}
// NewGenericProvider creates a new instance of the GenericProvider.
func NewGenericProvider() *GenericProvider {
return &GenericProvider{
BaseProvider: NewBaseProvider(),
}
}
// GetType returns the provider's type.
func (p *GenericProvider) GetType() ProviderType {
return ProviderTypeGeneric
}
-59
View File
@@ -1,59 +0,0 @@
package providers
import (
"net/url"
)
// GoogleProvider encapsulates Google-specific OIDC logic.
type GoogleProvider struct {
*BaseProvider
}
// NewGoogleProvider creates a new instance of the GoogleProvider.
func NewGoogleProvider() *GoogleProvider {
return &GoogleProvider{
BaseProvider: NewBaseProvider(),
}
}
// GetType returns the provider's type.
func (p *GoogleProvider) GetType() ProviderType {
return ProviderTypeGoogle
}
// GetCapabilities returns the specific capabilities of the Google provider.
func (p *GoogleProvider) GetCapabilities() ProviderCapabilities {
return ProviderCapabilities{
SupportsRefreshTokens: true,
RequiresOfflineAccessScope: false, // Google uses access_type=offline instead
RequiresPromptConsent: true,
PreferredTokenValidation: "id",
}
}
// BuildAuthParams configures Google-specific authentication parameters.
func (p *GoogleProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) {
baseParams.Set("access_type", "offline")
baseParams.Set("prompt", "consent")
// Google does not use the "offline_access" scope, so we remove it if present.
var filteredScopes []string
for _, scope := range scopes {
if scope != "offline_access" {
filteredScopes = append(filteredScopes, scope)
}
}
return &AuthParams{
URLValues: baseParams,
Scopes: filteredScopes,
}, nil
}
// ValidateConfig validates Google-specific configuration requirements.
// Google requires specific scopes and client configuration for proper operation.
func (p *GoogleProvider) ValidateConfig() error {
// Google provider doesn't require additional validation beyond the base implementation
// All Google-specific requirements are handled in BuildAuthParams
return p.BaseProvider.ValidateConfig()
}
-105
View File
@@ -1,105 +0,0 @@
// Package providers implements a universal OIDC provider abstraction system.
// It provides a clean interface for different OIDC providers (Google, Azure, Generic)
// with provider-specific logic encapsulated in separate implementations.
package providers
import (
"net/url"
"time"
)
// TokenVerifier defines the interface for token verification.
type TokenVerifier interface {
VerifyToken(token string) error
}
// TokenCache defines the interface for a token cache.
type TokenCache interface {
Get(key string) (map[string]interface{}, bool)
}
// ProviderType is an enumeration for identifying different OIDC providers.
type ProviderType int
const (
// ProviderTypeGeneric represents a standard, compliant OIDC provider.
ProviderTypeGeneric ProviderType = iota
// ProviderTypeGoogle represents Google as the OIDC provider.
ProviderTypeGoogle
// ProviderTypeAzure represents Microsoft Azure AD as the OIDC provider.
ProviderTypeAzure
)
// ProviderCapabilities defines the specific features and behaviors of an OIDC provider.
type ProviderCapabilities struct {
// SupportsRefreshTokens indicates if the provider issues refresh tokens.
SupportsRefreshTokens bool
// RequiresOfflineAccessScope indicates if the "offline_access" scope is needed for refresh tokens.
RequiresOfflineAccessScope bool
// RequiresPromptConsent indicates if "prompt=consent" is needed to ensure a refresh token is issued.
RequiresPromptConsent bool
// PreferredTokenValidation specifies the recommended token type to validate (e.g., "access" or "id").
PreferredTokenValidation string
}
// ValidationResult holds the outcome of a token validation check.
type ValidationResult struct {
// Authenticated is true if the token is valid and the user is authenticated.
Authenticated bool
// NeedsRefresh is true if the token is approaching its expiry and should be refreshed.
NeedsRefresh bool
// IsExpired is true if the token has expired or is invalid.
IsExpired bool
}
// AuthParams contains the provider-specific parameters for building the authorization URL.
type AuthParams struct {
// URLValues are the query parameters to be added to the authorization URL.
URLValues url.Values
// Scopes is the list of scopes to be requested.
Scopes []string
}
// TokenResult holds the tokens returned by the provider.
type TokenResult struct {
// IDToken is the OIDC ID token.
IDToken string
// AccessToken is the OAuth2 access token.
AccessToken string
// RefreshToken is the OAuth2 refresh token.
RefreshToken string
}
// OIDCProvider defines the interface for an OIDC provider implementation.
// This abstraction allows for provider-specific logic to be encapsulated.
type OIDCProvider interface {
// GetType returns the type of the provider (e.g., Google, Azure, Generic).
GetType() ProviderType
// GetCapabilities returns the feature set of the provider.
GetCapabilities() ProviderCapabilities
// ValidateTokens performs token validation according to the provider's specific rules.
// It should check the validity of the access and/or ID tokens from the session.
ValidateTokens(session Session, verifier TokenVerifier, tokenCache TokenCache, refreshGracePeriod time.Duration) (*ValidationResult, error)
// BuildAuthParams modifies the authorization URL parameters for the provider.
// This can be used to add provider-specific parameters like "access_type" for Google.
BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error)
// HandleTokenRefresh manages the token refresh process for the provider.
// It can modify the token request or handle the response as needed.
HandleTokenRefresh(tokenData *TokenResult) error
// ValidateConfig checks if the user's configuration is valid for this provider.
ValidateConfig() error
}
// Session represents the session data required by providers for validation.
// This interface decouples the providers from the main session management implementation.
type Session interface {
GetIDToken() string
GetAccessToken() string
GetRefreshToken() string
GetAuthenticated() bool
}
-109
View File
@@ -1,109 +0,0 @@
package providers
import (
"net/url"
"strings"
"sync"
)
// ProviderRegistry holds and manages the available OIDC provider implementations.
// It provides thread-safe access to provider instances and caches detection results.
type ProviderRegistry struct {
mu sync.RWMutex
providers []OIDCProvider
cache map[string]OIDCProvider
typeMap map[ProviderType]OIDCProvider // Maps provider type to instance
}
// NewProviderRegistry creates and initializes a new ProviderRegistry.
func NewProviderRegistry() *ProviderRegistry {
return &ProviderRegistry{
providers: make([]OIDCProvider, 0),
cache: make(map[string]OIDCProvider),
typeMap: make(map[ProviderType]OIDCProvider),
}
}
// RegisterProvider adds a new provider to the registry.
// It maintains both a list of providers and a type-to-provider mapping for efficient lookups.
func (r *ProviderRegistry) RegisterProvider(provider OIDCProvider) {
r.mu.Lock()
defer r.mu.Unlock()
r.providers = append(r.providers, provider)
r.typeMap[provider.GetType()] = provider
}
// GetProviderByType returns a provider instance for the specified type.
// Returns nil if the provider type is not registered.
func (r *ProviderRegistry) GetProviderByType(providerType ProviderType) OIDCProvider {
r.mu.RLock()
defer r.mu.RUnlock()
return r.typeMap[providerType]
}
// GetRegisteredProviders returns a slice of all registered provider types.
func (r *ProviderRegistry) GetRegisteredProviders() []ProviderType {
r.mu.RLock()
defer r.mu.RUnlock()
types := make([]ProviderType, 0, len(r.typeMap))
for providerType := range r.typeMap {
types = append(types, providerType)
}
return types
}
// ClearCache removes all cached provider detection results.
// This can be useful for testing or when provider configuration changes.
func (r *ProviderRegistry) ClearCache() {
r.mu.Lock()
defer r.mu.Unlock()
r.cache = make(map[string]OIDCProvider)
}
// DetectProvider determines the most appropriate provider for a given issuer URL.
// It iterates through the registered providers and returns the first one that matches.
// Detection is based on URL patterns and other provider-specific criteria.
func (r *ProviderRegistry) DetectProvider(issuerURL string) OIDCProvider {
r.mu.RLock()
defer r.mu.RUnlock()
// Check cache first for performance
if provider, found := r.cache[issuerURL]; found {
return provider
}
// Normalize issuer URL for consistent matching
normalizedURL, err := url.Parse(issuerURL)
if err != nil {
// Log error or handle it appropriately
return nil
}
host := normalizedURL.Host
// Iterate through registered providers to find a match
for _, p := range r.providers {
switch p.GetType() {
case ProviderTypeGoogle:
if strings.Contains(host, "accounts.google.com") {
r.cache[issuerURL] = p
return p
}
case ProviderTypeAzure:
if strings.Contains(host, "login.microsoftonline.com") || strings.Contains(host, "sts.windows.net") {
r.cache[issuerURL] = p
return p
}
}
}
// Fallback to the generic provider if no specific provider is detected
for _, p := range r.providers {
if p.GetType() == ProviderTypeGeneric {
r.cache[issuerURL] = p
return p
}
}
return nil
}
-157
View File
@@ -1,157 +0,0 @@
package providers
import (
"fmt"
"net/url"
"strings"
)
// ConfigValidator provides common configuration validation utilities for providers.
type ConfigValidator struct{}
// NewConfigValidator creates a new configuration validator.
func NewConfigValidator() *ConfigValidator {
return &ConfigValidator{}
}
// ValidateIssuerURL validates that an issuer URL is properly formatted and accessible.
func (v *ConfigValidator) ValidateIssuerURL(issuerURL string) error {
if issuerURL == "" {
return fmt.Errorf("issuer URL cannot be empty")
}
parsedURL, err := url.Parse(issuerURL)
if err != nil {
return fmt.Errorf("invalid issuer URL format: %w", err)
}
if parsedURL.Scheme == "" {
return fmt.Errorf("issuer URL must include scheme (http/https)")
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("issuer URL scheme must be http or https")
}
if parsedURL.Host == "" {
return fmt.Errorf("issuer URL must include host")
}
return nil
}
// ValidateClientID validates that a client ID is properly formatted.
func (v *ConfigValidator) ValidateClientID(clientID string) error {
if clientID == "" {
return fmt.Errorf("client ID cannot be empty")
}
if len(clientID) < 3 {
return fmt.Errorf("client ID appears to be too short")
}
return nil
}
// ValidateScopes validates that the provided scopes are reasonable.
func (v *ConfigValidator) ValidateScopes(scopes []string) error {
if len(scopes) == 0 {
return fmt.Errorf("at least one scope must be provided")
}
// Check for required OIDC scope
hasOpenIDScope := false
for _, scope := range scopes {
if strings.TrimSpace(scope) == "openid" {
hasOpenIDScope = true
break
}
}
if !hasOpenIDScope {
return fmt.Errorf("'openid' scope is required for OIDC authentication")
}
return nil
}
// ValidateRedirectURL validates that a redirect URL is properly formatted.
func (v *ConfigValidator) ValidateRedirectURL(redirectURL string) error {
if redirectURL == "" {
return fmt.Errorf("redirect URL cannot be empty")
}
parsedURL, err := url.Parse(redirectURL)
if err != nil {
return fmt.Errorf("invalid redirect URL format: %w", err)
}
if parsedURL.Scheme == "" {
return fmt.Errorf("redirect URL must include scheme (http/https)")
}
return nil
}
// ValidateProviderSpecificConfig performs provider-specific validation.
func (v *ConfigValidator) ValidateProviderSpecificConfig(provider OIDCProvider, config map[string]interface{}) error {
switch provider.GetType() {
case ProviderTypeGoogle:
return v.validateGoogleConfig(config)
case ProviderTypeAzure:
return v.validateAzureConfig(config)
case ProviderTypeGeneric:
return v.validateGenericConfig(config)
default:
return fmt.Errorf("unknown provider type: %d", provider.GetType())
}
}
// validateGoogleConfig validates Google-specific configuration.
func (v *ConfigValidator) validateGoogleConfig(config map[string]interface{}) error {
// Google-specific validation logic
if issuerURL, ok := config["issuer_url"].(string); ok {
if !strings.Contains(issuerURL, "accounts.google.com") {
return fmt.Errorf("google provider requires issuer URL to contain accounts.google.com")
}
}
return nil
}
// validateAzureConfig validates Azure-specific configuration.
func (v *ConfigValidator) validateAzureConfig(config map[string]interface{}) error {
// Azure-specific validation logic
if issuerURL, ok := config["issuer_url"].(string); ok {
if !strings.Contains(issuerURL, "login.microsoftonline.com") && !strings.Contains(issuerURL, "sts.windows.net") {
return fmt.Errorf("azure provider requires issuer URL to contain login.microsoftonline.com or sts.windows.net")
}
}
// Check for tenant ID in the URL
if issuerURL, ok := config["issuer_url"].(string); ok {
parsedURL, err := url.Parse(issuerURL)
if err == nil {
pathParts := strings.Split(parsedURL.Path, "/")
hasTenantID := false
for _, part := range pathParts {
// Simple check for GUID-like structure (tenant ID)
if len(part) == 36 && strings.Count(part, "-") == 4 {
hasTenantID = true
break
}
}
if !hasTenantID {
return fmt.Errorf("azure issuer URL should include tenant ID")
}
}
}
return nil
}
// validateGenericConfig validates generic OIDC provider configuration.
func (v *ConfigValidator) validateGenericConfig(config map[string]interface{}) error {
// Generic provider validation - basic checks only
return nil
}
-239
View File
@@ -1,239 +0,0 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestIssue53Regression tests the specific issue reported in GitHub issue #53
// where Azure OIDC authentication fails with "CSRF token missing in session"
// This was caused by incorrect HTTPS detection in reverse proxy environments
func TestIssue53Regression(t *testing.T) {
t.Run("Issue53_CSRF_Missing_In_Session_Fix", func(t *testing.T) {
// This test reproduces the exact scenario from issue #53:
// 1. User accesses app via HTTPS through Traefik
// 2. Traefik terminates SSL and forwards HTTP internally
// 3. Session cookies must be properly configured for HTTPS
// 4. CSRF token must persist through the OAuth flow
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Step 1: Initial request to protected resource
// User accesses https://app.example.com/protected
// Traefik forwards as http://internal/protected with X-Forwarded-Proto: https
initReq := httptest.NewRequest("GET", "http://internal/protected", nil)
initReq.Header.Set("X-Forwarded-Proto", "https")
initReq.Header.Set("X-Forwarded-Host", "app.example.com")
initReq.Header.Set("User-Agent", "Mozilla/5.0") // Real browser
// Get session and set OAuth flow data
session, err := sessionManager.GetSession(initReq)
require.NoError(t, err)
// Set CSRF and other OAuth data
csrfToken := "csrf-token-for-azure"
nonce := "nonce-for-azure"
session.SetCSRF(csrfToken)
session.SetNonce(nonce)
session.SetCodeVerifier("pkce-verifier")
session.SetIncomingPath("/protected")
session.MarkDirty()
// Save session - this is where the bug was
// Previously: used r.URL.Scheme which is always "http" behind proxy
// Now: uses X-Forwarded-Proto header
rec := httptest.NewRecorder()
err = session.Save(initReq, rec)
require.NoError(t, err)
// Verify cookies are secure
cookies := rec.Result().Cookies()
require.NotEmpty(t, cookies, "Cookies must be set")
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie, "Main session cookie must be set")
// Critical assertions for issue #53
assert.True(t, mainCookie.Secure, "Cookie MUST have Secure flag for HTTPS (was the bug)")
assert.Equal(t, http.SameSiteLaxMode, mainCookie.SameSite, "MUST use Lax for OAuth callbacks to work")
assert.Equal(t, "/", mainCookie.Path, "Cookie path must be root")
assert.True(t, mainCookie.HttpOnly, "Cookie must be HttpOnly")
assert.Equal(t, "app.example.com", mainCookie.Domain, "Domain should use X-Forwarded-Host")
// Step 2: OAuth provider redirects back to callback
// Azure redirects to https://app.example.com/oidc/callback?code=...&state=...
// Traefik forwards as http://internal/oidc/callback with headers
callbackReq := httptest.NewRequest("GET",
"http://internal/oidc/callback?code=azure-auth-code&state="+csrfToken, nil)
callbackReq.Header.Set("X-Forwarded-Proto", "https")
callbackReq.Header.Set("X-Forwarded-Host", "app.example.com")
callbackReq.Header.Set("User-Agent", "Mozilla/5.0")
// Add cookies from initial request
// Browser sends secure cookies because request is HTTPS
for _, cookie := range cookies {
callbackReq.AddCookie(cookie)
}
// Get session in callback
callbackSession, err := sessionManager.GetSession(callbackReq)
require.NoError(t, err)
// Verify CSRF token is present (was missing in issue #53)
retrievedCSRF := callbackSession.GetCSRF()
assert.Equal(t, csrfToken, retrievedCSRF,
"CSRF token MUST persist (was missing in issue #53)")
// Verify other session data also persists
assert.Equal(t, nonce, callbackSession.GetNonce(),
"Nonce must persist for security")
assert.Equal(t, "pkce-verifier", callbackSession.GetCodeVerifier(),
"PKCE verifier must persist")
assert.Equal(t, "/protected", callbackSession.GetIncomingPath(),
"Original path must persist for redirect after auth")
})
t.Run("Issue53_Signature_Verification_With_Secure_Session", func(t *testing.T) {
// This test ensures that once the session is properly maintained,
// token signature verification works correctly for Azure tokens
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Create authenticated session with Azure tokens
req := httptest.NewRequest("GET", "http://internal/api/data", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "app.example.com")
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Simulate successful Azure authentication
session.SetAuthenticated(true)
session.SetEmail("user@example.com")
// Azure may use opaque access tokens
session.SetAccessToken("opaque-azure-access-token")
session.SetIDToken("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-RU8VNbftc4TSMb4bXP3l3YlNWACwyXPGffz5aXHc6lty1Y2t4SWRqGteragsVdZufDn5BlnJl9pdR_kdVFUsra2rWKEofkZeIC4yWytE58sMIihvo9H1ScmmVwBcQP6XETqYd0aSHp1gOa9RdUPDvoXQ5oqygTqVtxaDr6wUFKrKItgBMzWIdNZ6y7O9E0DhEPTbE9rfBo6KTFsHAZnMg4k68CDp2woYIaXbmYTWcvbzIuHO7_37GT79XdIwkm95QJ7hYC9RiwrV7mesbY4PAahERJawntho0my942XheVLmGwLMBkQ")
session.SetRefreshToken("azure-refresh-token")
// Save with proper security
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Verify session can be retrieved and tokens are intact
cookies := rec.Result().Cookies()
req2 := httptest.NewRequest("GET", "http://internal/api/data", nil)
req2.Header.Set("X-Forwarded-Proto", "https")
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
assert.True(t, session2.GetAuthenticated(), "User should remain authenticated")
assert.Equal(t, "user@example.com", session2.GetEmail())
assert.NotEmpty(t, session2.GetAccessToken(), "Access token should persist")
assert.NotEmpty(t, session2.GetIDToken(), "ID token should persist")
assert.NotEmpty(t, session2.GetRefreshToken(), "Refresh token should persist")
})
t.Run("Issue53_Redirect_Loop_Prevention", func(t *testing.T) {
// This test verifies the redirect loop prevention mechanism
// that was added to handle authentication failures gracefully
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
req := httptest.NewRequest("GET", "http://internal/protected", nil)
req.Header.Set("X-Forwarded-Proto", "https")
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Simulate multiple redirect attempts
for i := 0; i < 3; i++ {
session.IncrementRedirectCount()
}
// Verify redirect count is tracked
count := session.GetRedirectCount()
assert.Equal(t, 3, count, "Redirect count should be tracked")
// After successful auth, count should be reset
session.SetAuthenticated(true)
session.ResetRedirectCount()
assert.Equal(t, 0, session.GetRedirectCount(), "Count should reset after auth")
})
}
// TestReverseProxySameSiteHandling tests SameSite cookie attribute handling
// in different reverse proxy scenarios
func TestReverseProxySameSiteHandling(t *testing.T) {
t.Run("SameSite_Lax_For_HTTPS_OAuth", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// HTTPS request via proxy
req := httptest.NewRequest("GET", "http://internal/test", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("User-Agent", "Mozilla/5.0")
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
session.SetCSRF("test")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// HTTPS should use Lax mode for OAuth compatibility
cookies := rec.Result().Cookies()
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite,
"HTTPS should use Lax SameSite for OAuth callbacks")
break
}
}
})
t.Run("SameSite_Lax_For_HTTP", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Plain HTTP request (no proxy headers)
req := httptest.NewRequest("GET", "http://localhost/test", nil)
req.Header.Set("User-Agent", "Mozilla/5.0")
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
session.SetCSRF("test")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// HTTP should use Lax mode for compatibility
cookies := rec.Result().Cookies()
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite,
"HTTP should use Lax SameSite for compatibility")
break
}
}
})
}
-213
View File
@@ -1,213 +0,0 @@
package traefikoidc
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestIssue60Integration tests the complete fix for issue #60
// This test verifies that the plugin can handle missing claim fields without panicking
func TestIssue60Integration(t *testing.T) {
t.Run("Config_With_Safe_Functions_Validates", func(t *testing.T) {
config := CreateConfig()
config.ProviderURL = "https://example.com"
config.ClientID = "test-client"
config.ClientSecret = "test-secret"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = "test-encryption-key-32-characters"
// Templates using safe functions for missing fields
config.Headers = []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-Role", Value: "{{get .Claims \"internal_role\"}}"},
{Name: "X-User-Dept", Value: "{{default \"unknown\" .Claims.department}}"},
{Name: "X-User-Groups", Value: "{{with .Claims.groups}}{{.}}{{end}}"},
}
// Configuration should validate successfully
err := config.Validate()
assert.NoError(t, err, "Config with safe template functions should validate")
})
t.Run("Direct_Template_Access_Works", func(t *testing.T) {
config := CreateConfig()
config.ProviderURL = "https://example.com"
config.ClientID = "test-client"
config.ClientSecret = "test-secret"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = "test-encryption-key-32-characters"
// Direct claim access (will return <no value> if missing with missingkey=zero)
config.Headers = []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-Internal-Role", Value: "{{.Claims.internal_role}}"},
}
err := config.Validate()
assert.NoError(t, err, "Direct claim access should validate")
})
t.Run("Config_Rejects_Dangerous_Templates", func(t *testing.T) {
config := CreateConfig()
config.ProviderURL = "https://example.com"
config.ClientID = "test-client"
config.ClientSecret = "test-secret"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = "test-encryption-key-32-characters"
// Dangerous template patterns should be rejected
dangerousTemplates := []TemplatedHeader{
{Name: "X-Bad-1", Value: "{{call .SomeFunc}}"},
{Name: "X-Bad-2", Value: "{{range .Items}}{{.}}{{end}}"},
{Name: "X-Bad-3", Value: "{{index .Array 0}}"},
{Name: "X-Bad-4", Value: "{{printf \"%s\" .Data}}"},
}
for _, header := range dangerousTemplates {
config.Headers = []TemplatedHeader{header}
err := config.Validate()
require.Error(t, err, "Dangerous template should be rejected: %s", header.Value)
assert.Contains(t, err.Error(), "dangerous", "Error should mention dangerous pattern")
}
})
t.Run("Verify_Template_Execution_Context", func(t *testing.T) {
// This test verifies that our template context matches what's actually used
// The context should have these fields (all capitalized):
// - AccessToken
// - IDToken (or IdToken)
// - RefreshToken
// - Claims (map[string]interface{})
config := CreateConfig()
config.ProviderURL = "https://example.com"
config.ClientID = "test-client"
config.ClientSecret = "test-secret"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = "test-encryption-key-32-characters"
// These should all be valid based on the actual template context
validContextTemplates := []TemplatedHeader{
{Name: "X-Access-Token", Value: "{{.AccessToken}}"},
{Name: "X-ID-Token", Value: "{{.IdToken}}"},
{Name: "X-Refresh-Token", Value: "{{.RefreshToken}}"},
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-Sub", Value: "{{.Claims.sub}}"},
}
config.Headers = validContextTemplates
err := config.Validate()
assert.NoError(t, err, "All valid context fields should pass validation")
})
t.Run("Common_Azure_AD_Claims", func(t *testing.T) {
// Test Azure AD specific claims mentioned in issue #60
config := CreateConfig()
config.ProviderURL = "https://example.com"
config.ClientID = "test-client"
config.ClientSecret = "test-secret"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = "test-encryption-key-32-characters"
// Azure AD commonly uses these claim fields
config.Headers = []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-OID", Value: "{{.Claims.oid}}"},
{Name: "X-User-TID", Value: "{{.Claims.tid}}"},
{Name: "X-User-UPN", Value: "{{.Claims.upn}}"},
{Name: "X-Internal-Role", Value: "{{.Claims.internal_role}}"}, // Custom claim from issue #60
}
err := config.Validate()
assert.NoError(t, err, "Azure AD claims should validate")
})
}
// TestIssue60RealWorldScenarios tests real-world scenarios from issue #60
func TestIssue60RealWorldScenarios(t *testing.T) {
t.Run("Missing_Internal_Role_Field", func(t *testing.T) {
// This is the exact scenario from issue #60
// User passes {{.Claims.internal_role}} but the field doesn't exist
config := CreateConfig()
config.ProviderURL = "https://example.com"
config.ClientID = "test-client"
config.ClientSecret = "test-secret"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = "test-encryption-key-32-characters"
// The problematic template from issue #60
config.Headers = []TemplatedHeader{
{Name: "X-Internal-Role", Value: "{{.Claims.internal_role}}"},
}
// Should validate (internal_role is in the safe fields list)
err := config.Validate()
assert.NoError(t, err, "Template with internal_role should validate")
})
t.Run("Safe_Access_Patterns_From_Guide", func(t *testing.T) {
// Test all the safe patterns documented in TEMPLATE_HEADERS_GUIDE.md
config := CreateConfig()
config.ProviderURL = "https://example.com"
config.ClientID = "test-client"
config.ClientSecret = "test-secret"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = "test-encryption-key-32-characters"
// All safe patterns from the guide
config.Headers = []TemplatedHeader{
// Basic field access
{Name: "X-User-Role", Value: "{{.Claims.internal_role}}"},
// Using the get function
{Name: "X-User-Role-Get", Value: "{{get .Claims \"internal_role\"}}"},
// Using the default function
{Name: "X-User-Role-Default", Value: "{{default \"guest\" .Claims.role}}"},
// Nested fields with 'with'
{Name: "X-User-Admin", Value: "{{with .Claims.groups}}{{.admin}}{{end}}"},
}
err := config.Validate()
assert.NoError(t, err, "All safe patterns from guide should validate")
})
}
// TestIssue60DoubleProcessingConcern tests the user's specific concern about double processing
func TestIssue60DoubleProcessingConcern(t *testing.T) {
t.Run("Template_Not_Evaluated_During_Config_Parse", func(t *testing.T) {
// The user was concerned that templates might be processed twice:
// 1. Once when Traefik parses the config
// 2. Once when the plugin executes the template
// This test verifies that templates are stored as strings during config parsing
config := &Config{
Headers: []TemplatedHeader{
{Name: "X-Test", Value: "{{.Claims.test}}"},
},
}
// The template should still be a raw string after config creation
assert.Equal(t, "{{.Claims.test}}", config.Headers[0].Value,
"Template should remain as raw string in config")
// The template is only parsed/executed when the plugin initializes and processes requests
// Not during config unmarshaling
})
t.Run("Functions_Preserved_Through_Config_Marshaling", func(t *testing.T) {
// Test that our custom function syntax survives config marshaling/unmarshaling
originalValue := `{{get .Claims "internal_role"}}`
header := TemplatedHeader{
Name: "X-Role",
Value: originalValue,
}
// Even after any marshaling/unmarshaling, the template string should be preserved
assert.Equal(t, originalValue, header.Value,
"Template with functions should be preserved exactly")
})
}
+38 -170
View File
@@ -1,187 +1,85 @@
package traefikoidc
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"math/big"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net/http"
"sync"
"time"
)
// JWK represents a JSON Web Key as defined in RFC 7517.
// It contains the cryptographic key parameters used for verifying
// JWT signatures. Supports both RSA and ECDSA key types.
// JWK represents a JSON Web Key
type JWK struct {
Kty string `json:"kty"` // Key type (RSA, EC)
Kid string `json:"kid"` // Key ID
Use string `json:"use"` // Key use (sig, enc)
N string `json:"n"` // RSA modulus
E string `json:"e"` // RSA public exponent
Alg string `json:"alg"` // Algorithm
Crv string `json:"crv"` // ECDSA curve
X string `json:"x"` // ECDSA x coordinate
Y string `json:"y"` // ECDSA y coordinate
Kty string `json:"kty"`
Kid string `json:"kid"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
Alg string `json:"alg"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
}
// JWKSet represents a set of JSON Web Keys as returned by
// an OIDC provider's JWKS endpoint. It contains multiple keys
// to support key rotation.
// JWKSet represents a set of JWKs
type JWKSet struct {
Keys []JWK `json:"keys"`
}
// JWKCache provides thread-safe caching of JSON Web Key Sets.
// It fetches JWKS from OIDC providers and caches them to reduce
// network requests. The cache supports expiration and automatic
// refresh when keys expire.
// JWKCache caches the JWKs
type JWKCache struct {
internalCache *Cache
CacheLifetime time.Duration
maxSize int
mutex sync.RWMutex
jwks *JWKSet
expiresAt time.Time
mutex sync.RWMutex
}
// JWKCacheInterface defines the contract for JWK cache implementations.
// It provides methods for retrieving JWKS, performing cleanup, and
// graceful shutdown.
// JWKCacheInterface defines the interface for the JWK cache
type JWKCacheInterface interface {
GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error)
Cleanup()
Close()
GetJWKS(jwksURL string, httpClient *http.Client) (*JWKSet, error)
}
// GetJWKS retrieves the JSON Web Key Set (JWKS) from the cache or fetches it from the provider.
// It first checks if a valid, non-expired JWKS is present in the cache. If so, it returns the cached version.
// Otherwise, it attempts to fetch the JWKS from the specified jwksURL using the provided httpClient.
// If the fetch is successful, the JWKS is stored in the cache with an expiration time based on CacheLifetime
// (defaulting to 1 hour if not set) and returned.
// This method uses double-checked locking to minimize contention when the cache needs refreshing.
//
// Parameters:
// - ctx: Context for the HTTP request if fetching is required.
// - jwksURL: The URL of the OIDC provider's JWKS endpoint.
// - httpClient: The HTTP client to use for fetching the JWKS.
//
// Returns:
// - A pointer to the JWKSet containing the keys.
// - An error if fetching fails or the response cannot be decoded.
// NewJWKCache creates a new JWK cache with default configuration.
// It initializes a cache with a 1-hour lifetime and maximum size of 100 entries.
func NewJWKCache() *JWKCache {
cache := &JWKCache{
CacheLifetime: 1 * time.Hour,
maxSize: 100, // Default maximum size
internalCache: NewCache(),
// GetJWKS gets the JWKS, either from cache or by fetching it
func (c *JWKCache) GetJWKS(jwksURL string, httpClient *http.Client) (*JWKSet, error) {
c.mutex.RLock()
if c.jwks != nil && time.Now().Before(c.expiresAt) {
defer c.mutex.RUnlock()
return c.jwks, nil
}
return cache
}
c.mutex.RUnlock()
func (c *JWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
// Use only the internalCache for storage to avoid double storage
if c.internalCache != nil {
if cachedJwks, found := c.internalCache.Get(jwksURL); found {
return cachedJwks.(*JWKSet), nil
}
}
// Acquire write lock for potential update
c.mutex.Lock()
defer c.mutex.Unlock()
// Double-check after acquiring write lock
if c.internalCache != nil {
if cachedJwks, found := c.internalCache.Get(jwksURL); found {
return cachedJwks.(*JWKSet), nil
}
if c.jwks != nil && time.Now().Before(c.expiresAt) {
return c.jwks, nil
}
// Fetch new JWKS
jwks, err := fetchJWKS(ctx, jwksURL, httpClient)
jwks, err := fetchJWKS(jwksURL, httpClient)
if err != nil {
return nil, err
}
// STABILITY FIX: Validate JWKS contains keys before caching
if len(jwks.Keys) == 0 {
return nil, fmt.Errorf("JWKS response contains no keys")
}
// Store in the internalCache only (avoid double storage)
lifetime := c.CacheLifetime
if lifetime == 0 {
lifetime = 1 * time.Hour
}
if c.internalCache != nil {
c.internalCache.Set(jwksURL, jwks, lifetime)
}
c.jwks = jwks
c.expiresAt = time.Now().Add(1 * time.Hour)
return jwks, nil
}
// Cleanup removes the cached JWKS if it has expired.
// This is intended to be called periodically to ensure stale JWKS data is cleared.
// Cleanup removes expired entries from the cache.
// It delegates to the internal cache's cleanup method.
func (c *JWKCache) Cleanup() {
if c.internalCache != nil {
c.internalCache.Cleanup()
}
}
// Close shuts down the cache's auto-cleanup routine.
func (c *JWKCache) Close() {
// Delegate to internal cache's Close method
if c.internalCache != nil {
c.internalCache.Close()
}
}
// SetMaxSize sets the maximum number of items in the cache
func (c *JWKCache) SetMaxSize(size int) {
c.maxSize = size
if c.internalCache != nil {
c.internalCache.maxSize = size
}
}
// fetchJWKS retrieves the JSON Web Key Set (JWKS) from the specified URL.
// It uses the provided context and HTTP client to make the request.
//
// Parameters:
// - ctx: Context for the HTTP request.
// - jwksURL: The URL of the OIDC provider's JWKS endpoint.
// - httpClient: The HTTP client to use for the request.
//
// Returns:
// - A pointer to the fetched JWKSet.
// - An error if the request fails, the status code is not OK, or the response body cannot be decoded.
func fetchJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
// Create a request with context to enforce timeout
req, err := http.NewRequestWithContext(ctx, "GET", jwksURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create JWKS request: %w", err)
}
resp, err := httpClient.Do(req)
// fetchJWKS fetches the JWKS from the provider
func fetchJWKS(jwksURL string, httpClient *http.Client) (*JWKSet, error) {
resp, err := httpClient.Get(jwksURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
}
defer func() {
// Always drain the body before closing to ensure connection can be reused
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch JWKS: unexpected status code %d", resp.StatusCode)
@@ -195,16 +93,7 @@ func fetchJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*J
return &jwks, nil
}
// jwkToPEM converts a JWK (JSON Web Key) object into PEM (Privacy-Enhanced Mail) format.
// It selects the appropriate conversion function based on the JWK's key type ("kty").
// Currently supports "RSA" and "EC" key types.
//
// Parameters:
// - jwk: A pointer to the JWK object to convert.
//
// Returns:
// - A byte slice containing the public key in PEM format.
// - An error if the key type is unsupported or conversion fails.
// jwkToPEM converts a JWK to PEM format
func jwkToPEM(jwk *JWK) ([]byte, error) {
converter, ok := jwkConverters[jwk.Kty]
if !ok {
@@ -220,17 +109,7 @@ var jwkConverters = map[string]jwkToPEMConverter{
"EC": ecJWKToPEM,
}
// rsaJWKToPEM converts an RSA JWK into PEM format.
// It decodes the modulus (n) and exponent (e) from base64 URL encoding,
// constructs an rsa.PublicKey, marshals it into PKIX format, and then
// encodes it as a PEM block.
//
// Parameters:
// - jwk: A pointer to the RSA JWK object (must have "kty": "RSA").
//
// Returns:
// - A byte slice containing the RSA public key in PEM format.
// - An error if decoding parameters fails or key marshaling fails.
// rsaJWKToPEM converts an RSA JWK to PEM
func rsaJWKToPEM(jwk *JWK) ([]byte, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N)
if err != nil {
@@ -262,18 +141,7 @@ func rsaJWKToPEM(jwk *JWK) ([]byte, error) {
return pubKeyPEM, nil
}
// ecJWKToPEM converts an EC (Elliptic Curve) JWK into PEM format.
// It decodes the X and Y coordinates from base64 URL encoding, determines the
// elliptic curve based on the "crv" parameter (P-256, P-384, P-521),
// constructs an ecdsa.PublicKey, marshals it into PKIX format, and then
// encodes it as a PEM block.
//
// Parameters:
// - jwk: A pointer to the EC JWK object (must have "kty": "EC").
//
// Returns:
// - A byte slice containing the EC public key in PEM format.
// - An error if decoding parameters fails, the curve is unsupported, or key marshaling fails.
// ecJWKToPEM converts an EC JWK to PEM
func ecJWKToPEM(jwk *JWK) ([]byte, error) {
xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
if err != nil {
+49 -318
View File
@@ -1,249 +1,70 @@
package traefikoidc
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"math/big"
"strings"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"strings"
"sync"
"time"
)
var (
replayCacheMu sync.RWMutex // Use RWMutex for better read performance
replayCache *Cache // Replace unbounded map with bounded Cache
replayCacheOnce sync.Once
)
// initReplayCache initializes the global replay cache for JWT ID tracking.
// It uses sync.Once to ensure thread-safe single initialization.
// The cache is bounded to 10,000 entries to prevent unbounded memory growth.
func initReplayCache() {
replayCacheOnce.Do(func() {
replayCache = NewCache()
replayCache.SetMaxSize(10000)
})
}
// cleanupReplayCache gracefully shuts down the replay cache.
// It acquires a write lock, closes the cache, and sets it to nil
// to ensure proper cleanup during shutdown.
func cleanupReplayCache() {
replayCacheMu.Lock()
defer replayCacheMu.Unlock()
if replayCache != nil {
replayCache.Close()
replayCache = nil
// Reset the once to allow re-initialization
replayCacheOnce = sync.Once{}
}
}
// getReplayCacheStats returns current statistics about the replay cache.
// Due to sync.Pool limitations, it returns 0 for current size and the
// configured maximum size of 10,000.
//
// Returns:
// - size: Current number of entries (always 0 due to implementation).
// - maxSize: Maximum allowed entries (10,000).
func getReplayCacheStats() (size int, maxSize int) {
replayCacheMu.RLock()
defer replayCacheMu.RUnlock()
if replayCache == nil {
return 0, 0
}
return 0, 10000
}
// startReplayCacheCleanup initiates a background goroutine that periodically
// cleans up expired entries from the replay cache. It runs every 5 minutes
// and logs cache statistics if a logger is provided.
//
// Parameters:
// - ctx: Context for cancellation.
// - logger: Logger for debug output (can be nil).
func startReplayCacheCleanup(ctx context.Context, logger *Logger) {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
size, maxSize := getReplayCacheStats()
if logger != nil {
logger.Debugf("Replay cache stats: size=%d, maxSize=%d", size, maxSize)
}
replayCacheMu.RLock()
if replayCache != nil {
replayCache.Cleanup()
}
replayCacheMu.RUnlock()
case <-ctx.Done():
cleanupReplayCache()
if logger != nil {
logger.Debug("Replay cache cleanup goroutine stopped due to context cancellation")
}
return
}
}
}()
}
var ClockSkewToleranceFuture = 2 * time.Minute
var ClockSkewTolerancePast = 10 * time.Second
var ClockSkewTolerance = ClockSkewToleranceFuture
// JWT represents a JSON Web Token as defined in RFC 7519.
// JWT represents a parsed JSON Web Token with its three components.
// It provides structured access to the header, claims, and signature
// for validation and processing within the OIDC middleware.
// JWT represents a JSON Web Token
type JWT struct {
Header map[string]interface{}
Claims map[string]interface{}
Token string
Signature []byte
Token string
}
// parseJWT decodes a raw JWT string into its constituent parts: header, claims, and signature.
// It splits the token string by '.', decodes each part using base64 URL decoding,
// and unmarshals the header and claims JSON into maps. The raw signature bytes are stored.
// It performs basic format validation (expecting 3 parts).
// Note: This function does *not* validate the signature or the claims.
//
// Parameters:
// - tokenString: The raw JWT string.
//
// Returns:
// - A pointer to a JWT struct containing the decoded parts.
// - An error if the token format is invalid or decoding/unmarshaling fails.
// parseJWT parses a JWT token string into a JWT struct
func parseJWT(tokenString string) (*JWT, error) {
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
}
// Use memory pool for efficient buffer management
pools := GetGlobalMemoryPools()
jwtBuf := pools.GetJWTParsingBuffer()
defer pools.PutJWTParsingBuffer(jwtBuf)
jwt := &JWT{
Token: tokenString,
}
// Decode header using pooled buffer
headerLen := base64.RawURLEncoding.DecodedLen(len(parts[0]))
if headerLen > cap(jwtBuf.HeaderBuf) {
jwtBuf.HeaderBuf = make([]byte, headerLen)
} else {
jwtBuf.HeaderBuf = jwtBuf.HeaderBuf[:headerLen]
}
n, err := base64.RawURLEncoding.Decode(jwtBuf.HeaderBuf, []byte(parts[0]))
// Decode and unmarshal the header
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to decode header: %v", err)
}
headerBytes := jwtBuf.HeaderBuf[:n]
if err := json.Unmarshal(headerBytes, &jwt.Header); err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal header: %v", err)
}
if jwt.Header == nil {
return nil, fmt.Errorf("invalid JWT format: header is nil after unmarshaling")
}
// Decode claims using pooled buffer
claimsLen := base64.RawURLEncoding.DecodedLen(len(parts[1]))
if claimsLen > cap(jwtBuf.PayloadBuf) {
jwtBuf.PayloadBuf = make([]byte, claimsLen)
} else {
jwtBuf.PayloadBuf = jwtBuf.PayloadBuf[:claimsLen]
}
n, err = base64.RawURLEncoding.Decode(jwtBuf.PayloadBuf, []byte(parts[1]))
// Decode and unmarshal the claims
claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to decode claims: %v", err)
}
claimsBytes := jwtBuf.PayloadBuf[:n]
if err := json.Unmarshal(claimsBytes, &jwt.Claims); err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal claims: %v", err)
}
if jwt.Claims == nil {
return nil, fmt.Errorf("invalid JWT format: claims is nil after unmarshaling")
}
// Decode signature using pooled buffer
sigLen := base64.RawURLEncoding.DecodedLen(len(parts[2]))
if sigLen > cap(jwtBuf.SignatureBuf) {
jwtBuf.SignatureBuf = make([]byte, sigLen)
} else {
jwtBuf.SignatureBuf = jwtBuf.SignatureBuf[:sigLen]
}
n, err = base64.RawURLEncoding.Decode(jwtBuf.SignatureBuf, []byte(parts[2]))
// Decode the signature
signatureBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to decode signature: %v", err)
}
// Copy signature to JWT struct (create new slice to avoid pool retention)
jwt.Signature = make([]byte, n)
copy(jwt.Signature, jwtBuf.SignatureBuf[:n])
jwt.Signature = signatureBytes
return jwt, nil
}
// Verify performs standard claim validation on the JWT according to RFC 7519.
// It checks the following:
// - Algorithm ('alg') is supported.
// - Issuer ('iss') matches the expected issuerURL.
// - Audience ('aud') contains the expected clientID.
// - Expiration time ('exp') is in the future (within tolerance).
// - Issued at time ('iat') is in the past (within tolerance).
// - Not before time ('nbf'), if present, is in the past (within tolerance).
// - Subject ('sub') claim exists and is not empty.
// - JWT ID ('jti'), if present, is checked against a replay cache to prevent token reuse.
//
// Parameters:
// - issuerURL: The expected issuer URL (e.g., "https://accounts.google.com").
// - clientID: The expected audience value (the client ID of this application).
// - skipReplayCheck: If true, skips JTI replay detection (used for revalidation of cached tokens).
//
// Returns:
// - nil if all standard claims are valid.
// - An error describing the first validation failure encountered.
func (j *JWT) Verify(issuerURL, clientID string, skipReplayCheck ...bool) error {
// Validate algorithm to prevent algorithm switching attacks
alg, ok := j.Header["alg"].(string)
if !ok {
return fmt.Errorf("missing 'alg' header")
}
supportedAlgs := map[string]bool{
"RS256": true, "RS384": true, "RS512": true,
"PS256": true, "PS384": true, "PS512": true,
"ES256": true, "ES384": true, "ES512": true,
}
if !supportedAlgs[alg] {
return fmt.Errorf("unsupported algorithm: %s", alg)
}
// Verify verifies the standard claims in the JWT
func (j *JWT) Verify(issuerURL, clientID string) error {
claims := j.Claims
iss, ok := claims["iss"].(string)
@@ -278,47 +99,6 @@ func (j *JWT) Verify(issuerURL, clientID string, skipReplayCheck ...bool) error
return err
}
if nbf, ok := claims["nbf"].(float64); ok {
if err := verifyNotBefore(nbf); err != nil {
return err
}
}
shouldSkipReplay := len(skipReplayCheck) > 0 && skipReplayCheck[0]
if jti, ok := claims["jti"].(string); ok && !shouldSkipReplay {
if j.Token == "" {
return nil
}
initReplayCache()
replayCacheMu.RLock()
_, exists := replayCache.Get(jti)
replayCacheMu.RUnlock()
if exists {
return fmt.Errorf("token replay detected (jti: %s)", jti)
}
expFloat, ok := claims["exp"].(float64)
var expTime time.Time
if ok {
expTime = time.Unix(int64(expFloat), 0)
} else {
expTime = time.Now().Add(10 * time.Minute)
}
duration := time.Until(expTime)
if duration > 0 {
replayCacheMu.Lock()
if replayCache != nil {
replayCache.Set(jti, true, duration)
}
replayCacheMu.Unlock()
}
}
sub, ok := claims["sub"].(string)
if !ok || sub == "" {
return fmt.Errorf("missing or empty 'sub' claim")
@@ -327,16 +107,7 @@ func (j *JWT) Verify(issuerURL, clientID string, skipReplayCheck ...bool) error
return nil
}
// verifyAudience checks if the expected audience is present in the token's 'aud' claim.
// The 'aud' claim can be a single string or an array of strings.
//
// Parameters:
// - tokenAudience: The 'aud' claim value extracted from the token (can be string or []interface{}).
// - expectedAudience: The audience value expected for this application (client ID).
//
// Returns:
// - nil if the expected audience is found.
// - An error if the claim type is invalid or the expected audience is not present.
// verifyAudience verifies the audience claim
func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
switch aud := tokenAudience.(type) {
case string:
@@ -360,109 +131,62 @@ func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
return nil
}
// verifyIssuer checks if the token's 'iss' claim matches the expected issuer URL.
//
// Parameters:
// - tokenIssuer: The 'iss' claim value from the token.
// - expectedIssuer: The expected issuer URL configured for the OIDC provider.
//
// Returns:
// - nil if the issuers match.
// - An error if the issuers do not match.
// verifyIssuer verifies the issuer claim
func verifyIssuer(tokenIssuer, expectedIssuer string) error {
if tokenIssuer != expectedIssuer {
return fmt.Errorf("invalid issuer (token: %s, expected: %s)", tokenIssuer, expectedIssuer)
return fmt.Errorf("invalid issuer")
}
return nil
}
// verifyTimeConstraint checks time-based claims ('exp', 'iat', 'nbf') against the current time,
// allowing for configurable clock skew. It uses different tolerances for past and future checks.
//
// Parameters:
// - unixTime: The timestamp value from the claim (as a float64 Unix time).
// - claimName: The name of the claim being verified ("exp", "iat", "nbf").
// - future: A boolean indicating the direction of the check (true for 'exp', false for 'iat'/'nbf').
//
// Returns:
// - nil if the time constraint is met within the allowed tolerance.
// - An error describing the failure (e.g., "token has expired", "token used before issued").
func verifyTimeConstraint(unixTime float64, claimName string, future bool) error {
claimTime := time.Unix(int64(unixTime), 0)
now := time.Now()
var err error
if future {
allowedExpiry := claimTime.Add(ClockSkewToleranceFuture)
if now.After(allowedExpiry) {
err = fmt.Errorf("token has expired (exp: %v, now: %v, allowed_until: %v)", claimTime.UTC(), now.UTC(), allowedExpiry.UTC())
}
} else {
allowedStart := claimTime.Add(-ClockSkewTolerancePast)
if now.Before(allowedStart) {
reason := "not yet valid"
if claimName == "iat" {
reason = "used before issued"
}
err = fmt.Errorf("token %s (%s: %v, now: %v, allowed_from: %v)", reason, claimName, claimTime.UTC(), now.UTC(), allowedStart.UTC())
}
}
return err
}
// verifyExpiration checks the 'exp' (Expiration Time) claim.
// It calls verifyTimeConstraint with future=true.
// verifyExpiration checks if the token has expired
func verifyExpiration(expiration float64) error {
return verifyTimeConstraint(expiration, "exp", true)
expirationTime := time.Unix(int64(expiration), 0)
if time.Now().After(expirationTime) {
return fmt.Errorf("token has expired")
}
return nil
}
// verifyIssuedAt checks the 'iat' (Issued At) claim.
// It calls verifyTimeConstraint with future=false.
// verifyIssuedAt checks if the token was issued in the future
func verifyIssuedAt(issuedAt float64) error {
return verifyTimeConstraint(issuedAt, "iat", false)
issuedAtTime := time.Unix(int64(issuedAt), 0)
if time.Now().Before(issuedAtTime) {
return fmt.Errorf("token used before issued")
}
return nil
}
// verifyNotBefore checks the 'nbf' (Not Before) claim.
// It calls verifyTimeConstraint with future=false.
func verifyNotBefore(notBefore float64) error {
return verifyTimeConstraint(notBefore, "nbf", false)
}
// verifySignature validates the JWT's signature using the provided public key.
// It parses the public key from PEM format, selects the appropriate hashing algorithm
// based on the 'alg' parameter (SHA256/384/512), hashes the token's signing input
// (header + "." + payload), and then verifies the signature against the hash using
// the corresponding RSA (PKCS1v15 or PSS) or ECDSA verification method.
//
// Parameters:
// - tokenString: The raw, complete JWT string.
// - publicKeyPEM: The public key corresponding to the private key used for signing, in PEM format.
// - alg: The algorithm specified in the JWT header (e.g., "RS256", "ES384").
//
// Returns:
// - nil if the signature is valid.
// - An error if the token format is invalid, decoding fails, key parsing fails,
// the algorithm is unsupported, or the signature verification fails.
// verifySignature verifies the token signature using the provided public key and algorithm
func verifySignature(tokenString string, publicKeyPEM []byte, alg string) error {
// Split the token into its three parts
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
return fmt.Errorf("invalid token format")
}
signedContent := parts[0] + "." + parts[1]
// Decode the signature from the token
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return fmt.Errorf("failed to decode signature: %w", err)
}
// Decode the PEM-encoded public key
block, _ := pem.Decode(publicKeyPEM)
if block == nil {
return fmt.Errorf("failed to parse PEM block containing the public key")
}
// Parse the public key
pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return fmt.Errorf("failed to parse public key: %w", err)
}
// Determine the hash function to use based on the algorithm
var hashFunc crypto.Hash
switch alg {
case "RS256", "PS256", "ES256":
hashFunc = crypto.SHA256
@@ -473,20 +197,27 @@ func verifySignature(tokenString string, publicKeyPEM []byte, alg string) error
default:
return fmt.Errorf("unsupported algorithm: %s", alg)
}
// Hash the signed content
h := hashFunc.New()
h.Write([]byte(signedContent))
hashed := h.Sum(nil)
// Verify the signature based on the key type and algorithm
switch pubKey := pubKey.(type) {
case *rsa.PublicKey:
if strings.HasPrefix(alg, "RS") {
// RSA PKCS#1 v1.5 signature
return rsa.VerifyPKCS1v15(pubKey, hashFunc, hashed, signature)
} else if strings.HasPrefix(alg, "PS") {
// RSA PSS signature
return rsa.VerifyPSS(pubKey, hashFunc, hashed, signature, nil)
} else {
return fmt.Errorf("unexpected key type for algorithm %s", alg)
}
case *ecdsa.PublicKey:
if strings.HasPrefix(alg, "ES") {
// ECDSA signature
var r, s big.Int
sigLen := len(signature)
if sigLen%2 != 0 {
-433
View File
@@ -1,433 +0,0 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPostLogoutRedirectURIConfiguration(t *testing.T) {
tests := []struct {
name string
postLogoutRedirectURI string
expectDefault bool
expectedValue string
}{
{
name: "custom post logout redirect URI",
postLogoutRedirectURI: "/home",
expectDefault: false,
expectedValue: "/home",
},
{
name: "empty uses default",
postLogoutRedirectURI: "",
expectDefault: true,
expectedValue: "/",
},
{
name: "external URL allowed",
postLogoutRedirectURI: "https://example.com/goodbye",
expectDefault: false,
expectedValue: "https://example.com/goodbye",
},
{
name: "relative path with query",
postLogoutRedirectURI: "/logout-success?msg=goodbye",
expectDefault: false,
expectedValue: "/logout-success?msg=goodbye",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := createTestConfig()
config.PostLogoutRedirectURI = tt.postLogoutRedirectURI
oidc, _ := setupTestOIDCMiddleware(t, config)
// Check the configured value
if tt.expectDefault {
assert.Equal(t, tt.expectedValue, oidc.postLogoutRedirectURI)
} else {
assert.Equal(t, tt.postLogoutRedirectURI, oidc.postLogoutRedirectURI)
}
})
}
}
func TestLogoutWithPostLogoutRedirect(t *testing.T) {
tests := []struct {
name string
postLogoutRedirectURI string
oidcEndSessionURL string
expectRedirectTo string
expectEndSession bool
}{
{
name: "redirect to custom URI without end session",
postLogoutRedirectURI: "/goodbye",
oidcEndSessionURL: "",
expectRedirectTo: "http://example.com/goodbye",
expectEndSession: false,
},
{
name: "redirect to default when not configured",
postLogoutRedirectURI: "",
oidcEndSessionURL: "",
expectRedirectTo: "http://example.com/",
expectEndSession: false,
},
{
name: "end session URL takes precedence",
postLogoutRedirectURI: "/goodbye",
oidcEndSessionURL: "https://auth.example.com/logout",
expectRedirectTo: "https://auth.example.com/logout",
expectEndSession: true,
},
{
name: "external post logout redirect",
postLogoutRedirectURI: "https://app.example.com/logged-out",
oidcEndSessionURL: "",
expectRedirectTo: "https://app.example.com/logged-out",
expectEndSession: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := createTestConfig()
config.PostLogoutRedirectURI = tt.postLogoutRedirectURI
config.LogoutURL = "/logout"
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.endSessionURL = tt.oidcEndSessionURL
// Create authenticated session
session := createTestSession()
session.SetIDToken(createMockJWT(t, "user123", "test@example.com"))
session.SetAccessToken("test-access-token")
// Create logout request
req := httptest.NewRequest("GET", "/logout", nil)
rec := httptest.NewRecorder()
// Inject session into request
injectSessionIntoRequest(t, req, session)
// Handle logout
oidc.ServeHTTP(rec, req)
// Check redirect
assert.Equal(t, http.StatusFound, rec.Code)
location := rec.Header().Get("Location")
if tt.expectEndSession {
// When end session URL is present, it should redirect there
assert.Contains(t, location, tt.oidcEndSessionURL)
// Should include id_token_hint
assert.Contains(t, location, "id_token_hint=")
// Should include post_logout_redirect_uri
if tt.postLogoutRedirectURI != "" {
assert.Contains(t, location, "post_logout_redirect_uri=")
}
} else {
// Otherwise, should redirect to post logout redirect URI
assert.Equal(t, tt.expectRedirectTo, location)
}
// Session should be cleared
cookies := rec.Result().Cookies()
for _, cookie := range cookies {
if cookie.Name == "oidc_session" {
assert.Equal(t, -1, cookie.MaxAge, "Session cookie should be deleted")
}
}
})
}
}
func TestBuildLogoutURLWithPostLogoutRedirect(t *testing.T) {
tests := []struct {
name string
oidcEndSessionURL string
postLogoutRedirectURI string
idToken string
expectedParams map[string]string
}{
{
name: "includes all parameters",
oidcEndSessionURL: "https://auth.example.com/logout",
postLogoutRedirectURI: "https://app.example.com/goodbye",
idToken: "test-id-token",
expectedParams: map[string]string{
"id_token_hint": "test-id-token",
"post_logout_redirect_uri": "https://app.example.com/goodbye",
},
},
{
name: "relative post logout URI",
oidcEndSessionURL: "https://auth.example.com/logout",
postLogoutRedirectURI: "/logout-success",
idToken: "test-id-token",
expectedParams: map[string]string{
"id_token_hint": "test-id-token",
"post_logout_redirect_uri": "/logout-success",
},
},
{
name: "empty post logout URI omitted",
oidcEndSessionURL: "https://auth.example.com/logout",
postLogoutRedirectURI: "",
idToken: "test-id-token",
expectedParams: map[string]string{
"id_token_hint": "test-id-token",
},
},
{
name: "special characters in URI",
oidcEndSessionURL: "https://auth.example.com/logout",
postLogoutRedirectURI: "/logout?msg=Thank you!",
idToken: "test-id-token",
expectedParams: map[string]string{
"id_token_hint": "test-id-token",
"post_logout_redirect_uri": "/logout?msg=Thank you!",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test the BuildLogoutURL function directly without middleware setup
logoutURL, err := BuildLogoutURL(tt.oidcEndSessionURL, tt.idToken, tt.postLogoutRedirectURI)
require.NoError(t, err)
parsedURL, err := url.Parse(logoutURL)
require.NoError(t, err)
// Check base URL
expectedBase := tt.oidcEndSessionURL
actualBase := parsedURL.Scheme + "://" + parsedURL.Host + parsedURL.Path
assert.Equal(t, expectedBase, actualBase)
// Check query parameters
params := parsedURL.Query()
for key, expectedValue := range tt.expectedParams {
assert.Equal(t, expectedValue, params.Get(key), "Parameter %s mismatch", key)
}
// Ensure no extra parameters
if tt.postLogoutRedirectURI == "" {
assert.Empty(t, params.Get("post_logout_redirect_uri"))
}
})
}
}
func TestLogoutFlowIntegration(t *testing.T) {
// Mock provider's end session endpoint
providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// This won't be called in a unit test, but we keep it for completeness
if r.URL.Path == "/endsession" {
// Provider would handle logout and redirect to post_logout_redirect_uri
w.WriteHeader(http.StatusOK)
}
}))
defer providerServer.Close()
config := createTestConfig()
config.LogoutURL = "/logout"
config.PostLogoutRedirectURI = "/thank-you"
config.OIDCEndSessionURL = providerServer.URL + "/endsession"
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.endSessionURL = config.OIDCEndSessionURL
oidc.postLogoutRedirectURI = config.PostLogoutRedirectURI
// Create authenticated session
idToken := createMockJWT(t, "user123", "test@example.com")
session := createTestSession()
session.SetIDToken(idToken)
session.SetAccessToken("test-access-token")
// Initiate logout
req := httptest.NewRequest("GET", "/logout", nil)
rec := httptest.NewRecorder()
// Inject session into request
injectSessionIntoRequest(t, req, session)
oidc.ServeHTTP(rec, req)
// Verify redirect to provider's end session
assert.Equal(t, http.StatusFound, rec.Code)
location := rec.Header().Get("Location")
// Parse the redirect URL to check parameters
parsedURL, err := url.Parse(location)
assert.NoError(t, err)
// Verify it's redirecting to the correct endpoint
assert.Equal(t, providerServer.URL+"/endsession", parsedURL.Scheme+"://"+parsedURL.Host+parsedURL.Path)
// Verify query parameters
queryParams := parsedURL.Query()
assert.Equal(t, idToken, queryParams.Get("id_token_hint"))
assert.Equal(t, "http://example.com/thank-you", queryParams.Get("post_logout_redirect_uri"))
// Note: The provider server won't actually be called in a unit test,
// as the redirect response is returned to the test client
}
func TestLogoutWithoutSession(t *testing.T) {
config := createTestConfig()
config.LogoutURL = "/logout"
config.PostLogoutRedirectURI = "/goodbye"
oidc, _ := setupTestOIDCMiddleware(t, config)
// Logout request without session
req := httptest.NewRequest("GET", "/logout", nil)
rec := httptest.NewRecorder()
oidc.ServeHTTP(rec, req)
// Should still redirect to post logout URI
assert.Equal(t, http.StatusFound, rec.Code)
// Relative URLs get converted to absolute URLs
assert.Equal(t, "http://example.com/goodbye", rec.Header().Get("Location"))
}
func TestPostLogoutRedirectEdgeCases(t *testing.T) {
tests := []struct {
name string
postLogoutRedirectURI string
requestURL string
expectedBehavior string
}{
{
name: "preserves fragment in redirect",
postLogoutRedirectURI: "/app#section",
requestURL: "/logout",
expectedBehavior: "Should preserve URL fragment",
},
{
name: "handles encoded characters",
postLogoutRedirectURI: "/message?text=Thank%20you%21",
requestURL: "/logout",
expectedBehavior: "Should handle URL encoding properly",
},
{
name: "absolute URL with different domain",
postLogoutRedirectURI: "https://other-app.com/logout-landing",
requestURL: "/logout",
expectedBehavior: "Should allow external redirects",
},
{
name: "protocol-relative URL",
postLogoutRedirectURI: "//example.com/logout",
requestURL: "/logout",
expectedBehavior: "Should handle protocol-relative URLs",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := createTestConfig()
config.LogoutURL = "/logout"
config.PostLogoutRedirectURI = tt.postLogoutRedirectURI
oidc, _ := setupTestOIDCMiddleware(t, config)
req := httptest.NewRequest("GET", tt.requestURL, nil)
rec := httptest.NewRecorder()
// Add minimal session
session := createTestSession()
session.SetIDToken("dummy-token")
// Inject session into request
injectSessionIntoRequest(t, req, session)
oidc.ServeHTTP(rec, req)
assert.Equal(t, http.StatusFound, rec.Code)
location := rec.Header().Get("Location")
// Check based on the type of URL
switch {
case strings.HasPrefix(tt.postLogoutRedirectURI, "https://") || strings.HasPrefix(tt.postLogoutRedirectURI, "http://"):
// Absolute URLs should be preserved
assert.Equal(t, tt.postLogoutRedirectURI, location, tt.expectedBehavior)
case strings.HasPrefix(tt.postLogoutRedirectURI, "//"):
// Protocol-relative URLs get the scheme prepended
assert.Equal(t, "http://example.com"+tt.postLogoutRedirectURI, location, tt.expectedBehavior)
default:
// Relative URLs get the full base URL prepended
assert.Equal(t, "http://example.com"+tt.postLogoutRedirectURI, location, tt.expectedBehavior)
}
})
}
}
func TestLogoutURLConfiguration(t *testing.T) {
tests := []struct {
name string
logoutURL string
callbackURL string
expectedLogoutURL string
}{
{
name: "custom logout URL",
logoutURL: "/auth/logout",
callbackURL: "/auth/callback",
expectedLogoutURL: "/auth/logout",
},
{
name: "default logout URL from callback",
logoutURL: "",
callbackURL: "/oauth2/callback",
expectedLogoutURL: "/oauth2/callback/logout",
},
{
name: "logout URL with trailing slash",
logoutURL: "/logout/",
callbackURL: "/callback",
expectedLogoutURL: "/logout/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := createTestConfig()
config.LogoutURL = tt.logoutURL
config.CallbackURL = tt.callbackURL
oidc, _ := setupTestOIDCMiddleware(t, config)
// The logout URL should be set correctly
assert.Equal(t, tt.expectedLogoutURL, oidc.logoutURLPath)
// Test that the logout URL is recognized
req := httptest.NewRequest("GET", tt.expectedLogoutURL, nil)
rec := httptest.NewRecorder()
// Add session to trigger logout logic
session := createTestSession()
session.SetIDToken("test-token")
// Inject session into request
injectSessionIntoRequest(t, req, session)
oidc.ServeHTTP(rec, req)
// Should trigger logout (redirect)
assert.Equal(t, http.StatusFound, rec.Code)
})
}
}
+366 -2469
View File
File diff suppressed because it is too large Load Diff
+223 -3101
View File
File diff suppressed because it is too large Load Diff
-245
View File
@@ -1,245 +0,0 @@
package traefikoidc
import (
"context"
"net/http"
"net/http/httptest"
"runtime"
"sync"
"testing"
"time"
)
func TestMemoryLeakFixes(t *testing.T) {
t.Run("Cache cleanup stops properly", func(t *testing.T) {
// Track goroutine count before starting
initialGoroutines := runtime.NumGoroutine()
// Create multiple caches
caches := make([]*Cache, 10)
for i := 0; i < 10; i++ {
caches[i] = NewCache()
caches[i].Set("key", "value", time.Hour)
}
// Wait for goroutines to start
time.Sleep(100 * time.Millisecond)
// Check that goroutines were created
afterCreateGoroutines := runtime.NumGoroutine()
if afterCreateGoroutines <= initialGoroutines {
t.Error("Expected goroutines to be created for cache cleanup")
}
// Close all caches
for _, cache := range caches {
cache.Close()
}
// Wait for goroutines to stop
time.Sleep(200 * time.Millisecond)
// Check that goroutines were cleaned up
finalGoroutines := runtime.NumGoroutine()
if finalGoroutines > initialGoroutines+2 { // Allow some tolerance
t.Errorf("Goroutine leak detected: initial=%d, final=%d", initialGoroutines, finalGoroutines)
}
})
t.Run("Global cache manager cleanup", func(t *testing.T) {
// Get the global cache manager
cm := GetGlobalCacheManager()
if cm == nil {
t.Fatal("Failed to get global cache manager")
}
// Use the caches
cm.GetSharedTokenBlacklist().Set("key", "value", time.Hour)
cm.GetSharedTokenCache().Set("key", map[string]interface{}{"test": "data"}, time.Hour)
// Clean up the global cache manager
err := CleanupGlobalCacheManager()
if err != nil {
t.Errorf("Failed to cleanup global cache manager: %v", err)
}
// Verify it can be re-initialized
cm2 := GetGlobalCacheManager()
if cm2 == nil {
t.Fatal("Failed to re-initialize global cache manager")
}
})
t.Run("Session pool returns properly", func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("test-encryption-key-that-is-long-enough-32bytes", false, "", logger)
if err != nil {
t.Fatal(err)
}
// Create multiple sessions
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
req := httptest.NewRequest("GET", "/", nil)
session, err := sm.GetSession(req)
if err != nil {
return
}
// Simulate some work
session.SetAccessToken("dummy-access-token")
// Properly return to pool
session.returnToPoolSafely()
}()
}
wg.Wait()
// Check that sessions can still be obtained
req := httptest.NewRequest("GET", "/", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Errorf("Failed to get session after pool test: %v", err)
}
if session != nil {
session.returnToPoolSafely()
}
})
t.Run("HTTP response bodies are drained", func(t *testing.T) {
// Create a test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Return a response with body
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"test": "data"}`))
}))
defer server.Close()
// Create HTTP client with our fixes
client := createDefaultHTTPClient()
// Make multiple requests
for i := 0; i < 10; i++ {
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
// Our fix ensures body is drained
resp.Body.Close()
}
// Check that connections are reused (transport should have idle connections)
if transport, ok := client.Transport.(*http.Transport); ok {
transport.CloseIdleConnections()
// If connections were properly reused, we shouldn't have leaked connections
t.Log("HTTP connections properly managed")
}
})
t.Run("Middleware cleanup releases all resources", func(t *testing.T) {
// Track initial goroutines
initialGoroutines := runtime.NumGoroutine()
// Create a middleware instance
config := CreateConfig()
config.ProviderURL = "https://example.com"
config.ClientID = "test-client"
config.ClientSecret = "test-secret"
config.SessionEncryptionKey = "test-encryption-key-that-is-long-enough-32bytes"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler, err := New(ctx, next, config, "test-middleware")
if err != nil {
t.Fatal(err)
}
// Cast to TraefikOidc to access Close method
if middleware, ok := handler.(*TraefikOidc); ok {
// Wait for initialization
time.Sleep(100 * time.Millisecond)
// Close the middleware
err := middleware.Close()
if err != nil {
t.Errorf("Failed to close middleware: %v", err)
}
// Wait for cleanup
time.Sleep(500 * time.Millisecond)
// Check goroutines
finalGoroutines := runtime.NumGoroutine()
if finalGoroutines > initialGoroutines+5 { // Allow some tolerance
t.Errorf("Possible goroutine leak: initial=%d, final=%d", initialGoroutines, finalGoroutines)
}
}
})
}
func TestJWKCacheNoDoubleStorage(t *testing.T) {
cache := NewJWKCache()
defer cache.Close()
// Create a test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"keys": [{"kty": "RSA", "kid": "test-key", "use": "sig", "n": "test", "e": "AQAB"}]}`))
}))
defer server.Close()
ctx := context.Background()
client := &http.Client{Timeout: 5 * time.Second}
// Get JWKS multiple times
for i := 0; i < 3; i++ {
jwks, err := cache.GetJWKS(ctx, server.URL, client)
if err != nil {
t.Fatal(err)
}
if jwks == nil || len(jwks.Keys) != 1 {
t.Error("Expected JWKS with one key")
}
}
// Verify no double storage by checking cache internals
// The cache should only use internalCache, not the jwks field
if cache.internalCache == nil {
t.Error("Internal cache should be initialized")
}
// Run cleanup
cache.Cleanup()
}
func TestGlobalSingletonCleanup(t *testing.T) {
// Test memory pool cleanup
pools := GetGlobalMemoryPools()
if pools == nil {
t.Fatal("Failed to get global memory pools")
}
// Use the pools
buf := pools.GetHTTPResponseBuffer()
pools.PutHTTPResponseBuffer(buf)
// Clean up
CleanupGlobalMemoryPools()
// Verify it can be re-initialized
pools2 := GetGlobalMemoryPools()
if pools2 == nil {
t.Fatal("Failed to re-initialize global memory pools")
}
}
-243
View File
@@ -1,243 +0,0 @@
package traefikoidc
import (
"bytes"
"strings"
"sync"
)
// MemoryPoolManager manages various memory pools for high-frequency allocations
// to reduce garbage collection pressure and improve performance. It provides
// thread-safe object pools for compression buffers, JWT parsing, HTTP responses,
// and string building operations.
type MemoryPoolManager struct {
compressionBufferPool *sync.Pool
jwtParsingPool *sync.Pool
httpResponsePool *sync.Pool
stringBuilderPool *sync.Pool
}
// JWTParsingBuffer contains reusable byte buffers for JWT parsing operations.
// By reusing these buffers, we avoid frequent allocations during token validation,
// which can significantly improve performance under high load.
type JWTParsingBuffer struct {
HeaderBuf []byte
PayloadBuf []byte
SignatureBuf []byte
}
// NewMemoryPoolManager creates and initializes all memory pools with appropriate
// default sizes based on typical usage patterns. The pools are configured to
// balance memory usage with performance benefits.
func NewMemoryPoolManager() *MemoryPoolManager {
return &MemoryPoolManager{
// Pool for compression/decompression buffers (4KB default)
compressionBufferPool: &sync.Pool{
New: func() interface{} {
return bytes.NewBuffer(make([]byte, 0, 4096))
},
},
// Pool for JWT parsing buffers
jwtParsingPool: &sync.Pool{
New: func() interface{} {
return &JWTParsingBuffer{
HeaderBuf: make([]byte, 0, 512), // JWT headers are typically small
PayloadBuf: make([]byte, 0, 2048), // Payloads can be larger
SignatureBuf: make([]byte, 0, 512), // Signatures are fixed size
}
},
},
// Pool for HTTP response buffers (8KB default)
httpResponsePool: &sync.Pool{
New: func() interface{} {
buf := make([]byte, 0, 8192)
return &buf
},
},
// Pool for string builders
stringBuilderPool: &sync.Pool{
New: func() interface{} {
var sb strings.Builder
sb.Grow(1024) // Pre-allocate 1KB
return &sb
},
},
}
}
// GetCompressionBuffer retrieves a reusable buffer from the compression pool.
// The buffer should be returned to the pool using PutCompressionBuffer when done.
func (m *MemoryPoolManager) GetCompressionBuffer() *bytes.Buffer {
return m.compressionBufferPool.Get().(*bytes.Buffer)
}
// PutCompressionBuffer returns a buffer to the compression pool for reuse.
// Buffers larger than 16KB are not pooled to prevent excessive memory retention.
// The buffer is reset before being returned to the pool.
func (m *MemoryPoolManager) PutCompressionBuffer(buf *bytes.Buffer) {
if buf == nil {
return
}
// Reset buffer but keep capacity if reasonable size
if buf.Cap() <= 16384 { // Don't pool buffers larger than 16KB
buf.Reset()
m.compressionBufferPool.Put(buf)
}
}
// GetJWTParsingBuffer retrieves buffers for JWT parsing
func (m *MemoryPoolManager) GetJWTParsingBuffer() *JWTParsingBuffer {
return m.jwtParsingPool.Get().(*JWTParsingBuffer)
}
// PutJWTParsingBuffer returns JWT parsing buffers to the pool
func (m *MemoryPoolManager) PutJWTParsingBuffer(buf *JWTParsingBuffer) {
if buf == nil {
return
}
// Reset buffers but keep capacity if reasonable
if cap(buf.HeaderBuf) <= 2048 && cap(buf.PayloadBuf) <= 8192 && cap(buf.SignatureBuf) <= 2048 {
buf.HeaderBuf = buf.HeaderBuf[:0]
buf.PayloadBuf = buf.PayloadBuf[:0]
buf.SignatureBuf = buf.SignatureBuf[:0]
m.jwtParsingPool.Put(buf)
}
}
// GetHTTPResponseBuffer retrieves a buffer for HTTP responses
func (m *MemoryPoolManager) GetHTTPResponseBuffer() []byte {
return *m.httpResponsePool.Get().(*[]byte)
}
// PutHTTPResponseBuffer returns an HTTP response buffer to the pool
func (m *MemoryPoolManager) PutHTTPResponseBuffer(buf []byte) {
if buf == nil {
return
}
// Don't pool extremely large buffers
if cap(buf) <= 32768 { // 32KB limit
buf = buf[:0] // Reset length but keep capacity
m.httpResponsePool.Put(&buf)
}
}
// GetStringBuilder retrieves a string builder from the pool
func (m *MemoryPoolManager) GetStringBuilder() *strings.Builder {
return m.stringBuilderPool.Get().(*strings.Builder)
}
// PutStringBuilder returns a string builder to the pool
func (m *MemoryPoolManager) PutStringBuilder(sb *strings.Builder) {
if sb == nil {
return
}
// Don't pool extremely large builders
if sb.Cap() <= 16384 { // 16KB limit
sb.Reset()
m.stringBuilderPool.Put(sb)
}
}
// TokenCompressionPool manages memory pools for token compression operations
type TokenCompressionPool struct {
compressionBuffers sync.Pool
decompressionBuffers sync.Pool
stringBuilders sync.Pool
}
// NewTokenCompressionPool creates a specialized pool for token operations
func NewTokenCompressionPool() *TokenCompressionPool {
return &TokenCompressionPool{
compressionBuffers: sync.Pool{
New: func() interface{} {
return bytes.NewBuffer(make([]byte, 0, 4096))
},
},
decompressionBuffers: sync.Pool{
New: func() interface{} {
return bytes.NewBuffer(make([]byte, 0, 8192))
},
},
stringBuilders: sync.Pool{
New: func() interface{} {
var sb strings.Builder
sb.Grow(2048) // Pre-allocate for token operations
return &sb
},
},
}
}
// GetCompressionBuffer gets a buffer for compression
func (p *TokenCompressionPool) GetCompressionBuffer() *bytes.Buffer {
return p.compressionBuffers.Get().(*bytes.Buffer)
}
// PutCompressionBuffer returns a compression buffer
func (p *TokenCompressionPool) PutCompressionBuffer(buf *bytes.Buffer) {
if buf != nil && buf.Cap() <= 16384 {
buf.Reset()
p.compressionBuffers.Put(buf)
}
}
// GetDecompressionBuffer gets a buffer for decompression
func (p *TokenCompressionPool) GetDecompressionBuffer() *bytes.Buffer {
return p.decompressionBuffers.Get().(*bytes.Buffer)
}
// PutDecompressionBuffer returns a decompression buffer
func (p *TokenCompressionPool) PutDecompressionBuffer(buf *bytes.Buffer) {
if buf != nil && buf.Cap() <= 32768 {
buf.Reset()
p.decompressionBuffers.Put(buf)
}
}
// GetStringBuilder gets a string builder for token operations
func (p *TokenCompressionPool) GetStringBuilder() *strings.Builder {
return p.stringBuilders.Get().(*strings.Builder)
}
// PutStringBuilder returns a string builder
func (p *TokenCompressionPool) PutStringBuilder(sb *strings.Builder) {
if sb != nil && sb.Cap() <= 16384 {
sb.Reset()
p.stringBuilders.Put(sb)
}
}
// Global memory pool manager instance
var globalMemoryPools *MemoryPoolManager
var memoryPoolOnce sync.Once
var memoryPoolMutex sync.RWMutex
// GetGlobalMemoryPools returns the singleton memory pool manager
func GetGlobalMemoryPools() *MemoryPoolManager {
memoryPoolOnce.Do(func() {
globalMemoryPools = NewMemoryPoolManager()
})
return globalMemoryPools
}
// CleanupGlobalMemoryPools cleans up the global memory pool manager singleton.
// This should be called during application shutdown to prevent memory leaks.
// It's safe to call multiple times.
func CleanupGlobalMemoryPools() {
memoryPoolMutex.Lock()
defer memoryPoolMutex.Unlock()
if globalMemoryPools != nil {
// Clear the pools to release any pooled objects
globalMemoryPools = nil
// Reset the once to allow re-initialization if needed
memoryPoolOnce = sync.Once{}
}
}
-213
View File
@@ -1,213 +0,0 @@
package traefikoidc
import (
"context"
"fmt"
"net/http"
"sync"
"time"
)
// MetadataCache provides thread-safe caching for OIDC provider metadata.
// It stores provider discovery information (endpoints, issuer, etc.) to reduce
// network requests to the provider's .well-known/openid-configuration endpoint.
// The cache includes automatic expiration and periodic cleanup.
type MetadataCache struct {
expiresAt time.Time
metadata *ProviderMetadata
cleanupTask *BackgroundTask
logger *Logger
autoCleanupInterval time.Duration
mutex sync.RWMutex
}
// NewMetadataCache creates a new MetadataCache instance.
// It initializes the cache structure and starts the background cleanup task.
func NewMetadataCache() *MetadataCache {
return NewMetadataCacheWithLogger(nil)
}
// NewMetadataCacheWithLogger creates a new MetadataCache with a specified logger.
func NewMetadataCacheWithLogger(logger *Logger) *MetadataCache {
if logger == nil {
logger = newNoOpLogger()
}
c := &MetadataCache{
autoCleanupInterval: 5 * time.Minute,
logger: logger,
}
c.startAutoCleanup()
return c
}
// Cleanup removes the cached provider metadata if it has expired.
// This is called periodically by the auto-cleanup goroutine.
func (c *MetadataCache) Cleanup() {
c.mutex.Lock()
defer c.mutex.Unlock()
now := time.Now()
if c.metadata != nil && now.After(c.expiresAt) {
c.metadata = nil
}
}
// isCacheValid checks if the cached metadata is present and has not expired.
// This method assumes the caller holds the appropriate lock.
func (c *MetadataCache) isCacheValid() bool {
return c.metadata != nil && time.Now().Before(c.expiresAt)
}
// GetMetadataWithRecovery retrieves the OIDC provider metadata with comprehensive error recovery.
// It uses circuit breaker protection and graceful degradation patterns.
// Similar to GetMetadata but with enhanced error handling capabilities.
//
// Parameters:
// - providerURL: The base URL of the OIDC provider.
// - httpClient: The HTTP client to use for fetching metadata.
// - logger: The logger instance for recording errors or warnings.
// - errorRecoveryManager: The error recovery manager for circuit breaker and retry handling.
//
// Returns:
// - A pointer to the ProviderMetadata struct.
// - An error if metadata cannot be retrieved from cache or fetched from the provider.
func (c *MetadataCache) GetMetadataWithRecovery(providerURL string, httpClient *http.Client, logger *Logger, errorRecoveryManager *ErrorRecoveryManager) (*ProviderMetadata, error) {
c.mutex.RLock()
if c.isCacheValid() {
defer c.mutex.RUnlock()
return c.metadata, nil
}
c.mutex.RUnlock()
c.mutex.Lock()
defer c.mutex.Unlock()
// Double-check after acquiring write lock
if c.isCacheValid() {
return c.metadata, nil
}
// Use error recovery manager for fetching metadata with circuit breaker protection
serviceName := fmt.Sprintf("metadata-provider-%s", providerURL)
// Register fallback function for graceful degradation
errorRecoveryManager.gracefulDegradation.RegisterFallback(serviceName, func() (interface{}, error) {
if c.metadata != nil {
logger.Infof("Using cached metadata as fallback for service %s", serviceName)
// Extend cache by 10 minutes when using fallback
c.expiresAt = time.Now().Add(10 * time.Minute)
return c.metadata, nil
}
return nil, fmt.Errorf("no cached metadata available for fallback")
})
// Register health check function
errorRecoveryManager.gracefulDegradation.RegisterHealthCheck(serviceName, func() bool {
// Simple health check by attempting a quick metadata fetch
_, err := discoverProviderMetadata(providerURL, httpClient, logger)
return err == nil
})
// Execute metadata discovery with circuit breaker and retry protection
ctx := context.Background()
var metadata *ProviderMetadata
err := errorRecoveryManager.ExecuteWithRecovery(ctx, serviceName, func() error {
var fetchErr error
metadata, fetchErr = discoverProviderMetadata(providerURL, httpClient, logger)
return fetchErr
})
if err != nil {
// Try graceful degradation fallback
fallbackResult, fallbackErr := errorRecoveryManager.gracefulDegradation.ExecuteWithFallback(serviceName, func() (interface{}, error) {
return discoverProviderMetadata(providerURL, httpClient, logger)
})
if fallbackErr == nil {
if fallbackMetadata, ok := fallbackResult.(*ProviderMetadata); ok {
logger.Infof("Successfully used fallback metadata for service %s", serviceName)
c.metadata = fallbackMetadata
// Cache fallback result for 10 minutes
c.expiresAt = time.Now().Add(10 * time.Minute)
return fallbackMetadata, nil
}
}
return nil, fmt.Errorf("failed to fetch provider metadata with error recovery and fallback: %w", err)
}
c.metadata = metadata
c.expiresAt = time.Now().Add(1 * time.Hour)
return metadata, nil
}
// GetMetadata retrieves the OIDC provider metadata.
// It first checks the cache for valid, non-expired metadata. If found, it's returned immediately.
// If the cache is empty or expired, it attempts to fetch the metadata from the provider's
// well-known endpoint using discoverProviderMetadata.
// If fetching is successful, the new metadata is cached for 1 hour.
// If fetching fails but valid metadata exists in the cache (even if expired), the cache expiry
// is extended by 5 minutes, and the cached data is returned to prevent thundering herd issues.
// If fetching fails and there's no cached data, an error is returned.
// It employs double-checked locking for thread safety and performance.
//
// Parameters:
// - providerURL: The base URL of the OIDC provider.
// - httpClient: The HTTP client to use for fetching metadata.
// - logger: The logger instance for recording errors or warnings.
//
// Returns:
// - A pointer to the ProviderMetadata struct.
// - An error if metadata cannot be retrieved from cache or fetched from the provider.
func (c *MetadataCache) GetMetadata(providerURL string, httpClient *http.Client, logger *Logger) (*ProviderMetadata, error) {
c.mutex.RLock()
if c.isCacheValid() {
defer c.mutex.RUnlock()
return c.metadata, nil
}
c.mutex.RUnlock()
c.mutex.Lock()
defer c.mutex.Unlock()
// Double-check after acquiring write lock
if c.isCacheValid() {
return c.metadata, nil
}
metadata, err := discoverProviderMetadata(providerURL, httpClient, logger)
if err != nil {
if c.metadata != nil {
// On error, extend current cache by 5 minutes to prevent thundering herd
c.expiresAt = time.Now().Add(5 * time.Minute)
logger.Errorf("Failed to refresh metadata, using cached version for 5 more minutes: %v", err)
return c.metadata, nil
}
return nil, fmt.Errorf("failed to fetch provider metadata: %w", err)
}
c.metadata = metadata
// Set a fixed cache lifetime (e.g., 1 hour)
// Consider making this configurable or respecting HTTP cache headers
c.expiresAt = time.Now().Add(1 * time.Hour)
// End of GetMetadata
return metadata, nil
}
// startAutoCleanup starts the background task that periodically calls Cleanup
// to remove expired metadata from the cache.
func (c *MetadataCache) startAutoCleanup() {
c.cleanupTask = NewBackgroundTask("metadata-cache-cleanup", c.autoCleanupInterval, c.Cleanup, c.logger)
c.cleanupTask.Start()
}
// Close stops the automatic cleanup task associated with this metadata cache.
func (c *MetadataCache) Close() {
if c.cleanupTask != nil {
c.cleanupTask.Stop()
c.cleanupTask = nil
}
}
-103
View File
@@ -1,103 +0,0 @@
package traefikoidc
import (
"fmt"
"net/http"
"testing"
"time"
)
func TestCleanup(t *testing.T) {
pm := &ProviderMetadata{}
mc := &MetadataCache{
metadata: pm,
expiresAt: time.Now().Add(-1 * time.Hour),
}
mc.Cleanup()
if mc.metadata != nil {
t.Errorf("Expected metadata to be nil after cleanup")
}
}
func TestGetMetadata_Cached(t *testing.T) {
dummyData := &ProviderMetadata{}
// Construct MetadataCache manually to avoid interference from auto cleanup.
mc := &MetadataCache{
metadata: dummyData,
expiresAt: time.Now().Add(1 * time.Hour),
autoCleanupInterval: 5 * time.Minute,
logger: newNoOpLogger(),
}
// Use NewLogger to create a logger that writes errors only.
logger := NewLogger("error")
result, err := mc.GetMetadata("http://example.com", http.DefaultClient, logger)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if result != dummyData {
t.Errorf("Expected cached metadata to be returned")
}
}
func TestMetadataCacheAutoCleanup(t *testing.T) {
mc := &MetadataCache{
autoCleanupInterval: 50 * time.Millisecond,
logger: newNoOpLogger(),
}
// Start auto cleanup.
mc.startAutoCleanup()
mc.mutex.Lock()
mc.metadata = &ProviderMetadata{}
mc.expiresAt = time.Now().Add(-50 * time.Millisecond)
mc.mutex.Unlock()
// Wait enough time for the auto cleanup to run.
time.Sleep(200 * time.Millisecond)
mc.Close()
mc.mutex.RLock()
defer mc.mutex.RUnlock()
if mc.metadata != nil {
t.Errorf("Expected metadata to be cleared by auto cleanup")
}
}
type errorRoundTripper struct {
err error
}
func (e errorRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return nil, e.err
}
func TestGetMetadata_FetchError(t *testing.T) {
// Create an HTTP client that always returns an error.
errorClient := &http.Client{
Transport: errorRoundTripper{err: fmt.Errorf("fake fetch error")},
}
// Case 1: Cache is empty.
mc := &MetadataCache{
logger: newNoOpLogger(),
}
logger := NewLogger("error")
metadata, err := mc.GetMetadata("http://example.com", errorClient, logger)
if err == nil {
t.Errorf("Expected error, got nil")
}
if metadata != nil {
t.Errorf("Expected nil metadata, got %v", metadata)
}
// Case 2: Cache has old metadata.
dummy := &ProviderMetadata{}
mc.metadata = dummy
mc.expiresAt = time.Now().Add(-1 * time.Minute)
logger2 := NewLogger("error")
metadata, err = mc.GetMetadata("http://example.com", errorClient, logger2)
if err != nil {
t.Errorf("Expected no error when cached metadata exists, got %v", err)
}
if metadata != dummy {
t.Errorf("Expected cached metadata to be returned")
}
}
-514
View File
@@ -1,514 +0,0 @@
package traefikoidc
import (
"context"
"crypto/sha256"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPKCEGeneration(t *testing.T) {
tests := []struct {
name string
test func(t *testing.T)
}{
{
name: "generateCodeVerifier creates valid verifier",
test: func(t *testing.T) {
verifier, err := generateCodeVerifier()
require.NoError(t, err)
// RFC 7636: code_verifier must be 43-128 characters
assert.GreaterOrEqual(t, len(verifier), 43)
assert.LessOrEqual(t, len(verifier), 128)
// Should be base64url encoded (no padding, no +/)
assert.NotContains(t, verifier, "=")
assert.NotContains(t, verifier, "+")
assert.NotContains(t, verifier, "/")
// Should be URL safe
assert.Equal(t, url.QueryEscape(verifier), verifier)
},
},
{
name: "generateCodeVerifier creates unique values",
test: func(t *testing.T) {
verifiers := make(map[string]bool)
for i := 0; i < 100; i++ {
v, err := generateCodeVerifier()
require.NoError(t, err)
assert.False(t, verifiers[v], "Generated duplicate code verifier")
verifiers[v] = true
}
},
},
{
name: "deriveCodeChallenge creates valid S256 challenge",
test: func(t *testing.T) {
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge := deriveCodeChallenge(verifier)
// Expected challenge for the test verifier (from RFC 7636 example)
expected := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
assert.Equal(t, expected, challenge)
// Should be base64url encoded
assert.NotContains(t, challenge, "=")
assert.NotContains(t, challenge, "+")
assert.NotContains(t, challenge, "/")
},
},
{
name: "deriveCodeChallenge handles empty verifier",
test: func(t *testing.T) {
challenge := deriveCodeChallenge("")
// SHA256 of empty string, base64url encoded
h := sha256.Sum256([]byte(""))
expected := base64.RawURLEncoding.EncodeToString(h[:])
assert.Equal(t, expected, challenge)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.test(t)
})
}
}
func TestPKCEAuthorizationFlow(t *testing.T) {
tests := []struct {
name string
enablePKCE bool
test func(t *testing.T, authURL string)
}{
{
name: "PKCE enabled adds code_challenge parameters",
enablePKCE: true,
test: func(t *testing.T, authURL string) {
u, err := url.Parse(authURL)
require.NoError(t, err)
params := u.Query()
// Should have code_challenge and code_challenge_method
assert.NotEmpty(t, params.Get("code_challenge"))
assert.Equal(t, "S256", params.Get("code_challenge_method"))
// Code challenge should be properly formatted
challenge := params.Get("code_challenge")
assert.NotContains(t, challenge, "=")
assert.NotContains(t, challenge, "+")
assert.NotContains(t, challenge, "/")
assert.Greater(t, len(challenge), 0)
},
},
{
name: "PKCE disabled omits code_challenge parameters",
enablePKCE: false,
test: func(t *testing.T, authURL string) {
u, err := url.Parse(authURL)
require.NoError(t, err)
params := u.Query()
// Should not have PKCE parameters
assert.Empty(t, params.Get("code_challenge"))
assert.Empty(t, params.Get("code_challenge_method"))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup test environment
config := createTestConfig()
config.EnablePKCE = tt.enablePKCE
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.enablePKCE = tt.enablePKCE
// Create test request
req := httptest.NewRequest("GET", "/protected", nil)
rec := httptest.NewRecorder()
// Trigger authentication
oidc.ServeHTTP(rec, req)
// Check redirect
assert.Equal(t, http.StatusFound, rec.Code)
location := rec.Header().Get("Location")
assert.NotEmpty(t, location)
// Run test specific checks
tt.test(t, location)
})
}
}
func TestPKCESessionManagement(t *testing.T) {
tests := []struct {
name string
test func(t *testing.T)
}{
{
name: "stores and retrieves code verifier in session",
test: func(t *testing.T) {
session := createTestSession()
verifier, err := generateCodeVerifier()
require.NoError(t, err)
// Store verifier
session.SetCodeVerifier(verifier)
// Retrieve verifier
retrieved := session.GetCodeVerifier()
assert.Equal(t, verifier, retrieved)
},
},
{
name: "code verifier persists through session operations",
test: func(t *testing.T) {
session := createTestSession()
verifier, err := generateCodeVerifier()
require.NoError(t, err)
// Store verifier and other data
session.SetCodeVerifier(verifier)
session.SetAccessToken("test-access-token")
session.SetIDToken("test-id-token")
// Verifier should still be there
assert.Equal(t, verifier, session.GetCodeVerifier())
},
},
{
name: "code verifier cleared after token exchange",
test: func(t *testing.T) {
config := createTestConfig()
config.EnablePKCE = true
oidc, server := setupTestOIDCMiddleware(t, config)
defer server.Close()
oidc.enablePKCE = true
// Create session with code verifier
session := createTestSession()
verifier, err := generateCodeVerifier()
require.NoError(t, err)
session.SetCodeVerifier(verifier)
// Simulate callback with code
req := httptest.NewRequest("GET", config.CallbackURL+"?code=test-code&state=test-state", nil)
rec := httptest.NewRecorder()
// Add session cookie
// For testing, we would need to add the session to the request
// This is a simplified approach - in real tests, use proper session injection
// Handle callback
oidc.ServeHTTP(rec, req)
// Verify code verifier was used and cleared
// Note: In real implementation, this would be cleared after successful exchange
// This test verifies the session flow
assert.NotNil(t, session)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.test(t)
})
}
}
func TestPKCETokenExchange(t *testing.T) {
tests := []struct {
name string
enablePKCE bool
codeVerifier string
expectParam bool
}{
{
name: "includes code_verifier when PKCE enabled",
enablePKCE: true,
codeVerifier: "test-verifier-123",
expectParam: true,
},
{
name: "omits code_verifier when PKCE disabled",
enablePKCE: false,
codeVerifier: "",
expectParam: false,
},
{
name: "omits code_verifier when empty even if PKCE enabled",
enablePKCE: true,
codeVerifier: "",
expectParam: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a test server to capture the token exchange request
var capturedBody string
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
capturedBody = string(body)
// Return mock tokens
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"access_token": "test-access-token",
"id_token": "` + ValidIDToken + `",
"token_type": "bearer",
"expires_in": 3600
}`))
}))
defer tokenServer.Close()
// Setup OIDC with custom token endpoint
config := createTestConfig()
config.EnablePKCE = tt.enablePKCE
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.tokenURL = tokenServer.URL
// Exchange tokens
_, err := oidc.ExchangeCodeForToken(
context.Background(),
"authorization_code",
"test-code",
config.CallbackURL,
tt.codeVerifier,
)
require.NoError(t, err)
// Check if code_verifier was included
if tt.expectParam {
assert.Contains(t, capturedBody, "code_verifier="+tt.codeVerifier)
} else {
assert.NotContains(t, capturedBody, "code_verifier")
}
})
}
}
func TestPKCEEndToEndFlow(t *testing.T) {
// Setup test environment
config := createTestConfig()
config.EnablePKCE = true
oidc, server := setupTestOIDCMiddleware(t, config)
defer server.Close()
oidc.enablePKCE = true
// Generate a code verifier for testing
testCodeVerifier, err := generateCodeVerifier()
require.NoError(t, err)
testCodeChallenge := deriveCodeChallenge(testCodeVerifier)
// Mock the token exchange to verify code_verifier is sent
var receivedVerifier string
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
receivedVerifier = r.Form.Get("code_verifier")
// Return mock tokens
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"access_token": "test-access-token",
"id_token": "` + ValidIDToken + `",
"token_type": "bearer",
"expires_in": 3600
}`))
}))
defer tokenServer.Close()
oidc.tokenURL = tokenServer.URL
// Mock the token verifier to avoid JWKS lookup
oidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
// Always return success for test tokens
claims, err := extractClaims(token)
if err != nil {
return err
}
// Cache the claims for the token
oidc.tokenCache.Set(token, claims, time.Hour)
return nil
},
}
// Step 1: Simulate the callback directly with a pre-configured session
// This bypasses the session persistence issue in the test environment
callbackReq := httptest.NewRequest("GET", config.CallbackURL+"?code=test-code&state=test-state", nil)
callbackRec := httptest.NewRecorder()
// Get a session and set it up as if the auth flow had started
session, err := oidc.sessionManager.GetSession(callbackReq)
require.NoError(t, err)
// Set up the session as the auth initiation would have done
session.SetCSRF("test-state")
session.SetNonce("nonce123") // Must match the nonce in ValidIDToken
session.SetCodeVerifier(testCodeVerifier)
session.SetIncomingPath("/protected")
// Save the session
err = session.Save(callbackReq, callbackRec)
require.NoError(t, err)
// Create a new request with the session cookies
callbackReq2 := httptest.NewRequest("GET", config.CallbackURL+"?code=test-code&state=test-state", nil)
for _, cookie := range callbackRec.Result().Cookies() {
callbackReq2.AddCookie(cookie)
}
callbackRec2 := httptest.NewRecorder()
// Handle callback
oidc.ServeHTTP(callbackRec2, callbackReq2)
// Verify successful authentication
assert.Equal(t, http.StatusFound, callbackRec2.Code)
assert.Equal(t, testCodeVerifier, receivedVerifier, "Code verifier should be sent in token exchange")
// Also test the authorization URL building with PKCE
authURL := oidc.buildAuthURL("http://example.com/callback", "test-csrf", "test-nonce", testCodeChallenge)
parsedURL, err := url.Parse(authURL)
require.NoError(t, err)
assert.Equal(t, testCodeChallenge, parsedURL.Query().Get("code_challenge"))
assert.Equal(t, "S256", parsedURL.Query().Get("code_challenge_method"))
}
func TestPKCESecurityEdgeCases(t *testing.T) {
tests := []struct {
name string
test func(t *testing.T)
}{
{
name: "rejects callback without matching state",
test: func(t *testing.T) {
config := createTestConfig()
config.EnablePKCE = true
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.enablePKCE = true
// Create callback request with wrong state
req := httptest.NewRequest("GET", config.CallbackURL+"?code=test-code&state=wrong-state", nil)
rec := httptest.NewRecorder()
oidc.ServeHTTP(rec, req)
// Should reject due to state mismatch
assert.Equal(t, http.StatusBadRequest, rec.Code)
},
},
{
name: "handles missing code_verifier gracefully",
test: func(t *testing.T) {
config := createTestConfig()
config.EnablePKCE = true
oidc, server := setupTestOIDCMiddleware(t, config)
defer server.Close()
// Create session without code verifier
session := createTestSession()
session.mainSession.Values["state"] = "test-state"
// Intentionally not setting code verifier
req := httptest.NewRequest("GET", config.CallbackURL+"?code=test-code&state=test-state", nil)
rec := httptest.NewRecorder()
// Add session
// For testing, we would need to add the session to the request
// This is a simplified approach - in real tests, use proper session injection
// Should handle gracefully even without verifier
oidc.ServeHTTP(rec, req)
// The actual behavior depends on provider - some may reject, others may accept
// The important thing is no panic/crash
assert.NotNil(t, rec)
},
},
{
name: "code verifier is single use",
test: func(t *testing.T) {
session := createTestSession()
verifier, err := generateCodeVerifier()
require.NoError(t, err)
// Set verifier
session.SetCodeVerifier(verifier)
assert.Equal(t, verifier, session.GetCodeVerifier())
// In real flow, it would be cleared after use
// This test verifies the concept
session.SetCodeVerifier("")
assert.Empty(t, session.GetCodeVerifier())
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.test(t)
})
}
}
func TestPKCECompatibilityWithProviders(t *testing.T) {
providers := []struct {
name string
providerType string
supportsPKCE bool
}{
{"Google", "google", true},
{"Azure", "azure", true},
{"Generic", "generic", true},
}
for _, provider := range providers {
t.Run(provider.name+" provider with PKCE", func(t *testing.T) {
config := createTestConfig()
config.EnablePKCE = true
config.ProviderURL = "https://" + provider.providerType + ".example.com"
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.enablePKCE = true
// Test auth URL generation
req := httptest.NewRequest("GET", "/protected", nil)
rec := httptest.NewRecorder()
oidc.ServeHTTP(rec, req)
if provider.supportsPKCE {
location := rec.Header().Get("Location")
assert.Contains(t, location, "code_challenge")
assert.Contains(t, location, "code_challenge_method=S256")
}
})
}
}
-587
View File
@@ -1,587 +0,0 @@
package traefikoidc
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// Helper to create an authenticated session with tokens
func createAuthenticatedSession(accessToken, idToken, refreshToken string) *SessionData {
session := createTestSession()
session.SetAuthenticated(true)
session.SetAccessToken(accessToken)
session.SetIDToken(idToken)
if refreshToken != "" {
session.SetRefreshToken(refreshToken)
}
session.SetEmail("test@example.com")
return session
}
func TestRefreshGracePeriodConfiguration(t *testing.T) {
tests := []struct {
name string
refreshGracePeriodSeconds int
expectDefault bool
expectedValue int
}{
{
name: "custom grace period",
refreshGracePeriodSeconds: 120,
expectDefault: false,
expectedValue: 120,
},
{
name: "zero uses default",
refreshGracePeriodSeconds: 0,
expectDefault: true,
expectedValue: 60, // Default value
},
{
name: "negative uses default",
refreshGracePeriodSeconds: -30,
expectDefault: true,
expectedValue: 60,
},
{
name: "very large grace period",
refreshGracePeriodSeconds: 3600, // 1 hour
expectDefault: false,
expectedValue: 3600,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := createTestConfig()
config.RefreshGracePeriodSeconds = tt.refreshGracePeriodSeconds
oidc, _ := setupTestOIDCMiddleware(t, config)
// Check the configured value
assert.Equal(t, time.Duration(tt.expectedValue)*time.Second, oidc.refreshGracePeriod)
})
}
}
func TestTokenRefreshWithinGracePeriod(t *testing.T) {
refreshCount := int32(0)
tokenVersion := int32(1)
// Mock token server that returns new tokens
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&refreshCount, 1)
currentVersion := atomic.LoadInt32(&tokenVersion)
// Return new tokens
newToken := createMockJWTWithExpiry(t, "user123", "test@example.com", time.Now().Add(5*time.Minute))
response := map[string]interface{}{
"access_token": fmt.Sprintf("new-access-token-longer-than-20-v%d", currentVersion),
"id_token": newToken,
"refresh_token": fmt.Sprintf("new-refresh-token-v%d", currentVersion),
"expires_in": 300,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}))
defer tokenServer.Close()
config := createTestConfig()
config.RefreshGracePeriodSeconds = 30 // 30 second grace period
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.tokenURL = tokenServer.URL
oidc.refreshGracePeriod = time.Duration(30) * time.Second
// Mock the token verifier to avoid JWKS lookup
oidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
// Always return success for test tokens
claims, err := extractClaims(token)
if err != nil {
return err
}
// Cache the claims for the token
oidc.tokenCache.Set(token, claims, time.Hour)
return nil
},
}
// Create session with token expiring soon (within grace period)
expiryTime := time.Now().Add(25 * time.Second) // Expires in 25 seconds (within 30s grace)
idToken := createMockJWTWithExpiry(t, "user123", "test@example.com", expiryTime)
session := createAuthenticatedSession("old-access-token-longer-than-20-chars", idToken, "refresh-token-123")
// Set up the next handler before concurrent requests
var nextCallCount int32
oidc.next = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&nextCallCount, 1)
w.WriteHeader(http.StatusOK)
})
// Make concurrent requests during grace period
var wg sync.WaitGroup
results := make([]bool, 5)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
req := httptest.NewRequest("GET", "/api/data", nil)
rec := httptest.NewRecorder()
// Clone session for each request
reqSession := createTestSession()
reqSession.SetAuthenticated(true)
reqSession.SetAccessToken(session.GetAccessToken())
reqSession.SetIDToken(session.GetIDToken())
reqSession.SetRefreshToken(session.GetRefreshToken())
reqSession.SetEmail(session.GetEmail())
// Inject session into request
injectSessionIntoRequest(t, req, reqSession)
oidc.ServeHTTP(rec, req)
results[idx] = rec.Code == http.StatusOK
}(i)
}
wg.Wait()
// All requests should succeed
for i, success := range results {
assert.True(t, success, "Request %d should succeed", i)
}
// Verify all requests reached the next handler
assert.Equal(t, int32(5), atomic.LoadInt32(&nextCallCount), "All requests should reach next handler")
// Each concurrent request will perform its own refresh because they each have
// their own session instance loaded from cookies. The implementation doesn't
// have a global refresh synchronization mechanism across different session instances.
// This is a known limitation - the grace period only prevents repeated refreshes
// within the same session instance, not across concurrent requests.
assert.Equal(t, int32(5), atomic.LoadInt32(&refreshCount), "Each concurrent request performs its own refresh")
}
func TestTokenRefreshOutsideGracePeriod(t *testing.T) {
refreshCalled := false
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
refreshCalled = true
// Return new token
newToken := createMockJWTWithExpiry(t, "user123", "test@example.com", time.Now().Add(1*time.Hour))
response := map[string]interface{}{
"access_token": "new-access-token-longer-than-20-chars",
"id_token": newToken,
"refresh_token": "new-refresh-token",
"expires_in": 3600,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}))
defer tokenServer.Close()
config := createTestConfig()
config.RefreshGracePeriodSeconds = 60
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.tokenURL = tokenServer.URL
oidc.refreshGracePeriod = time.Duration(60) * time.Second
// Mock the token verifier
oidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
claims, err := extractClaims(token)
if err != nil {
return err
}
oidc.tokenCache.Set(token, claims, time.Hour)
return nil
},
}
// Create session with expired token (outside grace period)
expiredToken := createMockJWTWithExpiry(t, "user123", "test@example.com", time.Now().Add(-2*time.Minute))
session := createAuthenticatedSession("expired-access-token-longer-than-20", expiredToken, "refresh-token-123")
req := httptest.NewRequest("GET", "/api/data", nil)
rec := httptest.NewRecorder()
// Inject session into request
injectSessionIntoRequest(t, req, session)
nextCalled := false
oidc.next = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
})
oidc.ServeHTTP(rec, req)
// Request should succeed after refresh
assert.True(t, nextCalled)
assert.Equal(t, http.StatusOK, rec.Code)
// Refresh should have been called
assert.True(t, refreshCalled, "Token refresh should be triggered for expired token")
}
func TestGracePeriodWithProviderSpecificBehavior(t *testing.T) {
providers := []struct {
name string
providerType string
supportsRefresh bool
gracePeriodSeconds int
}{
{
name: "Google provider with grace period",
providerType: "google",
supportsRefresh: true,
gracePeriodSeconds: 120,
},
{
name: "Azure provider with grace period",
providerType: "azure",
supportsRefresh: true,
gracePeriodSeconds: 60,
},
{
name: "Generic provider with grace period",
providerType: "generic",
supportsRefresh: true,
gracePeriodSeconds: 90,
},
}
for _, provider := range providers {
t.Run(provider.name, func(t *testing.T) {
config := createTestConfig()
config.RefreshGracePeriodSeconds = provider.gracePeriodSeconds
config.ProviderURL = "https://" + provider.providerType + ".example.com"
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.refreshGracePeriod = time.Duration(provider.gracePeriodSeconds) * time.Second
// This test only verifies configuration, not actual refresh behavior
// Verify grace period is respected for this provider
assert.Equal(t, time.Duration(provider.gracePeriodSeconds)*time.Second, oidc.refreshGracePeriod)
})
}
}
func TestRefreshGracePeriodConcurrency(t *testing.T) {
var refreshMutex sync.Mutex
refreshCount := 0
blockedRequests := int32(0)
// Mock token server with delay to simulate slow refresh
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
refreshMutex.Lock()
refreshCount++
refreshMutex.Unlock()
// Simulate slow token refresh
time.Sleep(100 * time.Millisecond)
newToken := createMockJWTWithExpiry(t, "user123", "test@example.com", time.Now().Add(1*time.Hour))
response := map[string]interface{}{
"access_token": "new-access-token-longer-than-20-chars",
"id_token": newToken,
"refresh_token": "new-refresh-token",
"expires_in": 3600,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}))
defer tokenServer.Close()
config := createTestConfig()
config.RefreshGracePeriodSeconds = 30
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.tokenURL = tokenServer.URL
oidc.refreshGracePeriod = time.Duration(30) * time.Second
// Mock the token verifier
oidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
claims, err := extractClaims(token)
if err != nil {
return err
}
oidc.tokenCache.Set(token, claims, time.Hour)
return nil
},
}
// Create session with token expiring within grace period
expiryTime := time.Now().Add(20 * time.Second)
idToken := createMockJWTWithExpiry(t, "user123", "test@example.com", expiryTime)
session := createAuthenticatedSession("old-access-token-longer-than-20-chars", idToken, "refresh-token-123")
// Set up the next handler before concurrent requests
successCount := int32(0)
oidc.next = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&successCount, 1)
w.WriteHeader(http.StatusOK)
})
// Make many concurrent requests
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
req := httptest.NewRequest("GET", "/api/data", nil)
rec := httptest.NewRecorder()
// Each request gets its own session copy
reqSession := createAuthenticatedSession(
session.GetAccessToken(),
session.GetIDToken(),
session.GetRefreshToken(),
)
// Inject session into request
injectSessionIntoRequest(t, req, reqSession)
start := time.Now()
oidc.ServeHTTP(rec, req)
elapsed := time.Since(start)
// Track if request was blocked waiting for refresh
if elapsed > 50*time.Millisecond {
atomic.AddInt32(&blockedRequests, 1)
}
}()
}
wg.Wait()
// All requests should succeed
assert.Equal(t, int32(20), successCount, "All requests should succeed")
// Each concurrent request performs its own refresh due to separate session instances
// The implementation lacks global refresh synchronization across session instances
assert.Equal(t, 20, refreshCount, "Each concurrent request performs its own refresh")
// With the current implementation, requests aren't blocked because each has its own mutex
t.Logf("Requests with >50ms delay (own refresh): %d", blockedRequests)
}
func TestRefreshGracePeriodEdgeCases(t *testing.T) {
tests := []struct {
name string
gracePeriodSeconds int
tokenExpiryDelta time.Duration
expectRefresh bool
description string
}{
{
name: "token exactly at grace boundary",
gracePeriodSeconds: 60,
tokenExpiryDelta: 60 * time.Second,
expectRefresh: true,
description: "Should refresh when exactly at grace period boundary",
},
{
name: "token just inside grace period",
gracePeriodSeconds: 60,
tokenExpiryDelta: 59 * time.Second,
expectRefresh: true,
description: "Should refresh when inside grace period",
},
{
name: "token just outside grace period",
gracePeriodSeconds: 60,
tokenExpiryDelta: 61 * time.Second,
expectRefresh: false,
description: "Should not refresh when outside grace period",
},
{
name: "already expired token",
gracePeriodSeconds: 60,
tokenExpiryDelta: -10 * time.Second,
expectRefresh: true,
description: "Should always refresh expired tokens",
},
{
name: "very short grace period",
gracePeriodSeconds: 1,
tokenExpiryDelta: 500 * time.Millisecond,
expectRefresh: true,
description: "Should handle sub-second grace periods",
},
{
name: "zero grace period",
gracePeriodSeconds: 0, // Will use default 60
tokenExpiryDelta: 30 * time.Second,
expectRefresh: true,
description: "Should use default when zero configured",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
refreshCalled := false
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
refreshCalled = true
newToken := createMockJWTWithExpiry(t, "user123", "test@example.com", time.Now().Add(1*time.Hour))
response := map[string]interface{}{
"access_token": "new-access-token-longer-than-20-chars",
"id_token": newToken,
"refresh_token": "new-refresh-token",
"expires_in": 3600,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}))
defer tokenServer.Close()
config := createTestConfig()
config.RefreshGracePeriodSeconds = tt.gracePeriodSeconds
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.tokenURL = tokenServer.URL
// Handle zero grace period defaulting to 60
if tt.gracePeriodSeconds > 0 {
oidc.refreshGracePeriod = time.Duration(tt.gracePeriodSeconds) * time.Second
} else {
oidc.refreshGracePeriod = time.Duration(60) * time.Second
}
// Mock the token verifier
oidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
claims, err := extractClaims(token)
if err != nil {
return err
}
oidc.tokenCache.Set(token, claims, time.Hour)
return nil
},
}
// Create token with specified expiry
expiryTime := time.Now().Add(tt.tokenExpiryDelta)
idToken := createMockJWTWithExpiry(t, "user123", "test@example.com", expiryTime)
session := createAuthenticatedSession("test-access-token-longer-than-20-chars", idToken, "refresh-token-123")
req := httptest.NewRequest("GET", "/api/data", nil)
rec := httptest.NewRecorder()
// Inject session into request
injectSessionIntoRequest(t, req, session)
oidc.next = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
oidc.ServeHTTP(rec, req)
assert.Equal(t, tt.expectRefresh, refreshCalled, tt.description)
})
}
}
func TestRefreshGracePeriodWithoutRefreshToken(t *testing.T) {
config := createTestConfig()
config.RefreshGracePeriodSeconds = 30
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.refreshGracePeriod = time.Duration(30) * time.Second
// Mock the token verifier
oidc.tokenVerifier = &mockTokenVerifier{
verifyFunc: func(token string) error {
claims, err := extractClaims(token)
if err != nil {
return err
}
oidc.tokenCache.Set(token, claims, time.Hour)
return nil
},
}
// Create session with token expiring within grace period but NO refresh token
expiryTime := time.Now().Add(20 * time.Second)
idToken := createMockJWTWithExpiry(t, "user123", "test@example.com", expiryTime)
// Create session with access token but no refresh token
// Access token must be at least 20 chars for opaque tokens
session := createAuthenticatedSession("test-access-token-longer-than-20-chars", idToken, "") // No refresh token
req := httptest.NewRequest("GET", "/api/data", nil)
rec := httptest.NewRecorder()
// Inject session into request
injectSessionIntoRequest(t, req, session)
nextCalled := false
oidc.next = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
})
oidc.ServeHTTP(rec, req)
// Should still allow access even though token is near expiry
// because we can't refresh without a refresh token
assert.True(t, nextCalled, "Request should proceed even without refresh capability")
assert.Equal(t, http.StatusOK, rec.Code)
}
// Helper function to create JWT with specific expiry
func createMockJWTWithExpiry(t *testing.T, sub, email string, expiry time.Time) string {
header := map[string]interface{}{
"alg": "RS256",
"typ": "JWT",
"kid": "test-key-id",
}
claims := map[string]interface{}{
"sub": sub,
"email": email,
"iss": "https://test-provider.com",
"aud": "test-client-id",
"exp": expiry.Unix(),
"iat": time.Now().Unix(),
"name": "Test User",
}
headerJSON, _ := json.Marshal(header)
claimsJSON, _ := json.Marshal(claims)
headerEncoded := base64.RawURLEncoding.EncodeToString(headerJSON)
claimsEncoded := base64.RawURLEncoding.EncodeToString(claimsJSON)
// Create a fake signature
signature := base64.RawURLEncoding.EncodeToString([]byte("fake-signature"))
return headerEncoded + "." + claimsEncoded + "." + signature
}
-357
View File
@@ -1,357 +0,0 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestReverseProxyHTTPSDetection tests that HTTPS is properly detected in reverse proxy environments
func TestReverseProxyHTTPSDetection(t *testing.T) {
t.Run("HTTPS_Detection_With_X_Forwarded_Proto", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Simulate request from reverse proxy (Traefik/nginx)
// The reverse proxy terminates SSL and forwards HTTP internally
req := httptest.NewRequest("GET", "http://example.com/test", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "example.com")
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Set critical session data
session.SetCSRF("important-csrf-token")
session.SetNonce("test-nonce")
// Save session
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Verify cookies have Secure flag when X-Forwarded-Proto is https
cookies := rec.Result().Cookies()
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie, "Main session cookie should be set")
assert.True(t, mainCookie.Secure, "Cookie should have Secure flag when X-Forwarded-Proto is https")
assert.Equal(t, http.SameSiteLaxMode, mainCookie.SameSite, "Should use Lax SameSite for OAuth compatibility")
})
t.Run("HTTPS_Detection_Without_Headers", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Request without reverse proxy headers (direct HTTP)
req := httptest.NewRequest("GET", "http://example.com/test", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
session.SetCSRF("test-csrf")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Verify cookies don't have Secure flag for plain HTTP
cookies := rec.Result().Cookies()
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie)
assert.False(t, mainCookie.Secure, "Cookie should not have Secure flag for HTTP")
assert.Equal(t, http.SameSiteLaxMode, mainCookie.SameSite, "Should use Lax SameSite in HTTP")
})
t.Run("HTTPS_Detection_With_ForceHTTPS", func(t *testing.T) {
// Test with forceHTTPS enabled
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", true, "", NewLogger("debug"))
require.NoError(t, err)
// Even without headers, forceHTTPS should make cookies secure
req := httptest.NewRequest("GET", "http://example.com/test", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
session.SetCSRF("forced-secure-csrf")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
cookies := rec.Result().Cookies()
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie)
assert.True(t, mainCookie.Secure, "Cookie should have Secure flag with forceHTTPS")
})
}
// TestCSRFPersistenceInReverseProxy tests CSRF token persistence in reverse proxy setups
func TestCSRFPersistenceInReverseProxy(t *testing.T) {
t.Run("CSRF_Persists_Through_OAuth_Flow_With_Proxy", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Step 1: Initial request to protected resource (HTTPS via proxy)
req1 := httptest.NewRequest("GET", "http://example.com/protected", nil)
req1.Header.Set("X-Forwarded-Proto", "https")
req1.Header.Set("X-Forwarded-Host", "example.com")
session1, err := sessionManager.GetSession(req1)
require.NoError(t, err)
// Set CSRF and other auth flow data
csrfToken := "proxy-csrf-token-12345"
session1.SetCSRF(csrfToken)
session1.SetNonce("proxy-nonce")
session1.SetIncomingPath("/protected")
// Save session (should set Secure cookie)
rec1 := httptest.NewRecorder()
err = session1.Save(req1, rec1)
require.NoError(t, err)
cookies := rec1.Result().Cookies()
// Step 2: Simulate OAuth callback (also HTTPS via proxy)
req2 := httptest.NewRequest("GET", "http://example.com/oidc/callback?code=auth-code&state="+csrfToken, nil)
req2.Header.Set("X-Forwarded-Proto", "https")
req2.Header.Set("X-Forwarded-Host", "example.com")
// Add cookies from step 1
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
// Get session in callback
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
// CSRF token should persist
retrievedCSRF := session2.GetCSRF()
assert.Equal(t, csrfToken, retrievedCSRF, "CSRF token must persist through OAuth flow in reverse proxy")
assert.Equal(t, "proxy-nonce", session2.GetNonce(), "Nonce should also persist")
assert.Equal(t, "/protected", session2.GetIncomingPath(), "Incoming path should persist")
})
t.Run("Session_Cookie_Domain_With_Proxy_Headers", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Test with X-Forwarded-Host header
req := httptest.NewRequest("GET", "http://internal.local/test", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "public.example.com")
req.Host = "internal.local" // Internal host
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
session.SetCSRF("domain-test-csrf")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
cookies := rec.Result().Cookies()
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie)
// Domain should be set based on X-Forwarded-Host when present
// This ensures cookies work correctly with the public domain
assert.Equal(t, "public.example.com", mainCookie.Domain, "Cookie domain should use forwarded host")
})
}
// TestAzureOIDCWithReverseProxy simulates Azure OIDC flow behind a reverse proxy
func TestAzureOIDCWithReverseProxy(t *testing.T) {
t.Run("Azure_Provider_Detection_And_Configuration", func(t *testing.T) {
// This test verifies Azure-specific provider detection and configuration
// without making actual network calls
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Step 1: Test session setup for Azure OAuth flow
req := httptest.NewRequest("GET", "http://internal/protected", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "app.example.com")
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Simulate OAuth flow initialization
csrfToken := "azure-csrf-token"
nonce := "azure-nonce"
session.SetCSRF(csrfToken)
session.SetNonce(nonce)
session.SetIncomingPath("/protected")
session.MarkDirty()
// Save session with proper HTTPS detection
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Verify cookies have correct security attributes for Azure
cookies := rec.Result().Cookies()
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie, "Main session cookie must be set")
assert.True(t, mainCookie.Secure, "Cookie must be secure for HTTPS reverse proxy")
assert.Equal(t, http.SameSiteLaxMode, mainCookie.SameSite, "Should use Lax SameSite for OAuth compatibility")
// Step 2: Simulate callback and verify session persistence
callbackReq := httptest.NewRequest("GET", "http://internal/oidc/callback?code=azure-code&state="+csrfToken, nil)
callbackReq.Header.Set("X-Forwarded-Proto", "https")
callbackReq.Header.Set("X-Forwarded-Host", "app.example.com")
// Add cookies from initial request
for _, cookie := range cookies {
callbackReq.AddCookie(cookie)
}
// Get session in callback
callbackSession, err := sessionManager.GetSession(callbackReq)
require.NoError(t, err)
// Verify session data persisted correctly
assert.Equal(t, csrfToken, callbackSession.GetCSRF(), "CSRF token must persist in Azure flow")
assert.Equal(t, nonce, callbackSession.GetNonce(), "Nonce must persist")
assert.Equal(t, "/protected", callbackSession.GetIncomingPath(), "Original path must persist")
})
t.Run("Mixed_HTTP_HTTPS_Requests", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Simulate a scenario where some requests come via HTTPS proxy and some don't
// This can happen in development or misconfigured environments
// Request 1: HTTPS via proxy
req1 := httptest.NewRequest("GET", "http://localhost:8080/test", nil)
req1.Header.Set("X-Forwarded-Proto", "https")
session1, err := sessionManager.GetSession(req1)
require.NoError(t, err)
session1.SetCSRF("mixed-csrf")
rec1 := httptest.NewRecorder()
err = session1.Save(req1, rec1)
require.NoError(t, err)
cookies1 := rec1.Result().Cookies()
// Request 2: Direct HTTP (no proxy headers)
req2 := httptest.NewRequest("GET", "http://localhost:8080/test", nil)
// No X-Forwarded-Proto header
// Try to use cookies from HTTPS request
for _, cookie := range cookies1 {
// Remove Secure flag to simulate browser behavior
// (browser wouldn't send secure cookie over HTTP)
if !cookie.Secure {
req2.AddCookie(cookie)
}
}
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
// Session should be empty because secure cookies weren't sent
csrf2 := session2.GetCSRF()
assert.Empty(t, csrf2, "CSRF should be empty when secure cookies can't be sent over HTTP")
})
}
// TestEnhanceSessionSecurity verifies the security enhancement function
func TestEnhanceSessionSecurity(t *testing.T) {
t.Run("Security_Enhancement_For_AJAX_Requests", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// AJAX request via HTTPS proxy
req := httptest.NewRequest("GET", "http://internal/api/data", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
session.SetCSRF("ajax-csrf")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Check that AJAX requests get strict same-site
cookies := rec.Result().Cookies()
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
assert.Equal(t, http.SameSiteStrictMode, cookie.SameSite, "AJAX requests should use Strict SameSite")
break
}
}
})
t.Run("Security_Enhancement_Missing_User_Agent", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Request without User-Agent (potential bot/attack)
req := httptest.NewRequest("GET", "http://example.com/test", nil)
req.Header.Set("X-Forwarded-Proto", "https")
// No User-Agent header
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
session.SetCSRF("no-ua-csrf")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Verify reduced session timeout for suspicious requests
cookies := rec.Result().Cookies()
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
// Should have reduced MaxAge (half of normal)
assert.Less(t, cookie.MaxAge, int(absoluteSessionTimeout.Seconds()), "Suspicious requests should have reduced timeout")
break
}
}
})
}
-552
View File
@@ -1,552 +0,0 @@
package traefikoidc
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestRevocationURLConfiguration(t *testing.T) {
tests := []struct {
name string
revocationURL string
expectError bool
errorContains string
}{
{
name: "valid HTTPS revocation URL",
revocationURL: "https://auth.example.com/revoke",
expectError: false,
},
{
name: "empty revocation URL allowed",
revocationURL: "",
expectError: false,
},
{
name: "HTTP revocation URL rejected",
revocationURL: "http://auth.example.com/revoke",
expectError: true,
errorContains: "revocationURL must be a valid HTTPS URL",
},
{
name: "invalid URL format",
revocationURL: "not-a-url",
expectError: true,
errorContains: "revocationURL must be a valid HTTPS URL",
},
{
name: "auto-discovered URL accepted",
revocationURL: "", // Will be auto-discovered
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := createTestConfig()
config.RevocationURL = tt.revocationURL
err := config.Validate()
if tt.expectError {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errorContains)
} else {
assert.NoError(t, err)
}
})
}
}
func TestRevocationURLAutoDiscovery(t *testing.T) {
// Create mock OIDC discovery server
var serverURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/openid-configuration" {
discoveryData := map[string]interface{}{
"issuer": serverURL,
"authorization_endpoint": serverURL + "/auth",
"token_endpoint": serverURL + "/token",
"userinfo_endpoint": serverURL + "/userinfo",
"revocation_endpoint": serverURL + "/revoke",
"jwks_uri": serverURL + "/keys",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(discoveryData)
}
}))
serverURL = server.URL
defer server.Close()
config := createTestConfig()
config.ProviderURL = server.URL
config.RevocationURL = "" // Let it auto-discover
// Use our test helper which doesn't do real discovery
oidc, _ := setupTestOIDCMiddleware(t, config)
// Simulate auto-discovery by setting the URL directly
// In a real scenario, this would be discovered from the provider metadata
oidc.revocationURL = server.URL + "/revoke"
// Check that revocation URL was set
assert.Contains(t, oidc.revocationURL, "/revoke")
}
func TestRevokeTokenWithProviderFlow(t *testing.T) {
tests := []struct {
name string
serverResponse int
serverBody string
expectError bool
validateRequest func(t *testing.T, r *http.Request)
}{
{
name: "successful revocation",
serverResponse: http.StatusOK,
serverBody: "",
expectError: false,
validateRequest: func(t *testing.T, r *http.Request) {
// Verify request format
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
// Parse form data
body, _ := io.ReadAll(r.Body)
values, _ := url.ParseQuery(string(body))
// Verify required parameters
assert.Equal(t, "test-token", values.Get("token"))
assert.Equal(t, "access_token", values.Get("token_type_hint"))
assert.NotEmpty(t, values.Get("client_id"))
assert.NotEmpty(t, values.Get("client_secret"))
},
},
{
name: "revocation with refresh token",
serverResponse: http.StatusOK,
serverBody: "",
expectError: false,
validateRequest: func(t *testing.T, r *http.Request) {
body, _ := io.ReadAll(r.Body)
values, _ := url.ParseQuery(string(body))
assert.Equal(t, "refresh-token-123", values.Get("token"))
assert.Equal(t, "refresh_token", values.Get("token_type_hint"))
},
},
{
name: "provider returns error",
serverResponse: http.StatusBadRequest,
serverBody: `{"error":"unsupported_token_type"}`,
expectError: true,
validateRequest: func(t *testing.T, r *http.Request) {},
},
{
name: "provider unavailable",
serverResponse: http.StatusServiceUnavailable,
serverBody: "Service Unavailable",
expectError: true,
validateRequest: func(t *testing.T, r *http.Request) {},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock revocation server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tt.validateRequest(t, r)
w.WriteHeader(tt.serverResponse)
w.Write([]byte(tt.serverBody))
}))
defer server.Close()
config := createTestConfig()
config.RevocationURL = server.URL
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.revocationURL = server.URL
// Test token revocation
var err error
if strings.Contains(tt.name, "refresh token") {
err = oidc.RevokeTokenWithProvider("refresh-token-123", "refresh_token")
} else {
err = oidc.RevokeTokenWithProvider("test-token", "access_token")
}
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestLocalTokenRevocation(t *testing.T) {
config := createTestConfig()
oidc, _ := setupTestOIDCMiddleware(t, config)
// Create a test JWT token
token := createMockJWT(t, "user123", "test@example.com")
// Add token to cache first
oidc.tokenCache.Set(token, map[string]interface{}{"test": "claims"}, 5*time.Minute)
// Verify token is in cache
_, found := oidc.tokenCache.Get(token)
assert.True(t, found)
// Revoke the token locally
oidc.RevokeToken(token)
// Verify token is removed from validation cache
_, found = oidc.tokenCache.Get(token)
assert.False(t, found)
// Verify token is in blacklist
_, blacklisted := oidc.tokenBlacklist.Get(token)
assert.True(t, blacklisted)
}
func TestRevocationDuringLogout(t *testing.T) {
// Track revocation calls
accessTokenRevoked := false
refreshTokenRevoked := false
idTokenRevoked := false
// Create mock revocation server
revocationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
values, _ := url.ParseQuery(string(body))
token := values.Get("token")
tokenType := values.Get("token_type_hint")
switch {
case strings.HasPrefix(token, "access-"):
accessTokenRevoked = true
assert.Equal(t, "access_token", tokenType)
case strings.HasPrefix(token, "refresh-"):
refreshTokenRevoked = true
assert.Equal(t, "refresh_token", tokenType)
case strings.HasPrefix(token, "id-"):
idTokenRevoked = true
// ID tokens might not have a type hint
}
w.WriteHeader(http.StatusOK)
}))
defer revocationServer.Close()
config := createTestConfig()
config.RevocationURL = revocationServer.URL
config.LogoutURL = "/logout"
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.revocationURL = revocationServer.URL
// Create authenticated session
session := createTestSession()
session.SetAuthenticated(true)
session.SetAccessToken("access-token-123-longer-than-20-chars")
session.SetRefreshToken("refresh-token-123")
session.SetIDToken("id-token-123")
// Create logout request
req := httptest.NewRequest("GET", "/logout", nil)
rec := httptest.NewRecorder()
// Inject session
// For testing, we would need to add the session to the request
// This is a simplified approach - in real tests, use proper session injection
// Handle logout
oidc.ServeHTTP(rec, req)
// Verify logout happened
assert.Equal(t, http.StatusFound, rec.Code)
// NOTE: Current implementation doesn't revoke tokens on logout
// These assertions document what SHOULD happen:
// assert.True(t, accessTokenRevoked, "Access token should be revoked on logout")
// assert.True(t, refreshTokenRevoked, "Refresh token should be revoked on logout")
// assert.True(t, idTokenRevoked, "ID token should be revoked on logout")
// For now, verify current behavior (no revocation)
assert.False(t, accessTokenRevoked, "Access token is not currently revoked on logout")
assert.False(t, refreshTokenRevoked, "Refresh token is not currently revoked on logout")
assert.False(t, idTokenRevoked, "ID token is not currently revoked on logout")
}
func TestRevocationWithCircuitBreaker(t *testing.T) {
failureCount := 0
// Create flaky revocation server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
failureCount++
if failureCount == 1 {
// Fail first attempt
w.WriteHeader(http.StatusInternalServerError)
return
}
// Succeed on subsequent attempts
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
config := createTestConfig()
config.RevocationURL = server.URL
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.revocationURL = server.URL
// First attempt should fail
err := oidc.RevokeTokenWithProvider("test-token", "access_token")
assert.Error(t, err, "First attempt should fail")
assert.Equal(t, 1, failureCount)
// Second attempt should succeed
err = oidc.RevokeTokenWithProvider("test-token", "access_token")
assert.NoError(t, err, "Second attempt should succeed")
assert.Equal(t, 2, failureCount)
}
func TestRevocationErrorHandling(t *testing.T) {
tests := []struct {
name string
setupServer func() *httptest.Server
expectError bool
errorType string
}{
{
name: "network timeout",
setupServer: func() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(5 * time.Second) // Cause timeout
}))
},
expectError: true,
errorType: "timeout",
},
{
name: "invalid response format",
setupServer: func() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte("<html>Not JSON</html>"))
}))
},
expectError: false, // 200 OK is considered success regardless of body
},
{
name: "connection refused",
setupServer: func() *httptest.Server {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
server.Close() // Close immediately to cause connection refused
return server
},
expectError: true,
errorType: "connection",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := tt.setupServer()
if server != nil {
defer server.Close()
}
config := createTestConfig()
config.RevocationURL = server.URL
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.revocationURL = server.URL
// Use shorter timeout for tests
originalClient := oidc.httpClient
oidc.httpClient = &http.Client{Timeout: 1 * time.Second}
defer func() { oidc.httpClient = originalClient }()
err := oidc.RevokeTokenWithProvider("test-token", "access_token")
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestRevocationConcurrency(t *testing.T) {
// Test concurrent revocation requests
revocationCount := 0
var mu sync.Mutex
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
revocationCount++
mu.Unlock()
time.Sleep(10 * time.Millisecond) // Simulate processing
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
config := createTestConfig()
config.RevocationURL = server.URL
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.revocationURL = server.URL
// Revoke multiple tokens concurrently
var wg sync.WaitGroup
errors := make([]error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
token := fmt.Sprintf("token-%d", idx)
errors[idx] = oidc.RevokeTokenWithProvider(token, "access_token")
}(i)
}
wg.Wait()
// All revocations should succeed
for i, err := range errors {
assert.NoError(t, err, "Revocation %d failed", i)
}
assert.Equal(t, 10, revocationCount)
}
func TestRevocationWithDifferentTokenTypes(t *testing.T) {
tokenTypes := []struct {
token string
tokenType string
desc string
}{
{"access-token-123", "access_token", "Access token revocation"},
{"refresh-token-456", "refresh_token", "Refresh token revocation"},
{"unknown-token-789", "", "Token without type hint"},
{"id-token-abc", "id_token", "ID token revocation"},
}
for _, tt := range tokenTypes {
t.Run(tt.desc, func(t *testing.T) {
receivedToken := ""
receivedType := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
values, _ := url.ParseQuery(string(body))
receivedToken = values.Get("token")
receivedType = values.Get("token_type_hint")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
config := createTestConfig()
config.RevocationURL = server.URL
oidc, _ := setupTestOIDCMiddleware(t, config)
oidc.revocationURL = server.URL
err := oidc.RevokeTokenWithProvider(tt.token, tt.tokenType)
assert.NoError(t, err)
assert.Equal(t, tt.token, receivedToken)
assert.Equal(t, tt.tokenType, receivedType)
})
}
}
func TestRevocationIntegration(t *testing.T) {
// Complete integration test with full authentication and revocation flow
// Setup servers
var revokedTokens []string
var revokeMu sync.Mutex
// Revocation server
revocationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
values, _ := url.ParseQuery(string(body))
token := values.Get("token")
revokeMu.Lock()
revokedTokens = append(revokedTokens, token)
revokeMu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer revocationServer.Close()
// Setup OIDC
config := createTestConfig()
config.RevocationURL = revocationServer.URL
oidc, authServer := setupTestOIDCMiddleware(t, config)
defer authServer.Close()
oidc.revocationURL = revocationServer.URL
// Step 1: Authenticate user
session := createTestSession()
session.SetAuthenticated(true) // Must set authenticated flag
session.SetAccessToken("access-token-user1-longer-than-20-chars") // Must be longer than 20 chars
session.SetRefreshToken("refresh-token-user1")
session.SetIDToken(createMockJWT(t, "user1", "user1@example.com"))
session.SetEmail("user1@example.com")
// Step 2: Make authenticated request
req := httptest.NewRequest("GET", "/api/data", nil)
rec := httptest.NewRecorder()
// Inject session into request
injectSessionIntoRequest(t, req, session)
nextCalled := false
oidc.next = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
})
oidc.ServeHTTP(rec, req)
assert.True(t, nextCalled, "Authenticated request should pass through")
// Step 3: Revoke tokens
err := oidc.RevokeTokenWithProvider("access-token-user1-longer-than-20-chars", "access_token")
assert.NoError(t, err)
err = oidc.RevokeTokenWithProvider("refresh-token-user1", "refresh_token")
assert.NoError(t, err)
// Verify tokens were revoked
assert.Contains(t, revokedTokens, "access-token-user1-longer-than-20-chars")
assert.Contains(t, revokedTokens, "refresh-token-user1")
// Step 4: Local revocation should also work
oidc.RevokeToken("access-token-user1-longer-than-20-chars")
// Verify token is blacklisted locally
_, blacklisted := oidc.tokenBlacklist.Get("access-token-user1-longer-than-20-chars")
assert.True(t, blacklisted)
}
-778
View File
@@ -1,778 +0,0 @@
package traefikoidc
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"golang.org/x/time/rate"
)
// TestConcurrentTokenVerification tests race conditions in token verification
func TestConcurrentTokenVerification(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
// Create multiple valid tokens to avoid replay detection
tokens := make([]string, 10)
for i := 0; i < 10; i++ {
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "user@example.com",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create test token %d: %v", i, err)
}
tokens[i] = token
}
// Create a fresh instance for this test
tOidc := &TraefikOidc{
issuerURL: "https://test-issuer.com",
clientID: "test-client-id",
jwkCache: ts.mockJWKCache,
tokenBlacklist: NewCache(),
tokenCache: NewTokenCache(),
limiter: rate.NewLimiter(rate.Every(time.Microsecond), 10000), // Very high rate limit
logger: NewLogger("debug"),
allowedUserDomains: map[string]struct{}{"example.com": {}},
httpClient: &http.Client{},
extractClaimsFunc: extractClaims,
}
tOidc.tokenVerifier = tOidc
tOidc.jwtVerifier = tOidc
// Ensure cleanup when test finishes
defer func() {
if err := tOidc.Close(); err != nil {
t.Logf("Error closing TraefikOidc instance: %v", err)
}
}()
// Test concurrent verification
const numGoroutines = 50
const verificationsPerGoroutine = 10
var wg sync.WaitGroup
var successCount int64
var errorCount int64
errors := make(chan error, numGoroutines*verificationsPerGoroutine)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < verificationsPerGoroutine; j++ {
tokenIndex := (goroutineID*verificationsPerGoroutine + j) % len(tokens)
err := tOidc.VerifyToken(tokens[tokenIndex])
if err != nil {
atomic.AddInt64(&errorCount, 1)
select {
case errors <- fmt.Errorf("goroutine %d, verification %d: %w", goroutineID, j, err):
default:
}
} else {
atomic.AddInt64(&successCount, 1)
}
}
}(i)
}
wg.Wait()
close(errors)
// Check results
totalOperations := int64(numGoroutines * verificationsPerGoroutine)
t.Logf("Concurrent verification results: %d successes, %d errors out of %d total operations",
successCount, errorCount, totalOperations)
// Collect and log errors
var errorList []error
for err := range errors {
errorList = append(errorList, err)
}
if len(errorList) > 0 {
t.Logf("Errors encountered during concurrent verification:")
for i, err := range errorList {
if i < 10 { // Log first 10 errors
t.Logf(" %d: %v", i+1, err)
}
}
if len(errorList) > 10 {
t.Logf(" ... and %d more errors", len(errorList)-10)
}
}
// We expect most operations to succeed
if successCount < totalOperations/2 {
t.Errorf("Too many failures in concurrent verification: %d successes out of %d operations", successCount, totalOperations)
}
// Check for data races by verifying cache consistency
cacheSize := len(tOidc.tokenCache.cache.items)
blacklistSize := len(tOidc.tokenBlacklist.items)
t.Logf("Final cache sizes: token cache=%d, blacklist=%d", cacheSize, blacklistSize)
}
// TestCacheMemoryExhaustion tests cache behavior under memory pressure
func TestCacheMemoryExhaustion(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
// Create a cache with limited size
cache := NewTokenCache()
cache.cache.SetMaxSize(100) // Small cache size
// Ensure cleanup when test finishes
defer cache.Close()
// Create many tokens to exceed cache capacity
const numTokens = 500
tokens := make([]string, numTokens)
for i := 0; i < numTokens; i++ {
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "user@example.com",
"jti": fmt.Sprintf("jti-%d", i),
})
if err != nil {
t.Fatalf("Failed to create token %d: %v", i, err)
}
tokens[i] = token
// Add to cache
claims := map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"sub": "test-subject",
"email": "user@example.com",
"jti": fmt.Sprintf("jti-%d", i),
}
cache.Set(token, claims, time.Hour)
}
// Verify cache size is within limits
cacheSize := len(cache.cache.items)
if cacheSize > 100 {
t.Errorf("Cache size exceeded limit: got %d, expected <= 100", cacheSize)
}
// Verify LRU eviction works
// The first tokens should have been evicted
firstToken := tokens[0]
if _, exists := cache.Get(firstToken); exists {
t.Errorf("First token should have been evicted from cache")
}
// The last tokens should still be in cache
lastToken := tokens[numTokens-1]
if _, exists := cache.Get(lastToken); !exists {
t.Errorf("Last token should still be in cache")
}
t.Logf("Cache memory exhaustion test passed: cache size=%d", cacheSize)
}
// TestSessionConcurrencyProtection tests session safety under concurrent access
func TestSessionConcurrencyProtection(t *testing.T) {
logger := NewLogger("debug")
sessionManager, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Test concurrent session access with separate requests
const numGoroutines = 20
const operationsPerGoroutine = 10 // Reduced to avoid overwhelming
var wg sync.WaitGroup
var successCount int64
var errorCount int64
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(goroutineID int) {
defer wg.Done()
// Each goroutine gets its own request and session
req := httptest.NewRequest("GET", "/test", nil)
for j := 0; j < operationsPerGoroutine; j++ {
// Get a fresh session for each operation
s, err := sessionManager.GetSession(req)
if err != nil {
atomic.AddInt64(&errorCount, 1)
continue
}
// Perform operations on session
s.SetEmail(fmt.Sprintf("user%d-%d@example.com", goroutineID, j))
s.SetAuthenticated(true)
s.SetAccessToken(ValidAccessToken)
// Save session
testRR := httptest.NewRecorder()
if err := s.Save(req, testRR); err != nil {
atomic.AddInt64(&errorCount, 1)
} else {
atomic.AddInt64(&successCount, 1)
}
// Copy cookies back to request for next iteration
for _, cookie := range testRR.Result().Cookies() {
req.Header.Set("Cookie", cookie.String())
}
}
}(i)
}
wg.Wait()
totalOperations := int64(numGoroutines * operationsPerGoroutine)
t.Logf("Session concurrency test results: %d successes, %d errors out of %d operations",
successCount, errorCount, totalOperations)
// Most operations should succeed
if successCount < totalOperations/2 {
t.Errorf("Too many session operation failures: %d successes out of %d operations", successCount, totalOperations)
}
}
// TestParallelCacheOperations tests cache thread safety
func TestParallelCacheOperations(t *testing.T) {
cache := NewCache()
cache.SetMaxSize(1000)
// Ensure cleanup when test finishes
defer cache.Close()
const numGoroutines = 10
const operationsPerGoroutine = 100
var wg sync.WaitGroup
var setCount int64
var getCount int64
var deleteCount int64
// Start multiple goroutines performing cache operations
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
key := fmt.Sprintf("key-%d-%d", goroutineID, j)
value := fmt.Sprintf("value-%d-%d", goroutineID, j)
// Set operation
cache.Set(key, value, time.Minute)
atomic.AddInt64(&setCount, 1)
// Get operation
if _, exists := cache.Get(key); exists {
atomic.AddInt64(&getCount, 1)
}
// Delete some items
if j%10 == 0 {
cache.Delete(key)
atomic.AddInt64(&deleteCount, 1)
}
}
}(i)
}
wg.Wait()
t.Logf("Parallel cache operations completed: %d sets, %d gets, %d deletes",
setCount, getCount, deleteCount)
// Verify cache is still functional
cache.Set("test-key", "test-value", time.Minute)
if value, exists := cache.Get("test-key"); !exists || value != "test-value" {
t.Errorf("Cache corrupted after parallel operations")
}
// Check cache size is reasonable
cacheSize := len(cache.items)
expectedSize := int(setCount - deleteCount)
if cacheSize > expectedSize {
t.Logf("Cache size after operations: %d (expected around %d)", cacheSize, expectedSize)
}
}
// TestProviderFailureRecovery tests network failure scenarios
func TestProviderFailureRecovery(t *testing.T) {
// Create a server that fails initially then recovers
var requestCount int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count := atomic.AddInt64(&requestCount, 1)
if count <= 1 {
// Fail first request only (we now retry max 2 times)
w.WriteHeader(http.StatusInternalServerError)
return
}
// Succeed on 2nd attempt
metadata := ProviderMetadata{
Issuer: "https://test-issuer.com",
AuthURL: "https://test-issuer.com/auth",
TokenURL: "https://test-issuer.com/token",
JWKSURL: "https://test-issuer.com/jwks",
RevokeURL: "https://test-issuer.com/revoke",
EndSessionURL: "https://test-issuer.com/end-session",
}
json.NewEncoder(w).Encode(metadata)
}))
defer server.Close()
// Test metadata discovery with retries
logger := NewLogger("debug")
httpClient := createDefaultHTTPClient()
start := time.Now()
metadata, err := discoverProviderMetadata(server.URL, httpClient, logger)
duration := time.Since(start)
if err != nil {
t.Errorf("Provider metadata discovery failed after retries: %v", err)
}
if metadata == nil {
t.Errorf("Expected metadata to be returned after recovery")
}
// Should have taken some time due to retries (with 2 max attempts: ~10ms delay)
expectedMinDuration := 10 * time.Millisecond
if duration < expectedMinDuration {
t.Errorf("Expected discovery to take at least %v due to retries, but took %v", expectedMinDuration, duration)
}
t.Logf("Provider failure recovery test passed: %d requests, duration: %v", requestCount, duration)
}
// TestOversizedTokenHandling tests boundary value handling
func TestOversizedTokenHandling(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
// Create an oversized token with large claims
largeClaim := strings.Repeat("x", 10000) // 10KB claim
oversizedClaims := map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "user@example.com",
"jti": generateRandomString(16),
"large_data": largeClaim,
}
oversizedToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", oversizedClaims)
if err != nil {
t.Fatalf("Failed to create oversized token: %v", err)
}
t.Logf("Created oversized token of length: %d bytes", len(oversizedToken))
// Test verification of oversized token
err = ts.tOidc.VerifyToken(oversizedToken)
if err != nil {
t.Logf("Oversized token verification failed as expected: %v", err)
// This is acceptable - oversized tokens should be rejected
} else {
t.Logf("Oversized token verification succeeded")
// Verify it was cached properly
if _, exists := ts.tOidc.tokenCache.Get(oversizedToken); !exists {
t.Errorf("Oversized token was not cached after successful verification")
}
}
// Test extremely long token (beyond reasonable limits)
extremelyLongClaim := strings.Repeat("y", 100000) // 100KB claim
extremeClaims := map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "user@example.com",
"jti": generateRandomString(16),
"extreme_data": extremelyLongClaim,
}
extremeToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", extremeClaims)
if err != nil {
t.Fatalf("Failed to create extreme token: %v", err)
}
t.Logf("Created extreme token of length: %d bytes", len(extremeToken))
// This should likely fail due to size limits
err = ts.tOidc.VerifyToken(extremeToken)
if err != nil {
t.Logf("Extreme token verification failed as expected: %v", err)
} else {
t.Logf("Warning: Extreme token verification succeeded - consider adding size limits")
}
}
// TestMaliciousInputValidation tests security input validation
func TestMaliciousInputValidation(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
maliciousInputs := []struct {
name string
token string
}{
{
name: "Empty token",
token: "",
},
{
name: "Single dot",
token: ".",
},
{
name: "Two dots only",
token: "..",
},
{
name: "SQL injection attempt",
token: "'; DROP TABLE users; --",
},
{
name: "Script injection attempt",
token: "<script>alert('xss')</script>",
},
{
name: "Path traversal attempt",
token: "../../../etc/passwd",
},
{
name: "Null bytes",
token: "token\x00with\x00nulls",
},
{
name: "Unicode control characters",
token: "token\u0000\u0001\u0002",
},
{
name: "Extremely long string",
token: strings.Repeat("a", 1000000), // 1MB string
},
{
name: "Invalid base64 characters",
token: "header.payload!@#$%^&*().signature",
},
{
name: "Binary data",
token: string([]byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD}),
},
}
for _, test := range maliciousInputs {
t.Run(test.name, func(t *testing.T) {
// Create a fresh instance for each test to avoid rate limiting issues
freshOidc := &TraefikOidc{
issuerURL: "https://test-issuer.com",
clientID: "test-client-id",
jwkCache: ts.mockJWKCache,
tokenBlacklist: NewCache(),
tokenCache: NewTokenCache(),
limiter: rate.NewLimiter(rate.Every(time.Microsecond), 10000), // Very high rate limit
logger: NewLogger("debug"),
allowedUserDomains: map[string]struct{}{"example.com": {}},
httpClient: &http.Client{},
extractClaimsFunc: extractClaims,
}
freshOidc.tokenVerifier = freshOidc
freshOidc.jwtVerifier = freshOidc
// Ensure cleanup when test finishes
defer func() {
if err := freshOidc.Close(); err != nil {
t.Logf("Error closing TraefikOidc instance: %v", err)
}
}()
// All malicious inputs should be safely rejected
err := freshOidc.VerifyToken(test.token)
if err == nil {
t.Errorf("Malicious input '%s' was not rejected", test.name)
} else {
t.Logf("Malicious input '%s' correctly rejected: %v", test.name, err)
}
// Verify the system is still functional after malicious input
validToken, createErr := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "user@example.com",
"jti": generateRandomString(16),
})
if createErr != nil {
t.Fatalf("Failed to create valid token for recovery test: %v", createErr)
}
// System should still work with valid tokens
if verifyErr := freshOidc.VerifyToken(validToken); verifyErr != nil {
t.Errorf("System failed to process valid token after malicious input: %v", verifyErr)
}
})
}
}
// TestNetworkErrorCleanup tests resource cleanup on network errors
func TestNetworkErrorCleanup(t *testing.T) {
// Create a server that times out
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate network timeout by sleeping
time.Sleep(2 * time.Second)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Create HTTP client with short timeout
httpClient := &http.Client{
Timeout: 100 * time.Millisecond, // Very short timeout
}
logger := NewLogger("debug")
// Track goroutines before test
initialGoroutines := runtime.NumGoroutine()
// Attempt metadata discovery that should timeout
start := time.Now()
_, err := discoverProviderMetadata(server.URL, httpClient, logger)
duration := time.Since(start)
// Should fail due to timeout
if err == nil {
t.Errorf("Expected timeout error, but request succeeded")
}
// Should fail quickly due to timeout
if duration > time.Second {
t.Errorf("Request took too long despite timeout: %v", duration)
}
// Give time for cleanup
time.Sleep(100 * time.Millisecond)
// Check for goroutine leaks
finalGoroutines := runtime.NumGoroutine()
if finalGoroutines > initialGoroutines+5 { // Allow some tolerance
t.Errorf("Potential goroutine leak: started with %d, ended with %d goroutines",
initialGoroutines, finalGoroutines)
}
t.Logf("Network error cleanup test passed: duration=%v, goroutines=%d->%d",
duration, initialGoroutines, finalGoroutines)
}
// TestResourceLimits tests system behavior under resource constraints
func TestResourceLimits(t *testing.T) {
// Test memory allocation limits
cache := NewCache()
cache.SetMaxSize(10) // Very small cache
// Ensure cleanup when test finishes
defer cache.Close()
// Try to overwhelm the cache
for i := 0; i < 1000; i++ {
key := fmt.Sprintf("key-%d", i)
value := fmt.Sprintf("value-%d", i)
cache.Set(key, value, time.Minute)
}
// Cache should not exceed its limit
if len(cache.items) > 10 {
t.Errorf("Cache exceeded size limit: got %d items, expected <= 10", len(cache.items))
}
// Test rate limiting under load
limiter := rate.NewLimiter(rate.Every(time.Second), 5) // 5 requests per second
allowed := 0
denied := 0
// Make many requests quickly
for i := 0; i < 100; i++ {
if limiter.Allow() {
allowed++
} else {
denied++
}
}
// Most should be denied due to rate limiting
if denied < 90 {
t.Errorf("Rate limiting not effective: allowed=%d, denied=%d", allowed, denied)
}
t.Logf("Resource limits test passed: cache size=%d, rate limiting: allowed=%d, denied=%d",
len(cache.items), allowed, denied)
}
// TestErrorRecoveryPatterns tests various error recovery scenarios
func TestErrorRecoveryPatterns(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup()
// Test recovery from cache corruption
t.Run("CacheCorruption", func(t *testing.T) {
// Corrupt the cache by using the Set method to avoid data race
ts.tOidc.tokenCache.cache.Set("corrupted", "invalid-data", time.Hour)
// System should handle corrupted cache gracefully
validToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "user@example.com",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create valid token: %v", err)
}
// Should still work despite cache corruption
if err := ts.tOidc.VerifyToken(validToken); err != nil {
t.Errorf("Token verification failed despite cache corruption: %v", err)
}
})
// Test recovery from blacklist corruption
t.Run("BlacklistCorruption", func(t *testing.T) {
// Add invalid data to blacklist
ts.tOidc.tokenBlacklist.Set("corrupted-entry", "invalid-data", time.Hour)
// System should still function
validToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "user@example.com",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create valid token: %v", err)
}
if err := ts.tOidc.VerifyToken(validToken); err != nil {
t.Errorf("Token verification failed despite blacklist corruption: %v", err)
}
})
}
// TestPerformanceUnderLoad tests system performance under high load
func TestPerformanceUnderLoad(t *testing.T) {
if testing.Short() {
t.Skip("Skipping performance test in short mode")
}
ts := &TestSuite{t: t}
ts.Setup()
// Create multiple valid tokens
const numTokens = 100
tokens := make([]string, numTokens)
for i := 0; i < numTokens; i++ {
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "user@example.com",
"jti": fmt.Sprintf("jti-%d", i),
})
if err != nil {
t.Fatalf("Failed to create token %d: %v", i, err)
}
tokens[i] = token
}
// Create fresh instance with high rate limit
tOidc := &TraefikOidc{
issuerURL: "https://test-issuer.com",
clientID: "test-client-id",
jwkCache: ts.mockJWKCache,
tokenBlacklist: NewCache(),
tokenCache: NewTokenCache(),
limiter: rate.NewLimiter(rate.Every(time.Microsecond), 10000), // Very high limit
logger: NewLogger("info"), // Reduce logging for performance
allowedUserDomains: map[string]struct{}{"example.com": {}},
httpClient: &http.Client{},
extractClaimsFunc: extractClaims,
}
tOidc.tokenVerifier = tOidc
tOidc.jwtVerifier = tOidc
// Ensure cleanup when test finishes
defer func() {
if err := tOidc.Close(); err != nil {
t.Logf("Error closing TraefikOidc instance: %v", err)
}
}()
// Performance test
const iterations = 1000
start := time.Now()
for i := 0; i < iterations; i++ {
tokenIndex := i % numTokens
err := tOidc.VerifyToken(tokens[tokenIndex])
if err != nil {
t.Errorf("Token verification failed at iteration %d: %v", i, err)
}
}
duration := time.Since(start)
opsPerSecond := float64(iterations) / duration.Seconds()
t.Logf("Performance test completed: %d operations in %v (%.2f ops/sec)",
iterations, duration, opsPerSecond)
// Should achieve reasonable performance
if opsPerSecond < 100 {
t.Errorf("Performance too low: %.2f ops/sec (expected > 100)", opsPerSecond)
}
}
-362
View File
@@ -1,362 +0,0 @@
package traefikoidc
import (
"net/url"
"reflect"
"testing"
)
func TestMergeScopes(t *testing.T) {
testCases := []struct {
name string
defaultScopes []string
userScopes []string
expectedScopes []string
}{
{
name: "Empty user scopes",
defaultScopes: []string{"openid", "profile", "email"},
userScopes: []string{},
expectedScopes: []string{"openid", "profile", "email"},
},
{
name: "Non-overlapping scopes",
defaultScopes: []string{"openid", "profile", "email"},
userScopes: []string{"roles", "custom_scope"},
expectedScopes: []string{"openid", "profile", "email", "roles", "custom_scope"},
},
{
name: "Overlapping scopes",
defaultScopes: []string{"openid", "profile", "email"},
userScopes: []string{"openid", "roles", "profile", "permissions"},
expectedScopes: []string{"openid", "profile", "email", "roles", "permissions"},
},
{
name: "Nil user scopes",
defaultScopes: []string{"openid", "profile", "email"},
userScopes: nil,
expectedScopes: []string{"openid", "profile", "email"},
},
{
name: "Nil default scopes",
defaultScopes: nil,
userScopes: []string{"roles", "custom_scope"},
expectedScopes: []string{"roles", "custom_scope"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := mergeScopes(tc.defaultScopes, tc.userScopes)
if !reflect.DeepEqual(result, tc.expectedScopes) {
t.Errorf("Expected %v, got %v", tc.expectedScopes, result)
}
})
}
}
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) {
defaultScopes := []string{"openid", "profile", "email"}
testCases := []struct {
name string
configScopes []string // Scopes from Traefik config
overrideScopes bool
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"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Simulate the logic within TraefikOidc.New for setting t.scopes
var result []string
uniqueConfigScopes := deduplicateScopes(tc.configScopes)
if tc.overrideScopes {
result = uniqueConfigScopes
} else {
result = mergeScopes(defaultScopes, uniqueConfigScopes)
}
if !reflect.DeepEqual(result, tc.expectedResult) {
t.Errorf("Expected scopes %v, got %v", tc.expectedResult, result)
}
})
}
}
func TestBuildAuthURLScopeHandling(t *testing.T) {
ts := &TestSuite{t: t}
ts.Setup() // Basic setup for TraefikOidc instance
// Default scopes expected if not overridden and no user scopes provided
defaultInitialScopes := []string{"openid", "profile", "email"}
testCases := []struct {
name string
configScopes []string // Scopes from Traefik config
overrideScopes bool
isGoogle bool
isAzure bool
expectedScopeString string // Expected final scope string in the auth URL
expectedParams map[string]string
}{
{
name: "Deduplication: Default append, duplicate in user scopes",
configScopes: []string{"openid", "custom", "profile", "custom"},
overrideScopes: false,
expectedScopeString: "openid profile email custom offline_access",
},
{
name: "Deduplication: Override, duplicate in user scopes",
configScopes: []string{"openid", "custom", "profile", "custom"},
overrideScopes: true,
expectedScopeString: "openid custom profile", // offline_access not added
},
{
name: "Override True: No automatic offline_access",
configScopes: []string{"scope1", "scope2"},
overrideScopes: true,
expectedScopeString: "scope1 scope2",
},
{
name: "Override True: User includes offline_access",
configScopes: []string{"scope1", "offline_access", "scope2"},
overrideScopes: true,
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
})
}
}
File diff suppressed because it is too large Load Diff
-581
View File
@@ -1,581 +0,0 @@
package traefikoidc
import (
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
)
// SecurityEventType represents different categories of security events
// that can occur during OIDC authentication and authorization flows.
type SecurityEventType string
const (
// AuthFailure represents an authentication failure event
AuthFailure SecurityEventType = "authentication_failure"
// TokenValidFailure represents a token validation failure event
TokenValidFailure SecurityEventType = "token_validation_failure"
// RateLimitHit represents a rate limit hit event
RateLimitHit SecurityEventType = "rate_limit_hit"
// SuspiciousActivity represents a suspicious activity event
SuspiciousActivity SecurityEventType = "suspicious_activity"
)
// DefaultSeverity returns the default severity level for a security event type.
// Severity levels are: low, medium, high.
func (t SecurityEventType) DefaultSeverity() string {
switch t {
case AuthFailure:
return "medium"
case TokenValidFailure:
return "medium"
case RateLimitHit:
return "low"
case SuspiciousActivity:
return "high"
default:
return "medium"
}
}
// IPFailureType returns the appropriate IP failure tracking category
// for a given security event type. This is used to categorize failures
// by IP address for rate limiting and blocking decisions.
func (t SecurityEventType) IPFailureType() string {
switch t {
case AuthFailure:
return "auth_failure"
case TokenValidFailure:
return "token_failure"
case SuspiciousActivity:
return "suspicious"
default:
return "general"
}
}
// SecurityEvent represents a security-related event that should be logged and monitored.
// It captures comprehensive context about the event including timestamp, client information,
// request details, and custom event-specific data.
type SecurityEvent struct {
Timestamp time.Time `json:"timestamp"`
Details map[string]interface{} `json:"details,omitempty"`
Type string `json:"type"`
Severity string `json:"severity"`
ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent"`
RequestPath string `json:"request_path"`
Message string `json:"message"`
}
// SecurityMonitor provides centralized security event tracking and analysis.
// It monitors authentication failures, detects suspicious patterns, enforces
// rate limits, and can trigger custom security event handlers.
type SecurityMonitor struct {
ipFailures map[string]*IPFailureTracker
patternDetector *SuspiciousPatternDetector
logger *Logger
eventHandlers []SecurityEventHandler
config SecurityMonitorConfig
ipMutex sync.RWMutex
}
// IPFailureTracker maintains failure statistics for a specific IP address.
// It tracks different types of failures, timestamps, and counts to support
// rate limiting and IP blocking decisions.
type IPFailureTracker struct {
LastFailure time.Time
FirstFailure time.Time
BlockedUntil time.Time
FailureTypes map[string]int64
FailureCount int64
mutex sync.RWMutex
IsBlocked bool
}
// SuspiciousPatternDetector identifies patterns that may indicate attacks
type SuspiciousPatternDetector struct {
recentEvents []SecurityEvent
shortWindow time.Duration
mediumWindow time.Duration
longWindow time.Duration
rapidFailureThreshold int
distributedAttackThreshold int
persistentAttackThreshold int
eventsMutex sync.RWMutex
}
// SecurityEventHandler defines the interface for handling security events
type SecurityEventHandler interface {
HandleSecurityEvent(event SecurityEvent)
}
// SecurityMonitorConfig contains configuration for the security monitor
type SecurityMonitorConfig struct {
MaxFailuresPerIP int `json:"max_failures_per_ip"`
FailureWindowMinutes int `json:"failure_window_minutes"`
BlockDurationMinutes int `json:"block_duration_minutes"`
RapidFailureThreshold int `json:"rapid_failure_threshold"`
CleanupIntervalMinutes int `json:"cleanup_interval_minutes"`
RetentionHours int `json:"retention_hours"`
EnablePatternDetection bool `json:"enable_pattern_detection"`
EnableDetailedLogging bool `json:"enable_detailed_logging"`
LogSuspiciousOnly bool `json:"log_suspicious_only"`
}
// DefaultSecurityMonitorConfig returns a default configuration
func DefaultSecurityMonitorConfig() SecurityMonitorConfig {
return SecurityMonitorConfig{
MaxFailuresPerIP: 10,
FailureWindowMinutes: 15,
BlockDurationMinutes: 60,
EnablePatternDetection: true,
RapidFailureThreshold: 5,
EnableDetailedLogging: true,
LogSuspiciousOnly: false,
CleanupIntervalMinutes: 30,
RetentionHours: 24,
}
}
// cleanupTask holds the BackgroundTask for security cleanup
var cleanupTask *BackgroundTask
// NewSecurityMonitor creates a new security monitor instance
func NewSecurityMonitor(config SecurityMonitorConfig, logger *Logger) *SecurityMonitor {
sm := &SecurityMonitor{
ipFailures: make(map[string]*IPFailureTracker),
eventHandlers: make([]SecurityEventHandler, 0),
config: config,
logger: logger,
patternDetector: NewSuspiciousPatternDetector(),
}
// Start cleanup routine
sm.startCleanupRoutine()
return sm
}
// NewSuspiciousPatternDetector creates a new pattern detector
func NewSuspiciousPatternDetector() *SuspiciousPatternDetector {
return &SuspiciousPatternDetector{
shortWindow: 1 * time.Minute,
mediumWindow: 5 * time.Minute,
longWindow: 15 * time.Minute,
rapidFailureThreshold: 5,
distributedAttackThreshold: 20,
persistentAttackThreshold: 50,
recentEvents: make([]SecurityEvent, 0),
}
}
// RecordSecurityEvent is a generic method to record any type of security event
func (sm *SecurityMonitor) RecordSecurityEvent(
eventType SecurityEventType,
clientIP, userAgent, requestPath string,
message string,
details map[string]interface{},
trackIPFailure bool) {
// Create event with default values for the event type
event := SecurityEvent{
Type: string(eventType),
Severity: eventType.DefaultSeverity(),
Timestamp: time.Now(),
ClientIP: clientIP,
UserAgent: userAgent,
RequestPath: requestPath,
Message: message,
Details: details,
}
// Track IP failures if requested
if trackIPFailure {
sm.recordIPFailure(clientIP, eventType.IPFailureType())
}
// Process the event
sm.processSecurityEvent(event)
}
// RecordAuthenticationFailure records an authentication failure event
func (sm *SecurityMonitor) RecordAuthenticationFailure(clientIP, userAgent, requestPath, reason string, details map[string]interface{}) {
if details == nil {
details = make(map[string]interface{})
}
details["reason"] = reason
sm.RecordSecurityEvent(
AuthFailure,
clientIP,
userAgent,
requestPath,
fmt.Sprintf("Authentication failed: %s", reason),
details,
true,
)
}
// RecordTokenValidationFailure records a token validation failure
func (sm *SecurityMonitor) RecordTokenValidationFailure(clientIP, userAgent, requestPath, reason string, tokenPrefix string) {
details := map[string]interface{}{
"reason": reason,
}
if tokenPrefix != "" {
details["token_prefix"] = tokenPrefix
}
sm.RecordSecurityEvent(
TokenValidFailure,
clientIP,
userAgent,
requestPath,
fmt.Sprintf("Token validation failed: %s", reason),
details,
true,
)
}
// RecordRateLimitHit records when rate limiting is triggered
func (sm *SecurityMonitor) RecordRateLimitHit(clientIP, userAgent, requestPath string) {
details := map[string]interface{}{
"limit_type": "token_verification",
}
sm.RecordSecurityEvent(
RateLimitHit,
clientIP,
userAgent,
requestPath,
"Rate limit exceeded",
details,
true, // Track IP failure for rate limiting
)
}
// RecordSuspiciousActivity records suspicious activity that doesn't fit other categories
func (sm *SecurityMonitor) RecordSuspiciousActivity(clientIP, userAgent, requestPath, activityType, description string, details map[string]interface{}) {
if details == nil {
details = make(map[string]interface{})
}
details["activity_type"] = activityType
sm.RecordSecurityEvent(
SuspiciousActivity,
clientIP,
userAgent,
requestPath,
fmt.Sprintf("Suspicious activity detected: %s - %s", activityType, description),
details,
true,
)
}
// recordIPFailure tracks failures for a specific IP address
func (sm *SecurityMonitor) recordIPFailure(clientIP, failureType string) {
sm.ipMutex.Lock()
defer sm.ipMutex.Unlock()
tracker, exists := sm.ipFailures[clientIP]
if !exists {
tracker = &IPFailureTracker{
FailureTypes: make(map[string]int64),
FirstFailure: time.Now(),
}
sm.ipFailures[clientIP] = tracker
}
tracker.mutex.Lock()
defer tracker.mutex.Unlock()
tracker.FailureCount++
tracker.LastFailure = time.Now()
tracker.FailureTypes[failureType]++
// Check if IP should be blocked
windowStart := time.Now().Add(-time.Duration(sm.config.FailureWindowMinutes) * time.Minute)
if tracker.FirstFailure.After(windowStart) && tracker.FailureCount >= int64(sm.config.MaxFailuresPerIP) {
if !tracker.IsBlocked {
tracker.IsBlocked = true
tracker.BlockedUntil = time.Now().Add(time.Duration(sm.config.BlockDurationMinutes) * time.Minute)
sm.logger.Errorf("IP %s blocked due to %d failures (types: %v)", clientIP, tracker.FailureCount, tracker.FailureTypes)
// Record blocking event
blockEvent := SecurityEvent{
Type: "ip_blocked",
Severity: "high",
Timestamp: time.Now(),
ClientIP: clientIP,
Message: fmt.Sprintf("IP blocked due to %d failures in %d minutes", tracker.FailureCount, sm.config.FailureWindowMinutes),
Details: map[string]interface{}{
"failure_count": tracker.FailureCount,
"failure_types": tracker.FailureTypes,
"blocked_until": tracker.BlockedUntil,
},
}
sm.processSecurityEvent(blockEvent)
}
}
}
// IsIPBlocked checks if an IP address is currently blocked
func (sm *SecurityMonitor) IsIPBlocked(clientIP string) bool {
sm.ipMutex.RLock()
defer sm.ipMutex.RUnlock()
tracker, exists := sm.ipFailures[clientIP]
if !exists {
return false
}
tracker.mutex.RLock()
defer tracker.mutex.RUnlock()
if tracker.IsBlocked && time.Now().Before(tracker.BlockedUntil) {
return true
}
// Unblock if time has passed
if tracker.IsBlocked && time.Now().After(tracker.BlockedUntil) {
tracker.IsBlocked = false
sm.logger.Infof("IP %s automatically unblocked", clientIP)
}
return false
}
// processSecurityEvent processes a security event through all handlers and pattern detection
func (sm *SecurityMonitor) processSecurityEvent(event SecurityEvent) {
// Add to pattern detector
if sm.config.EnablePatternDetection {
sm.patternDetector.AddEvent(event)
// Check for suspicious patterns
if patterns := sm.patternDetector.DetectSuspiciousPatterns(); len(patterns) > 0 {
// Log once with all patterns instead of logging each pattern
if len(patterns) == 1 {
sm.logger.Errorf("Suspicious pattern detected: %s", patterns[0])
} else {
sm.logger.Errorf("Multiple suspicious patterns detected: %v", patterns)
}
for _, pattern := range patterns {
patternEvent := SecurityEvent{
Type: "suspicious_pattern",
Severity: "high",
Timestamp: time.Now(),
Message: fmt.Sprintf("Suspicious pattern detected: %s", pattern),
Details: map[string]interface{}{
"pattern_type": pattern,
"trigger_event": event,
},
}
sm.handleSecurityEvent(patternEvent)
}
}
}
sm.handleSecurityEvent(event)
}
// handleSecurityEvent sends the event to all registered handlers
func (sm *SecurityMonitor) handleSecurityEvent(event SecurityEvent) {
// Log the event
if sm.config.EnableDetailedLogging && (!sm.config.LogSuspiciousOnly || event.Severity == "high") {
sm.logger.Infof("Security Event [%s/%s]: %s (IP: %s, Path: %s)",
event.Type, event.Severity, event.Message, event.ClientIP, event.RequestPath)
}
// Send to all handlers
for _, handler := range sm.eventHandlers {
go handler.HandleSecurityEvent(event)
}
}
// AddEventHandler adds a security event handler
func (sm *SecurityMonitor) AddEventHandler(handler SecurityEventHandler) {
sm.eventHandlers = append(sm.eventHandlers, handler)
}
// GetSecurityMetrics returns minimal security metrics
// This is kept for API compatibility but doesn't collect actual metrics
func (sm *SecurityMonitor) GetSecurityMetrics() map[string]interface{} {
return map[string]interface{}{
"tracked_ips": 0,
}
}
// AddEvent adds an event to the pattern detector
func (spd *SuspiciousPatternDetector) AddEvent(event SecurityEvent) {
spd.eventsMutex.Lock()
defer spd.eventsMutex.Unlock()
spd.recentEvents = append(spd.recentEvents, event)
// Clean old events
cutoff := time.Now().Add(-spd.longWindow)
var filteredEvents []SecurityEvent
for _, e := range spd.recentEvents {
if e.Timestamp.After(cutoff) {
filteredEvents = append(filteredEvents, e)
}
}
spd.recentEvents = filteredEvents
}
// DetectSuspiciousPatterns analyzes recent events for suspicious patterns
func (spd *SuspiciousPatternDetector) DetectSuspiciousPatterns() []string {
spd.eventsMutex.RLock()
defer spd.eventsMutex.RUnlock()
var patterns []string
now := time.Now()
// Check for rapid failures from single IP
ipCounts := make(map[string]int)
shortWindowStart := now.Add(-spd.shortWindow)
for _, event := range spd.recentEvents {
if event.Timestamp.After(shortWindowStart) &&
(event.Type == "authentication_failure" || event.Type == "token_validation_failure") {
ipCounts[event.ClientIP]++
}
}
for ip, count := range ipCounts {
if count >= spd.rapidFailureThreshold {
patterns = append(patterns, fmt.Sprintf("rapid_failures_from_ip_%s", ip))
}
}
// Check for distributed attack (many IPs failing)
mediumWindowStart := now.Add(-spd.mediumWindow)
uniqueFailingIPs := make(map[string]bool)
for _, event := range spd.recentEvents {
if event.Timestamp.After(mediumWindowStart) &&
(event.Type == "authentication_failure" || event.Type == "token_validation_failure") {
uniqueFailingIPs[event.ClientIP] = true
}
}
if len(uniqueFailingIPs) >= spd.distributedAttackThreshold {
patterns = append(patterns, "distributed_attack_pattern")
}
// Check for persistent attack
longWindowStart := now.Add(-spd.longWindow)
persistentFailures := 0
for _, event := range spd.recentEvents {
if event.Timestamp.After(longWindowStart) &&
(event.Type == "authentication_failure" || event.Type == "token_validation_failure") {
persistentFailures++
}
}
if persistentFailures >= spd.persistentAttackThreshold {
patterns = append(patterns, "persistent_attack_pattern")
}
return patterns
}
// startCleanupRoutine starts the background cleanup routine
func (sm *SecurityMonitor) startCleanupRoutine() {
// Use BackgroundTask abstraction for consistent management
cleanupTask = NewBackgroundTask(
"security-monitor-cleanup",
time.Duration(sm.config.CleanupIntervalMinutes)*time.Minute,
sm.cleanup,
sm.logger)
cleanupTask.Start()
}
// StopCleanupRoutine stops the background cleanup routine
func (sm *SecurityMonitor) StopCleanupRoutine() {
if cleanupTask != nil {
cleanupTask.Stop()
cleanupTask = nil
}
}
// cleanup removes old tracking data
func (sm *SecurityMonitor) cleanup() {
sm.ipMutex.Lock()
defer sm.ipMutex.Unlock()
cutoff := time.Now().Add(-time.Duration(sm.config.RetentionHours) * time.Hour)
for ip, tracker := range sm.ipFailures {
tracker.mutex.RLock()
shouldRemove := tracker.LastFailure.Before(cutoff) && !tracker.IsBlocked
tracker.mutex.RUnlock()
if shouldRemove {
delete(sm.ipFailures, ip)
}
}
sm.logger.Debugf("Security monitor cleanup completed, tracking %d IPs", len(sm.ipFailures))
}
// ExtractClientIP extracts the client IP from the request, considering proxy headers
func ExtractClientIP(r *http.Request) string {
// Check X-Real-IP header first (highest priority)
if xri := r.Header.Get("X-Real-IP"); xri != "" {
if net.ParseIP(xri) != nil {
return xri
}
}
// Check X-Forwarded-For header second
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Take the first IP in the chain
ips := strings.Split(xff, ",")
if len(ips) > 0 {
ip := strings.TrimSpace(ips[0])
if net.ParseIP(ip) != nil {
return ip
}
}
}
// Fall back to RemoteAddr
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// LoggingSecurityEventHandler logs security events to the standard logger
type LoggingSecurityEventHandler struct {
logger *Logger
}
// NewLoggingSecurityEventHandler creates a new logging event handler
func NewLoggingSecurityEventHandler(logger *Logger) *LoggingSecurityEventHandler {
return &LoggingSecurityEventHandler{logger: logger}
}
// HandleSecurityEvent implements SecurityEventHandler
func (h *LoggingSecurityEventHandler) HandleSecurityEvent(event SecurityEvent) {
switch event.Severity {
case "high":
h.logger.Errorf("SECURITY [%s]: %s (IP: %s)", event.Type, event.Message, event.ClientIP)
case "medium":
h.logger.Errorf("SECURITY [%s]: %s (IP: %s)", event.Type, event.Message, event.ClientIP)
case "low":
h.logger.Infof("SECURITY [%s]: %s (IP: %s)", event.Type, event.Message, event.ClientIP)
default:
h.logger.Debugf("SECURITY [%s]: %s (IP: %s)", event.Type, event.Message, event.ClientIP)
}
}
// Note: MetricsSecurityEventHandler has been removed as part of metrics cleanup
-285
View File
@@ -1,285 +0,0 @@
package traefikoidc
import (
"net/http/httptest"
"strconv"
"testing"
"time"
)
func TestSecurityMonitor(t *testing.T) {
config := DefaultSecurityMonitorConfig()
config.MaxFailuresPerIP = 3
config.BlockDurationMinutes = 1 // 1 minute for testing
config.CleanupIntervalMinutes = 1
logger := NewLogger("debug")
monitor := NewSecurityMonitor(config, logger)
defer func() {
// Allow cleanup goroutine to finish
time.Sleep(150 * time.Millisecond)
}()
t.Run("Record authentication failure", func(t *testing.T) {
monitor.RecordAuthenticationFailure("192.168.1.1", "test-agent", "/login", "invalid credentials", nil)
// Should not be blocked after first failure
if monitor.IsIPBlocked("192.168.1.1") {
t.Error("IP should not be blocked after first failure")
}
})
t.Run("IP blocked after max failures", func(t *testing.T) {
// Record multiple failures
for i := 0; i < config.MaxFailuresPerIP; i++ {
monitor.RecordAuthenticationFailure("192.168.1.2", "test-agent", "/login", "invalid credentials", nil)
}
// Should be blocked now
if !monitor.IsIPBlocked("192.168.1.2") {
t.Error("IP should be blocked after max failures")
}
})
t.Run("Token validation failure", func(t *testing.T) {
// Just verify the method doesn't panic
monitor.RecordTokenValidationFailure("192.168.1.3", "test-agent", "/api", "invalid token", "abc123")
})
t.Run("Rate limit hit", func(t *testing.T) {
// Just verify the method doesn't panic
monitor.RecordRateLimitHit("192.168.1.4", "test-agent", "/api")
})
t.Run("Suspicious activity", func(t *testing.T) {
details := map[string]interface{}{"pattern": "unusual"}
// Just verify the method doesn't panic
monitor.RecordSuspiciousActivity("192.168.1.5", "test-agent", "/admin", "unusual pattern", "high frequency requests", details)
})
}
func TestSuspiciousPatternDetector(t *testing.T) {
detector := NewSuspiciousPatternDetector()
t.Run("Add events and detect patterns", func(t *testing.T) {
// Add multiple events from same IP
for i := 0; i < 10; i++ {
event := SecurityEvent{
Type: "authentication_failure",
ClientIP: "192.168.1.100",
Timestamp: time.Now(),
}
detector.AddEvent(event)
}
patterns := detector.DetectSuspiciousPatterns()
found := false
for _, p := range patterns {
if p == "rapid_failures_from_ip_192.168.1.100" {
found = true
break
}
}
if !found {
t.Error("Expected to detect rapid failure pattern")
}
})
t.Run("Detect distributed attack pattern", func(t *testing.T) {
// Add failures from many different IPs
for i := 0; i < 25; i++ {
event := SecurityEvent{
Type: "authentication_failure",
ClientIP: "192.168.1." + strconv.Itoa(100+i),
Timestamp: time.Now(),
}
detector.AddEvent(event)
}
patterns := detector.DetectSuspiciousPatterns()
found := false
for _, p := range patterns {
if p == "distributed_attack_pattern" {
found = true
break
}
}
if !found {
t.Error("Expected to detect distributed attack pattern")
}
})
}
func TestExtractClientIP(t *testing.T) {
tests := []struct {
name string
remoteAddr string
headers map[string]string
expectedIP string
}{
{
name: "Direct connection",
remoteAddr: "192.168.1.1:12345",
expectedIP: "192.168.1.1",
},
{
name: "X-Forwarded-For header",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{"X-Forwarded-For": "203.0.113.1, 10.0.0.1"},
expectedIP: "203.0.113.1",
},
{
name: "X-Real-IP header",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{"X-Real-IP": "203.0.113.2"},
expectedIP: "203.0.113.2",
},
{
name: "Multiple headers - X-Real-IP takes precedence",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{
"X-Forwarded-For": "203.0.113.1",
"X-Real-IP": "203.0.113.2",
},
expectedIP: "203.0.113.2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = tt.remoteAddr
for key, value := range tt.headers {
req.Header.Set(key, value)
}
ip := ExtractClientIP(req)
if ip != tt.expectedIP {
t.Errorf("Expected IP %s, got %s", tt.expectedIP, ip)
}
})
}
}
func TestSecurityEventHandlers(t *testing.T) {
t.Run("Logging security event handler", func(t *testing.T) {
logger := NewLogger("debug")
handler := NewLoggingSecurityEventHandler(logger)
event := SecurityEvent{
Type: "authentication_failure",
ClientIP: "192.168.1.1",
Timestamp: time.Now(),
Message: "Test failure",
Severity: "medium",
}
// Should not panic
handler.HandleSecurityEvent(event)
})
// Metrics security event handler test removed as part of metrics cleanup
}
func TestSecurityMonitorEventHandlers(t *testing.T) {
config := DefaultSecurityMonitorConfig()
logger := NewLogger("debug")
monitor := NewSecurityMonitor(config, logger)
// Add event handler with proper synchronization
handlerCalled := make(chan bool, 1)
handler := &testSecurityEventHandler{
callback: func(event SecurityEvent) {
select {
case handlerCalled <- true:
default:
// Channel already has a value, don't block
}
},
}
monitor.AddEventHandler(handler)
monitor.RecordAuthenticationFailure("192.168.1.1", "test-agent", "/login", "test failure", nil)
// Wait for event handler to be called with timeout
select {
case <-handlerCalled:
// Success - handler was called
case <-time.After(100 * time.Millisecond):
t.Error("Expected event handler to be called within timeout")
}
}
// Test helper for security event handler
type testSecurityEventHandler struct {
callback func(SecurityEvent)
}
func (h *testSecurityEventHandler) HandleSecurityEvent(event SecurityEvent) {
h.callback(event)
}
func TestDefaultSecurityMonitorConfig(t *testing.T) {
config := DefaultSecurityMonitorConfig()
if config.MaxFailuresPerIP <= 0 {
t.Error("Expected positive MaxFailuresPerIP")
}
if config.BlockDurationMinutes <= 0 {
t.Error("Expected positive BlockDurationMinutes")
}
if config.CleanupIntervalMinutes <= 0 {
t.Error("Expected positive CleanupIntervalMinutes")
}
if config.FailureWindowMinutes <= 0 {
t.Error("Expected positive FailureWindowMinutes")
}
}
func TestSecurityMonitorCleanup(t *testing.T) {
config := DefaultSecurityMonitorConfig()
config.CleanupIntervalMinutes = 1
config.BlockDurationMinutes = 1
config.RetentionHours = 1
logger := NewLogger("debug")
monitor := NewSecurityMonitor(config, logger)
// Block an IP
for i := 0; i < config.MaxFailuresPerIP; i++ {
monitor.RecordAuthenticationFailure("192.168.1.99", "test-agent", "/login", "test", nil)
}
// Verify it's blocked
if !monitor.IsIPBlocked("192.168.1.99") {
t.Error("IP should be blocked")
}
// Wait a bit and check if it gets unblocked automatically
time.Sleep(100 * time.Millisecond)
// The IP should still be blocked since we haven't waited long enough
if !monitor.IsIPBlocked("192.168.1.99") {
t.Error("IP should still be blocked")
}
}
func TestSecurityEventTypes(t *testing.T) {
config := DefaultSecurityMonitorConfig()
logger := NewLogger("debug")
monitor := NewSecurityMonitor(config, logger)
// Test different event types - just verify they don't panic
monitor.RecordAuthenticationFailure("192.168.1.200", "test-agent", "/login", "invalid password", nil)
monitor.RecordTokenValidationFailure("192.168.1.200", "test-agent", "/api", "expired token", "abc123")
monitor.RecordRateLimitHit("192.168.1.200", "test-agent", "/api")
details := map[string]interface{}{"pattern": "test"}
monitor.RecordSuspiciousActivity("192.168.1.200", "test-agent", "/admin", "unusual pattern", "multiple failed logins", details)
// Just verify GetSecurityMetrics doesn't panic
_ = monitor.GetSecurityMetrics()
}
-10
View File
@@ -1,10 +0,0 @@
version: 1
force:
existing: true
wording:
patch:
- patch-release
minor:
- minor-release
major:
- breaking
-2114
View File
File diff suppressed because it is too large Load Diff
-889
View File
@@ -1,889 +0,0 @@
package traefikoidc
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/gorilla/sessions"
)
// TokenConfig defines validation rules and constraints for different token types.
// It specifies size limits, chunking parameters, and format requirements to ensure
// tokens can be safely stored in browser cookies while maintaining security.
type TokenConfig struct {
Type string
MinLength int
MaxLength int
MaxChunks int // Maximum number of chunks allowed
MaxChunkSize int // Maximum size per chunk
AllowOpaqueTokens bool
RequireJWTFormat bool
}
// Predefined configurations for each token type
var (
AccessTokenConfig = TokenConfig{
Type: "access",
MinLength: 5,
MaxLength: 100 * 1024, // 100KB total limit
MaxChunks: 25, // Maximum 25 chunks
MaxChunkSize: maxCookieSize, // Use global chunk size limit
AllowOpaqueTokens: true,
RequireJWTFormat: false,
}
RefreshTokenConfig = TokenConfig{
Type: "refresh",
MinLength: 5,
MaxLength: 50 * 1024, // 50KB total limit (refresh tokens are typically smaller)
MaxChunks: 15, // Maximum 15 chunks
MaxChunkSize: maxCookieSize,
AllowOpaqueTokens: true,
RequireJWTFormat: false,
}
IDTokenConfig = TokenConfig{
Type: "id",
MinLength: 5,
MaxLength: 75 * 1024, // 75KB total limit
MaxChunks: 20, // Maximum 20 chunks
MaxChunkSize: maxCookieSize,
AllowOpaqueTokens: false,
RequireJWTFormat: true,
}
)
// TokenRetrievalResult encapsulates the result of a token retrieval operation.
// It contains either a successfully retrieved token or an error describing
// what went wrong during retrieval.
type TokenRetrievalResult struct {
Token string
Error error
}
// ChunkManager provides thread-safe operations for splitting large tokens
// into smaller chunks that fit within browser cookie size limits. It handles
// the chunking and reassembly of tokens transparently, ensuring data integrity
// throughout the process.
type ChunkManager struct {
logger *Logger
mutex *sync.RWMutex
}
// NewChunkManager creates a new ChunkManager instance with the specified logger.
// If no logger is provided, a no-op logger is used to prevent nil pointer errors.
//
// Parameters:
// - logger: The logger instance for recording chunk operations.
//
// Returns:
// - A new ChunkManager instance ready for use.
func NewChunkManager(logger *Logger) *ChunkManager {
if logger == nil {
logger = newNoOpLogger()
}
return &ChunkManager{
logger: logger,
mutex: &sync.RWMutex{},
}
}
// GetToken retrieves and validates a token from either single storage or chunks
// GetToken retrieves and validates a token, handling both single-cookie
// and chunked storage scenarios. It performs decompression if needed and
// validates the token according to the provided configuration.
//
// Parameters:
// - singleToken: The token string if stored in a single cookie.
// - compressed: Whether the token is compressed.
// - chunks: Map of session chunks if token is split across cookies.
// - config: Token validation configuration.
//
// Returns:
// - TokenRetrievalResult containing the token or an error.
func (cm *ChunkManager) GetToken(
singleToken string,
compressed bool,
chunks map[int]*sessions.Session,
config TokenConfig,
) TokenRetrievalResult {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
// Handle single-token storage
if singleToken != "" {
return cm.processSingleToken(singleToken, compressed, config)
}
// Handle chunked storage
if len(chunks) == 0 {
return TokenRetrievalResult{Token: "", Error: nil}
}
return cm.processChunkedToken(chunks, config)
}
// processSingleToken processes tokens stored in a single cookie.
// It handles decompression if needed and performs comprehensive validation
// including corruption detection, format validation, and size checks.
//
// Parameters:
// - token: The token string from the cookie.
// - compressed: Whether the token needs decompression.
// - config: Token validation configuration.
//
// Returns:
// - TokenRetrievalResult containing the processed token or an error.
func (cm *ChunkManager) processSingleToken(token string, compressed bool, config TokenConfig) TokenRetrievalResult {
// Detect corruption markers
if isCorruptionMarker(token) {
err := fmt.Errorf("%s token contains corruption marker", config.Type)
// Only log if not a known test scenario
if !strings.Contains(token, "TEST_CORRUPTION") {
cm.logger.Debug("Token corruption detected for %s", config.Type)
}
return TokenRetrievalResult{Token: "", Error: err}
}
var finalToken string
if compressed {
decompressed := decompressToken(token)
if isCorruptionMarker(decompressed) {
err := fmt.Errorf("decompressed %s token contains corruption marker", config.Type)
cm.logger.Debug("Decompressed token corruption detected for %s", config.Type)
return TokenRetrievalResult{Token: "", Error: err}
}
finalToken = decompressed
} else {
finalToken = token
}
return cm.validateToken(finalToken, config)
}
// validateToken performs comprehensive validation on a token.
// It checks size limits, chunking efficiency, content validity,
// expiration, freshness, and format requirements based on the token configuration.
//
// Parameters:
// - token: The token string to validate.
// - config: Token validation configuration.
//
// Returns:
// - TokenRetrievalResult with the validated token or validation error.
func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult {
// Validate token size against configured limits
if sizeErr := cm.validateTokenSize(token, config); sizeErr != nil {
return TokenRetrievalResult{Token: "", Error: sizeErr}
}
// Check if token would chunk efficiently
if chunkErr := cm.validateChunkingEfficiency(token, config); chunkErr != nil {
return TokenRetrievalResult{Token: "", Error: chunkErr}
}
// Validate token content and structure
if contentErr := cm.validateTokenContent(token, config); contentErr != nil {
return TokenRetrievalResult{Token: "", Error: contentErr}
}
// Token expiration validation
if expErr := cm.validateTokenExpiration(token, config); expErr != nil {
return TokenRetrievalResult{Token: "", Error: expErr}
}
// Token freshness validation
if freshnessErr := cm.validateTokenFreshness(token, config); freshnessErr != nil {
return TokenRetrievalResult{Token: "", Error: freshnessErr}
}
// Validate JWT format if required
if config.RequireJWTFormat && !config.AllowOpaqueTokens {
if validationErr := cm.validateJWTFormat(token, config.Type); validationErr != nil {
return TokenRetrievalResult{Token: "", Error: validationErr}
}
} else if config.RequireJWTFormat && config.AllowOpaqueTokens {
// For tokens that can be either JWT or opaque, validate JWT format only if it has dots
dotCount := strings.Count(token, ".")
if dotCount > 0 {
if validationErr := cm.validateJWTFormat(token, config.Type); validationErr != nil {
return TokenRetrievalResult{Token: "", Error: validationErr}
}
} else {
// Validate as opaque token
if validationErr := cm.validateOpaqueToken(token, config.Type); validationErr != nil {
return TokenRetrievalResult{Token: "", Error: validationErr}
}
}
}
return TokenRetrievalResult{Token: token, Error: nil}
}
// processChunkedToken handles tokens stored across multiple chunks
func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, config TokenConfig) TokenRetrievalResult {
// Validate chunk count against configured maximum
if len(chunks) > config.MaxChunks {
err := fmt.Errorf("too many %s token chunks (%d, max: %d)", config.Type, len(chunks), config.MaxChunks)
cm.logger.Info("Token chunk count exceeded for %s: %d chunks", config.Type, len(chunks))
return TokenRetrievalResult{Token: "", Error: err}
}
// Additional safety check for extremely large chunk counts
if len(chunks) > 100 {
err := fmt.Errorf("excessive %s token chunks (%d), potential security issue", config.Type, len(chunks))
cm.logger.Error("Security: Excessive token chunks detected for %s: %d", config.Type, len(chunks))
return TokenRetrievalResult{Token: "", Error: err}
}
// Sequential chunk validation and assembly
var tokenParts []string
totalSize := 0
for i := 0; i < len(chunks); i++ {
session, ok := chunks[i]
if !ok {
err := fmt.Errorf("%s token chunk %d missing", config.Type, i)
// Only log once for missing chunks, not for each missing chunk
if i == 0 {
cm.logger.Debug("Token chunks missing for %s starting at index %d", config.Type, i)
}
return TokenRetrievalResult{Token: "", Error: err}
}
chunk, chunkOk := session.Values["token_chunk"].(string)
if !chunkOk || chunk == "" {
err := fmt.Errorf("%s token chunk %d invalid", config.Type, i)
return TokenRetrievalResult{Token: "", Error: err}
}
if isCorruptionMarker(chunk) {
err := fmt.Errorf("%s token chunk %d corrupted", config.Type, i)
return TokenRetrievalResult{Token: "", Error: err}
}
// Validate individual chunk sizes
if len(chunk) > config.MaxChunkSize {
err := fmt.Errorf("%s token chunk %d exceeds size limit (%d bytes, max: %d)",
config.Type, i, len(chunk), config.MaxChunkSize)
return TokenRetrievalResult{Token: "", Error: err}
}
// Additional safety check for extremely large chunks
if len(chunk) > maxBrowserCookieSize {
err := fmt.Errorf("%s token chunk %d exceeds browser limit (%d bytes)",
config.Type, i, len(chunk))
return TokenRetrievalResult{Token: "", Error: err}
}
totalSize += len(chunk)
if totalSize > config.MaxLength {
err := fmt.Errorf("%s token total size exceeds limit", config.Type)
return TokenRetrievalResult{Token: "", Error: err}
}
tokenParts = append(tokenParts, chunk)
}
// Reassemble token
reassembledToken := strings.Join(tokenParts, "")
// Check compression flag from first chunk
compressed, _ := chunks[0].Values["compressed"].(bool)
if compressed {
decompressed := decompressToken(reassembledToken)
if isCorruptionMarker(decompressed) {
err := fmt.Errorf("decompressed chunked %s token corrupted", config.Type)
return TokenRetrievalResult{Token: "", Error: err}
}
return cm.validateToken(decompressed, config)
}
return cm.validateToken(reassembledToken, config)
}
// validateJWTFormat performs enhanced JWT format validation
func (cm *ChunkManager) validateJWTFormat(token string, tokenType string) error {
// Check for exactly 2 dots
dotCount := strings.Count(token, ".")
if dotCount != 2 {
err := fmt.Errorf("%s token invalid JWT format (dots: %d)", tokenType, dotCount)
return err
}
// Split into parts
parts := strings.Split(token, ".")
if len(parts) != 3 {
err := fmt.Errorf("%s token invalid JWT structure", tokenType)
return err
}
// Validate each part is non-empty and contains valid base64url characters
for i, part := range parts {
if part == "" {
err := fmt.Errorf("%s token has empty JWT part %d", tokenType, i)
return err
}
// Check for valid base64url characters only (RFC 4648)
// Valid characters: A-Z, a-z, 0-9, -, _, and = for padding
for _, char := range part {
if !((char >= 'A' && char <= 'Z') ||
(char >= 'a' && char <= 'z') ||
(char >= '0' && char <= '9') ||
char == '-' || char == '_' || char == '=') {
err := fmt.Errorf("%s token contains invalid base64url character in part %d", tokenType, i)
return err
}
}
// Validate base64url padding rules
if strings.Contains(part, "=") {
// Padding can only be at the end
paddingIndex := strings.Index(part, "=")
if paddingIndex != len(part)-1 && paddingIndex != len(part)-2 {
err := fmt.Errorf("%s token has invalid base64url padding in part %d", tokenType, i)
return err
}
// Check that after padding, no other characters exist
for j := paddingIndex; j < len(part); j++ {
if part[j] != '=' {
err := fmt.Errorf("%s token has characters after padding in part %d", tokenType, i)
return err
}
}
}
}
// Additional length checks for JWT parts
if len(parts[0]) < 10 { // Header too short
err := fmt.Errorf("%s token header too short", tokenType)
return err
}
if len(parts[1]) < 10 { // Payload too short
err := fmt.Errorf("%s token payload too short", tokenType)
return err
}
if len(parts[2]) < 10 { // Signature too short
err := fmt.Errorf("%s token signature too short", tokenType)
return err
}
return nil
}
// validateOpaqueToken performs validation for opaque (non-JWT) tokens
func (cm *ChunkManager) validateOpaqueToken(token string, tokenType string) error {
// Check for obviously invalid characters for opaque tokens
if strings.Contains(token, " ") {
err := fmt.Errorf("%s opaque token contains spaces", tokenType)
return err
}
// Check for control characters
for _, char := range token {
if char < 32 || char == 127 {
err := fmt.Errorf("%s opaque token contains control characters", tokenType)
return err
}
}
// Ensure minimum entropy for opaque tokens (basic check)
if len(token) >= 20 {
uniqueChars := make(map[rune]bool)
for _, char := range token {
uniqueChars[char] = true
}
// Require at least 8 unique characters for reasonable entropy
if len(uniqueChars) < 8 {
err := fmt.Errorf("%s opaque token has insufficient entropy", tokenType)
return err
}
}
return nil
}
// validateTokenSize performs comprehensive token size validation
func (cm *ChunkManager) validateTokenSize(token string, config TokenConfig) error {
tokenLen := len(token)
// Basic length validation
if tokenLen < config.MinLength {
err := fmt.Errorf("%s token below minimum length (%d bytes, min: %d)",
config.Type, tokenLen, config.MinLength)
return err
}
if tokenLen > config.MaxLength {
err := fmt.Errorf("%s token exceeds maximum length (%d bytes, max: %d)",
config.Type, tokenLen, config.MaxLength)
return err
}
// JWT-specific size validation
if config.RequireJWTFormat || (config.AllowOpaqueTokens && strings.Contains(token, ".")) {
parts := strings.Split(token, ".")
if len(parts) == 3 {
// Validate individual JWT part sizes
headerLen := len(parts[0])
payloadLen := len(parts[1])
signatureLen := len(parts[2])
// Check for unreasonably large JWT parts (potential security issue)
if headerLen > 5*1024 { // 5KB header limit
err := fmt.Errorf("%s token header too large (%d bytes)", config.Type, headerLen)
return err
}
if payloadLen > config.MaxLength-10*1024 { // Leave room for header and signature
err := fmt.Errorf("%s token payload too large (%d bytes)", config.Type, payloadLen)
return err
}
if signatureLen > 2*1024 { // 2KB signature limit
err := fmt.Errorf("%s token signature too large (%d bytes)", config.Type, signatureLen)
return err
}
}
}
// Opaque token size validation
if config.AllowOpaqueTokens && !strings.Contains(token, ".") {
// For opaque tokens, check for reasonable size limits
if tokenLen > 8*1024 { // 8KB limit for opaque tokens
err := fmt.Errorf("%s opaque token unusually large (%d bytes)", config.Type, tokenLen)
return err
}
}
return nil
}
// validateChunkingEfficiency ensures that chunking is used appropriately
func (cm *ChunkManager) validateChunkingEfficiency(token string, config TokenConfig) error {
tokenLen := len(token)
// If token is small enough to fit in a single chunk, warn about unnecessary chunking
if tokenLen <= config.MaxChunkSize && tokenLen <= maxCookieSize {
// This is just informational - not an error, but helps with monitoring
// Token could fit in single chunk - this is fine, just informational
}
// Calculate expected number of chunks
expectedChunks := (tokenLen + config.MaxChunkSize - 1) / config.MaxChunkSize
if expectedChunks > config.MaxChunks {
err := fmt.Errorf("%s token would require %d chunks (max: %d)",
config.Type, expectedChunks, config.MaxChunks)
return err
}
// Check for potential storage efficiency issues
if expectedChunks > 10 && tokenLen < 50*1024 {
cm.logger.Info("%s token requires many chunks (%d) for size (%d bytes) - consider token optimization",
config.Type, expectedChunks, tokenLen)
}
return nil
}
// validateTokenContent performs comprehensive token content validation
func (cm *ChunkManager) validateTokenContent(token string, config TokenConfig) error {
// Basic content sanitization checks
if err := cm.validateTokenSanitization(token, config); err != nil {
return err
}
// JWT-specific content validation
if config.RequireJWTFormat || (config.AllowOpaqueTokens && strings.Contains(token, ".")) {
if err := cm.validateJWTContent(token, config); err != nil {
return err
}
}
// Opaque token content validation
if config.AllowOpaqueTokens && !strings.Contains(token, ".") {
if err := cm.validateOpaqueTokenContent(token, config); err != nil {
return err
}
}
return nil
}
// validateTokenSanitization checks for basic security issues in token content
func (cm *ChunkManager) validateTokenSanitization(token string, config TokenConfig) error {
// Check for null bytes (potential injection attacks)
if strings.Contains(token, "\x00") {
err := fmt.Errorf("%s token contains null bytes", config.Type)
return err
}
// Check for line feed/carriage return (header injection attacks)
if strings.ContainsAny(token, "\r\n") {
err := fmt.Errorf("%s token contains line breaks", config.Type)
return err
}
// Check for suspicious escape sequences
suspiciousPatterns := []string{
"\\x", "\\u", "\\n", "\\r", "\\t", "\\0",
"<script", "</script", "javascript:", "data:",
"file://", "ftp://", "ldap://",
}
tokenLower := strings.ToLower(token)
for _, pattern := range suspiciousPatterns {
if strings.Contains(tokenLower, pattern) {
err := fmt.Errorf("%s token contains suspicious pattern: %s", config.Type, pattern)
return err
}
}
// Check for excessive repeated characters (potential buffer overflow attempts)
if err := cm.detectRepeatedCharacters(token, config); err != nil {
return err
}
return nil
}
// validateJWTContent performs JWT-specific content validation
func (cm *ChunkManager) validateJWTContent(token string, config TokenConfig) error {
parts := strings.Split(token, ".")
if len(parts) != 3 {
err := fmt.Errorf("%s JWT token malformed for content validation", config.Type)
return err
}
// Validate header content
if err := cm.validateJWTHeader(parts[0], config); err != nil {
return err
}
// Validate payload content
if err := cm.validateJWTPayload(parts[1], config); err != nil {
return err
}
// Validate signature content
if err := cm.validateJWTSignature(parts[2], config); err != nil {
return err
}
return nil
}
// validateJWTHeader validates JWT header content
func (cm *ChunkManager) validateJWTHeader(header string, config TokenConfig) error {
// Basic header structure validation
if len(header) == 0 {
err := fmt.Errorf("%s JWT header is empty", config.Type)
return err
}
// Validate base64url encoding
if _, err := base64.RawURLEncoding.DecodeString(header); err != nil {
err := fmt.Errorf("%s JWT header not valid base64url", config.Type)
return err
}
return nil
}
// validateJWTPayload validates JWT payload content
func (cm *ChunkManager) validateJWTPayload(payload string, config TokenConfig) error {
// Basic payload structure validation
if len(payload) == 0 {
err := fmt.Errorf("%s JWT payload is empty", config.Type)
return err
}
// Payload should be decodable (basic structural check)
if _, err := base64.RawURLEncoding.DecodeString(payload); err != nil {
err := fmt.Errorf("%s JWT payload not valid base64url", config.Type)
return err
}
return nil
}
// validateJWTSignature validates JWT signature content
func (cm *ChunkManager) validateJWTSignature(signature string, config TokenConfig) error {
// Basic signature structure validation
if len(signature) == 0 {
err := fmt.Errorf("%s JWT signature is empty", config.Type)
return err
}
// Validate base64url encoding
if _, err := base64.RawURLEncoding.DecodeString(signature); err != nil {
err := fmt.Errorf("%s JWT signature not valid base64url", config.Type)
return err
}
return nil
}
// validateOpaqueTokenContent validates opaque token content
func (cm *ChunkManager) validateOpaqueTokenContent(token string, config TokenConfig) error {
// Check for reasonable character distribution in opaque tokens
if len(token) >= 10 {
alphabetic := 0
numeric := 0
special := 0
for _, char := range token {
if (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') {
alphabetic++
} else if char >= '0' && char <= '9' {
numeric++
} else {
special++
}
}
total := alphabetic + numeric + special
if total > 0 {
// Require some distribution of character types for legitimate tokens
alphaRatio := float64(alphabetic) / float64(total)
numericRatio := float64(numeric) / float64(total)
// Opaque tokens should have reasonable character distribution
if alphaRatio < 0.1 && numericRatio < 0.1 {
err := fmt.Errorf("%s opaque token has suspicious character distribution", config.Type)
return err
}
}
}
// Check for common token prefixes/suffixes that might indicate legitimate tokens
legitimatePrefixes := []string{
"Bearer ", "bearer ", "eyJ", // JWT prefix
"refresh_", "access_", "id_",
"token_", "oauth_", "oidc_",
}
hasLegitimatePrefix := false
for _, prefix := range legitimatePrefixes {
if strings.HasPrefix(token, prefix) {
hasLegitimatePrefix = true
break
}
}
// For longer tokens without legitimate prefixes, be more suspicious
if len(token) > 50 && !hasLegitimatePrefix {
// Opaque token without common prefixes - this is fine
}
return nil
}
// detectRepeatedCharacters detects potential buffer overflow attempts
func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfig) error {
if len(token) < 10 {
return nil // Too short to analyze meaningfully
}
// Count consecutive repeated characters
maxRepeated := 0
currentRepeated := 1
var lastChar rune
for i, char := range token {
if i > 0 && char == lastChar {
currentRepeated++
if currentRepeated > maxRepeated {
maxRepeated = currentRepeated
}
} else {
currentRepeated = 1
}
lastChar = char
}
// Flag tokens with excessive character repetition
threshold := 20 // Allow up to 20 consecutive identical characters
if maxRepeated > threshold {
err := fmt.Errorf("%s token has excessive repeated characters (%d consecutive)",
config.Type, maxRepeated)
return err
}
// Check for overall character frequency (detect padding attacks)
charFreq := make(map[rune]int)
for _, char := range token {
charFreq[char]++
}
tokenLen := len(token)
for char, count := range charFreq {
frequency := float64(count) / float64(tokenLen)
// Flag if any single character makes up more than 70% of the token
if frequency > 0.7 && tokenLen > 20 {
err := fmt.Errorf("%s token has suspicious character frequency (char '%c': %.1f%%)",
config.Type, char, frequency*100)
return err
}
}
return nil
}
// validateTokenExpiration validates token expiration during storage/retrieval
func (cm *ChunkManager) validateTokenExpiration(token string, config TokenConfig) error {
// Only validate expiration for JWT tokens
if !strings.Contains(token, ".") {
return nil // Opaque tokens don't have embedded expiration
}
// Parse JWT expiration claim
expiration, err := cm.extractJWTExpiration(token)
if err != nil {
// If we can't parse expiration, log it but don't fail - the token might be valid but malformed
cm.logger.Debugf("Could not extract expiration from %s token: %v", config.Type, err)
return nil
}
// Check if token is expired
if expiration != nil && time.Now().After(*expiration) {
err := fmt.Errorf("%s token is expired (expired at: %v)", config.Type, expiration.Format(time.RFC3339))
return err
}
// Check if token expires too far in the future (potential security issue)
if expiration != nil {
maxFutureTime := time.Now().Add(10 * 365 * 24 * time.Hour) // 10 years
if expiration.After(maxFutureTime) {
cm.logger.Info("%s token expires very far in future (%v) - potential security issue",
config.Type, expiration.Format(time.RFC3339))
}
}
return nil
}
// extractJWTExpiration extracts the expiration time from a JWT token
func (cm *ChunkManager) extractJWTExpiration(token string) (*time.Time, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format")
}
// Decode the payload (second part)
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode JWT payload: %w", err)
}
// Parse the JSON payload
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("failed to parse JWT claims: %w", err)
}
// Extract expiration claim
exp, exists := claims["exp"]
if !exists {
return nil, nil // No expiration claim
}
// Convert expiration to time.Time
var expTime time.Time
switch v := exp.(type) {
case float64:
expTime = time.Unix(int64(v), 0)
case int64:
expTime = time.Unix(v, 0)
case int:
expTime = time.Unix(int64(v), 0)
default:
return nil, fmt.Errorf("invalid expiration format: %T", exp)
}
return &expTime, nil
}
// validateTokenFreshness checks if token is fresh enough for storage
func (cm *ChunkManager) validateTokenFreshness(token string, config TokenConfig) error {
// Only validate freshness for JWT tokens
if !strings.Contains(token, ".") {
return nil
}
// Extract issued at time
issuedAt, err := cm.extractJWTIssuedAt(token)
if err != nil {
cm.logger.Debugf("Could not extract issued time from %s token: %v", config.Type, err)
return nil
}
if issuedAt != nil {
now := time.Now()
// Check if token was issued in the future (clock skew tolerance: 5 minutes)
if issuedAt.After(now.Add(5 * time.Minute)) {
err := fmt.Errorf("%s token issued in future (issued at: %v)",
config.Type, issuedAt.Format(time.RFC3339))
return err
}
// Check if token is too old (potential replay attack)
maxAge := 24 * time.Hour // Tokens older than 24 hours are suspicious
if now.Sub(*issuedAt) > maxAge {
cm.logger.Info("%s token is quite old (issued: %v) - potential replay",
config.Type, issuedAt.Format(time.RFC3339))
}
}
return nil
}
// extractJWTIssuedAt extracts the issued at time from a JWT token
func (cm *ChunkManager) extractJWTIssuedAt(token string) (*time.Time, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format")
}
// Decode the payload (second part)
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode JWT payload: %w", err)
}
// Parse the JSON payload
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("failed to parse JWT claims: %w", err)
}
// Extract issued at claim
iat, exists := claims["iat"]
if !exists {
return nil, nil // No issued at claim
}
// Convert issued at to time.Time
var iatTime time.Time
switch v := iat.(type) {
case float64:
iatTime = time.Unix(int64(v), 0)
case int64:
iatTime = time.Unix(v, 0)
case int:
iatTime = time.Unix(int64(v), 0)
default:
return nil, fmt.Errorf("invalid issued at format: %T", iat)
}
return &iatTime, nil
}
-199
View File
@@ -1,199 +0,0 @@
package traefikoidc
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSessionCompatibilityAfterChanges ensures our session changes maintain backward compatibility
func TestSessionCompatibilityAfterChanges(t *testing.T) {
t.Run("Plain_HTTP_Without_Proxy_Headers", func(t *testing.T) {
// Test that plain HTTP requests without proxy headers still work
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
req := httptest.NewRequest("GET", "http://localhost:8080/test", nil)
req.Header.Set("User-Agent", "Mozilla/5.0")
// No X-Forwarded-Proto header
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
session.SetCSRF("plain-http-csrf")
session.SetAuthenticated(true)
session.SetEmail("user@example.com")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Verify cookies work for plain HTTP
cookies := rec.Result().Cookies()
require.NotEmpty(t, cookies)
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie)
// Plain HTTP should NOT have Secure flag
assert.False(t, mainCookie.Secure, "Plain HTTP should not have Secure flag")
// Should use Lax for compatibility
assert.Equal(t, http.SameSiteLaxMode, mainCookie.SameSite, "Plain HTTP should use Lax SameSite")
// Verify session can be retrieved
req2 := httptest.NewRequest("GET", "http://localhost:8080/test2", nil)
req2.Header.Set("User-Agent", "Mozilla/5.0")
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
assert.Equal(t, "plain-http-csrf", session2.GetCSRF())
assert.True(t, session2.GetAuthenticated())
assert.Equal(t, "user@example.com", session2.GetEmail())
})
t.Run("HTTPS_With_TLS_Field", func(t *testing.T) {
// Test direct HTTPS connection (not proxied)
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
req := httptest.NewRequest("GET", "https://example.com/test", nil)
req.Header.Set("User-Agent", "Mozilla/5.0")
// Simulate TLS connection
req.TLS = &tls.ConnectionState{}
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
session.SetCSRF("direct-https-csrf")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
cookies := rec.Result().Cookies()
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie)
// Direct HTTPS should have Secure flag
assert.True(t, mainCookie.Secure, "Direct HTTPS should have Secure flag")
assert.Equal(t, http.SameSiteLaxMode, mainCookie.SameSite, "HTTPS should use Lax for OAuth compatibility")
})
t.Run("ForceHTTPS_Setting", func(t *testing.T) {
// Test forceHTTPS setting works regardless of request
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", true, "", NewLogger("debug"))
require.NoError(t, err)
// Plain HTTP request
req := httptest.NewRequest("GET", "http://localhost/test", nil)
req.Header.Set("User-Agent", "Mozilla/5.0")
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
session.SetCSRF("forced-https-csrf")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
cookies := rec.Result().Cookies()
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie)
// With forceHTTPS, even HTTP requests get Secure cookies
assert.True(t, mainCookie.Secure, "ForceHTTPS should always set Secure flag")
})
t.Run("AJAX_Request_Gets_Strict_SameSite", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// AJAX request
req := httptest.NewRequest("GET", "http://example.com/api/data", nil)
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("User-Agent", "Mozilla/5.0")
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
session.SetCSRF("ajax-csrf")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
cookies := rec.Result().Cookies()
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie)
// AJAX requests always get Strict SameSite
assert.Equal(t, http.SameSiteStrictMode, mainCookie.SameSite, "AJAX requests should use Strict SameSite")
})
t.Run("Missing_UserAgent_Gets_Reduced_Timeout", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
require.NoError(t, err)
// Request without User-Agent (suspicious)
req := httptest.NewRequest("GET", "http://example.com/test", nil)
req.Header.Set("X-Forwarded-Proto", "https")
// No User-Agent
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
session.SetCSRF("no-ua-csrf")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
cookies := rec.Result().Cookies()
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie)
// Should have reduced MaxAge for suspicious requests
expectedMaxAge := int((absoluteSessionTimeout / 2).Seconds())
assert.Equal(t, expectedMaxAge, mainCookie.MaxAge, "Missing User-Agent should get reduced timeout")
})
}
-835
View File
@@ -1,835 +0,0 @@
package traefikoidc
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
"time"
"github.com/gorilla/sessions"
)
func TestSessionPoolMemoryLeak(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create a fake request
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
// Test 1: Successful session creation and return
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("GetSession failed: %v", err)
}
// Clear the session which should return it to the pool
session.Clear(req, nil)
// Test 2: ReturnToPool explicit method
session, err = sm.GetSession(req)
if err != nil {
t.Fatalf("GetSession failed: %v", err)
}
// Call ReturnToPool directly
session.ReturnToPool()
// Test 3: Error path in GetSession
// Modify the session store to force an error - use a different encryption key
badSM, _ := NewSessionManager("different0123456789abcdef0123456789abcdef0123456789", false, "", logger)
// Get session using mismatched manager/request to force error
_, err = badSM.GetSession(req)
if err == nil {
// We don't test the exact error since it could vary, just that we get one
t.Log("Note: Expected error when using mismatched encryption keys")
}
// Force GC to ensure any objects are cleaned up
runtime.GC()
// Wait a moment for GC to complete
time.Sleep(100 * time.Millisecond)
// Check if we have objects in the pool
// This is just a simple check; in a real scenario, we'd have to
// consider that sync.Pool can discard objects at any time.
pooledCount := getPooledObjects(sm)
t.Logf("Pooled objects count: %d", pooledCount)
}
func TestSessionErrorHandling(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create a fake request
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
// Call the GetSession method, corrupting the cookie to force an error
req.AddCookie(&http.Cookie{
Name: mainCookieName,
Value: "corrupt-value",
})
_, err = sm.GetSession(req)
if err == nil {
t.Fatal("Expected error, got nil")
}
// Check that the error message contains our expected prefix
if err != nil && !strings.Contains(err.Error(), "failed to get main session:") {
t.Fatalf("Unexpected error message: %v", err)
}
}
func TestSessionClearAlwaysReturnsToPool(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create a test request with the special header that will trigger an error
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
req.Header.Set("X-Test-Error", "true") // This will trigger the error in session.Clear
// Get a session
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("GetSession failed: %v", err)
}
// Create a response writer
w := httptest.NewRecorder()
// Call Clear with the test request (with X-Test-Error header) and response writer
// This should trigger the serialization error in Save
clearErr := session.Clear(req, w)
// Verify that Clear returned the error from Save
if clearErr == nil {
t.Error("Expected an error from Clear with X-Test-Error header, but got nil")
} else {
t.Logf("Received expected error from Clear: %v", clearErr)
}
// Force GC to ensure any objects are cleaned up
runtime.GC()
time.Sleep(100 * time.Millisecond)
// Create and clear another session (without the error header) to verify the pool is still working
normalReq := httptest.NewRequest("GET", "http://example.com/foo", nil)
session2, err := sm.GetSession(normalReq)
if err != nil {
t.Fatalf("Second GetSession failed: %v", err)
}
session2.Clear(normalReq, nil)
// If we got here without panics, the test is successful
t.Log("Session returned to pool despite errors")
}
// This placeholder comment is intentionally left empty since we're removing redundant code
// Helper function to count objects in the session pool for a given manager
func getPooledObjects(sm *SessionManager) int {
// Collect objects until we can't get any more from the pool
// Set a max limit to avoid potential infinite loops
var objects []*SessionData
maxAttempts := 100 // Safety limit to prevent infinite loops
for i := 0; i < maxAttempts; i++ {
obj := sm.sessionPool.Get()
if obj == nil {
break
}
// Type assertion with validation
sessionData, ok := obj.(*SessionData)
if !ok {
// Return the object even if it's not the right type to avoid leaks
sm.sessionPool.Put(obj)
break
}
objects = append(objects, sessionData)
}
// Count how many objects we found
count := len(objects)
// Return all objects back to the pool to preserve the pool state
for _, obj := range objects {
sm.sessionPool.Put(obj)
}
return count
}
// TestSessionObjectTracking verifies that session objects are properly
// returned to the pool in various scenarios including normal usage and error paths
func TestSessionObjectTracking(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create a fake request
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
// Test that the session pool is used as expected
hasNew := sm.sessionPool.New != nil
if !hasNew {
t.Error("Expected sessionPool.New function to be set")
}
// Create and discard 5 sessions
for i := 0; i < 5; i++ {
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("GetSession failed: %v", err)
}
session.ReturnToPool()
}
// Create a session and get an error when trying to clear it
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("GetSession failed: %v", err)
}
// Deliberately cause bad state in the session object
session.mainSession = nil // This will cause an error in Clear
// Even with an error, the pool should not leak
session.ReturnToPool()
runtime.GC()
time.Sleep(100 * time.Millisecond)
// Success - if we got here without crashing, the pool is working as expected
t.Log("Session pool handling verified")
}
// TestTokenCompressionIntegrity tests that token compression and decompression maintains JWT integrity
func TestTokenCompressionIntegrity(t *testing.T) {
tests := []struct {
name string
token string
wantFail bool
}{
{
name: "Valid JWT - Small",
token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.signature",
},
{
name: "Valid JWT - Large",
token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + strings.Repeat("eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9", 100) + ".signature",
},
{
name: "Invalid JWT - Wrong dot count",
token: "invalid.token",
wantFail: true,
},
{
name: "Invalid JWT - No dots",
token: "invalidtoken",
wantFail: true,
},
{
name: "Invalid JWT - Too many dots",
token: "part1.part2.part3.part4",
wantFail: true,
},
{
name: "Empty token",
token: "",
wantFail: false, // Empty tokens are handled gracefully
},
{
name: "Oversized token (>50KB)",
token: "part1." + strings.Repeat("A", 51*1024) + ".part3",
wantFail: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compressed := compressToken(tt.token)
if tt.wantFail {
// For invalid tokens, compression should return original
if compressed != tt.token {
t.Errorf("Expected compression to return original for invalid token, got different result")
}
return
}
// For valid tokens, test round-trip integrity
decompressed := decompressToken(compressed)
if decompressed != tt.token {
t.Errorf("Token integrity lost: original=%q, compressed=%q, decompressed=%q",
tt.token, compressed, decompressed)
}
// Test that decompression is idempotent
decompressed2 := decompressToken(decompressed)
if decompressed2 != tt.token {
t.Errorf("Decompression not idempotent: %q != %q", decompressed2, tt.token)
}
})
}
}
// TestTokenCompressionCorruptionDetection tests that gzip corruption is detected and handled
func TestTokenCompressionCorruptionDetection(t *testing.T) {
validJWT := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.signature"
tests := []struct {
name string
corruptedInput string
expectOriginal bool
}{
{
name: "Invalid base64",
corruptedInput: "!@#$%^&*()",
expectOriginal: true,
},
{
name: "Valid base64 but invalid gzip",
corruptedInput: base64.StdEncoding.EncodeToString([]byte("not gzip data")),
expectOriginal: true,
},
{
name: "Truncated gzip data",
corruptedInput: "H4sI", // Incomplete gzip header
expectOriginal: true,
},
{
name: "Empty string",
corruptedInput: "",
expectOriginal: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := decompressToken(tt.corruptedInput)
if tt.expectOriginal && result != tt.corruptedInput {
t.Errorf("Expected decompression to return original corrupted input, got: %q", result)
}
})
}
// Test that valid compression still works
compressed := compressToken(validJWT)
decompressed := decompressToken(compressed)
if decompressed != validJWT {
t.Errorf("Valid compression/decompression failed: %q != %q", decompressed, validJWT)
}
}
// TestTokenChunkingIntegrity tests that large tokens are properly chunked and reassembled
func TestTokenChunkingIntegrity(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create tokens of various sizes to test chunking
testTokens := NewTestTokens()
tests := []struct {
name string
tokenSize int
expectChunked bool
}{
{
name: "Small token (no chunking)",
tokenSize: 100,
expectChunked: false,
},
{
name: "Medium token (no chunking)",
tokenSize: 800, // FIXED: Reduced further to account for new conservative chunk size (1200 bytes)
expectChunked: false,
},
{
name: "Large token (chunking required)",
tokenSize: 5000,
expectChunked: true,
},
{
name: "Very large token (multiple chunks)",
tokenSize: 10000,
expectChunked: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// FIXED: Use incompressible tokens to ensure chunking occurs
var token string
if tt.expectChunked {
token = testTokens.CreateIncompressibleToken(tt.tokenSize)
} else {
token = testTokens.CreateLargeValidJWT(tt.tokenSize)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Store the token
session.SetAccessToken(token)
// Retrieve the token
retrievedToken := session.GetAccessToken()
// Verify integrity
if retrievedToken != token {
t.Errorf("Token integrity lost:\nOriginal: %q\nRetrieved: %q", token, retrievedToken)
}
// Check if chunking occurred as expected
hasChunks := len(session.accessTokenChunks) > 0
if tt.expectChunked != hasChunks {
t.Errorf("Chunking expectation mismatch: expected chunked=%v, has chunks=%v", tt.expectChunked, hasChunks)
}
session.ReturnToPool()
})
}
}
// TestTokenChunkingCorruptionResistance tests handling of corrupted chunks
func TestTokenChunkingCorruptionResistance(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create a large token that will be chunked
largeToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." +
base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{"sub":"test","data":"%s"}`, strings.Repeat("A", 5000))) +
".signature"
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Store the token (this should create chunks)
session.SetAccessToken(largeToken)
if len(session.accessTokenChunks) == 0 {
t.Skip("Token was not chunked, skipping corruption test")
}
tests := []struct {
corruptChunk func(chunks map[int]*sessions.Session)
name string
expectEmpty bool
}{
{
name: "Missing chunk in sequence",
corruptChunk: func(chunks map[int]*sessions.Session) {
// Remove a middle chunk
if len(chunks) > 1 {
delete(chunks, 1)
}
},
expectEmpty: true,
},
{
name: "Empty chunk data",
corruptChunk: func(chunks map[int]*sessions.Session) {
// Set first chunk to empty
if chunk, exists := chunks[0]; exists {
chunk.Values["token_chunk"] = ""
}
},
expectEmpty: true,
},
{
name: "Wrong data type in chunk",
corruptChunk: func(chunks map[int]*sessions.Session) {
// Set chunk data to wrong type
if chunk, exists := chunks[0]; exists {
chunk.Values["token_chunk"] = 123 // Should be string
}
},
expectEmpty: true,
},
{
name: "Oversized chunk",
corruptChunk: func(chunks map[int]*sessions.Session) {
// Set chunk to oversized data
if chunk, exists := chunks[0]; exists {
chunk.Values["token_chunk"] = strings.Repeat("A", maxCookieSize+200)
}
},
expectEmpty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Get a fresh session
freshSession, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get fresh session: %v", err)
}
// Store the token again
freshSession.SetAccessToken(largeToken)
// Apply corruption
tt.corruptChunk(freshSession.accessTokenChunks)
// Try to retrieve the token
retrievedToken := freshSession.GetAccessToken()
if tt.expectEmpty {
if retrievedToken != "" {
t.Errorf("Expected empty token due to corruption, got: %q", retrievedToken)
}
} else {
if retrievedToken != largeToken {
t.Errorf("Expected original token despite corruption, got: %q", retrievedToken)
}
}
freshSession.ReturnToPool()
})
}
session.ReturnToPool()
}
// TestTokenSizeLimits tests that token size limits are enforced
func TestTokenSizeLimits(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
testTokens := NewTestTokens()
tests := []struct {
name string
tokenSize int
expectStored bool
}{
{
name: "Normal size token",
tokenSize: 1000,
expectStored: true,
},
{
name: "Large but acceptable token",
tokenSize: 20000, // 20KB to ensure it fits within chunk limits (≤25 chunks)
expectStored: true,
},
{
name: "Oversized token (>100KB)",
tokenSize: 120000, // FIXED: 120KB to ensure rejection after compression
expectStored: false, // Should be rejected
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// FIXED: Use proper token generation that accounts for base64 encoding
var token string
if tt.expectStored {
token = testTokens.CreateLargeValidJWT(tt.tokenSize)
} else {
token = testTokens.CreateIncompressibleToken(tt.tokenSize)
}
// Store the token
session.SetAccessToken(token)
// Try to retrieve it
retrievedToken := session.GetAccessToken()
if tt.expectStored {
if retrievedToken != token {
t.Errorf("Expected token to be stored and retrieved, but got different token")
}
} else {
if retrievedToken == token {
t.Errorf("Expected oversized token to be rejected, but it was stored")
}
}
})
}
}
// TestConcurrentTokenOperations tests thread safety of token operations
func TestConcurrentTokenOperations(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
const numGoroutines = 10
const numOperations = 100
// Test concurrent access and refresh token operations
done := make(chan bool, numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer func() { done <- true }()
for j := 0; j < numOperations; j++ {
// Create unique tokens for each goroutine/operation
accessToken := ValidAccessToken
refreshToken := fmt.Sprintf("refresh_token_%d_%d", id, j)
// Concurrent operations
session.SetAccessToken(accessToken)
session.SetRefreshToken(refreshToken)
retrievedAccess := session.GetAccessToken()
retrievedRefresh := session.GetRefreshToken()
// Verify tokens are still valid (should be one of the tokens set by any goroutine)
if retrievedAccess != "" && strings.Count(retrievedAccess, ".") != 2 {
t.Errorf("Retrieved access token has invalid format: %q", retrievedAccess)
}
if retrievedRefresh != "" && len(retrievedRefresh) < 10 {
t.Errorf("Retrieved refresh token is too short: %q", retrievedRefresh)
}
}
}(i)
}
// Wait for all goroutines to complete
for i := 0; i < numGoroutines; i++ {
<-done
}
}
// TestSessionValidationAndCleanup tests session validation and orphan cleanup
func TestSessionValidationAndCleanup(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
rw := httptest.NewRecorder()
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Set tokens that will create chunks
largeToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." +
base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat(`{"data":"large"}`, 500))) +
".signature"
session.SetAccessToken(largeToken)
session.SetRefreshToken("refresh_token_test")
// Save session to create cookies
if err := session.Save(req, rw); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Verify chunks were created
if len(session.accessTokenChunks) == 0 {
t.Log("No chunks created, large token test may not be applicable")
}
// Test cleanup by clearing session
if err := session.Clear(req, rw); err != nil {
t.Logf("Clear returned error (may be expected): %v", err)
}
// Verify tokens are cleared
if token := session.GetAccessToken(); token != "" {
t.Errorf("Access token should be empty after clear, got: %q", token)
}
if token := session.GetRefreshToken(); token != "" {
t.Errorf("Refresh token should be empty after clear, got: %q", token)
}
}
// TestLargeIDTokenChunking tests that large ID tokens are properly chunked across multiple cookies
func TestLargeIDTokenChunking(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create a large ID token (>4KB) to force chunking
largeIDToken := createLargeIDToken(20000) // 20KB token to ensure chunking after compression
t.Logf("Created large ID token with length: %d", len(largeIDToken))
// Create a request and response recorder
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
rr := httptest.NewRecorder()
// Get session and set large ID token
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Set the large ID token
session.SetIDToken(largeIDToken)
t.Logf("Set large ID token in session")
// Save the session to trigger chunking
err = session.Save(req, rr)
if err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Let's check what the GetIDToken returns to confirm it's set
retrievedToken := session.GetIDToken()
t.Logf("Retrieved ID token length: %d", len(retrievedToken))
if len(retrievedToken) != len(largeIDToken) {
t.Errorf("Token length mismatch: expected %d, got %d", len(largeIDToken), len(retrievedToken))
}
// Verify that chunked cookies were created
cookies := rr.Result().Cookies()
t.Logf("Total cookies in response: %d", len(cookies))
for _, cookie := range cookies {
valuePreview := cookie.Value
if len(valuePreview) > 50 {
valuePreview = valuePreview[:50] + "..."
}
t.Logf("Cookie: %s = %s (len=%d)", cookie.Name, valuePreview, len(cookie.Value))
}
var chunkCookies []*http.Cookie
for _, cookie := range cookies {
if strings.HasPrefix(cookie.Name, idTokenCookie+"_") {
chunkCookies = append(chunkCookies, cookie)
}
}
// Verify chunk cookies exist (should be at least 2 for a 20KB token)
if len(chunkCookies) < 2 {
t.Fatalf("Expected at least 2 chunk cookies, got %d", len(chunkCookies))
}
// Verify chunk cookie naming convention
expectedChunkNames := make(map[string]bool)
for i := 0; i < len(chunkCookies); i++ {
expectedChunkNames[idTokenCookie+"_"+fmt.Sprintf("%d", i)] = true
}
for _, cookie := range chunkCookies {
if !expectedChunkNames[cookie.Name] {
t.Errorf("Unexpected chunk cookie name: %s", cookie.Name)
}
}
// Test token retrieval from chunked cookies
// Create a new request with all the cookies
newReq := httptest.NewRequest("GET", "http://example.com/foo", nil)
for _, cookie := range cookies {
newReq.AddCookie(cookie)
}
// Get session and retrieve the ID token
retrievedSession, err := sm.GetSession(newReq)
if err != nil {
t.Fatalf("Failed to get session from chunked cookies: %v", err)
}
retrievedToken2 := retrievedSession.GetIDToken()
// Verify the retrieved token matches the original
if retrievedToken2 != largeIDToken {
t.Errorf("Retrieved ID token doesn't match original. Expected length: %d, got: %d", len(largeIDToken), len(retrievedToken2))
}
// Test clearing the ID token removes all chunks
retrievedSession.SetIDToken("")
clearRR := httptest.NewRecorder()
err = retrievedSession.Save(newReq, clearRR)
if err != nil {
t.Fatalf("Failed to save session after clearing ID token: %v", err)
}
// Verify chunks are expired (MaxAge = -1)
clearCookies := clearRR.Result().Cookies()
for _, cookie := range clearCookies {
if strings.HasPrefix(cookie.Name, idTokenCookie+"_") {
if cookie.MaxAge != -1 {
t.Errorf("Expected chunk cookie %s to be expired (MaxAge=-1), got MaxAge=%d", cookie.Name, cookie.MaxAge)
}
}
}
}
// createLargeIDToken creates a JWT-like token of specified size for testing
func createLargeIDToken(size int) string {
// Create truly random data that won't compress well
randomBytes := make([]byte, size*3/4) // base64 encoding increases size by ~4/3
_, err := rand.Read(randomBytes)
if err != nil {
// Fallback to pseudo-random if crypto/rand fails
for i := range randomBytes {
randomBytes[i] = byte(i % 256)
}
}
// Base64url encode the random data to make it look like a JWT (JWT uses base64url, not base64)
encoded := base64.RawURLEncoding.EncodeToString(randomBytes)
// Create JWT-like structure with truly random data
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
// Truncate or pad to desired size
if len(encoded) > size-len(header)-100 {
encoded = encoded[:size-len(header)-100]
}
signature := "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
return header + "." + encoded + "." + signature
}
+61 -470
View File
@@ -5,461 +5,103 @@ import (
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"github.com/gorilla/sessions"
)
// TemplatedHeader represents a custom HTTP header with a templated value.
// The value can contain template expressions that will be evaluated for each
// authenticated request, such as {{.claims.email}} or {{.accessToken}}.
type TemplatedHeader struct {
// Name is the HTTP header name to set (e.g., "X-Forwarded-Email")
Name string `json:"name"`
// Value is the template string for the header value
// Example: "{{.claims.email}}", "Bearer {{.accessToken}}"
Value string `json:"value"`
}
// Config holds the configuration for the OIDC middleware.
// It provides all necessary settings to configure OpenID Connect authentication
// with various providers like Auth0, Logto, or any standard OIDC provider.
type Config struct {
HTTPClient *http.Client `json:"-"` // Exclude from JSON marshaling
ProviderURL string `json:"providerURL"`
RevocationURL string `json:"revocationURL"`
CallbackURL string `json:"callbackURL"`
LogoutURL string `json:"logoutURL"`
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
PostLogoutRedirectURI string `json:"postLogoutRedirectURI"`
LogLevel string `json:"logLevel"`
SessionEncryptionKey string `json:"sessionEncryptionKey"`
OIDCEndSessionURL string `json:"oidcEndSessionURL"`
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
ExcludedURLs []string `json:"excludedURLs"`
AllowedUserDomains []string `json:"allowedUserDomains"`
AllowedUsers []string `json:"allowedUsers"`
Scopes []string `json:"scopes"`
Headers []TemplatedHeader `json:"headers"`
CookieDomain string `json:"cookieDomain"`
RateLimit int `json:"rateLimit"`
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
ForceHTTPS bool `json:"forceHTTPS"`
EnablePKCE bool `json:"enablePKCE"`
OverrideScopes bool `json:"overrideScopes"`
}
const (
// DefaultRateLimit defines the default rate limit for requests per second
DefaultRateLimit = 100
// MinRateLimit defines the minimum allowed rate limit to prevent DOS
MinRateLimit = 10
// DefaultLogLevel defines the default logging level
DefaultLogLevel = "info"
// MinSessionEncryptionKeyLength defines the minimum length for session encryption key
MinSessionEncryptionKeyLength = 32
cookieName = "_raczylo_oidc"
)
// CreateConfig creates a new Config with secure default values.
// Default values are set for optional fields:
// - Scopes: ["openid", "profile", "email"]
// - LogLevel: "info"
// - LogoutURL: CallbackURL + "/logout"
// - RateLimit: 100 requests per second
// - PostLogoutRedirectURI: "/"
// - ForceHTTPS: true (for security)
// - EnablePKCE: false (PKCE is opt-in)
//
// CreateConfig initializes a new Config struct with default values for optional fields.
// It sets default scopes, log level, rate limit, enables ForceHTTPS, and sets the
// default refresh grace period. Required fields like ProviderURL, ClientID, ClientSecret,
// CallbackURL, and SessionEncryptionKey must be set explicitly after creation.
//
// Returns:
// - A pointer to a new Config struct with default settings applied.
// Config holds the configuration for the OIDC middleware
type Config struct {
ProviderURL string `json:"providerURL"`
RevocationURL string `json:"revocationURL"`
CallbackURL string `json:"callbackURL"`
LogoutURL string `json:"logoutURL"`
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
Scopes []string `json:"scopes"`
LogLevel string `json:"logLevel"`
SessionEncryptionKey string `json:"sessionEncryptionKey"`
ForceHTTPS bool `json:"forceHTTPS"`
RateLimit int `json:"rateLimit"`
ExcludedURLs []string `json:"excludedURLs"`
AllowedUserDomains []string `json:"allowedUserDomains"`
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
OIDCEndSessionURL string `json:"oidcEndSessionURL"`
PostLogoutRedirectURI string `json:"postLogoutRedirectURI"`
HTTPClient *http.Client
}
var defaultSessionOptions = &sessions.Options{
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteLaxMode,
MaxAge: ConstSessionTimeout,
Path: "/",
}
// CreateConfig creates a new Config with default values
func CreateConfig() *Config {
c := &Config{
Scopes: []string{"openid", "profile", "email"},
LogLevel: DefaultLogLevel,
RateLimit: DefaultRateLimit,
ForceHTTPS: true, // Secure by default
EnablePKCE: false, // PKCE is opt-in
OverrideScopes: false, // Default to appending scopes, not overriding
RefreshGracePeriodSeconds: 60, // Default grace period of 60 seconds
c := &Config{}
if c.Scopes == nil {
c.Scopes = []string{"openid", "profile", "email"}
}
if c.LogLevel == "" {
c.LogLevel = "info"
}
if c.LogoutURL == "" {
c.LogoutURL = c.CallbackURL + "/logout"
}
if c.RateLimit == 0 {
c.RateLimit = 100
}
return c
}
// Validate checks the configuration settings for validity.
// It ensures that required fields (ProviderURL, CallbackURL, ClientID, ClientSecret, SessionEncryptionKey)
// are present and that URLs are well-formed (HTTPS where required). It also validates
// the session key length, log level, rate limit, and refresh grace period.
//
// Returns:
// - nil if the configuration is valid.
// - An error describing the first validation failure encountered.
// Validate validates the Config
func (c *Config) Validate() error {
// Validate provider URL
if c.ProviderURL == "" {
return fmt.Errorf("providerURL is required")
}
if !isValidSecureURL(c.ProviderURL) {
return fmt.Errorf("providerURL must be a valid HTTPS URL")
}
// Validate callback URL
if c.CallbackURL == "" {
return fmt.Errorf("callbackURL is required")
}
if !strings.HasPrefix(c.CallbackURL, "/") {
return fmt.Errorf("callbackURL must start with /")
}
// Validate client credentials
if c.ClientID == "" {
return fmt.Errorf("clientID is required")
}
if c.ClientSecret == "" {
return fmt.Errorf("clientSecret is required")
}
// Validate session encryption key
if c.SessionEncryptionKey == "" {
return fmt.Errorf("sessionEncryptionKey is required")
}
if len(c.SessionEncryptionKey) < MinSessionEncryptionKeyLength {
return fmt.Errorf("sessionEncryptionKey must be at least %d characters long", MinSessionEncryptionKeyLength)
}
// Validate log level
if c.LogLevel != "" && !isValidLogLevel(c.LogLevel) {
return fmt.Errorf("logLevel must be one of: debug, info, error")
}
// Validate excluded URLs
for _, url := range c.ExcludedURLs {
if !strings.HasPrefix(url, "/") {
return fmt.Errorf("excluded URL must start with /: %s", url)
}
if strings.Contains(url, "..") {
return fmt.Errorf("excluded URL must not contain path traversal: %s", url)
}
if strings.Contains(url, "*") {
return fmt.Errorf("excluded URL must not contain wildcards: %s", url)
}
}
// Validate revocation URL if set
if c.RevocationURL != "" && !isValidSecureURL(c.RevocationURL) {
return fmt.Errorf("revocationURL must be a valid HTTPS URL")
}
// Validate end session URL if set
if c.OIDCEndSessionURL != "" && !isValidSecureURL(c.OIDCEndSessionURL) {
return fmt.Errorf("oidcEndSessionURL must be a valid HTTPS URL")
}
// Validate post-logout redirect URI if set
if c.PostLogoutRedirectURI != "" && c.PostLogoutRedirectURI != "/" {
if !isValidSecureURL(c.PostLogoutRedirectURI) && !strings.HasPrefix(c.PostLogoutRedirectURI, "/") {
return fmt.Errorf("postLogoutRedirectURI must be either a valid HTTPS URL or start with /")
}
}
// Validate rate limit
if c.RateLimit < MinRateLimit {
return fmt.Errorf("rateLimit must be at least %d", MinRateLimit)
}
// Validate refresh grace period
if c.RefreshGracePeriodSeconds < 0 {
return fmt.Errorf("refreshGracePeriodSeconds cannot be negative")
}
// Validate headers configuration for template security
for _, header := range c.Headers {
if header.Name == "" {
return fmt.Errorf("header name cannot be empty")
}
if header.Value == "" {
return fmt.Errorf("header value template cannot be empty")
}
if !strings.Contains(header.Value, "{{") || !strings.Contains(header.Value, "}}") {
return fmt.Errorf("header value '%s' does not appear to be a valid template (missing {{ }})", header.Value)
}
// Provide more helpful guidance for common template errors BEFORE security validation
if strings.Contains(header.Value, "{{.claims") {
return fmt.Errorf("header template '%s' appears to use lowercase 'claims' - use '{{.Claims...' instead (case sensitive)", header.Value)
}
if strings.Contains(header.Value, "{{.accessToken") {
return fmt.Errorf("header template '%s' appears to use lowercase 'accessToken' - use '{{.AccessToken...' instead (case sensitive)", header.Value)
}
if strings.Contains(header.Value, "{{.idToken") {
return fmt.Errorf("header template '%s' appears to use lowercase 'idToken' - use '{{.IdToken...' instead (case sensitive)", header.Value)
}
if strings.Contains(header.Value, "{{.refreshToken") {
return fmt.Errorf("header template '%s' appears to use lowercase 'refreshToken' - use '{{.RefreshToken...' instead (case sensitive)", header.Value)
}
// Validate template syntax and security
if err := validateTemplateSecure(header.Value); err != nil {
return fmt.Errorf("header template '%s' failed security validation: %w", header.Value, err)
}
}
return nil
}
// validateTemplateSecure validates template expressions for security vulnerabilities
func validateTemplateSecure(templateStr string) error {
// Allow our specific safe custom functions
// These are added specifically to handle missing fields safely (issue #60)
safeCustomFunctions := []string{
"{{get ", // Safe map access function
"{{default ", // Safe default value function
}
// Check if template uses safe custom functions
usesSafeFunctions := false
for _, safeFn := range safeCustomFunctions {
if strings.Contains(templateStr, safeFn) {
usesSafeFunctions = true
// These functions are explicitly allowed for safe field access
}
}
// Check for dangerous template functions and patterns
// Skip certain checks if using our safe functions
dangerousPatterns := []string{
"{{call", // Function calls (except our safe ones)
"{{range", // Range over arbitrary data
"{{define", // Template definitions
"{{template", // Template inclusions
"{{block", // Block definitions
"{{/*", // Comments that could hide malicious code
"{{-", // Trim whitespace (could be used to obfuscate)
"-}}", // Trim whitespace (could be used to obfuscate)
"{{printf", // Printf functions
"{{print", // Print functions (but not our safe ones)
"{{println", // Println functions
"{{html", // HTML functions
"{{js", // JavaScript functions
"{{urlquery", // URL query functions
"{{index", // Index access to arbitrary data
"{{slice", // Slice operations
"{{len", // Length operations on arbitrary data
"{{eq", // Comparison operations
"{{ne", // Comparison operations
"{{lt", // Comparison operations
"{{le", // Comparison operations
"{{gt", // Comparison operations
"{{ge", // Comparison operations
"{{and", // Logical operations
"{{or", // Logical operations
"{{not", // Logical operations
}
// Allow 'with' for safe conditional access
if !strings.Contains(templateStr, "{{with .Claims") {
dangerousPatterns = append(dangerousPatterns, "{{with")
}
templateLower := strings.ToLower(templateStr)
for _, pattern := range dangerousPatterns {
// Skip check if it's one of our safe functions
if usesSafeFunctions && (pattern == "{{call" || pattern == "{{print") {
// Allow these if we're using safe functions
continue
}
// Special handling for comparison operators to avoid false positives with "get" and "default"
if pattern == "{{ge" && (strings.Contains(templateStr, "{{get ") || strings.Contains(templateStr, "{{default ")) {
// Skip {{ge check if we're using the safe {{get or {{default functions
continue
}
// Skip {{de checks if using {{default
if pattern == "{{define" && strings.Contains(templateStr, "{{default ") {
continue
}
if strings.Contains(templateLower, strings.ToLower(pattern)) {
return fmt.Errorf("dangerous template pattern detected: %s", pattern)
}
}
// Validate template variables against whitelist
allowedPatterns := []string{
"{{.AccessToken}}",
"{{.IdToken}}",
"{{.RefreshToken}}",
"{{.Claims.",
"{{get ", // Safe custom function
"{{default ", // Safe custom function
"{{with ", // Safe conditional (when used with Claims)
}
// Check if template contains only allowed patterns
hasAllowedPattern := false
for _, pattern := range allowedPatterns {
if strings.Contains(templateStr, pattern) {
hasAllowedPattern = true
break
}
}
if !hasAllowedPattern {
return fmt.Errorf("template must use only allowed variables: AccessToken, IdToken, RefreshToken, Claims.*, or safe functions (get, default, with)")
}
// Validate claims access patterns
if strings.Contains(templateStr, "{{.Claims.") {
// Simple validation - ensure claims access is to known safe fields
// This list includes standard OIDC claims and common provider-specific claims
safeClaimsFields := map[string]bool{
// Standard OIDC claims
"email": true,
"name": true,
"given_name": true,
"family_name": true,
"preferred_username": true,
"sub": true,
"iss": true,
"aud": true,
"exp": true,
"iat": true,
"groups": true,
"roles": true,
// Common custom claims
"internal_role": true, // Custom roles field (issue #60)
"role": true, // Alternative role field
"department": true, // Organization info
"organization": true, // Organization info
// Provider-specific claims
"realm_access": true, // Keycloak specific
"resource_access": true, // Keycloak specific
"oid": true, // Azure AD object ID
"tid": true, // Azure AD tenant ID
"upn": true, // Azure AD User Principal Name
"hd": true, // Google hosted domain
"picture": true, // Profile picture
// Additional standard claims
"locale": true, // User locale
"zoneinfo": true, // Timezone
"phone_number": true, // Contact info
"email_verified": true, // Email verification status
"updated_at": true, // Last update time
}
// Extract field names from Claims access
start := strings.Index(templateStr, "{{.Claims.")
for start != -1 {
end := strings.Index(templateStr[start:], "}}")
if end == -1 {
return fmt.Errorf("malformed Claims template syntax")
}
// Extract the content between "{{.Claims." and "}}"
// start+10 skips "{{.Claims." and start+end is the position of "}}"
claimsContent := templateStr[start+10 : start+end]
// Get the field name (first part before any dots)
fieldName := strings.Split(claimsContent, ".")[0]
if !safeClaimsFields[fieldName] {
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", fieldName)
}
// Search for next occurrence
nextStart := strings.Index(templateStr[start+end+2:], "{{.Claims.")
if nextStart != -1 {
start = start + end + 2 + nextStart
} else {
start = -1
}
}
}
// Prevent code injection through template syntax
if strings.Contains(templateStr, "{{") && strings.Contains(templateStr, "}}") {
// Count opening and closing braces
openCount := strings.Count(templateStr, "{{")
closeCount := strings.Count(templateStr, "}}")
if openCount != closeCount {
return fmt.Errorf("unbalanced template braces")
}
}
return nil
}
// isValidSecureURL checks if a given string represents a valid, absolute HTTPS URL.
// It uses url.Parse and checks for a nil error, an "https" scheme, and a non-empty host.
//
// Parameters:
// - s: The URL string to validate.
//
// Returns:
// - true if the string is a valid HTTPS URL, false otherwise.
func isValidSecureURL(s string) bool {
u, err := url.Parse(s)
return err == nil && u.Scheme == "https" && u.Host != ""
}
// isValidLogLevel checks if the provided log level string is one of the supported values ("debug", "info", "error").
//
// Parameters:
// - level: The log level string to validate.
//
// Returns:
// - true if the log level is valid, false otherwise.
func isValidLogLevel(level string) bool {
return level == "debug" || level == "info" || level == "error"
}
// Logger provides structured logging capabilities with different severity levels.
// It supports error, info, and debug levels with appropriate output streams
// and formatting for each level.
// Logger is a simple logger with different levels
type Logger struct {
// logError handles error-level messages, writing to stderr
logError *log.Logger
// logInfo handles informational messages, writing to stdout
logInfo *log.Logger
// logDebug handles debug-level messages, writing to stdout when debug is enabled
logInfo *log.Logger
logDebug *log.Logger
}
// NewLogger creates and configures a new Logger instance based on the provided log level.
// It initializes loggers for ERROR (stderr), INFO (stdout), and DEBUG (stdout) levels,
// enabling output based on the specified level:
// - "error": Only ERROR messages are output.
// - "info": INFO and ERROR messages are output.
// - "debug": DEBUG, INFO, and ERROR messages are output.
//
// If an invalid level is provided, it defaults to behavior similar to "error".
//
// Parameters:
// - logLevel: The desired logging level ("debug", "info", or "error").
//
// Returns:
// - A pointer to the configured Logger instance.
// NewLogger creates a new Logger
func NewLogger(logLevel string) *Logger {
logError := log.New(io.Discard, "ERROR: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
logInfo := log.New(io.Discard, "INFO: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
logDebug := log.New(io.Discard, "DEBUG: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
logError.SetOutput(os.Stderr)
logInfo.SetOutput(os.Stdout)
if logLevel == "debug" || logLevel == "info" {
logInfo.SetOutput(os.Stdout)
}
if logLevel == "debug" {
logDebug.SetOutput(os.Stdout)
}
@@ -471,88 +113,37 @@ func NewLogger(logLevel string) *Logger {
}
}
// Info logs a message at the INFO level using Printf style formatting.
// Output is directed to stdout if the configured log level is "info" or "debug".
//
// Parameters:
// - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string.
// Info logs an info message
func (l *Logger) Info(format string, args ...interface{}) {
l.logInfo.Printf(format, args...)
}
// Debug logs a message at the DEBUG level.
// Output is directed to stdout only if the configured log level is "debug".
//
// Parameters:
// - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string.
// Debug logs a debug message
func (l *Logger) Debug(format string, args ...interface{}) {
l.logDebug.Printf(format, args...)
}
// Error logs a message at the ERROR level using Printf style formatting.
// Output is always directed to stderr, regardless of the configured log level.
//
// Parameters:
// - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string.
// Error logs an error message
func (l *Logger) Error(format string, args ...interface{}) {
l.logError.Printf(format, args...)
}
// Infof logs a message at the INFO level using Printf style formatting.
// Equivalent to calling l.Info(format, args...).
// Output is directed to stdout if the configured log level is "info" or "debug".
//
// Parameters:
// - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string.
// Infof logs an info message
func (l *Logger) Infof(format string, args ...interface{}) {
l.logInfo.Printf(format, args...)
}
// Debugf logs a formatted message at the DEBUG level.
// Equivalent to calling l.Debug(format, args...).
// Output is directed to stdout only if the configured log level is "debug".
//
// Parameters:
// - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string.
// Debugf logs a debug message
func (l *Logger) Debugf(format string, args ...interface{}) {
l.logDebug.Printf(format, args...)
}
// Errorf logs a message at the ERROR level using Printf style formatting.
// Equivalent to calling l.Error(format, args...).
// Output is always directed to stderr, regardless of the configured log level.
//
// Parameters:
// - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string.
// Errorf logs an error message
func (l *Logger) Errorf(format string, args ...interface{}) {
l.logError.Printf(format, args...)
}
// newNoOpLogger creates a silent logger that doesn't output anything.
// This is useful for internal components that need a logger instance
// but should not produce any output by default.
func newNoOpLogger() *Logger {
return &Logger{
logError: log.New(io.Discard, "", 0),
logInfo: log.New(io.Discard, "", 0),
logDebug: log.New(io.Discard, "", 0),
}
}
// handleError logs an error message using the provided logger and sends an HTTP error
// response to the client with the specified message and status code.
//
// Parameters:
// - w: The http.ResponseWriter to send the error response to.
// - message: The error message string.
// - code: The HTTP status code for the response.
// - logger: The Logger instance to use for logging the error.
// handleError writes an error message to the response and logs it
func handleError(w http.ResponseWriter, message string, code int, logger *Logger) {
logger.Error(message)
http.Error(w, message, code)
-408
View File
@@ -1,408 +0,0 @@
package traefikoidc
import (
"bytes"
"log"
"net/http"
"testing"
)
// Helper function to compare string slices
func equalSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i, v := range a {
if v != b[i] {
return false
}
}
return true
}
func TestCreateConfig(t *testing.T) {
t.Run("Default Values", func(t *testing.T) {
config := CreateConfig()
// Check default scopes
expectedScopes := []string{"openid", "profile", "email"}
if len(config.Scopes) != len(expectedScopes) {
t.Errorf("Expected %d default scopes, got %d", len(expectedScopes), len(config.Scopes))
}
for i, scope := range expectedScopes {
if config.Scopes[i] != scope {
t.Errorf("Expected scope %s at position %d, got %s", scope, i, config.Scopes[i])
}
}
// Check default log level
if config.LogLevel != DefaultLogLevel {
t.Errorf("Expected default log level '%s', got '%s'", DefaultLogLevel, config.LogLevel)
}
// Check default rate limit
if config.RateLimit != DefaultRateLimit {
t.Errorf("Expected default rate limit %d, got %d", DefaultRateLimit, config.RateLimit)
}
// Check ForceHTTPS default
if !config.ForceHTTPS {
t.Error("Expected ForceHTTPS to be true by default")
}
// Check OverrideScopes default
if config.OverrideScopes {
t.Error("Expected OverrideScopes to be false by default")
}
})
}
func TestConfigValidate(t *testing.T) {
tests := []struct {
name string
config *Config
expectedError string
}{
{
name: "Empty Config",
config: &Config{},
expectedError: "providerURL is required",
},
{
name: "Missing CallbackURL",
config: &Config{
ProviderURL: "https://provider.com",
},
expectedError: "callbackURL is required",
},
{
name: "Missing ClientID",
config: &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
},
expectedError: "clientID is required",
},
{
name: "Missing ClientSecret",
config: &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
},
expectedError: "clientSecret is required",
},
{
name: "Missing SessionEncryptionKey",
config: &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
},
expectedError: "sessionEncryptionKey is required",
},
{
name: "Non-HTTPS ProviderURL",
config: &Config{
ProviderURL: "http://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
SessionEncryptionKey: "encryption-key",
},
expectedError: "providerURL must be a valid HTTPS URL",
},
{
name: "Invalid CallbackURL",
config: &Config{
ProviderURL: "https://provider.com",
CallbackURL: "callback", // Missing leading slash
ClientID: "client-id",
ClientSecret: "client-secret",
SessionEncryptionKey: "encryption-key",
},
expectedError: "callbackURL must start with /",
},
{
name: "Short SessionEncryptionKey",
config: &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
SessionEncryptionKey: "short",
},
expectedError: "sessionEncryptionKey must be at least 32 characters long",
},
{
name: "Low RateLimit",
config: &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
RateLimit: 5,
},
expectedError: "rateLimit must be at least 10",
},
{
name: "Invalid LogLevel",
config: &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
LogLevel: "invalid",
},
expectedError: "logLevel must be one of: debug, info, error",
},
{
name: "Non-HTTPS RevocationURL",
config: &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
RevocationURL: "http://revoke.com",
},
expectedError: "revocationURL must be a valid HTTPS URL",
},
{
name: "Non-HTTPS OIDCEndSessionURL",
config: &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
OIDCEndSessionURL: "http://endsession.com",
},
expectedError: "oidcEndSessionURL must be a valid HTTPS URL",
},
{
name: "Valid Config",
config: &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
LogLevel: "debug",
RateLimit: 100,
RevocationURL: "https://revoke.com",
OIDCEndSessionURL: "https://endsession.com",
},
expectedError: "",
},
{
name: "Valid Config With AllowedUsers",
config: &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
LogLevel: "debug",
RateLimit: 100,
AllowedUsers: []string{"user1@example.com", "user2@example.com"},
},
expectedError: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.config.Validate()
if tc.expectedError == "" {
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
} else {
if err == nil {
t.Errorf("Expected error containing '%s', got nil", tc.expectedError)
} else if err.Error() != tc.expectedError {
t.Errorf("Expected error '%s', got '%s'", tc.expectedError, err.Error())
}
}
})
}
}
func TestLogger(t *testing.T) {
// Capture log output
var debugBuf, infoBuf, errorBuf bytes.Buffer
tests := []struct {
testFunc func(*Logger)
checkFunc func(t *testing.T, debugOut, infoOut, errorOut string)
name string
logLevel string
}{
{
name: "Debug Level",
logLevel: "debug",
testFunc: func(l *Logger) {
l.Debug("debug message")
l.Info("info message")
l.Error("error message")
},
checkFunc: func(t *testing.T, debugOut, infoOut, errorOut string) {
if debugOut == "" {
t.Error("Expected debug message in output")
}
if infoOut == "" {
t.Error("Expected info message in output")
}
if errorOut == "" {
t.Error("Expected error message in output")
}
},
},
{
name: "Info Level",
logLevel: "info",
testFunc: func(l *Logger) {
l.Debug("debug message")
l.Info("info message")
l.Error("error message")
},
checkFunc: func(t *testing.T, debugOut, infoOut, errorOut string) {
if debugOut != "" {
t.Error("Did not expect debug message in output")
}
if infoOut == "" {
t.Error("Expected info message in output")
}
if errorOut == "" {
t.Error("Expected error message in output")
}
},
},
{
name: "Error Level",
logLevel: "error",
testFunc: func(l *Logger) {
l.Debug("debug message")
l.Info("info message")
l.Error("error message")
},
checkFunc: func(t *testing.T, debugOut, infoOut, errorOut string) {
if debugOut != "" {
t.Error("Did not expect debug message in output")
}
if infoOut != "" {
t.Error("Did not expect info message in output")
}
if errorOut == "" {
t.Error("Expected error message in output")
}
},
},
{
name: "Printf Methods",
logLevel: "debug",
testFunc: func(l *Logger) {
l.Debugf("debug %s", "formatted")
l.Infof("info %s", "formatted")
l.Errorf("error %s", "formatted")
},
checkFunc: func(t *testing.T, debugOut, infoOut, errorOut string) {
if !bytes.Contains([]byte(debugOut), []byte("debug formatted")) {
t.Error("Expected formatted debug message")
}
if !bytes.Contains([]byte(infoOut), []byte("info formatted")) {
t.Error("Expected formatted info message")
}
if !bytes.Contains([]byte(errorOut), []byte("error formatted")) {
t.Error("Expected formatted error message")
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Reset buffers
debugBuf.Reset()
infoBuf.Reset()
errorBuf.Reset()
// Create logger with test buffers
logger := NewLogger(tc.logLevel)
logger.logError.SetOutput(&errorBuf)
if tc.logLevel == "debug" || tc.logLevel == "info" {
logger.logInfo.SetOutput(&infoBuf)
}
if tc.logLevel == "debug" {
logger.logDebug.SetOutput(&debugBuf)
}
// Run test
tc.testFunc(logger)
// Check results
tc.checkFunc(t, debugBuf.String(), infoBuf.String(), errorBuf.String())
})
}
}
func TestHandleError(t *testing.T) {
// Create a test logger with captured output
var errorBuf bytes.Buffer
logger := &Logger{
logError: log.New(&errorBuf, "ERROR: ", log.Ldate|log.Ltime),
}
logger.logError.SetOutput(&errorBuf)
// Create a test response recorder
rr := &testResponseRecorder{
headers: make(map[string][]string),
}
// Test error handling
message := "test error message"
code := 400
handleError(rr, message, code, logger)
// Check response code
if rr.statusCode != code {
t.Errorf("Expected status code %d, got %d", code, rr.statusCode)
}
// Check response body
expectedBody := message + "\n"
if rr.body != expectedBody {
t.Errorf("Expected body %q, got %q", expectedBody, rr.body)
}
// Check error was logged
if !bytes.Contains(errorBuf.Bytes(), []byte(message)) {
t.Error("Error message was not logged")
}
}
// Test helper types
type testResponseRecorder struct {
headers map[string][]string
body string
statusCode int
}
func (r *testResponseRecorder) Header() http.Header {
return r.headers
}
func (r *testResponseRecorder) Write(b []byte) (int, error) {
r.body = string(b)
return len(b), nil
}
func (r *testResponseRecorder) WriteHeader(code int) {
r.statusCode = code
}
-362
View File
@@ -1,362 +0,0 @@
package traefikoidc
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"text/template"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestTemplateDoubleProcessing tests if template strings are being double-processed
// This addresses the user's concern about potential double processing by the parser
func TestTemplateDoubleProcessing(t *testing.T) {
t.Run("Template_Strings_Not_Double_Processed", func(t *testing.T) {
// Simulate how Traefik passes config to the plugin
// Traefik uses YAML/TOML config which gets unmarshaled into the Config struct
// yamlConfig example:
// headers:
// - name: "X-User-Email"
// value: "{{.Claims.email}}"
// - name: "X-User-Role"
// value: "{{.Claims.internal_role}}"
// This simulates what Traefik does internally - it parses YAML/TOML and creates a Config struct
// The template strings are NOT processed at this stage, they're just strings
config := &Config{
Headers: []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-Role", Value: "{{.Claims.internal_role}}"},
},
}
// Verify that template strings are still raw (not processed)
assert.Equal(t, "{{.Claims.email}}", config.Headers[0].Value)
assert.Equal(t, "{{.Claims.internal_role}}", config.Headers[1].Value)
// Now simulate what happens when the plugin initializes
// The template strings should only be parsed once during initialization
headerTemplates := make(map[string]*template.Template)
funcMap := template.FuncMap{
"default": func(defaultVal interface{}, val interface{}) interface{} {
if val == nil || val == "" || val == "<no value>" {
return defaultVal
}
return val
},
"get": func(m interface{}, key string) interface{} {
if mapVal, ok := m.(map[string]interface{}); ok {
if val, exists := mapVal[key]; exists {
return val
}
}
return ""
},
}
for _, header := range config.Headers {
tmpl := template.New(header.Name).Funcs(funcMap).Option("missingkey=zero")
parsedTmpl, err := tmpl.Parse(header.Value)
require.NoError(t, err)
headerTemplates[header.Name] = parsedTmpl
}
// Test execution with actual claims
claims := map[string]interface{}{
"email": "user@example.com",
// Note: internal_role is missing
}
templateData := map[string]interface{}{
"Claims": claims,
}
// Execute templates
for headerName, tmpl := range headerTemplates {
var buf bytes.Buffer
err := tmpl.Execute(&buf, templateData)
require.NoError(t, err)
result := buf.String()
if headerName == "X-User-Email" {
assert.Equal(t, "user@example.com", result)
} else if headerName == "X-User-Role" {
// With missingkey=zero, missing fields return "<no value>"
assert.Equal(t, "<no value>", result)
}
}
})
t.Run("Config_Marshaling_Preserves_Template_Syntax", func(t *testing.T) {
// Test that marshaling/unmarshaling config doesn't affect template strings
originalConfig := &Config{
ProviderURL: "https://example.com",
ClientID: "test-client",
ClientSecret: "test-secret",
CallbackURL: "/callback",
SessionEncryptionKey: "test-encryption-key-32-characters",
Headers: []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-Role", Value: "{{get .Claims \"internal_role\"}}"},
{Name: "X-User-Dept", Value: "{{default \"unknown\" .Claims.department}}"},
},
}
// Marshal to JSON (simulating Traefik's config processing)
jsonData, err := json.Marshal(originalConfig)
require.NoError(t, err)
// Unmarshal back
var unmarshaledConfig Config
err = json.Unmarshal(jsonData, &unmarshaledConfig)
require.NoError(t, err)
// Verify template strings are preserved exactly
assert.Equal(t, "{{.Claims.email}}", unmarshaledConfig.Headers[0].Value)
assert.Equal(t, `{{get .Claims "internal_role"}}`, unmarshaledConfig.Headers[1].Value)
assert.Equal(t, `{{default "unknown" .Claims.department}}`, unmarshaledConfig.Headers[2].Value)
})
t.Run("Template_Functions_Work_After_Config_Processing", func(t *testing.T) {
// Simulate the full flow from config to execution
jsonConfig := `{
"providerURL": "https://example.com",
"clientID": "test-client",
"clientSecret": "test-secret",
"callbackURL": "/callback",
"sessionEncryptionKey": "test-encryption-key-32-characters",
"headers": [
{"name": "X-User-Email", "value": "{{.Claims.email}}"},
{"name": "X-User-Role", "value": "{{get .Claims \"internal_role\"}}"},
{"name": "X-User-Dept", "value": "{{default \"engineering\" .Claims.department}}"}
]
}`
var config Config
err := json.Unmarshal([]byte(jsonConfig), &config)
require.NoError(t, err)
// Initialize templates with functions
funcMap := template.FuncMap{
"default": func(defaultVal interface{}, val interface{}) interface{} {
if val == nil || val == "" || val == "<no value>" {
return defaultVal
}
return val
},
"get": func(m interface{}, key string) interface{} {
if mapVal, ok := m.(map[string]interface{}); ok {
if val, exists := mapVal[key]; exists {
return val
}
}
return ""
},
}
headerTemplates := make(map[string]*template.Template)
for _, header := range config.Headers {
tmpl := template.New(header.Name).Funcs(funcMap).Option("missingkey=zero")
parsedTmpl, err := tmpl.Parse(header.Value)
require.NoError(t, err)
headerTemplates[header.Name] = parsedTmpl
}
// Test with claims
claims := map[string]interface{}{
"email": "user@example.com",
// internal_role and department are missing
}
templateData := map[string]interface{}{
"Claims": claims,
}
results := make(map[string]string)
for headerName, tmpl := range headerTemplates {
var buf bytes.Buffer
err := tmpl.Execute(&buf, templateData)
require.NoError(t, err)
results[headerName] = buf.String()
}
// Verify results
assert.Equal(t, "user@example.com", results["X-User-Email"])
assert.Equal(t, "", results["X-User-Role"]) // get function returns empty string
assert.Equal(t, "engineering", results["X-User-Dept"]) // default function provides fallback
})
}
// TestTemplateIntegrationWithPlugin tests template processing in the actual plugin
func TestTemplateIntegrationWithPlugin(t *testing.T) {
t.Run("Plugin_Handles_Missing_Claims_Safely", func(t *testing.T) {
// Set up test OIDC server
var testServerURL string
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
json.NewEncoder(w).Encode(map[string]interface{}{
"issuer": testServerURL,
"authorization_endpoint": testServerURL + "/auth",
"token_endpoint": testServerURL + "/token",
"jwks_uri": testServerURL + "/jwks",
"userinfo_endpoint": testServerURL + "/userinfo",
})
case "/jwks":
json.NewEncoder(w).Encode(map[string]interface{}{
"keys": []interface{}{},
})
default:
http.NotFound(w, r)
}
}))
defer testServer.Close()
testServerURL = testServer.URL
// Create config with templates that reference potentially missing fields
config := &Config{
ProviderURL: testServer.URL,
ClientID: "test-client",
ClientSecret: "test-secret",
CallbackURL: "/callback",
SessionEncryptionKey: "test-encryption-key-32-characters",
Headers: []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-Role", Value: "{{.Claims.internal_role}}"},
},
}
// Initialize plugin
ctx := context.Background()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check headers set by the plugin
email := r.Header.Get("X-User-Email")
role := r.Header.Get("X-User-Role")
// Write headers to response for testing
w.Header().Set("X-Test-Email", email)
w.Header().Set("X-Test-Role", role)
w.WriteHeader(http.StatusOK)
})
handler, err := New(ctx, next, config, "test-plugin")
require.NoError(t, err)
traefikOidc, ok := handler.(*TraefikOidc)
require.True(t, ok)
// Create a mock session with claims
req := httptest.NewRequest("GET", "/protected", nil)
// Create session and set authentication
session, err := traefikOidc.sessionManager.GetSession(req)
require.NoError(t, err)
// Set authentication with minimal claims (missing internal_role)
session.SetAuthenticated(true)
session.SetEmail("user@example.com")
// Create ID token with limited claims
claims := map[string]interface{}{
"email": "user@example.com",
"sub": "user123",
// internal_role is missing
}
// Create a simple test JWT (signature verification is mocked in tests)
idToken, _ := createTestJWT(nil, "test-issuer", "test-client", claims)
session.SetIDToken(idToken)
// Save session
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Create new request with session cookie
cookies := rec.Result().Cookies()
req2 := httptest.NewRequest("GET", "/protected", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
// Process request through plugin
rec2 := httptest.NewRecorder()
handler.ServeHTTP(rec2, req2)
// Plugin should handle missing claims gracefully
// The request should proceed without errors
assert.NotEqual(t, http.StatusInternalServerError, rec2.Code)
})
}
// Removed createTestJWT as it already exists in main_test.go
// TestTemplateSyntaxValidation tests that template syntax is properly validated
func TestTemplateSyntaxValidation(t *testing.T) {
t.Run("Valid_Template_Syntax", func(t *testing.T) {
validTemplates := []string{
"{{.Claims.email}}",
"{{.Claims.internal_role}}",
"{{.AccessToken}}",
"{{.IdToken}}",
"{{.RefreshToken}}",
}
for _, tmplStr := range validTemplates {
err := validateTemplateSecure(tmplStr)
assert.NoError(t, err, "Template should be valid: %s", tmplStr)
}
})
t.Run("Invalid_Template_Syntax_Blocked", func(t *testing.T) {
invalidTemplates := []struct {
template string
reason string
}{
{"{{call .SomeFunc}}", "function calls not allowed"},
{"{{range .Items}}{{.}}{{end}}", "range not allowed"},
{"{{with .Data}}{{.Field}}{{end}}", "with statements blocked"},
{"{{index .Array 0}}", "index access blocked"},
{"{{printf \"%s\" .Data}}", "printf blocked"},
}
for _, tc := range invalidTemplates {
err := validateTemplateSecure(tc.template)
assert.Error(t, err, "Template should be invalid: %s (%s)", tc.template, tc.reason)
assert.Contains(t, strings.ToLower(err.Error()), "dangerous")
}
})
t.Run("Template_With_Custom_Functions", func(t *testing.T) {
// These templates use our safe custom functions which are now allowed
templates := []string{
"{{get .Claims \"internal_role\"}}",
"{{default \"guest\" .Claims.role}}",
}
// These safe custom functions should now be allowed
for _, tmplStr := range templates {
err := validateTemplateSecure(tmplStr)
assert.NoError(t, err, "Safe custom functions should be allowed: %s", tmplStr)
}
// But other function calls should still be blocked
dangerousFunctions := []string{
"{{call .SomeFunc}}",
"{{index .Array 0}}",
"{{slice .Data 0 10}}",
}
for _, tmplStr := range dangerousFunctions {
err := validateTemplateSecure(tmplStr)
assert.Error(t, err, "Dangerous function calls should still be blocked: %s", tmplStr)
}
})
}
-197
View File
@@ -1,197 +0,0 @@
package traefikoidc
import (
"testing"
"text/template"
)
func TestTemplatedHeaderValidation(t *testing.T) {
tests := []struct {
name string
header TemplatedHeader
expectedError string
}{
{
name: "Empty Name",
header: TemplatedHeader{Name: "", Value: "{{.Claims.email}}"},
expectedError: "header name cannot be empty",
},
{
name: "Empty Value",
header: TemplatedHeader{Name: "X-Email", Value: ""},
expectedError: "header value template cannot be empty",
},
{
name: "Not a Template",
header: TemplatedHeader{Name: "X-Email", Value: "static-value"},
expectedError: "header value 'static-value' does not appear to be a valid template (missing {{ }})",
},
{
name: "Lowercase claims",
header: TemplatedHeader{Name: "X-Email", Value: "{{.claims.email}}"},
expectedError: "header template '{{.claims.email}}' appears to use lowercase 'claims' - use '{{.Claims...' instead (case sensitive)",
},
{
name: "Lowercase accessToken",
header: TemplatedHeader{Name: "X-Token", Value: "Bearer {{.accessToken}}"},
expectedError: "header template 'Bearer {{.accessToken}}' appears to use lowercase 'accessToken' - use '{{.AccessToken...' instead (case sensitive)",
},
{
name: "Lowercase idToken",
header: TemplatedHeader{Name: "X-Token", Value: "Bearer {{.idToken}}"},
expectedError: "header template 'Bearer {{.idToken}}' appears to use lowercase 'idToken' - use '{{.IdToken...' instead (case sensitive)",
},
{
name: "Lowercase refreshToken",
header: TemplatedHeader{Name: "X-Refresh", Value: "Bearer {{.refreshToken}}"},
expectedError: "header template 'Bearer {{.refreshToken}}' appears to use lowercase 'refreshToken' - use '{{.RefreshToken...' instead (case sensitive)",
},
{
name: "Valid Template",
header: TemplatedHeader{Name: "X-Email", Value: "{{.Claims.email}}"},
expectedError: "",
},
{
name: "Valid Bearer Token Template",
header: TemplatedHeader{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
expectedError: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
config := &Config{
ProviderURL: "https://provider.com",
CallbackURL: "/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
RateLimit: 10, // Adding minimum required rate limit
Headers: []TemplatedHeader{tc.header},
}
err := config.Validate()
if tc.expectedError == "" {
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
} else {
if err == nil {
t.Errorf("Expected error: %s, got nil", tc.expectedError)
} else if err.Error() != tc.expectedError {
t.Errorf("Expected error: %s, got: %s", tc.expectedError, err.Error())
}
}
})
}
}
func TestTemplateParsingInNew(t *testing.T) {
// Test successful parsing of templates during middleware creation
tests := []struct {
name string
headers []TemplatedHeader
expectedTemplates int
expectError bool
}{
{
name: "Single Valid Template",
headers: []TemplatedHeader{
{Name: "X-Email", Value: "{{.Claims.email}}"},
},
expectedTemplates: 1,
expectError: false,
},
{
name: "Multiple Valid Templates",
headers: []TemplatedHeader{
{Name: "X-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-ID", Value: "{{.Claims.sub}}"},
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
},
expectedTemplates: 3,
expectError: false,
},
{
name: "Invalid Template",
headers: []TemplatedHeader{
{Name: "X-Email", Value: "{{.Claims.email"}, // Missing closing braces
},
expectedTemplates: 0,
expectError: true,
},
{
name: "Mix of Valid and Invalid Templates",
headers: []TemplatedHeader{
{Name: "X-Email", Value: "{{.Claims.email}}"},
{Name: "X-Invalid", Value: "{{if .Claims.admin}}Admin{{end"}, // Invalid template
},
expectedTemplates: 1, // Only the valid template should be parsed
expectError: true, // We expect an error for the invalid template, but we'll handle it
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// For testing template parsing, we'll directly try to parse the templates instead of using New()
// This avoids the provider discovery that would fail in tests
headerTemplates := make(map[string]*template.Template)
// Special handling for the mixed valid/invalid templates case
if tc.name == "Mix of Valid and Invalid Templates" {
// Process templates one at a time so we can still have valid templates
for _, header := range tc.headers {
tmpl, err := template.New(header.Name).Parse(header.Value)
if err != nil {
// We expect an error for the invalid template
if !tc.expectError {
t.Errorf("Unexpected error parsing template %s: %v", header.Name, err)
}
// Skip this template but continue processing others
continue
}
headerTemplates[header.Name] = tmpl
}
} else {
// Normal handling for other test cases
var parseErr error
for _, header := range tc.headers {
tmpl, err := template.New(header.Name).Parse(header.Value)
if err != nil {
parseErr = err
break
}
headerTemplates[header.Name] = tmpl
}
if tc.expectError {
if parseErr == nil {
t.Error("Expected error parsing templates but got nil")
}
return
}
if parseErr != nil {
t.Fatalf("Unexpected error: %v", parseErr)
}
}
// Check the number of parsed templates
if len(headerTemplates) != tc.expectedTemplates {
t.Errorf("Expected %d parsed templates, got %d", tc.expectedTemplates, len(headerTemplates))
}
// Check each template was parsed
for _, header := range tc.headers {
// Skip the known invalid templates
if header.Value == "{{.Claims.email" || header.Value == "{{if .Claims.admin}}Admin{{end" {
continue
}
if _, ok := headerTemplates[header.Name]; !ok {
t.Errorf("Template for header %s was not parsed", header.Name)
}
}
})
}
}
-606
View File
@@ -1,606 +0,0 @@
package traefikoidc
import (
"bytes"
"testing"
"text/template"
)
// TestTemplateExecution tests that templates are executed correctly with different types of claims
func TestTemplateExecution(t *testing.T) {
tests := []struct {
name string
templateText string
data map[string]interface{}
expectedValue string
expectError bool
}{
{
name: "String Claim",
templateText: "{{.Claims.email}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "user@example.com",
},
},
expectedValue: "user@example.com",
expectError: false,
},
{
name: "Number Claim",
templateText: "{{.Claims.age}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"age": 30,
},
},
expectedValue: "30",
expectError: false,
},
{
name: "Boolean Claim",
templateText: "{{.Claims.admin}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"admin": true,
},
},
expectedValue: "true",
expectError: false,
},
{
name: "Array Claim",
templateText: "{{index .Claims.roles 0}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"roles": []string{"admin", "user"},
},
},
expectedValue: "admin",
expectError: false,
},
{
name: "Nested Object Claim",
templateText: "{{.Claims.user.name}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"user": map[string]interface{}{
"name": "John Doe",
},
},
},
expectedValue: "John Doe",
expectError: false,
},
{
name: "Access Token",
templateText: "Bearer {{.AccessToken}}",
data: map[string]interface{}{
"AccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
},
expectedValue: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
expectError: false,
},
{
name: "ID Token",
templateText: "{{.IDToken}}",
data: map[string]interface{}{
"IDToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
},
expectedValue: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
expectError: false,
},
{
name: "Refresh Token",
templateText: "{{.RefreshToken}}",
data: map[string]interface{}{
"RefreshToken": "refresh-token-value",
},
expectedValue: "refresh-token-value",
expectError: false,
},
{
name: "Conditional Template",
templateText: "{{if .Claims.admin}}Admin User{{else}}Regular User{{end}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"admin": true,
},
},
expectedValue: "Admin User",
expectError: false,
},
{
name: "Multiple Claims",
templateText: "{{.Claims.firstName}} {{.Claims.lastName}} <{{.Claims.email}}>",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com",
},
},
expectedValue: "John Doe <john.doe@example.com>",
expectError: false,
},
{
name: "Missing Claim",
templateText: "{{.Claims.missing}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{},
},
expectedValue: "<no value>",
expectError: false, // Go templates don't error on missing values
},
{
name: "Invalid Template Syntax",
templateText: "{{.Claims.email",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "user@example.com",
},
},
expectedValue: "",
expectError: true, // Parsing should fail
},
{
name: "Custom Claims",
templateText: "Role: {{.Claims.role}}, Department: {{.Claims.department}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "user@example.com",
"role": "admin",
"department": "engineering",
},
},
expectedValue: "Role: admin, Department: engineering",
expectError: false,
},
{
name: "Nested Custom Claims",
templateText: "Org: {{.Claims.metadata.organization}}, Team: {{.Claims.metadata.team}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "user@example.com",
"metadata": map[string]interface{}{
"organization": "company-name",
"team": "platform",
},
},
},
expectedValue: "Org: company-name, Team: platform",
expectError: false,
},
{
name: "Email Claims",
templateText: "Email: {{.Claims.email}}, Verified: {{.Claims.email_verified}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "user@example.com",
"email_verified": true,
},
},
expectedValue: "Email: user@example.com, Verified: true",
expectError: false,
},
{
name: "User Identity Claims",
templateText: "Name: {{.Claims.name}}, Subject: {{.Claims.sub}}, Username: {{.Claims.preferred_username}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"name": "John Doe",
"sub": "user123",
"preferred_username": "johndoe",
},
},
expectedValue: "Name: John Doe, Subject: user123, Username: johndoe",
expectError: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
if tc.expectError {
if err == nil {
t.Fatal("Expected template parsing error, but got nil")
}
return
}
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, tc.data)
if err != nil {
t.Fatalf("Failed to execute template: %v", err)
}
result := buf.String()
if result != tc.expectedValue {
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
}
})
}
}
// TestTemplateExecutionContext tests the specific template data context used in processAuthorizedRequest
func TestTemplateExecutionContext(t *testing.T) {
// Test cases for map-based template data, matching the new implementation
mapTests := []struct {
name string
templateText string
data map[string]interface{}
expectedValue string
}{
{
name: "Access and ID token distinction with map",
templateText: "Access: {{.AccessToken}} ID: {{.IDToken}}",
data: map[string]interface{}{
"AccessToken": "access-token-value",
"IDToken": "id-token-value",
"Claims": map[string]interface{}{},
"RefreshToken": "refresh-token-value",
},
expectedValue: "Access: access-token-value ID: id-token-value",
},
{
name: "Combining tokens and claims with map",
templateText: "User: {{.Claims.sub}} Token: {{.AccessToken}}",
data: map[string]interface{}{
"AccessToken": "access-token",
"IDToken": "id-token",
"Claims": map[string]interface{}{
"sub": "user123",
},
"RefreshToken": "refresh-token",
},
expectedValue: "User: user123 Token: access-token",
},
{
name: "Authorization header with Bearer token",
templateText: "Bearer {{.AccessToken}}",
data: map[string]interface{}{
"AccessToken": "jwt-access-token",
"IDToken": "id-token",
"Claims": map[string]interface{}{},
},
expectedValue: "Bearer jwt-access-token",
},
{
name: "Boolean template data with AccessToken",
templateText: "Bearer {{.AccessToken}}",
data: map[string]interface{}{
"AccessToken": true, // Test boolean values to ensure they render correctly
},
expectedValue: "Bearer true",
},
{
name: "Custom non-standard claims in ID token",
templateText: "X-User-Role: {{.Claims.role}}, X-User-Permissions: {{.Claims.permissions}}",
data: map[string]interface{}{
"AccessToken": "access-token-value",
"IDToken": "id-token-value",
"Claims": map[string]interface{}{
"email": "user@example.com",
"role": "admin",
"permissions": "read:all,write:own",
},
},
expectedValue: "X-User-Role: admin, X-User-Permissions: read:all,write:own",
},
{
name: "Deeply nested custom claims",
templateText: "X-Organization: {{.Claims.app_metadata.organization.name}}, X-Team: {{.Claims.app_metadata.team}}",
data: map[string]interface{}{
"AccessToken": "access-token-value",
"Claims": map[string]interface{}{
"app_metadata": map[string]interface{}{
"organization": map[string]interface{}{
"name": "acme-corp",
"id": "org-123",
},
"team": "platform",
},
},
},
expectedValue: "X-Organization: acme-corp, X-Team: platform",
},
{
name: "Email in claims",
templateText: "X-User-Email: {{.Claims.email}}, X-Email-Verified: {{.Claims.email_verified}}",
data: map[string]interface{}{
"AccessToken": "access-token-value",
"IDToken": "id-token-value",
"Claims": map[string]interface{}{
"email": "user@example.com",
"email_verified": true,
},
},
expectedValue: "X-User-Email: user@example.com, X-Email-Verified: true",
},
{
name: "User info from claims",
templateText: "X-User-ID: {{.Claims.sub}}, X-User-Name: {{.Claims.name}}, X-Username: {{.Claims.preferred_username}}",
data: map[string]interface{}{
"AccessToken": "access-token-value",
"IDToken": "id-token-value",
"Claims": map[string]interface{}{
"sub": "user123456",
"name": "Jane Doe",
"preferred_username": "jane.doe",
},
},
expectedValue: "X-User-ID: user123456, X-User-Name: Jane Doe, X-Username: jane.doe",
},
}
// Run map-based tests (matching the new implementation)
for _, tc := range mapTests {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, tc.data)
if err != nil {
t.Fatalf("Failed to execute template: %v", err)
}
result := buf.String()
if result != tc.expectedValue {
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
}
})
}
// For backward compatibility, also test the original struct-based implementation
type templateData struct {
Claims map[string]interface{}
AccessToken string
IDToken string
RefreshToken string
}
// Test cases for struct-based template data (original implementation)
structTests := []struct {
name string
templateText string
data templateData
expectedValue string
}{
{
name: "Access and ID token distinction with struct",
templateText: "Access: {{.AccessToken}} ID: {{.IDToken}}",
data: templateData{
AccessToken: "access-token-value",
IDToken: "id-token-value", // Now these should be distinct values
Claims: map[string]interface{}{},
},
expectedValue: "Access: access-token-value ID: id-token-value",
},
{
name: "Combining tokens and claims with struct",
templateText: "User: {{.Claims.sub}} Token: {{.AccessToken}}",
data: templateData{
AccessToken: "access-token",
IDToken: "access-token",
Claims: map[string]interface{}{
"sub": "user123",
},
},
expectedValue: "User: user123 Token: access-token",
},
{
name: "Custom claims with struct",
templateText: "X-Custom: {{.Claims.custom_field}}, X-Group: {{.Claims.group}}",
data: templateData{
AccessToken: "access-token",
IDToken: "id-token",
Claims: map[string]interface{}{
"sub": "user123",
"custom_field": "custom-value",
"group": "admins",
},
},
expectedValue: "X-Custom: custom-value, X-Group: admins",
},
{
name: "Email claim in struct context",
templateText: "X-Email: {{.Claims.email}}, X-Name: {{.Claims.name}}",
data: templateData{
AccessToken: "access-token",
IDToken: "id-token",
Claims: map[string]interface{}{
"email": "user@example.com",
"name": "John Smith",
},
},
expectedValue: "X-Email: user@example.com, X-Name: John Smith",
},
}
for _, tc := range structTests {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, tc.data)
if err != nil {
t.Fatalf("Failed to execute template: %v", err)
}
result := buf.String()
if result != tc.expectedValue {
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
}
})
}
}
// TestRegressionBooleanAccessToken specifically tests the regression case where
// a boolean value was causing "can't evaluate field AccessToken in type bool" error
func TestRegressionBooleanAccessToken(t *testing.T) {
// Test the specific case where we execute a template referencing AccessToken
// using a boolean context value
testCases := []struct {
name string
templateText string
dataContext interface{}
expectedValue string
expectError bool // Added to skip the test that demonstrates the error
}{
{
name: "Map with boolean as root",
templateText: "{{.AccessToken}}",
dataContext: map[string]interface{}{"AccessToken": "token-value"},
expectedValue: "token-value",
expectError: false,
},
{
name: "Boolean as root context",
templateText: "{{.AccessToken}}",
dataContext: true,
expectedValue: "<no value>",
expectError: true, // Skip this test as it demonstrates the error we're fixing
},
{
name: "Bearer with map context",
templateText: "Bearer {{.AccessToken}}",
dataContext: map[string]interface{}{"AccessToken": "token-value"},
expectedValue: "Bearer token-value",
expectError: false,
},
{
name: "Complex nesting with authorization",
templateText: "Authorization: Bearer {{.AccessToken}}",
dataContext: map[string]interface{}{
"AccessToken": "jwt-token-123",
"something": true,
"anotherField": map[string]interface{}{
"nested": "value",
},
},
expectedValue: "Authorization: Bearer jwt-token-123",
expectError: false,
},
{
name: "Custom claims access",
templateText: "X-User-Role: {{.Claims.role}}, X-User-Groups: {{.Claims.groups}}",
dataContext: map[string]interface{}{
"AccessToken": "jwt-token-xyz",
"Claims": map[string]interface{}{
"email": "user@example.com",
"role": "admin",
"groups": "group1,group2,group3",
"custom_data": map[string]interface{}{
"organization": "company-name",
"department": "engineering",
},
},
},
expectedValue: "X-User-Role: admin, X-User-Groups: group1,group2,group3",
expectError: false,
},
{
name: "Nested custom claims access",
templateText: "X-Organization: {{.Claims.custom_data.organization}}, X-Department: {{.Claims.custom_data.department}}",
dataContext: map[string]interface{}{
"Claims": map[string]interface{}{
"custom_data": map[string]interface{}{
"organization": "company-name",
"department": "engineering",
},
},
},
expectedValue: "X-Organization: company-name, X-Department: engineering",
expectError: false,
},
{
name: "Azure AD specific claims",
templateText: "X-TenantID: {{.Claims.tid}}, X-Roles: {{.Claims.roles}}",
dataContext: map[string]interface{}{
"Claims": map[string]interface{}{
"tid": "tenant-id-12345",
"roles": "User,Admin,Developer",
},
},
expectedValue: "X-TenantID: tenant-id-12345, X-Roles: User,Admin,Developer",
expectError: false,
},
{
name: "Auth0 specific claims",
templateText: "X-Permissions: {{.Claims.permissions}}, X-AppMetadata: {{.Claims.app_metadata.plan}}",
dataContext: map[string]interface{}{
"Claims": map[string]interface{}{
"permissions": "read:products,write:orders",
"app_metadata": map[string]interface{}{
"plan": "premium",
"status": "active",
"trial_ended": false,
},
},
},
expectedValue: "X-Permissions: read:products,write:orders, X-AppMetadata: premium",
expectError: false,
},
{
name: "Standard claims with email",
templateText: "X-Email: {{.Claims.email}}, X-Name: {{.Claims.name}}, X-Subject: {{.Claims.sub}}",
dataContext: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "user@example.com",
"name": "John Doe",
"sub": "auth0|12345",
},
},
expectedValue: "X-Email: user@example.com, X-Name: John Doe, X-Subject: auth0|12345",
expectError: false,
},
{
name: "Verified email claim",
templateText: "X-Email: {{.Claims.email}}, X-Email-Verified: {{.Claims.email_verified}}",
dataContext: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "user@example.com",
"email_verified": true,
},
},
expectedValue: "X-Email: user@example.com, X-Email-Verified: true",
expectError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
// Skip tests that demonstrate the error
if tc.expectError {
t.Skip("Skipping test that demonstrates the error we're fixing")
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, tc.dataContext)
if err != nil {
t.Fatalf("Failed to execute template: %v", err)
}
result := buf.String()
if result != tc.expectedValue {
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
}
})
}
}
-600
View File
@@ -1,600 +0,0 @@
package traefikoidc
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"text/template"
"time"
"golang.org/x/time/rate"
)
// TestTemplatedHeadersIntegration tests that templated headers are correctly added to requests
// in the actual middleware flow
func TestTemplatedHeadersIntegration(t *testing.T) {
// Create a TestSuite to use its helper methods and fields
ts := &TestSuite{t: t}
ts.Setup()
tests := []struct {
sessionSetup func(*SessionData)
claims map[string]interface{}
expectedHeaders map[string]string
interceptedHeaders map[string]string
name string
headers []TemplatedHeader
}{
{
name: "Basic Email Header",
headers: []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
},
claims: map[string]interface{}{
"email": "user@example.com",
},
expectedHeaders: map[string]string{
"X-User-Email": "user@example.com",
},
},
{
name: "Multiple Headers",
headers: []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-ID", Value: "{{.Claims.sub}}"},
{Name: "X-User-Name", Value: "{{.Claims.given_name}} {{.Claims.family_name}}"},
},
claims: map[string]interface{}{
"email": "user@example.com",
"sub": "user123",
"given_name": "John",
"family_name": "Doe",
},
expectedHeaders: map[string]string{
"X-User-Email": "user@example.com",
"X-User-ID": "user123",
"X-User-Name": "John Doe",
},
},
{
name: "Authorization Header with Bearer Token",
headers: []TemplatedHeader{
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
},
expectedHeaders: map[string]string{
// We'll update this dynamically after generating the token
"Authorization": "",
},
},
{
name: "ID Token Header",
headers: []TemplatedHeader{
{Name: "X-ID-Token", Value: "{{.IDToken}}"},
},
expectedHeaders: map[string]string{
// We'll update this dynamically after generating the token
"X-ID-Token": "",
},
},
{
name: "Both Token Types",
headers: []TemplatedHeader{
{Name: "X-Access-Token", Value: "{{.AccessToken}}"},
{Name: "X-ID-Token", Value: "{{.IDToken}}"},
},
expectedHeaders: map[string]string{
// We'll update these dynamically after generating the tokens
"X-Access-Token": "",
"X-ID-Token": "",
},
},
{
name: "Missing Claim",
headers: []TemplatedHeader{
{Name: "X-User-Role", Value: "{{.Claims.role}}"},
},
claims: map[string]interface{}{
"email": "user@example.com",
// role claim is missing
},
expectedHeaders: map[string]string{
"X-User-Role": "<no value>", // Go templates provide <no value> for missing fields
},
},
{
name: "Conditional Header",
headers: []TemplatedHeader{
{Name: "X-User-Admin", Value: "{{if .Claims.is_admin}}true{{else}}false{{end}}"},
},
claims: map[string]interface{}{
"email": "admin@example.com",
"is_admin": true,
},
expectedHeaders: map[string]string{
"X-User-Admin": "true",
},
},
{
name: "Combined Token and Claim",
headers: []TemplatedHeader{
{Name: "X-Auth-Info", Value: "User={{.Claims.email}}, Token={{.AccessToken}}"},
},
claims: map[string]interface{}{
"email": "user@example.com",
},
expectedHeaders: map[string]string{
// We'll update this dynamically after generating the token
"X-Auth-Info": "",
},
},
{
name: "Opaque Access Token with AccessTokenField",
headers: []TemplatedHeader{
{Name: "X-User-AccessToken", Value: "{{.AccessToken}}"},
},
claims: map[string]interface{}{ // For ID Token
"email": "opaque_user@example.com",
"sub": "opaque_sub_for_id_token",
},
expectedHeaders: map[string]string{
"X-User-AccessToken": "this_is_an_opaque_access_token",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Create token with the test claims
token := ts.token
if len(tc.claims) > 0 {
var err error
baseClaims := map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(3000000000), // Far future timestamp
"iat": float64(1000000000),
"nbf": float64(1000000000),
"sub": "test-subject",
"nonce": "test-nonce",
"jti": generateRandomString(16),
}
// Add the test-specific claims
for k, v := range tc.claims {
baseClaims[k] = v
}
token, err = createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", baseClaims)
if err != nil {
t.Fatalf("Failed to create test JWT: %v", err)
}
}
// Update expectedHeaders for the token-based tests after token generation
if tc.name == "Authorization Header with Bearer Token" {
tc.expectedHeaders["Authorization"] = "Bearer " + token
}
if tc.name == "Combined Token and Claim" {
// If this test case uses specific ID/Access tokens, 'token' here might be just the ID token.
// This part might need adjustment if AccessToken is different and opaque.
// For now, assuming 'token' is the one to be used if not overridden later.
// The specific test "Opaque Access Token with AccessTokenField" will handle its AccessToken.
// This generic 'token' is used as a fallback if specific logic isn't hit.
// Let's ensure this test case uses the JWT access token if not otherwise specified.
accessTokenForHeader := token // Default to the generated JWT 'token'
if sessionVal, ok := tc.claims["_accessToken"]; ok { // Check if a specific access token is provided for this test
accessTokenForHeader = sessionVal.(string)
}
tc.expectedHeaders["X-Auth-Info"] = "User=" + tc.claims["email"].(string) + ", Token=" + accessTokenForHeader
}
// Store intercepted headers for verification
interceptedHeaders := make(map[string]string)
// Create a test next handler that captures the headers
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture headers for verification
for name := range tc.expectedHeaders {
if value := r.Header.Get(name); value != "" {
interceptedHeaders[name] = value
}
}
w.WriteHeader(http.StatusOK)
})
tOidc := &TraefikOidc{
next: nextHandler,
name: "test",
redirURLPath: "/callback",
logoutURLPath: "/callback/logout",
issuerURL: "https://test-issuer.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
jwkCache: ts.mockJWKCache,
jwksURL: "https://test-jwks-url.com",
tokenBlacklist: NewCache(),
tokenCache: NewTokenCache(),
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
logger: NewLogger("debug"),
allowedUserDomains: map[string]struct{}{"example.com": {}, "opaque_user@example.com": {}}, // Ensure domain for opaque test is allowed
excludedURLs: map[string]struct{}{"/favicon": {}},
httpClient: &http.Client{},
initComplete: make(chan struct{}),
sessionManager: ts.sessionManager,
extractClaimsFunc: extractClaims,
headerTemplates: make(map[string]*template.Template),
// Default to true, which means PopulateSessionWithIdTokenClaims is true
// UseIdTokenForSession: true, // Explicitly can be set if needed
}
tOidc.tokenVerifier = tOidc
tOidc.jwtVerifier = tOidc
tOidc.tokenExchanger = tOidc
// Initialize and parse header templates
for _, header := range tc.headers {
tmpl, err := template.New(header.Name).Parse(header.Value)
if err != nil {
t.Fatalf("Failed to parse header template for %s: %v", header.Name, err)
}
tOidc.headerTemplates[header.Name] = tmpl
}
close(tOidc.initComplete)
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "example.com")
rr := httptest.NewRecorder()
session, err := tOidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
session.SetAuthenticated(true)
// Set a default email; specific tests might override or rely on ID token population
defaultEmail := "user@example.com"
if emailClaim, ok := tc.claims["email"].(string); ok {
defaultEmail = emailClaim // Use email from claims if available for initial setup
}
session.SetEmail(defaultEmail)
// Default token setup (can be overridden by specific test cases below)
session.SetIDToken(token)
session.SetAccessToken(token)
session.SetRefreshToken("test-refresh-token")
if tc.name == "ID Token Header" || tc.name == "Both Token Types" {
idTokenClaims := map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000),
"iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject",
"nonce": "test-nonce", "jti": generateRandomString(16), "type": "id_token",
"email": tc.claims["email"], // Ensure email from test case claims is in ID token
}
// Add other claims from tc.claims to idTokenClaims
for k, v := range tc.claims {
if _, exists := idTokenClaims[k]; !exists {
idTokenClaims[k] = v
}
}
idTokenForSession, idErr := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", idTokenClaims)
if idErr != nil {
t.Fatalf("Failed to create test ID JWT: %v", idErr)
}
accessTokenClaims := map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000),
"iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject",
"jti": generateRandomString(16), "type": "access_token", "scope": "openid email profile",
"email": tc.claims["email"], // Include email in access token too for these tests
}
accessTokenForSession, accessErr := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", accessTokenClaims)
if accessErr != nil {
t.Fatalf("Failed to create test access JWT: %v", accessErr)
}
session.SetIDToken(idTokenForSession)
session.SetAccessToken(accessTokenForSession)
tOidc.tokenExchanger = &MockTokenExchanger{
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
return &TokenResponse{
IDToken: idTokenForSession, AccessToken: accessTokenForSession,
RefreshToken: refreshToken, ExpiresIn: 3600,
}, nil
},
}
tOidc.tokenVerifier = &MockTokenVerifier{VerifyFunc: func(token string) error { return nil }}
if tc.name == "ID Token Header" {
tc.expectedHeaders["X-ID-Token"] = idTokenForSession
} else if tc.name == "Both Token Types" {
tc.expectedHeaders["X-ID-Token"] = idTokenForSession
tc.expectedHeaders["X-Access-Token"] = accessTokenForSession
}
} else if tc.name == "Opaque Access Token with AccessTokenField" {
idTokenClaims := map[string]interface{}{
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000),
"iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject", // Default sub
"nonce": "test-nonce", "jti": generateRandomString(16), "type": "id_token",
}
// Populate ID token claims from tc.claims
for k, v := range tc.claims {
idTokenClaims[k] = v
}
// Ensure email from tc.claims is used for the ID token
session.SetEmail(tc.claims["email"].(string)) // Also set it directly for initial session state
idTokenForSession, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", idTokenClaims)
if err != nil {
t.Fatalf("Failed to create test ID JWT for opaque test: %v", err)
}
opaqueAccessToken := "this_is_an_opaque_access_token"
session.SetIDToken(idTokenForSession)
session.SetAccessToken(opaqueAccessToken)
tOidc.tokenExchanger = &MockTokenExchanger{
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
return &TokenResponse{
IDToken: idTokenForSession,
AccessToken: opaqueAccessToken,
RefreshToken: refreshToken,
ExpiresIn: 3600,
}, nil
},
}
tOidc.tokenVerifier = &MockTokenVerifier{
VerifyFunc: func(tokenToVerify string) error {
if tokenToVerify == idTokenForSession {
return nil // ID token is expected to be verified
}
if tokenToVerify == opaqueAccessToken {
t.Errorf("TokenVerifier was incorrectly called with the opaque access token.")
return errors.New("opaque access token should not be verified by this path")
}
t.Logf("TokenVerifier called with unexpected token: %s", tokenToVerify)
return errors.New("unexpected token passed to verifier for this test case")
},
}
// Expected header X-User-AccessToken is already set in tc.expectedHeaders
}
if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
for _, cookie := range rr.Result().Cookies() {
req.AddCookie(cookie)
}
rr = httptest.NewRecorder()
tOidc.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected status code %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String())
}
for name, expectedValue := range tc.expectedHeaders {
if value, exists := interceptedHeaders[name]; !exists {
// For <no value> case, it might not be set if template resolves to empty and header is omitted.
// However, Go templates usually insert "<no value>" string.
if expectedValue == "<no value>" && tc.name == "Missing Claim" { // Special handling for <no value>
// If the template {{.Claims.role}} results in an empty string because role is missing,
// and the header is not set, this is also acceptable for "<no value>".
// The current test expects the literal string "<no value>".
// Let's assume for now that if it's missing, it's an error unless specifically handled.
// The test as written expects "<no value>" to be present.
t.Logf("Header %s not set, but expected '<no value>' for missing claim", name)
}
t.Errorf("Expected header %s was not set", name)
} else if value != expectedValue {
t.Errorf("Header %s expected value %q, got %q", name, expectedValue, value)
}
}
if tc.name == "Opaque Access Token with AccessTokenField" {
postReq := httptest.NewRequest("GET", "/protected", nil)
for _, cookie := range rr.Result().Cookies() {
postReq.AddCookie(cookie)
}
updatedSession, err := tOidc.sessionManager.GetSession(postReq)
if err != nil {
t.Fatalf("Failed to get updated session for opaque test: %v", err)
}
expectedEmail := tc.claims["email"].(string)
if updatedSession.GetEmail() != expectedEmail {
t.Errorf("Expected session email to be %q (from ID token), got %q", expectedEmail, updatedSession.GetEmail())
}
if !updatedSession.GetAuthenticated() {
t.Errorf("Session should be authenticated after successful flow for opaque test")
}
}
})
}
}
// TestEdgeCaseTemplatedHeaders tests edge cases for templated headers
func TestEdgeCaseTemplatedHeaders(t *testing.T) {
// Create a TestSuite to use its helper methods and fields
ts := &TestSuite{t: t}
ts.Setup()
tests := []struct {
claims map[string]interface{}
name string
headers []TemplatedHeader
shouldExecuteCheck bool
}{
{
name: "Very Large Template",
headers: []TemplatedHeader{
{
Name: "X-Large-Header",
Value: createLargeTemplate(500), // Template with 500 variable references
},
},
claims: createLargeClaims(500), // Map with 500 claims
shouldExecuteCheck: true,
},
{
name: "Array Claim Access",
headers: []TemplatedHeader{
{Name: "X-Roles", Value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"},
},
claims: map[string]interface{}{
"roles": []interface{}{"admin", "user", "manager"},
},
shouldExecuteCheck: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Create token with the test claims
claims := map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(3000000000), // Far future timestamp
"iat": float64(1000000000),
"nbf": float64(1000000000),
"sub": "test-subject",
"nonce": "test-nonce",
"jti": generateRandomString(16),
}
// Add the test-specific claims
for k, v := range tc.claims {
claims[k] = v
}
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims)
if err != nil {
t.Fatalf("Failed to create test JWT: %v", err)
}
// Create a test next handler
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
tOidc := &TraefikOidc{
next: nextHandler,
name: "test",
redirURLPath: "/callback",
logoutURLPath: "/callback/logout",
issuerURL: "https://test-issuer.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
jwkCache: ts.mockJWKCache,
jwksURL: "https://test-jwks-url.com",
tokenBlacklist: NewCache(),
tokenCache: NewTokenCache(),
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
logger: NewLogger("debug"),
allowedUserDomains: map[string]struct{}{"example.com": {}},
excludedURLs: map[string]struct{}{"/favicon": {}},
httpClient: &http.Client{},
initComplete: make(chan struct{}),
sessionManager: ts.sessionManager,
extractClaimsFunc: extractClaims,
headerTemplates: make(map[string]*template.Template),
}
tOidc.tokenVerifier = tOidc
tOidc.jwtVerifier = tOidc
// Initialize and parse header templates
for _, header := range tc.headers {
tmpl, err := template.New(header.Name).Parse(header.Value)
if err != nil {
t.Fatalf("Failed to parse header template for %s: %v", header.Name, err)
}
tOidc.headerTemplates[header.Name] = tmpl
}
close(tOidc.initComplete)
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "example.com")
rr := httptest.NewRecorder()
session, err := tOidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
session.SetAuthenticated(true)
session.SetEmail("user@example.com")
session.SetIDToken(token) // Use the new method
session.SetAccessToken(token) // Also set access token to match
session.SetRefreshToken("test-refresh-token")
tOidc.extractClaimsFunc = extractClaims
tOidc.tokenExchanger = &MockTokenExchanger{
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
return &TokenResponse{
IDToken: token,
AccessToken: token,
RefreshToken: refreshToken,
ExpiresIn: 3600,
}, nil
},
}
if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
for _, cookie := range rr.Result().Cookies() {
req.AddCookie(cookie)
}
rr = httptest.NewRecorder()
tOidc.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected status code %d, got %d", http.StatusOK, rr.Code)
}
// The "Array Claim Access" check previously here was problematic as it didn't correctly
// intercept headers in TestEdgeCaseTemplatedHeaders. The primary goal of this
// function is to test edge cases for panics/errors, and robust header value
// checking is already covered in TestTemplatedHeadersIntegration.
// Removing the ineffective check to resolve the "declared and not used" error.
})
}
}
// Helper functions for edge case tests
// createLargeTemplate creates a template with many variable references
func createLargeTemplate(size int) string {
template := "{{with .Claims}}"
for i := 0; i < size; i++ {
if i > 0 {
template += ","
}
template += "{{.field" + string(rune('a'+i%26)) + string(rune('0'+i%10)) + "}}"
}
template += "{{end}}"
return template
}
// createLargeClaims creates a map with many claims for testing large templates
func createLargeClaims(size int) map[string]interface{} {
claims := make(map[string]interface{})
for i := 0; i < size; i++ {
claims["email"] = "largeclaimsuser@example.com" // Add email claim
key := "field" + string(rune('a'+i%26)) + string(rune('0'+i%10))
claims[key] = "value" + string(rune('a'+i%26)) + string(rune('0'+i%10))
}
return claims
}
-467
View File
@@ -1,467 +0,0 @@
package traefikoidc
import (
"bytes"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"text/template"
)
// TestIssue55TemplateExecutionWithWrongTypes tests what happens when templates
// receive wrong data types during execution - this reproduces the exact error
// from GitHub issue #55: "can't evaluate field AccessToken in type bool"
func TestIssue55TemplateExecutionWithWrongTypes(t *testing.T) {
testCases := []struct {
name string
templateText string
templateData interface{}
expectError bool
errorContains string
}{
{
name: "correct map data",
templateText: "Bearer {{.AccessToken}}",
templateData: map[string]interface{}{
"AccessToken": "valid-token",
},
expectError: false,
},
{
name: "boolean as root context - reproduces issue #55",
templateText: "Bearer {{.AccessToken}}",
templateData: true,
expectError: true,
errorContains: "can't evaluate field AccessToken in type bool",
},
{
name: "string as root context",
templateText: "Bearer {{.AccessToken}}",
templateData: "just a string",
expectError: true,
errorContains: "can't evaluate field AccessToken in type string",
},
{
name: "nil as root context",
templateText: "Bearer {{.AccessToken}}",
templateData: nil,
expectError: false, // nil renders as <no value>
errorContains: "",
},
{
name: "map with wrong field type",
templateText: "Bearer {{.AccessToken}}",
templateData: map[string]interface{}{
"AccessToken": true, // boolean instead of string
},
expectError: false, // This should work, template will convert bool to string
},
{
name: "nested claims access with correct data",
templateText: "User: {{.Claims.email}}",
templateData: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "user@example.com",
},
},
expectError: false,
},
{
name: "nested claims with wrong structure",
templateText: "User: {{.Claims.email}}",
templateData: map[string]interface{}{
"Claims": "not a map", // string instead of map
},
expectError: true,
errorContains: "can't evaluate field email in type", // interface{} or string
},
{
name: "array as root context",
templateText: "Bearer {{.AccessToken}}",
templateData: []string{"item1", "item2"},
expectError: true,
errorContains: "can't evaluate field AccessToken in type []string",
},
{
name: "integer as root context",
templateText: "Bearer {{.AccessToken}}",
templateData: 42,
expectError: true,
errorContains: "can't evaluate field AccessToken in type int",
},
{
name: "empty template data map",
templateText: "Bearer {{.AccessToken}}",
templateData: map[string]interface{}{},
expectError: false, // Should render as "Bearer <no value>"
},
{
name: "complex nested structure",
templateText: "{{.Claims.sub}} - {{.Claims.groups}} - {{.AccessToken}}",
templateData: map[string]interface{}{
"AccessToken": "token123",
"Claims": map[string]interface{}{
"sub": "user-id",
"groups": "admin,users",
},
},
expectError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, tc.templateData)
if tc.expectError {
if err == nil {
t.Fatalf("Expected error but got none, output: %q", buf.String())
}
if tc.errorContains != "" && !strings.Contains(err.Error(), tc.errorContains) {
t.Errorf("Expected error to contain %q, got %q", tc.errorContains, err.Error())
}
} else {
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}
})
}
}
// TestIssue55TemplateParsingValidation ensures templates are parsed correctly
// and validates the template data structure used in the middleware
func TestIssue55TemplateParsingValidation(t *testing.T) {
testCases := []struct {
name string
headerTemplates []TemplatedHeader
shouldError bool
}{
{
name: "valid bearer token template",
headerTemplates: []TemplatedHeader{
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
},
shouldError: false,
},
{
name: "multiple valid templates",
headerTemplates: []TemplatedHeader{
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-ID", Value: "{{.Claims.sub}}"},
},
shouldError: false,
},
{
name: "template with conditional logic",
headerTemplates: []TemplatedHeader{
{Name: "X-Auth-Info", Value: "{{if .AccessToken}}Bearer {{.AccessToken}}{{else}}No Token{{end}}"},
},
shouldError: false,
},
{
name: "invalid template syntax",
headerTemplates: []TemplatedHeader{
{Name: "Bad-Template", Value: "{{.AccessToken"},
},
shouldError: true,
},
{
name: "empty template value",
headerTemplates: []TemplatedHeader{
{Name: "Empty-Header", Value: ""},
},
shouldError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
for _, header := range tc.headerTemplates {
tmpl, err := template.New(header.Name).Parse(header.Value)
if tc.shouldError {
if err == nil {
t.Errorf("Expected template parsing to fail for %s", header.Name)
}
} else {
if err != nil {
t.Errorf("Failed to parse template for header %s: %v", header.Name, err)
continue
}
// Test execution with correct data structure
templateData := map[string]interface{}{
"AccessToken": "test-access-token",
"IDToken": "test-id-token",
"RefreshToken": "test-refresh-token",
"Claims": map[string]interface{}{
"email": "test@example.com",
"sub": "user123",
},
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, templateData)
if err != nil {
t.Errorf("Failed to execute valid template: %v", err)
}
}
}
})
}
}
// TestIssue55MiddlewareHeaderTemplating simulates the actual middleware flow
// to ensure templated headers work correctly in request processing
func TestIssue55MiddlewareHeaderTemplating(t *testing.T) {
// Test cases that simulate real-world usage
testCases := []struct {
name string
headers []TemplatedHeader
accessToken string
idToken string
claims map[string]interface{}
expectedValues map[string]string
}{
{
name: "authorization header with access token",
headers: []TemplatedHeader{
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
},
accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
expectedValues: map[string]string{
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
},
},
{
name: "multiple headers with claims",
headers: []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-Groups", Value: "{{.Claims.groups}}"},
{Name: "X-Auth-Token", Value: "{{.AccessToken}}"},
},
accessToken: "token123",
claims: map[string]interface{}{
"email": "user@example.com",
"groups": "admin,developers",
},
expectedValues: map[string]string{
"X-User-Email": "user@example.com",
"X-User-Groups": "admin,developers",
"X-Auth-Token": "token123",
},
},
{
name: "complex template expressions",
headers: []TemplatedHeader{
{Name: "X-User-Info", Value: "{{.Claims.sub}} ({{.Claims.email}})"},
{Name: "X-Auth-Header", Value: "Bearer {{.AccessToken}} | ID: {{.IDToken}}"},
},
accessToken: "access-token",
idToken: "id-token",
claims: map[string]interface{}{
"sub": "user-12345",
"email": "john@example.com",
},
expectedValues: map[string]string{
"X-User-Info": "user-12345 (john@example.com)",
"X-Auth-Header": "Bearer access-token | ID: id-token",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Parse all templates
headerTemplates := make(map[string]*template.Template)
for _, header := range tc.headers {
tmpl, err := template.New(header.Name).Parse(header.Value)
if err != nil {
t.Fatalf("Failed to parse template for %s: %v", header.Name, err)
}
headerTemplates[header.Name] = tmpl
}
// Create template data (simulating what the middleware does)
templateData := map[string]interface{}{
"AccessToken": tc.accessToken,
"IDToken": tc.idToken,
"RefreshToken": "refresh-token", // Default value
"Claims": tc.claims,
}
// Create a test request
req := httptest.NewRequest("GET", "/test", nil)
// Execute templates and set headers
for headerName, tmpl := range headerTemplates {
var buf bytes.Buffer
err := tmpl.Execute(&buf, templateData)
if err != nil {
t.Fatalf("Failed to execute template for %s: %v", headerName, err)
}
req.Header.Set(headerName, buf.String())
}
// Verify all expected headers are set correctly
for headerName, expectedValue := range tc.expectedValues {
actualValue := req.Header.Get(headerName)
if actualValue != expectedValue {
t.Errorf("Header %s: expected %q, got %q", headerName, expectedValue, actualValue)
}
}
})
}
}
// TestIssue55JSONConfigParsing tests that JSON configuration with wrong types
// is properly rejected to prevent the boolean type error
func TestIssue55JSONConfigParsing(t *testing.T) {
testCases := []struct {
name string
jsonConfig string
expectedError bool
description string
}{
{
name: "valid JSON configuration",
jsonConfig: `{
"headers": [
{
"name": "Authorization",
"value": "Bearer {{.AccessToken}}"
}
]
}`,
expectedError: false,
description: "Properly formatted JSON with string values",
},
{
name: "JSON with boolean value",
jsonConfig: `{
"headers": [
{
"name": "Authorization",
"value": true
}
]
}`,
expectedError: true,
description: "Boolean value instead of string template",
},
{
name: "JSON with number value",
jsonConfig: `{
"headers": [
{
"name": "Authorization",
"value": 123
}
]
}`,
expectedError: true,
description: "Number value instead of string template",
},
{
name: "JSON with null value",
jsonConfig: `{
"headers": [
{
"name": "Authorization",
"value": null
}
]
}`,
expectedError: false, // JSON unmarshaling null to string results in empty string
description: "Null value instead of string template",
},
{
name: "JSON with array value",
jsonConfig: `{
"headers": [
{
"name": "Authorization",
"value": ["Bearer", "{{.AccessToken}}"]
}
]
}`,
expectedError: true,
description: "Array value instead of string template",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var config struct {
Headers []TemplatedHeader `json:"headers"`
}
err := json.Unmarshal([]byte(tc.jsonConfig), &config)
if tc.expectedError {
if err == nil {
t.Errorf("Expected error for %s, but parsing succeeded", tc.description)
}
} else {
if err != nil {
t.Errorf("Unexpected error for %s: %v", tc.description, err)
}
}
})
}
}
// TestIssue55RegressionScenario tests the exact scenario that would cause
// the "can't evaluate field AccessToken in type bool" error
func TestIssue55RegressionScenario(t *testing.T) {
// This test documents what NOT to do and ensures we catch it
t.Run("direct boolean context execution", func(t *testing.T) {
tmpl, err := template.New("test").Parse("{{.AccessToken}}")
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
var buf bytes.Buffer
// This is what would cause the issue - passing a boolean as template data
err = tmpl.Execute(&buf, true)
if err == nil {
t.Fatalf("Expected error when executing template with boolean context")
}
expectedError := "can't evaluate field AccessToken in type bool"
if !strings.Contains(err.Error(), expectedError) {
t.Errorf("Expected error containing %q, got %q", expectedError, err.Error())
}
})
t.Run("correct map context execution", func(t *testing.T) {
tmpl, err := template.New("test").Parse("{{.AccessToken}}")
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
var buf bytes.Buffer
// This is the correct way - passing a map with the expected fields
err = tmpl.Execute(&buf, map[string]interface{}{
"AccessToken": "test-token",
})
if err != nil {
t.Fatalf("Unexpected error with correct template data: %v", err)
}
if buf.String() != "test-token" {
t.Errorf("Expected 'test-token', got %q", buf.String())
}
})
}
-206
View File
@@ -1,206 +0,0 @@
package traefikoidc
import (
"bytes"
"testing"
"text/template"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestTemplatedHeaderMissingField tests that accessing non-existent claim fields doesn't cause panics (issue #60)
func TestTemplatedHeaderMissingField(t *testing.T) {
t.Run("Missing_Claim_Field_Returns_Empty", func(t *testing.T) {
// Create a template with the missingkey=zero option
funcMap := template.FuncMap{
"default": func(defaultVal interface{}, val interface{}) interface{} {
if val == nil || val == "" {
return defaultVal
}
return val
},
"get": func(m interface{}, key string) interface{} {
if mapVal, ok := m.(map[string]interface{}); ok {
if val, exists := mapVal[key]; exists {
return val
}
}
return ""
},
}
tmpl := template.New("test").Funcs(funcMap).Option("missingkey=zero")
parsed, err := tmpl.Parse("{{.Claims.internal_role}}")
require.NoError(t, err)
// Create template data with claims that don't have internal_role
claims := map[string]interface{}{
"email": "user@example.com",
"sub": "1234567890",
"name": "John Doe",
// Note: internal_role is NOT present
}
templateData := map[string]interface{}{
"Claims": claims,
}
// Execute template - should not panic
var buf bytes.Buffer
err = parsed.Execute(&buf, templateData)
require.NoError(t, err, "Template execution should not fail for missing field")
// Should return empty string for missing field with missingkey=zero
assert.Equal(t, "<no value>", buf.String(), "Missing field should return <no value>")
})
t.Run("Safe_Access_Pattern_For_Nested_Fields", func(t *testing.T) {
funcMap := template.FuncMap{
"get": func(m interface{}, key string) interface{} {
if mapVal, ok := m.(map[string]interface{}); ok {
if val, exists := mapVal[key]; exists {
return val
}
}
return ""
},
}
tmpl := template.New("test").Funcs(funcMap)
// Use 'with' to safely check if field exists before accessing nested properties
parsed, err := tmpl.Parse(`{{with .Claims.groups}}{{.admin}}{{end}}`)
require.NoError(t, err)
claims := map[string]interface{}{
"email": "user@example.com",
// groups field doesn't exist
}
templateData := map[string]interface{}{
"Claims": claims,
}
var buf bytes.Buffer
err = parsed.Execute(&buf, templateData)
require.NoError(t, err, "Should handle nested missing fields with 'with' construct")
assert.Equal(t, "", buf.String(), "Should return empty string when field doesn't exist")
})
t.Run("Using_Get_Function_For_Safe_Access", func(t *testing.T) {
funcMap := template.FuncMap{
"get": func(m interface{}, key string) interface{} {
if mapVal, ok := m.(map[string]interface{}); ok {
if val, exists := mapVal[key]; exists {
return val
}
}
return ""
},
}
tmpl := template.New("test").Funcs(funcMap)
// Use the get function to safely access the field
parsed, err := tmpl.Parse(`{{get .Claims "internal_role"}}`)
require.NoError(t, err)
claims := map[string]interface{}{
"email": "user@example.com",
// internal_role not present
}
templateData := map[string]interface{}{
"Claims": claims,
}
var buf bytes.Buffer
err = parsed.Execute(&buf, templateData)
require.NoError(t, err)
assert.Equal(t, "", buf.String(), "get function should return empty string for missing field")
})
t.Run("Using_Default_Function_For_Fallback", func(t *testing.T) {
funcMap := template.FuncMap{
"default": func(defaultVal interface{}, val interface{}) interface{} {
if val == nil || val == "" || val == "<no value>" {
return defaultVal
}
return val
},
}
tmpl := template.New("test").Funcs(funcMap).Option("missingkey=zero")
// Use default to provide a fallback value
parsed, err := tmpl.Parse(`{{default "guest" .Claims.role}}`)
require.NoError(t, err)
claims := map[string]interface{}{
"email": "user@example.com",
// role not present
}
templateData := map[string]interface{}{
"Claims": claims,
}
var buf bytes.Buffer
err = parsed.Execute(&buf, templateData)
require.NoError(t, err)
assert.Equal(t, "guest", buf.String(), "default function should provide fallback value")
})
t.Run("Existing_Field_Still_Works", func(t *testing.T) {
funcMap := template.FuncMap{
"get": func(m interface{}, key string) interface{} {
if mapVal, ok := m.(map[string]interface{}); ok {
if val, exists := mapVal[key]; exists {
return val
}
}
return ""
},
}
tmpl := template.New("test").Funcs(funcMap).Option("missingkey=zero")
parsed, err := tmpl.Parse("{{.Claims.email}}")
require.NoError(t, err)
claims := map[string]interface{}{
"email": "user@example.com",
"role": "admin",
}
templateData := map[string]interface{}{
"Claims": claims,
}
var buf bytes.Buffer
err = parsed.Execute(&buf, templateData)
require.NoError(t, err)
assert.Equal(t, "user@example.com", buf.String(), "Existing fields should work normally")
})
}
// TestHeaderTemplateIntegration tests the full integration of templated headers
func TestHeaderTemplateIntegration(t *testing.T) {
t.Run("Headers_With_Missing_Claims_Dont_Crash", func(t *testing.T) {
config := CreateConfig()
config.ProviderURL = "https://example.com"
config.ClientID = "test-client"
config.ClientSecret = "test-secret"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = "test-encryption-key-32-characters"
// Add headers that reference potentially missing fields
config.Headers = []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-Role", Value: "{{.Claims.internal_role}}"}, // This field might not exist
{Name: "X-User-Groups", Value: "{{.Claims.groups}}"}, // This field might not exist
}
// We can't fully initialize the plugin without network access,
// but we can test that the configuration validates
err := config.Validate()
assert.NoError(t, err, "Configuration should be valid even with potentially missing fields")
})
}
@@ -1,263 +0,0 @@
package traefikoidc
import (
"bytes"
"context"
"net/http"
"testing"
"text/template"
)
// TestTraefikConfigurationParsing tests various ways Traefik might pass configuration
// to the plugin, specifically focusing on the headers field
func TestTraefikConfigurationParsing(t *testing.T) {
testCases := []struct {
name string
config *Config
expectError bool
description string
}{
{
name: "valid configuration with templated headers",
config: &Config{
ProviderURL: "https://accounts.google.com",
ClientID: "test-client",
ClientSecret: "test-secret",
SessionEncryptionKey: "test-encryption-key-32-bytes-long",
CallbackURL: "/oauth2/callback",
Headers: []TemplatedHeader{
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
},
},
expectError: false,
description: "Standard configuration should work",
},
{
name: "configuration with multiple headers",
config: &Config{
ProviderURL: "https://accounts.google.com",
ClientID: "test-client",
ClientSecret: "test-secret",
SessionEncryptionKey: "test-encryption-key-32-bytes-long",
CallbackURL: "/oauth2/callback",
Headers: []TemplatedHeader{
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-ID", Value: "{{.Claims.sub}}"},
},
},
expectError: false,
description: "Multiple headers should work",
},
{
name: "empty headers configuration",
config: &Config{
ProviderURL: "https://accounts.google.com",
ClientID: "test-client",
ClientSecret: "test-secret",
SessionEncryptionKey: "test-encryption-key-32-bytes-long",
CallbackURL: "/oauth2/callback",
Headers: []TemplatedHeader{},
},
expectError: false,
description: "Empty headers should not cause issues",
},
{
name: "nil headers configuration",
config: &Config{
ProviderURL: "https://accounts.google.com",
ClientID: "test-client",
ClientSecret: "test-secret",
SessionEncryptionKey: "test-encryption-key-32-bytes-long",
CallbackURL: "/oauth2/callback",
Headers: nil,
},
expectError: false,
description: "Nil headers should be handled gracefully",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create a simple next handler
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Try to create the middleware
ctx := context.Background()
handler, err := New(ctx, next, tc.config, "test-middleware")
if tc.expectError {
if err == nil {
t.Errorf("Expected error for %s, but got none", tc.description)
}
} else {
if err != nil {
t.Errorf("Unexpected error for %s: %v", tc.description, err)
} else {
// Verify that the middleware was created successfully
middleware, ok := handler.(*TraefikOidc)
if !ok {
t.Fatalf("Handler is not of type *TraefikOidc")
}
// Check that templates were parsed correctly
if len(tc.config.Headers) > 0 {
if len(middleware.headerTemplates) != len(tc.config.Headers) {
t.Errorf("Expected %d templates, got %d",
len(tc.config.Headers), len(middleware.headerTemplates))
}
// Verify each template can be executed
for headerName, tmpl := range middleware.headerTemplates {
testData := map[string]interface{}{
"AccessToken": "test-token",
"Claims": map[string]interface{}{
"email": "test@example.com",
"sub": "user123",
},
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, testData); err != nil {
t.Errorf("Failed to execute template for header %s: %v",
headerName, err)
}
}
}
}
}
})
}
}
// TestTemplateParsingDuringInitialization specifically tests template parsing
// during middleware initialization to catch any issues that might occur
func TestTemplateParsingDuringInitialization(t *testing.T) {
// Test various template expressions that might cause issues
templateTests := []struct {
name string
templateValue string
shouldFail bool
}{
{
name: "simple access token",
templateValue: "{{.AccessToken}}",
shouldFail: false,
},
{
name: "bearer token format",
templateValue: "Bearer {{.AccessToken}}",
shouldFail: false,
},
{
name: "nested claim access",
templateValue: "{{.Claims.email}}",
shouldFail: false,
},
{
name: "multiple template expressions",
templateValue: "User: {{.Claims.email}}, Token: {{.AccessToken}}",
shouldFail: false,
},
{
name: "invalid template syntax",
templateValue: "{{.AccessToken",
shouldFail: true,
},
{
name: "empty template",
templateValue: "",
shouldFail: false,
},
}
for _, tt := range templateTests {
t.Run(tt.name, func(t *testing.T) {
// Test template parsing directly
tmpl := template.New("test")
_, err := tmpl.Parse(tt.templateValue)
if tt.shouldFail {
if err == nil {
t.Errorf("Expected template parsing to fail for %q", tt.templateValue)
}
} else {
if err != nil {
t.Errorf("Template parsing failed for %q: %v", tt.templateValue, err)
}
}
})
}
}
// TestIssue55ReproductionAttempt attempts to reproduce the exact scenario
// from GitHub issue #55 where the error occurs during configuration
func TestIssue55ReproductionAttempt(t *testing.T) {
// Create a configuration exactly as reported by the user
config := &Config{
ProviderURL: "https://accounts.google.com",
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
SessionEncryptionKey: "test-session-encryption-key-32-bytes-long",
CallbackURL: "/oauth2/callback",
LogoutURL: "/oauth2/logout",
LogLevel: "debug",
Scopes: []string{"openid", "profile", "email"},
Headers: []TemplatedHeader{
{
Name: "Authorization",
Value: "Bearer {{.AccessToken}}",
},
},
}
// Create a mock HTTP handler
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Try to initialize the middleware
ctx := context.Background()
handler, err := New(ctx, next, config, "test-oidc")
if err != nil {
t.Fatalf("Failed to create middleware: %v", err)
}
// Verify the middleware was created correctly
middleware, ok := handler.(*TraefikOidc)
if !ok {
t.Fatalf("Handler is not of type *TraefikOidc")
}
// Check that the header template was parsed
if len(middleware.headerTemplates) != 1 {
t.Errorf("Expected 1 header template, got %d", len(middleware.headerTemplates))
}
// Verify the template exists for the Authorization header
authTmpl, exists := middleware.headerTemplates["Authorization"]
if !exists {
t.Fatal("Authorization template not found")
}
// Test executing the template
templateData := map[string]interface{}{
"AccessToken": "test-access-token",
"Claims": map[string]interface{}{
"email": "user@example.com",
},
}
var buf bytes.Buffer
if err := authTmpl.Execute(&buf, templateData); err != nil {
t.Errorf("Failed to execute Authorization template: %v", err)
}
expectedValue := "Bearer test-access-token"
if buf.String() != expectedValue {
t.Errorf("Expected %q, got %q", expectedValue, buf.String())
}
}
-244
View File
@@ -1,244 +0,0 @@
package traefikoidc
import (
"context"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"golang.org/x/time/rate"
)
// testWriter is an io.Writer that writes to test log
type testWriter struct {
t *testing.T
}
func (w *testWriter) Write(p []byte) (n int, err error) {
w.t.Log(string(p))
return len(p), nil
}
// Test helper adapters for the new test files
// createTestConfig creates a config with all required fields populated for testing
func createTestConfig() *Config {
config := CreateConfig()
config.ProviderURL = "https://test-provider.com"
config.ClientID = "test-client-id"
config.ClientSecret = "test-client-secret"
config.SessionEncryptionKey = "test-encryption-key-32-characters"
config.CallbackURL = "/oauth2/callback"
return config
}
// setupTestOIDCMiddleware creates a test OIDC middleware instance with mock servers
func setupTestOIDCMiddleware(t *testing.T, config *Config) (*TraefikOidc, *httptest.Server) {
// Create mock OIDC server
var serverURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"issuer": serverURL,
"authorization_endpoint": serverURL + "/auth",
"token_endpoint": serverURL + "/token",
"userinfo_endpoint": serverURL + "/userinfo",
"jwks_uri": serverURL + "/keys",
"revocation_endpoint": serverURL + "/revoke",
})
case "/keys":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"keys": [{
"kty": "RSA",
"kid": "test-key-id",
"use": "sig",
"n": "test-n-value",
"e": "AQAB"
}]
}`))
case "/token":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"access_token": "test-access-token",
"id_token": "` + ValidIDToken + `",
"refresh_token": "test-refresh-token",
"token_type": "bearer",
"expires_in": 3600
}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
serverURL = server.URL
// Create middleware bypassing validation like main tests do
// Create a logger that outputs to test log
logger := &Logger{
logError: log.New(&testWriter{t}, "ERROR: ", 0),
logInfo: log.New(&testWriter{t}, "INFO: ", 0),
logDebug: log.New(&testWriter{t}, "DEBUG: ", 0),
}
sessionManager, _ := NewSessionManager(config.SessionEncryptionKey, false, "", logger)
// Create next handler
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Set default paths
callbackPath := config.CallbackURL
if callbackPath == "" {
callbackPath = "/oauth2/callback"
}
logoutPath := config.LogoutURL
if logoutPath == "" {
logoutPath = callbackPath + "/logout"
}
// Set default post logout redirect URI to match the actual implementation
postLogoutRedirectURI := config.PostLogoutRedirectURI
if postLogoutRedirectURI == "" {
postLogoutRedirectURI = "/" // Default to root path like the actual implementation
}
// Use test URLs that won't be blocked by validation
testIssuerURL := "https://test-provider.example.com"
testAuthURL := testIssuerURL + "/auth"
testTokenURL := testIssuerURL + "/token"
testJWKSURL := testIssuerURL + "/keys"
// Create TraefikOidc instance directly
oidc := &TraefikOidc{
next: nextHandler,
issuerURL: testIssuerURL,
clientID: config.ClientID,
clientSecret: config.ClientSecret,
redirURLPath: callbackPath,
logoutURLPath: logoutPath,
postLogoutRedirectURI: postLogoutRedirectURI,
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
tokenBlacklist: NewCache(),
tokenCache: NewTokenCache(),
logger: logger,
excludedURLs: make(map[string]struct{}),
httpClient: &http.Client{},
authURL: testAuthURL,
tokenURL: testTokenURL,
jwksURL: testJWKSURL,
initComplete: make(chan struct{}),
sessionManager: sessionManager,
extractClaimsFunc: extractClaims,
enablePKCE: config.EnablePKCE,
refreshGracePeriod: time.Duration(config.RefreshGracePeriodSeconds) * time.Second,
revocationURL: config.RevocationURL,
endSessionURL: config.OIDCEndSessionURL,
scopes: config.Scopes,
forceHTTPS: config.ForceHTTPS,
allowedUserDomains: make(map[string]struct{}),
jwkCache: &JWKCache{},
metadataCache: NewMetadataCache(),
ctx: context.Background(),
}
// Process excluded URLs
for _, url := range config.ExcludedURLs {
oidc.excludedURLs[url] = struct{}{}
}
// Set default excluded URLs
oidc.excludedURLs["/favicon"] = struct{}{}
oidc.excludedURLs["/favicon.ico"] = struct{}{}
// Close init channel
close(oidc.initComplete)
// Set verifiers
oidc.tokenVerifier = oidc
oidc.jwtVerifier = oidc
oidc.tokenExchanger = oidc // Set tokenExchanger to self
// Set default refresh grace period if not set or negative
if config.RefreshGracePeriodSeconds <= 0 {
oidc.refreshGracePeriod = 60 * time.Second
}
// Set authentication initiation function
oidc.initiateAuthenticationFunc = func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
// Generate CSRF token and nonce
csrfToken := uuid.NewString()
nonce := uuid.NewString()
// Store in session
session.SetCSRF(csrfToken)
session.SetNonce(nonce)
// Store the original path
session.SetIncomingPath(req.URL.RequestURI())
// Handle PKCE if enabled
var codeChallenge string
if oidc.enablePKCE {
verifier, _ := generateCodeVerifier()
session.SetCodeVerifier(verifier)
codeChallenge = deriveCodeChallenge(verifier)
}
// Save session
session.Save(req, rw)
// Build auth URL
authURL := oidc.buildAuthURL(redirectURL, csrfToken, nonce, codeChallenge)
// Redirect
http.Redirect(rw, req, authURL, http.StatusFound)
}
// Set scopes if not set
if len(oidc.scopes) == 0 {
oidc.scopes = []string{"openid", "profile", "email"}
}
return oidc, server
}
// createMockJWT creates a mock JWT token for testing - adapter for existing tests
func createMockJWT(t *testing.T, sub, email string) string {
return ValidIDToken
}
// createTestSession creates a properly initialized SessionData for testing
func createTestSession() *SessionData {
// Create a minimal session manager for testing
logger := newNoOpLogger()
sessionManager, _ := NewSessionManager("test-encryption-key-32-characters", false, "", logger)
// Create a test request
req := httptest.NewRequest("GET", "/", nil)
// Get a session from the manager
session, _ := sessionManager.GetSession(req)
return session
}
// injectSessionIntoRequest saves the session and adds the resulting cookies to the request
func injectSessionIntoRequest(t *testing.T, req *http.Request, session *SessionData) {
// Create a response recorder to capture cookies
rec := httptest.NewRecorder()
// Save the session (this sets cookies)
if err := session.Save(req, rec); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Add the cookies to the request
for _, cookie := range rec.Result().Cookies() {
req.AddCookie(cookie)
}
}
-412
View File
@@ -1,412 +0,0 @@
package traefikoidc
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"time"
)
// TestTokens provides a comprehensive set of standardized test tokens
// for consistent testing across the entire codebase.
type TestTokens struct{}
// NewTestTokens creates a new TestTokens instance
func NewTestTokens() *TestTokens {
return &TestTokens{}
}
// Valid JWT tokens for testing
const (
// ValidAccessToken - A properly formatted JWT access token for testing
ValidAccessToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0.eyJhdWQiOiJ0ZXN0LWNsaWVudC1pZCIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImV4cCI6MzAwMDAwMDAwMCwiaWF0IjoxMDAwMDAwMDAwLCJpc3MiOiJodHRwczovL3Rlc3QtaXNzdWVyLmNvbSIsImp0aSI6ImU0NzE3ZGFkMGZmMDI5M2QiLCJuYmYiOjEwMDAwMDAwMDAsIm5vbmNlIjoibm9uY2UxMjMiLCJzdWIiOiJ0ZXN0LXN1YmplY3QifQ.bmwp-vk0B7Ir9UiUkzib8L7yJbebJ00o3U9QrB6gP2H9-RfqyCbN8M9Rkx7Rb8Vdh3YzqkBBoLS_G0i414rs2I9uABnTC4E6-63qkGdUrLB7p-XbjcRW2RoIBwXHk7lfumi8eX0uWzBsJ9CY0__UECVsex5XORfBb4Bcqj0LK4y-glxkpI51I7BPySfciWC_PkdaQ1Qe5pCAlxeNs2E9NMGXp-Ox6vAufUzoC2cws1LswGPPP6icQ-Zlzd5WMCIWhdIkN4yTxk8FMqsTC52k2zskRHNSSd4DDVETonfzawZNqDcMpnTyN53sCJ9UHiQTl9mCm61ttYW-W9Gc-ze4Xw"
// ValidIDToken - A properly formatted JWT ID token for testing
ValidIDToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0.eyJhdWQiOiJ0ZXN0LWNsaWVudC1pZCIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImV4cCI6MzAwMDAwMDAwMCwiaWF0IjoxMDAwMDAwMDAwLCJpc3MiOiJodHRwczovL3Rlc3QtaXNzdWVyLmNvbSIsImp0aSI6IjZjMGNlNmYxMzhjYTMzNzYiLCJuYmYiOjEwMDAwMDAwMDAsIm5vbmNlIjoibm9uY2UxMjMiLCJzdWIiOiJ0ZXN0LXN1YmplY3QifQ.RBQYejA9vP4lnh2EhFqWerePWaCyDTF0ZE1jlU2xm4g2wWVeaEHpv5SNg92_gwk633N9xx7ugS0UrlEu4qbT7wSb1HBDR00q_andyYnyFk4OoxPpD0AqHkVr-pjS-Z7UCGF3sLgQ4ECmU9695PIys3XvgUGMzEn_mK-PHcpY5AnbBGFsbj7epUld_sb6WfjjjwAa8kKfKObPvaIpuJ4TlxI1Uf0wYOoIA0zh5ipeAn-i8Ud-GErxis1Hp8UQK7IRolXpToiXnFcnf3vI3eCS7Yu3oPl7LRxTxKMCI9h0MCwu25ZNsOg2C9ohyebpU0jbURX9Q74GNOaphv-Lz9rCRA"
// ValidRefreshToken - A properly formatted refresh token for testing
ValidRefreshToken = "valid-refresh-token-12345"
// MinimalValidJWT - The shortest valid JWT for testing (actual base64url)
MinimalValidJWT = "eyJ0eXAiOiJKV1QifQ.eyJzdWIiOiIxMjMifQ.abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"
// ValidRefreshTokenGoogle - A Google-style refresh token for testing
ValidRefreshTokenGoogle = "google_refresh_token_12345"
)
// Invalid tokens for testing validation
const (
// InvalidTokenNoDots - Token with no dots (invalid JWT format)
InvalidTokenNoDots = "notajwttoken"
// InvalidTokenOneDot - Token with one dot (invalid JWT format)
InvalidTokenOneDot = "header.payload"
// InvalidTokenThreeDots - Token with three dots (invalid JWT format)
InvalidTokenThreeDots = "header.payload.signature.extra"
// EmptyToken - Empty token
EmptyToken = ""
// CorruptedBase64Token - Token with invalid base64 data for chunking tests
CorruptedBase64Token = "corrupted_base64_!@#$"
)
// CreateLargeValidJWT creates a JWT of approximately the specified size
// This replaces the ad-hoc createLargeValidJWT function in tests
func (tt *TestTokens) CreateLargeValidJWT(targetSize int) string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
// Create a valid base64url signature
signatureBytes := make([]byte, 32)
rand.Read(signatureBytes)
signature := base64.RawURLEncoding.EncodeToString(signatureBytes)
// Calculate required payload size
usedSize := len(header) + len(signature) + 2 // account for dots
payloadSize := targetSize - usedSize
if payloadSize < 50 {
payloadSize = 50
}
// Create a payload with realistic JWT claims
claims := map[string]interface{}{
"sub": "user123",
"iss": "https://example.com",
"aud": "client123",
"exp": 9999999999,
"iat": 1000000000,
}
dataSize := payloadSize - 100 // Account for other claims and base64 encoding
if dataSize < 10 {
dataSize = 10 // Minimum data size
}
claims["data"] = tt.generateRandomString(dataSize)
claimsJSON, _ := json.Marshal(claims)
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
return fmt.Sprintf("%s.%s.%s", header, payload, signature)
}
// CreateLargeRefreshToken creates a refresh token of approximately the specified size
func (tt *TestTokens) CreateLargeRefreshToken(targetSize int) string {
baseToken := "refresh_token_"
padding := tt.generateRandomString(targetSize - len(baseToken))
return baseToken + padding
}
// CreateExpiredJWT creates an expired JWT token for testing
func (tt *TestTokens) CreateExpiredJWT() string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
// Create claims with expired timestamp
claims := map[string]interface{}{
"sub": "user123",
"iss": "https://example.com",
"aud": "client123",
"exp": time.Now().Unix() - 3600, // Expired 1 hour ago
"iat": time.Now().Unix() - 7200, // Issued 2 hours ago
}
claimsJSON, _ := json.Marshal(claims)
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
// Create a valid base64url signature
signatureBytes := make([]byte, 16)
rand.Read(signatureBytes)
signature := base64.RawURLEncoding.EncodeToString(signatureBytes)
return fmt.Sprintf("%s.%s.%s", header, payload, signature)
}
// CreateUniqueValidJWT creates a unique valid JWT for concurrent testing
func (tt *TestTokens) CreateUniqueValidJWT(id string) string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
claims := map[string]interface{}{
"sub": "user_" + id,
"iss": "https://example.com",
"aud": "client123",
"exp": 9999999999,
"iat": 1000000000,
"jti": id,
}
claimsJSON, _ := json.Marshal(claims)
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
// Create a valid base64url signature
signatureBytes := make([]byte, 16)
rand.Read(signatureBytes)
signature := base64.RawURLEncoding.EncodeToString(signatureBytes)
return fmt.Sprintf("%s.%s.%s", header, payload, signature)
}
// CreateIncompressibleToken creates a token that cannot be compressed effectively
// This is useful for testing chunking scenarios where compression doesn't help
func (tt *TestTokens) CreateIncompressibleToken(targetSize int) string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
// Create a valid base64url signature
signatureBytes := make([]byte, 32)
rand.Read(signatureBytes)
signature := base64.RawURLEncoding.EncodeToString(signatureBytes)
// Calculate required payload size
usedSize := len(header) + len(signature) + 2 // account for dots
payloadSize := max(targetSize-usedSize, 100)
// Generate multiple random fields to prevent compression
randomFields := make(map[string]interface{})
randomFields["sub"] = "user123"
randomFields["iss"] = "https://example.com"
randomFields["aud"] = "client123"
randomFields["exp"] = 9999999999
randomFields["iat"] = 1000000000
// Add many random fields with random data to prevent compression
remainingSize := payloadSize - 200 // Account for base64 encoding and other fields
fieldCount := remainingSize / 100 // ~100 bytes per field
if fieldCount < 1 {
fieldCount = 1
}
for i := 0; i < fieldCount; i++ {
// Generate truly random data for each field
randomBytes := make([]byte, 50)
rand.Read(randomBytes)
fieldName := fmt.Sprintf("random_field_%d_%s", i, tt.generateRandomString(8))
randomFields[fieldName] = base64.StdEncoding.EncodeToString(randomBytes)
}
claimsJSON, _ := json.Marshal(randomFields)
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
token := fmt.Sprintf("%s.%s.%s", header, payload, signature)
// If still too small, pad with more random data
if len(token) < targetSize {
padding := targetSize - len(token)
extraRandomBytes := make([]byte, padding/2)
rand.Read(extraRandomBytes)
randomFields["padding"] = base64.StdEncoding.EncodeToString(extraRandomBytes)
claimsJSON, _ = json.Marshal(randomFields)
payload = base64.RawURLEncoding.EncodeToString(claimsJSON)
token = fmt.Sprintf("%s.%s.%s", header, payload, signature)
}
return token
}
// GetValidTokenSet returns a complete set of valid tokens for testing
func (tt *TestTokens) GetValidTokenSet() TokenSet {
return TokenSet{
AccessToken: ValidAccessToken,
IDToken: ValidIDToken,
RefreshToken: ValidRefreshToken,
}
}
// GetGoogleTokenSet returns tokens that simulate Google OIDC provider responses
func (tt *TestTokens) GetGoogleTokenSet() TokenSet {
return TokenSet{
AccessToken: ValidAccessToken,
IDToken: ValidIDToken,
RefreshToken: ValidRefreshTokenGoogle,
}
}
// GetLargeTokenSet returns a set of large tokens for chunking tests
func (tt *TestTokens) GetLargeTokenSet() TokenSet {
return TokenSet{
AccessToken: tt.CreateLargeValidJWT(5000),
IDToken: tt.CreateLargeValidJWT(2000),
RefreshToken: tt.CreateLargeRefreshToken(3000),
}
}
// GetInvalidTokens returns various invalid tokens for validation testing
func (tt *TestTokens) GetInvalidTokens() InvalidTokenSet {
return InvalidTokenSet{
NoDots: InvalidTokenNoDots,
OneDot: InvalidTokenOneDot,
ThreeDots: InvalidTokenThreeDots,
Empty: EmptyToken,
Corrupted: CorruptedBase64Token,
}
}
// generateRandomString creates a random string of the specified length
func (tt *TestTokens) generateRandomString(length int) string {
// FIXED: Handle negative or zero lengths safely
if length <= 0 {
return ""
}
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := 0; i < length; i++ {
randomByte := make([]byte, 1)
rand.Read(randomByte)
b[i] = charset[int(randomByte[0])%len(charset)]
}
return string(b)
}
// TokenSet represents a complete set of tokens for testing
type TokenSet struct {
AccessToken string
IDToken string
RefreshToken string
}
// InvalidTokenSet represents various invalid tokens for validation testing
type InvalidTokenSet struct {
NoDots string // Token with 0 dots
OneDot string // Token with 1 dot
ThreeDots string // Token with 3 dots
Empty string // Empty token
Corrupted string // Corrupted/invalid characters
}
// TestScenarios provides predefined test scenarios
type TestScenarios struct {
tokens *TestTokens
}
// NewTestScenarios creates a new TestScenarios instance
func NewTestScenarios() *TestScenarios {
return &TestScenarios{
tokens: NewTestTokens(),
}
}
// NormalFlow returns tokens for normal authentication flow testing
func (ts *TestScenarios) NormalFlow() TokenSet {
return ts.tokens.GetValidTokenSet()
}
// GoogleFlow returns tokens simulating Google OIDC provider
func (ts *TestScenarios) GoogleFlow() TokenSet {
return ts.tokens.GetGoogleTokenSet()
}
// ChunkingRequired returns large tokens that require chunking
func (ts *TestScenarios) ChunkingRequired() TokenSet {
return ts.tokens.GetLargeTokenSet()
}
// CorruptionTest returns tokens and corruption scenarios for testing
func (ts *TestScenarios) CorruptionTest() CorruptionTestSet {
return CorruptionTestSet{
ValidTokens: ts.tokens.GetValidTokenSet(),
InvalidTokens: ts.tokens.GetInvalidTokens(),
LargeTokens: ts.tokens.GetLargeTokenSet(),
CorruptedToken: CorruptedBase64Token,
}
}
// ConcurrentTest returns unique tokens for concurrent testing
func (ts *TestScenarios) ConcurrentTest(count int) []TokenSet {
sets := make([]TokenSet, count)
for i := 0; i < count; i++ {
sets[i] = TokenSet{
AccessToken: ts.tokens.CreateUniqueValidJWT(fmt.Sprintf("concurrent_%d", i)),
IDToken: ts.tokens.CreateUniqueValidJWT(fmt.Sprintf("id_%d", i)),
RefreshToken: fmt.Sprintf("refresh_concurrent_%d", i),
}
}
return sets
}
// CorruptionTestSet represents tokens and scenarios for corruption testing
type CorruptionTestSet struct {
ValidTokens TokenSet
InvalidTokens InvalidTokenSet
LargeTokens TokenSet
CorruptedToken string
}
// TokenValidationTestCases returns test cases for token validation
func (tt *TestTokens) TokenValidationTestCases() []ValidationTestCase {
return []ValidationTestCase{
{
Name: "Empty token",
Token: EmptyToken,
ExpectStored: true, // Empty tokens are allowed for clearing
ExpectRetrieved: false, // But return as empty
},
{
Name: "Single dot",
Token: InvalidTokenOneDot,
ExpectStored: false, // Invalid JWT format
ExpectRetrieved: false,
},
{
Name: "No dots",
Token: InvalidTokenNoDots,
ExpectStored: false, // Invalid JWT format
ExpectRetrieved: false,
},
{
Name: "Too many dots",
Token: InvalidTokenThreeDots,
ExpectStored: false, // Invalid JWT format
ExpectRetrieved: false,
},
{
Name: "Valid minimal JWT",
Token: MinimalValidJWT,
ExpectStored: true,
ExpectRetrieved: true,
},
{
Name: "Valid standard JWT",
Token: ValidAccessToken,
ExpectStored: true,
ExpectRetrieved: true,
},
}
}
// ValidationTestCase represents a single token validation test case
type ValidationTestCase struct {
Name string
Token string
ExpectStored bool
ExpectRetrieved bool
}
// Helper functions for common test patterns
// AssertValidTokenStorage verifies that a valid token can be stored and retrieved
func AssertValidTokenStorage(t TestingInterface, session *SessionData, token string) {
session.SetAccessToken(token)
retrieved := session.GetAccessToken()
if retrieved != token {
t.Errorf("Token storage failed: expected %q, got %q", token, retrieved)
}
}
// AssertInvalidTokenRejection verifies that an invalid token is rejected
func AssertInvalidTokenRejection(t TestingInterface, session *SessionData, token string) {
original := session.GetAccessToken()
session.SetAccessToken(token)
after := session.GetAccessToken()
if after != original {
t.Errorf("Invalid token was not rejected: expected %q, got %q", original, after)
}
}
// TestingInterface provides the minimal interface needed for testing
type TestingInterface interface {
Errorf(format string, args ...interface{})
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
-17
View File
@@ -1,17 +0,0 @@
package traefikoidc
import (
"crypto/rand"
"encoding/hex"
)
// generateRandomString generates a random string of the specified length
// This is used in tests to create unique identifiers
func generateRandomString(length int) string {
bytes := make([]byte, length/2)
if _, err := rand.Read(bytes); err != nil {
// In tests, fallback to a predictable string if random fails
return "random-string-fallback"
}
return hex.EncodeToString(bytes)
}
-477
View File
@@ -1,477 +0,0 @@
package traefikoidc
import (
"bytes"
"compress/gzip"
"encoding/base64"
"fmt"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gorilla/sessions"
)
// TestTokenCorruptionScenario reproduces the exact failure pattern from GitHub issue #53:
// Token verified successfully multiple times, then fails with "signature verification failed"
func TestTokenCorruptionScenario(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create a valid JWT token with proper base64url signature
testTokens := NewTestTokens()
validJWT := testTokens.CreateLargeValidJWT(100) // Create a small valid token
tests := []struct {
corruptionScenario func(*SessionData)
name string
tokenSize int
iterations int
expectConsistent bool
}{
{
name: "Small token - multiple retrievals",
tokenSize: len(validJWT),
iterations: 10,
expectConsistent: true,
},
{
name: "Large chunked token - multiple retrievals",
tokenSize: 5000,
iterations: 10,
expectConsistent: true,
},
{
name: "Compression corruption simulation",
tokenSize: 2000,
iterations: 5,
expectConsistent: false, // Will be corrupted intentionally
corruptionScenario: func(session *SessionData) {
// Simulate corruption by directly modifying session values
if session.accessSession != nil {
// Simulate corrupted compressed data
session.accessSession.Values["token"] = "corrupted_base64_!@#$"
session.accessSession.Values["compressed"] = true
}
},
},
{
name: "Chunk reassembly corruption simulation",
tokenSize: 25000, // Large enough to force chunking even after compression
iterations: 5,
expectConsistent: false, // Will be corrupted intentionally
corruptionScenario: func(session *SessionData) {
// Simulate chunk corruption with invalid base64 characters
if len(session.accessTokenChunks) > 0 {
if chunk, exists := session.accessTokenChunks[0]; exists {
chunk.Values["token_chunk"] = "invalid_base64_!@#$%"
}
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
// Create token of specified size
token := createTokenOfSize(validJWT, tt.tokenSize)
// 1. Store the token
session.SetAccessToken(token)
t.Logf("Stored token of size %d bytes", len(token))
// 2. Verify token can be retrieved multiple times successfully
var retrievedTokens []string
for i := 0; i < tt.iterations; i++ {
retrieved := session.GetAccessToken()
retrievedTokens = append(retrievedTokens, retrieved)
if tt.expectConsistent && retrieved != token {
t.Errorf("Iteration %d: Token mismatch, expected consistency", i)
break
}
}
// 3. Apply corruption scenario if specified
if tt.corruptionScenario != nil {
tt.corruptionScenario(session)
}
// 4. Retrieve token after potential corruption
finalRetrieved := session.GetAccessToken()
if tt.expectConsistent {
// With fixes, token should still be retrievable correctly
if finalRetrieved != token {
t.Errorf("Final retrieval failed - corruption not handled correctly")
t.Logf("Expected: %q", token)
t.Logf("Got: %q", finalRetrieved)
}
} else {
// For corruption scenarios, expect empty string (graceful failure)
if finalRetrieved != "" {
t.Errorf("Expected corruption to result in empty token, got: %q", finalRetrieved)
}
}
// 5. Verify all previous retrievals were consistent (if expected)
if tt.expectConsistent {
for i, retrieved := range retrievedTokens {
if retrieved != token {
t.Errorf("Iteration %d produced inconsistent result", i)
}
}
}
})
}
}
// TestCompressionIntegrityFailure tests scenarios where compression fails integrity checks
func TestCompressionIntegrityFailure(t *testing.T) {
tests := []struct {
name string
token string
expectSame bool
}{
{
name: "Valid JWT",
token: NewTestTokens().CreateLargeValidJWT(100),
expectSame: true,
},
{
name: "Invalid JWT - wrong dots",
token: "invalid.token",
expectSame: true, // Should return unchanged
},
{
name: "Oversized token",
token: "header." + strings.Repeat("A", 60000) + ".sig",
expectSame: true, // Should return unchanged due to size limit
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compressed := compressToken(tt.token)
if tt.expectSame && compressed != tt.token {
// If we expect the token to remain the same but it was compressed,
// verify round-trip integrity
decompressed := decompressToken(compressed)
if decompressed != tt.token {
t.Errorf("Compression integrity failed: original=%q, decompressed=%q", tt.token, decompressed)
}
}
})
}
}
// TestChunkReassemblyEdgeCases tests edge cases in chunk reassembly that could cause corruption
func TestChunkReassemblyEdgeCases(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
// Create a large token that will definitely be chunked
testTokens := NewTestTokens()
largeToken := testTokens.CreateLargeValidJWT(8000)
// Store the token to create chunks
session.SetAccessToken(largeToken)
if len(session.accessTokenChunks) == 0 {
t.Skip("Token was not chunked, skipping reassembly tests")
}
t.Logf("Token was split into %d chunks", len(session.accessTokenChunks))
// Test various corruption scenarios
corruptionTests := []struct {
corruption func(map[int]*sessions.Session)
name string
expectEmpty bool
}{
{
name: "Gap in chunk sequence",
corruption: func(chunks map[int]*sessions.Session) {
// Remove chunk 1 if it exists
delete(chunks, 1)
},
expectEmpty: true,
},
{
name: "Chunk with nil value",
corruption: func(chunks map[int]*sessions.Session) {
if chunk, exists := chunks[0]; exists {
chunk.Values["token_chunk"] = nil
}
},
expectEmpty: true,
},
{
name: "Chunk with wrong type",
corruption: func(chunks map[int]*sessions.Session) {
if chunk, exists := chunks[0]; exists {
chunk.Values["token_chunk"] = 12345 // Should be string
}
},
expectEmpty: true,
},
{
name: "Empty chunk data",
corruption: func(chunks map[int]*sessions.Session) {
if chunk, exists := chunks[0]; exists {
chunk.Values["token_chunk"] = ""
}
},
expectEmpty: true,
},
{
name: "Excessive chunk count",
corruption: func(chunks map[int]*sessions.Session) {
// This test simulates having too many chunks (>50 limit)
// We'll create a scenario by adding many fake chunks
for i := 0; i < 60; i++ {
fakeSession := &sessions.Session{Values: make(map[interface{}]interface{})}
fakeSession.Values["token_chunk"] = "fake_chunk_data"
chunks[i] = fakeSession
}
},
expectEmpty: true,
},
}
for _, ct := range corruptionTests {
t.Run(ct.name, func(t *testing.T) {
// Get a fresh session for each test
freshReq := httptest.NewRequest("GET", "http://example.com/foo", nil)
freshSession, err := sm.GetSession(freshReq)
if err != nil {
t.Fatalf("Failed to get fresh session: %v", err)
}
defer freshSession.ReturnToPool()
// Store the large token again
freshSession.SetAccessToken(largeToken)
// Apply corruption
ct.corruption(freshSession.accessTokenChunks)
// Try to retrieve the token
retrieved := freshSession.GetAccessToken()
if ct.expectEmpty {
if retrieved != "" {
t.Errorf("Expected empty token due to corruption, got: %q", retrieved)
}
} else {
if retrieved != largeToken {
t.Errorf("Expected original token, got: %q", retrieved)
}
}
})
}
}
// TestRaceConditionProtection tests that concurrent access doesn't cause corruption
func TestRaceConditionProtection(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
const numGoroutines = 20
const numOperations = 50
// Create tokens of different sizes
testTokens := NewTestTokens()
tokens := []string{
testTokens.CreateUniqueValidJWT("token1"),
testTokens.CreateLargeValidJWT(3000),
testTokens.CreateLargeValidJWT(6000),
}
var wg sync.WaitGroup
errChan := make(chan error, numGoroutines*numOperations)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
tokenIndex := (goroutineID + j) % len(tokens)
expectedToken := tokens[tokenIndex]
// Set token
session.SetAccessToken(expectedToken)
// Retrieve token
retrieved := session.GetAccessToken()
// Verify it's a valid JWT (should have exactly 2 dots)
if retrieved != "" && strings.Count(retrieved, ".") != 2 {
errChan <- fmt.Errorf("goroutine %d, op %d: invalid JWT format in retrieved token: %q",
goroutineID, j, retrieved)
continue
}
// 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)
isValidToken := false
for _, validToken := range tokens {
if retrieved == validToken {
isValidToken = true
break
}
}
if retrieved != "" && !isValidToken {
errChan <- fmt.Errorf("goroutine %d, op %d: retrieved unknown token: %q",
goroutineID, j, retrieved)
}
}
}(i)
}
wg.Wait()
close(errChan)
// Check for any errors
for err := range errChan {
t.Error(err)
}
}
// TestMemoryExhaustionProtection tests protection against memory exhaustion attacks
func TestMemoryExhaustionProtection(t *testing.T) {
tests := []struct {
setupCorruption func() string
name string
expectRejection bool
}{
{
name: "Extremely large compressed data",
setupCorruption: func() string {
return base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("A"), 200*1024)) // 200KB
},
expectRejection: true,
},
{
name: "Malformed gzip bomb attempt",
setupCorruption: func() string {
// Create data that looks like gzip but would decompress to huge size
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
gz.Write(bytes.Repeat([]byte("A"), 10*1024)) // 10KB that compresses well
gz.Close()
compressed := buf.Bytes()
// Modify to make it potentially dangerous
return base64.StdEncoding.EncodeToString(compressed)
},
expectRejection: false, // Our decompression has size limits
},
{
name: "Token with excessive chunk simulation",
setupCorruption: func() string {
// This will be tested in the session layer
return strings.Repeat("chunk.", 100) + "final"
},
expectRejection: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
corruptedData := tt.setupCorruption()
result := decompressToken(corruptedData)
if tt.expectRejection {
// Should return original corrupted data, not attempt decompression
if result != corruptedData {
t.Errorf("Expected rejection of dangerous data, but decompression was attempted")
}
}
// Verify no excessive memory was used (this test would catch OOM in practice)
// The fact that we reach this point means memory limits were effective
})
}
}
// TestBackwardCompatibility ensures that sessions created before the fixes still work
func TestBackwardCompatibility(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
// Simulate old-style session data (without new validation fields)
testTokens := NewTestTokens()
oldStyleToken := testTokens.CreateUniqueValidJWT("old")
// Manually set token without going through new SetAccessToken validation
session.accessSession.Values["token"] = oldStyleToken
session.accessSession.Values["compressed"] = false
// Should still be retrievable
retrieved := session.GetAccessToken()
if retrieved != oldStyleToken {
t.Errorf("Backward compatibility failed: expected %q, got %q", oldStyleToken, retrieved)
}
// Test with simulated old compressed token
oldCompressed := compressToken(oldStyleToken)
session.accessSession.Values["token"] = oldCompressed
session.accessSession.Values["compressed"] = true
retrieved2 := session.GetAccessToken()
if retrieved2 != oldStyleToken {
t.Errorf("Backward compatibility with compression failed: expected %q, got %q", oldStyleToken, retrieved2)
}
}
// createTokenOfSize creates a JWT token of approximately the specified size
// This function is deprecated - use TestTokens.CreateLargeValidJWT instead
func createTokenOfSize(baseToken string, targetSize int) string {
testTokens := NewTestTokens()
return testTokens.CreateLargeValidJWT(targetSize)
}
-684
View File
@@ -1,684 +0,0 @@
package traefikoidc
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"text/template"
"time"
"golang.org/x/time/rate"
)
// TestTokenTypeDistinction tests that AccessToken and IDToken are correctly distinguished in templates
func TestTokenTypeDistinction(t *testing.T) {
// Define test data where AccessToken and IDToken are deliberately different
type templateData struct {
Claims map[string]interface{}
AccessToken string
IDToken string
RefreshToken string
}
testData := templateData{
AccessToken: "test-access-token-abc123",
IDToken: "test-id-token-xyz789",
RefreshToken: "test-refresh-token",
Claims: map[string]interface{}{
"sub": "test-subject",
"email": "user@example.com",
},
}
// Test cases
tests := []struct {
name string
templateText string
expectedValue string
}{
{
name: "Access Token Only",
templateText: "Bearer {{.AccessToken}}",
expectedValue: "Bearer test-access-token-abc123",
},
{
name: "ID Token Only",
templateText: "ID: {{.IDToken}}",
expectedValue: "ID: test-id-token-xyz789",
},
{
name: "Both Tokens",
templateText: "Access: {{.AccessToken}} ID: {{.IDToken}}",
expectedValue: "Access: test-access-token-abc123 ID: test-id-token-xyz789",
},
{
name: "Both Tokens in Authorization Format",
templateText: "Bearer {{.AccessToken}} and Bearer {{.IDToken}}",
expectedValue: "Bearer test-access-token-abc123 and Bearer test-id-token-xyz789",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, testData)
if err != nil {
t.Fatalf("Failed to execute template: %v", err)
}
result := buf.String()
if result != tc.expectedValue {
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
}
})
}
}
// TestTokenTypeIntegration tests the integration of ID and access tokens with the middleware
func TestTokenTypeIntegration(t *testing.T) {
// Create a TestSuite to use its helper methods and fields
ts := &TestSuite{t: t}
ts.Setup()
// Create different tokens for ID and access tokens
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(3000000000),
"iat": float64(1000000000),
"nbf": float64(1000000000),
"sub": "test-subject",
"nonce": "test-nonce",
"jti": generateRandomString(16),
"token_type": "id_token",
"email": "user@example.com",
})
if err != nil {
t.Fatalf("Failed to create test ID JWT: %v", err)
}
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(3000000000),
"iat": float64(1000000000),
"nbf": float64(1000000000),
"sub": "test-subject",
"jti": generateRandomString(16),
"token_type": "access_token",
"scope": "openid profile email",
"email": "user@example.com", // Add email to access token so it's available in claims
})
if err != nil {
t.Fatalf("Failed to create test access JWT: %v", err)
}
// Define test headers that use both token types
headers := []TemplatedHeader{
{Name: "X-ID-Token", Value: "{{.IDToken}}"},
{Name: "X-Access-Token", Value: "{{.AccessToken}}"},
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
{Name: "X-Email-From-Claims", Value: "{{.Claims.email}}"},
}
// Store intercepted headers for verification
interceptedHeaders := make(map[string]string)
// Create a test next handler that captures the headers
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture headers for verification
for _, header := range headers {
if value := r.Header.Get(header.Name); value != "" {
interceptedHeaders[header.Name] = value
}
}
w.WriteHeader(http.StatusOK)
})
// Create the TraefikOidc instance
tOidc := &TraefikOidc{
next: nextHandler,
name: "test",
redirURLPath: "/callback",
logoutURLPath: "/callback/logout",
issuerURL: "https://test-issuer.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
jwkCache: ts.mockJWKCache,
jwksURL: "https://test-jwks-url.com",
tokenBlacklist: NewCache(),
tokenCache: NewTokenCache(),
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
logger: NewLogger("debug"),
allowedUserDomains: map[string]struct{}{"example.com": {}},
excludedURLs: map[string]struct{}{"/favicon": {}},
httpClient: &http.Client{},
initComplete: make(chan struct{}),
sessionManager: ts.sessionManager,
extractClaimsFunc: extractClaims,
headerTemplates: make(map[string]*template.Template),
}
tOidc.tokenVerifier = tOidc
tOidc.jwtVerifier = tOidc
// Initialize and parse header templates
for _, header := range headers {
tmpl, err := template.New(header.Name).Parse(header.Value)
if err != nil {
t.Fatalf("Failed to parse header template for %s: %v", header.Name, err)
}
tOidc.headerTemplates[header.Name] = tmpl
}
// Close the initComplete channel to bypass the waiting
close(tOidc.initComplete)
// Create a test request
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "example.com")
rr := httptest.NewRecorder()
// Create a session
session, err := tOidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Setup the session with authentication data
session.SetAuthenticated(true)
session.SetEmail("user@example.com")
session.SetIDToken(idToken) // Set the ID token
session.SetAccessToken(accessToken) // Set the access token
session.SetRefreshToken("test-refresh-token")
if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Add session cookies to the request
for _, cookie := range rr.Result().Cookies() {
req.AddCookie(cookie)
}
// Reset the response recorder for the main test
rr = httptest.NewRecorder()
// Process the request
tOidc.ServeHTTP(rr, req)
// Check status code
if rr.Code != http.StatusOK {
t.Errorf("Expected status code %d, got %d", http.StatusOK, rr.Code)
}
// Verify headers were set correctly
expectedHeaders := map[string]string{
"X-ID-Token": idToken,
"X-Access-Token": accessToken,
"Authorization": "Bearer " + accessToken,
"X-Email-From-Claims": "user@example.com",
}
for name, expectedValue := range expectedHeaders {
if value, exists := interceptedHeaders[name]; !exists {
t.Errorf("Expected header %s was not set", name)
} else if value != expectedValue {
t.Errorf("Header %s expected value %q, got %q", name, expectedValue, value)
}
}
}
// TestSessionIDTokenAccessToken tests that the SessionData correctly stores and retrieves
// both ID tokens and access tokens separately
func TestSessionIDTokenAccessToken(t *testing.T) {
// Create a logger for the session manager
logger := NewLogger("debug")
// Create a session manager
sessionManager, err := NewSessionManager("test-session-encryption-key-at-least-32-bytes", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Create a test request
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
// Get a session
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Set test tokens using standardized tokens
idToken := ValidIDToken
accessToken := ValidAccessToken
refreshToken := ValidRefreshToken
// Store tokens in session
session.SetIDToken(idToken)
session.SetAccessToken(accessToken)
session.SetRefreshToken(refreshToken)
// Save the session
if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Get cookies from response
cookies := rr.Result().Cookies()
// Create a new request with those cookies
req2 := httptest.NewRequest("GET", "/test", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
// Get the session again
session2, err := sessionManager.GetSession(req2)
if err != nil {
t.Fatalf("Failed to get session from request with cookies: %v", err)
}
// Verify that the tokens were correctly stored and retrieved
retrievedIDToken := session2.GetIDToken()
retrievedAccessToken := session2.GetAccessToken()
retrievedRefreshToken := session2.GetRefreshToken()
if retrievedIDToken != idToken {
t.Errorf("ID token mismatch: expected %q, got %q", idToken, retrievedIDToken)
}
if retrievedAccessToken != accessToken {
t.Errorf("Access token mismatch: expected %q, got %q", accessToken, retrievedAccessToken)
}
if retrievedRefreshToken != refreshToken {
t.Errorf("Refresh token mismatch: expected %q, got %q", refreshToken, retrievedRefreshToken)
}
// Verify that the tokens are distinct
if retrievedIDToken == retrievedAccessToken {
t.Errorf("ID token and Access token should be different, but both are %q", retrievedIDToken)
}
}
// TestTokenCorruptionIntegrationFlows tests the complete token handling flow with corruption scenarios
func TestTokenCorruptionIntegrationFlows(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
tests := []struct {
corruptAction func(*SessionData)
name string
accessToken string
refreshToken string
idToken string
expectSuccess bool
}{
{
name: "Normal flow - small tokens",
accessToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.access_signature_data_here",
refreshToken: "refresh_token_12345",
idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_token_signature_data_here",
expectSuccess: true,
},
{
name: "Normal flow - large tokens (chunked)",
accessToken: createLargeValidJWT(5000),
refreshToken: createLargeRefreshToken(3000),
idToken: createLargeValidJWT(2000),
expectSuccess: true,
},
{
name: "Corrupted access token compression",
accessToken: createLargeValidJWT(3000),
refreshToken: "refresh_token_12345",
idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_token_signature_data_here",
expectSuccess: false,
corruptAction: func(session *SessionData) {
// Corrupt compressed access token
if session.accessSession != nil {
session.accessSession.Values["token"] = "corrupted_compressed_data_!@#"
session.accessSession.Values["compressed"] = true
}
},
},
{
name: "Corrupted chunk in large token",
accessToken: createLargeValidJWT(15000), // Force chunking with larger size
refreshToken: "refresh_token_12345",
idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_token_signature_data_here",
expectSuccess: false,
corruptAction: func(session *SessionData) {
// Corrupt first chunk if chunked, otherwise corrupt single token
if len(session.accessTokenChunks) > 0 {
if chunk, exists := session.accessTokenChunks[0]; exists {
chunk.Values["token_chunk"] = "__CORRUPTED_CHUNK_DATA__"
}
} else {
// Token is stored as single compressed token - corrupt it
if session.accessSession != nil {
session.accessSession.Values["token"] = "__CORRUPTED_CHUNK_DATA__"
}
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
// Get session
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
// Store tokens
session.SetAccessToken(tt.accessToken)
session.SetRefreshToken(tt.refreshToken)
session.SetIDToken(tt.idToken)
session.SetAuthenticated(true)
// Save session
if err := session.Save(req, rr); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Apply corruption if specified
if tt.corruptAction != nil {
tt.corruptAction(session)
}
// Test token retrieval after corruption
retrievedAccess := session.GetAccessToken()
retrievedRefresh := session.GetRefreshToken()
retrievedID := session.GetIDToken()
if tt.expectSuccess {
if retrievedAccess != tt.accessToken {
t.Errorf("Access token corruption: expected %q, got %q", tt.accessToken, retrievedAccess)
}
if retrievedRefresh != tt.refreshToken {
t.Errorf("Refresh token corruption: expected %q, got %q", tt.refreshToken, retrievedRefresh)
}
if retrievedID != tt.idToken {
t.Errorf("ID token corruption: expected %q, got %q", tt.idToken, retrievedID)
}
} else {
// For corruption scenarios, access token should be empty (graceful failure)
if retrievedAccess != "" {
t.Errorf("Expected corrupted access token to return empty, got: %q", retrievedAccess)
}
// Other tokens should still work
if retrievedRefresh != tt.refreshToken {
t.Errorf("Refresh token should not be affected by access token corruption: expected %q, got %q",
tt.refreshToken, retrievedRefresh)
}
}
})
}
}
// TestSessionPersistenceWithCorruption tests that session corruption is handled across requests
func TestSessionPersistenceWithCorruption(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// First request - store tokens
req1 := httptest.NewRequest("GET", "/test", nil)
rr1 := httptest.NewRecorder()
session1, err := sm.GetSession(req1)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Use a smaller token that's less likely to accidentally contain corruption markers
largeToken := createLargeValidJWT(2000)
session1.SetAccessToken(largeToken)
session1.SetAuthenticated(true)
if err := session1.Save(req1, rr1); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Get cookies from first response
cookies := rr1.Result().Cookies()
session1.ReturnToPool()
// Second request - retrieve tokens with cookies
req2 := httptest.NewRequest("GET", "/test", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sm.GetSession(req2)
if err != nil {
t.Fatalf("Failed to get session from cookies: %v", err)
}
defer session2.ReturnToPool()
// Verify token can be retrieved initially
retrieved := session2.GetAccessToken()
if retrieved != largeToken {
t.Errorf("Token persistence failed: expected valid token, got empty token")
}
// Simulate corruption by modifying chunks
if len(session2.accessTokenChunks) > 0 {
// Corrupt a middle chunk with a unique corruption marker
chunkIndex := len(session2.accessTokenChunks) / 2
if chunk, exists := session2.accessTokenChunks[chunkIndex]; exists {
chunk.Values["token_chunk"] = "__CORRUPTION_MARKER_TEST__"
}
// Try to retrieve again - should detect corruption and return empty
retrievedAfterCorruption := session2.GetAccessToken()
if retrievedAfterCorruption != "" {
t.Errorf("Expected corruption to be detected, but got token: %q", retrievedAfterCorruption)
}
}
}
// TestConcurrentTokenOperationsWithCorruption tests concurrent access with intentional corruption
func TestConcurrentTokenOperationsWithCorruption(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "/test", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
const numGoroutines = 10
const numOperations = 20
done := make(chan bool, numGoroutines)
errorChan := make(chan error, numGoroutines*numOperations)
// Start concurrent operations
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer func() { done <- true }()
for j := 0; j < numOperations; j++ {
// Create a unique valid token for each operation
token := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwib3AiOiIxMjMifQ.sig_%d_%d",
goroutineID, j)
// Store token
session.SetAccessToken(token)
// Retrieve token
retrieved := session.GetAccessToken()
// Validate retrieved token format
if retrieved != "" {
if strings.Count(retrieved, ".") != 2 {
errorChan <- fmt.Errorf("goroutine %d, op %d: invalid JWT format: %q",
goroutineID, j, retrieved)
continue
}
// Check if it's a reasonable length
if len(retrieved) < 10 || len(retrieved) > 100000 {
errorChan <- fmt.Errorf("goroutine %d, op %d: suspicious token length %d: %q",
goroutineID, j, len(retrieved), retrieved)
}
}
// Occasionally simulate corruption to test error handling
if j%5 == 0 && len(session.accessTokenChunks) > 0 {
// Intentionally corrupt a random chunk
for chunkID, chunk := range session.accessTokenChunks {
if chunkID%2 == 0 {
chunk.Values["token_chunk"] = "__CORRUPTION_MARKER_TEST__"
break
}
}
}
}
}(i)
}
// Wait for all goroutines to complete
for i := 0; i < numGoroutines; i++ {
<-done
}
close(errorChan)
// Check for any unexpected errors
errorCount := 0
for err := range errorChan {
t.Logf("Concurrent operation error: %v", err)
errorCount++
}
// We expect some corruption-related "errors" due to intentional corruption,
// but not format-related errors which would indicate actual corruption bugs
if errorCount > numGoroutines*numOperations/4 { // Allow up to 25% corruption-related issues
t.Errorf("Too many errors during concurrent operations: %d", errorCount)
}
}
// TestTokenValidationEdgeCases tests edge cases in token validation
func TestTokenValidationEdgeCases(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "/test", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
// Use standardized test tokens
testTokens := NewTestTokens()
edgeCases := testTokens.TokenValidationTestCases()
for _, ec := range edgeCases {
t.Run(ec.Name, func(t *testing.T) {
// Clear any previous token
session.SetAccessToken("")
// Store the test token
originalToken := session.GetAccessToken()
session.SetAccessToken(ec.Token)
afterStoreToken := session.GetAccessToken()
if ec.ExpectStored {
if afterStoreToken != ec.Token {
t.Errorf("Expected token to be stored, but got different value")
}
} else {
if afterStoreToken != originalToken {
t.Errorf("Expected invalid token to be rejected, but it was stored")
}
}
// Test retrieval
finalToken := session.GetAccessToken()
if ec.ExpectRetrieved {
if finalToken != ec.Token {
t.Errorf("Expected token to be retrievable: %q, got: %q", ec.Token, finalToken)
}
} else {
if finalToken != "" {
t.Errorf("Expected empty token due to invalid format, got: %q", finalToken)
}
}
})
}
}
// Helper functions for test data creation
// createLargeValidJWT creates a JWT of approximately the specified size
func createLargeValidJWT(targetSize int) string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
// Create a valid base64url signature
signatureBytes := make([]byte, 32)
rand.Read(signatureBytes)
signature := base64.RawURLEncoding.EncodeToString(signatureBytes)
// Calculate required payload size
usedSize := len(header) + len(signature) + 2 // account for dots
payloadSize := targetSize - usedSize
if payloadSize < 50 {
payloadSize = 50
}
// Create a payload with realistic JWT claims, using safe content
claims := map[string]interface{}{
"sub": "user123",
"iss": "https://example.com",
"aud": "client123",
"exp": 9999999999,
"iat": 1000000000,
"data": strings.Repeat("abcdef0123456789", (payloadSize-100)/16), // Safe repeating pattern
}
claimsJSON, _ := json.Marshal(claims)
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
return fmt.Sprintf("%s.%s.%s", header, payload, signature)
}
// createLargeRefreshToken creates a refresh token of approximately the specified size
func createLargeRefreshToken(targetSize int) string {
baseToken := "refresh_token_"
padding := generateRandomString(targetSize - len(baseToken))
return baseToken + padding
}
-15
View File
@@ -1,15 +0,0 @@
ISC License
Copyright (c) 2012-2016 Dave Collins <dave@davec.name>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
-146
View File
@@ -1,146 +0,0 @@
// Copyright (c) 2015-2016 Dave Collins <dave@davec.name>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
// NOTE: Due to the following build constraints, this file will only be compiled
// when the code is not running on Google App Engine, compiled by GopherJS, and
// "-tags safe" is not added to the go build command line. The "disableunsafe"
// tag is deprecated and thus should not be used.
// Go versions prior to 1.4 are disabled because they use a different layout
// for interfaces which make the implementation of unsafeReflectValue more complex.
//go:build !js && !appengine && !safe && !disableunsafe && go1.4
// +build !js,!appengine,!safe,!disableunsafe,go1.4
package spew
import (
"reflect"
"unsafe"
)
const (
// UnsafeDisabled is a build-time constant which specifies whether or
// not access to the unsafe package is available.
UnsafeDisabled = false
// ptrSize is the size of a pointer on the current arch.
ptrSize = unsafe.Sizeof((*byte)(nil))
)
type flag uintptr
var (
// flagRO indicates whether the value field of a reflect.Value
// is read-only.
flagRO flag
// flagAddr indicates whether the address of the reflect.Value's
// value may be taken.
flagAddr flag
)
// flagKindMask holds the bits that make up the kind
// part of the flags field. In all the supported versions,
// it is in the lower 5 bits.
const flagKindMask = flag(0x1f)
// Different versions of Go have used different
// bit layouts for the flags type. This table
// records the known combinations.
var okFlags = []struct {
ro, addr flag
}{{
// From Go 1.4 to 1.5
ro: 1 << 5,
addr: 1 << 7,
}, {
// Up to Go tip.
ro: 1<<5 | 1<<6,
addr: 1 << 8,
}}
var flagValOffset = func() uintptr {
field, ok := reflect.TypeOf(reflect.Value{}).FieldByName("flag")
if !ok {
panic("reflect.Value has no flag field")
}
return field.Offset
}()
// flagField returns a pointer to the flag field of a reflect.Value.
func flagField(v *reflect.Value) *flag {
return (*flag)(unsafe.Pointer(uintptr(unsafe.Pointer(v)) + flagValOffset))
}
// unsafeReflectValue converts the passed reflect.Value into a one that bypasses
// the typical safety restrictions preventing access to unaddressable and
// unexported data. It works by digging the raw pointer to the underlying
// value out of the protected value and generating a new unprotected (unsafe)
// reflect.Value to it.
//
// This allows us to check for implementations of the Stringer and error
// interfaces to be used for pretty printing ordinarily unaddressable and
// inaccessible values such as unexported struct fields.
func unsafeReflectValue(v reflect.Value) reflect.Value {
if !v.IsValid() || (v.CanInterface() && v.CanAddr()) {
return v
}
flagFieldPtr := flagField(&v)
*flagFieldPtr &^= flagRO
*flagFieldPtr |= flagAddr
return v
}
// Sanity checks against future reflect package changes
// to the type or semantics of the Value.flag field.
func init() {
field, ok := reflect.TypeOf(reflect.Value{}).FieldByName("flag")
if !ok {
panic("reflect.Value has no flag field")
}
if field.Type.Kind() != reflect.TypeOf(flag(0)).Kind() {
panic("reflect.Value flag field has changed kind")
}
type t0 int
var t struct {
A t0
// t0 will have flagEmbedRO set.
t0
// a will have flagStickyRO set
a t0
}
vA := reflect.ValueOf(t).FieldByName("A")
va := reflect.ValueOf(t).FieldByName("a")
vt0 := reflect.ValueOf(t).FieldByName("t0")
// Infer flagRO from the difference between the flags
// for the (otherwise identical) fields in t.
flagPublic := *flagField(&vA)
flagWithRO := *flagField(&va) | *flagField(&vt0)
flagRO = flagPublic ^ flagWithRO
// Infer flagAddr from the difference between a value
// taken from a pointer and not.
vPtrA := reflect.ValueOf(&t).Elem().FieldByName("A")
flagNoPtr := *flagField(&vA)
flagPtr := *flagField(&vPtrA)
flagAddr = flagNoPtr ^ flagPtr
// Check that the inferred flags tally with one of the known versions.
for _, f := range okFlags {
if flagRO == f.ro && flagAddr == f.addr {
return
}
}
panic("reflect.Value read-only flag has changed semantics")
}
-39
View File
@@ -1,39 +0,0 @@
// Copyright (c) 2015-2016 Dave Collins <dave@davec.name>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
// NOTE: Due to the following build constraints, this file will only be compiled
// when the code is running on Google App Engine, compiled by GopherJS, or
// "-tags safe" is added to the go build command line. The "disableunsafe"
// tag is deprecated and thus should not be used.
//go:build js || appengine || safe || disableunsafe || !go1.4
// +build js appengine safe disableunsafe !go1.4
package spew
import "reflect"
const (
// UnsafeDisabled is a build-time constant which specifies whether or
// not access to the unsafe package is available.
UnsafeDisabled = true
)
// unsafeReflectValue typically converts the passed reflect.Value into a one
// that bypasses the typical safety restrictions preventing access to
// unaddressable and unexported data. However, doing this relies on access to
// the unsafe package. This is a stub version which simply returns the passed
// reflect.Value when the unsafe package is not available.
func unsafeReflectValue(v reflect.Value) reflect.Value {
return v
}
-341
View File
@@ -1,341 +0,0 @@
/*
* Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
package spew
import (
"bytes"
"fmt"
"io"
"reflect"
"sort"
"strconv"
)
// Some constants in the form of bytes to avoid string overhead. This mirrors
// the technique used in the fmt package.
var (
panicBytes = []byte("(PANIC=")
plusBytes = []byte("+")
iBytes = []byte("i")
trueBytes = []byte("true")
falseBytes = []byte("false")
interfaceBytes = []byte("(interface {})")
commaNewlineBytes = []byte(",\n")
newlineBytes = []byte("\n")
openBraceBytes = []byte("{")
openBraceNewlineBytes = []byte("{\n")
closeBraceBytes = []byte("}")
asteriskBytes = []byte("*")
colonBytes = []byte(":")
colonSpaceBytes = []byte(": ")
openParenBytes = []byte("(")
closeParenBytes = []byte(")")
spaceBytes = []byte(" ")
pointerChainBytes = []byte("->")
nilAngleBytes = []byte("<nil>")
maxNewlineBytes = []byte("<max depth reached>\n")
maxShortBytes = []byte("<max>")
circularBytes = []byte("<already shown>")
circularShortBytes = []byte("<shown>")
invalidAngleBytes = []byte("<invalid>")
openBracketBytes = []byte("[")
closeBracketBytes = []byte("]")
percentBytes = []byte("%")
precisionBytes = []byte(".")
openAngleBytes = []byte("<")
closeAngleBytes = []byte(">")
openMapBytes = []byte("map[")
closeMapBytes = []byte("]")
lenEqualsBytes = []byte("len=")
capEqualsBytes = []byte("cap=")
)
// hexDigits is used to map a decimal value to a hex digit.
var hexDigits = "0123456789abcdef"
// catchPanic handles any panics that might occur during the handleMethods
// calls.
func catchPanic(w io.Writer, v reflect.Value) {
if err := recover(); err != nil {
w.Write(panicBytes)
fmt.Fprintf(w, "%v", err)
w.Write(closeParenBytes)
}
}
// handleMethods attempts to call the Error and String methods on the underlying
// type the passed reflect.Value represents and outputes the result to Writer w.
//
// It handles panics in any called methods by catching and displaying the error
// as the formatted value.
func handleMethods(cs *ConfigState, w io.Writer, v reflect.Value) (handled bool) {
// We need an interface to check if the type implements the error or
// Stringer interface. However, the reflect package won't give us an
// interface on certain things like unexported struct fields in order
// to enforce visibility rules. We use unsafe, when it's available,
// to bypass these restrictions since this package does not mutate the
// values.
if !v.CanInterface() {
if UnsafeDisabled {
return false
}
v = unsafeReflectValue(v)
}
// Choose whether or not to do error and Stringer interface lookups against
// the base type or a pointer to the base type depending on settings.
// Technically calling one of these methods with a pointer receiver can
// mutate the value, however, types which choose to satisify an error or
// Stringer interface with a pointer receiver should not be mutating their
// state inside these interface methods.
if !cs.DisablePointerMethods && !UnsafeDisabled && !v.CanAddr() {
v = unsafeReflectValue(v)
}
if v.CanAddr() {
v = v.Addr()
}
// Is it an error or Stringer?
switch iface := v.Interface().(type) {
case error:
defer catchPanic(w, v)
if cs.ContinueOnMethod {
w.Write(openParenBytes)
w.Write([]byte(iface.Error()))
w.Write(closeParenBytes)
w.Write(spaceBytes)
return false
}
w.Write([]byte(iface.Error()))
return true
case fmt.Stringer:
defer catchPanic(w, v)
if cs.ContinueOnMethod {
w.Write(openParenBytes)
w.Write([]byte(iface.String()))
w.Write(closeParenBytes)
w.Write(spaceBytes)
return false
}
w.Write([]byte(iface.String()))
return true
}
return false
}
// printBool outputs a boolean value as true or false to Writer w.
func printBool(w io.Writer, val bool) {
if val {
w.Write(trueBytes)
} else {
w.Write(falseBytes)
}
}
// printInt outputs a signed integer value to Writer w.
func printInt(w io.Writer, val int64, base int) {
w.Write([]byte(strconv.FormatInt(val, base)))
}
// printUint outputs an unsigned integer value to Writer w.
func printUint(w io.Writer, val uint64, base int) {
w.Write([]byte(strconv.FormatUint(val, base)))
}
// printFloat outputs a floating point value using the specified precision,
// which is expected to be 32 or 64bit, to Writer w.
func printFloat(w io.Writer, val float64, precision int) {
w.Write([]byte(strconv.FormatFloat(val, 'g', -1, precision)))
}
// printComplex outputs a complex value using the specified float precision
// for the real and imaginary parts to Writer w.
func printComplex(w io.Writer, c complex128, floatPrecision int) {
r := real(c)
w.Write(openParenBytes)
w.Write([]byte(strconv.FormatFloat(r, 'g', -1, floatPrecision)))
i := imag(c)
if i >= 0 {
w.Write(plusBytes)
}
w.Write([]byte(strconv.FormatFloat(i, 'g', -1, floatPrecision)))
w.Write(iBytes)
w.Write(closeParenBytes)
}
// printHexPtr outputs a uintptr formatted as hexadecimal with a leading '0x'
// prefix to Writer w.
func printHexPtr(w io.Writer, p uintptr) {
// Null pointer.
num := uint64(p)
if num == 0 {
w.Write(nilAngleBytes)
return
}
// Max uint64 is 16 bytes in hex + 2 bytes for '0x' prefix
buf := make([]byte, 18)
// It's simpler to construct the hex string right to left.
base := uint64(16)
i := len(buf) - 1
for num >= base {
buf[i] = hexDigits[num%base]
num /= base
i--
}
buf[i] = hexDigits[num]
// Add '0x' prefix.
i--
buf[i] = 'x'
i--
buf[i] = '0'
// Strip unused leading bytes.
buf = buf[i:]
w.Write(buf)
}
// valuesSorter implements sort.Interface to allow a slice of reflect.Value
// elements to be sorted.
type valuesSorter struct {
values []reflect.Value
strings []string // either nil or same len and values
cs *ConfigState
}
// newValuesSorter initializes a valuesSorter instance, which holds a set of
// surrogate keys on which the data should be sorted. It uses flags in
// ConfigState to decide if and how to populate those surrogate keys.
func newValuesSorter(values []reflect.Value, cs *ConfigState) sort.Interface {
vs := &valuesSorter{values: values, cs: cs}
if canSortSimply(vs.values[0].Kind()) {
return vs
}
if !cs.DisableMethods {
vs.strings = make([]string, len(values))
for i := range vs.values {
b := bytes.Buffer{}
if !handleMethods(cs, &b, vs.values[i]) {
vs.strings = nil
break
}
vs.strings[i] = b.String()
}
}
if vs.strings == nil && cs.SpewKeys {
vs.strings = make([]string, len(values))
for i := range vs.values {
vs.strings[i] = Sprintf("%#v", vs.values[i].Interface())
}
}
return vs
}
// canSortSimply tests whether a reflect.Kind is a primitive that can be sorted
// directly, or whether it should be considered for sorting by surrogate keys
// (if the ConfigState allows it).
func canSortSimply(kind reflect.Kind) bool {
// This switch parallels valueSortLess, except for the default case.
switch kind {
case reflect.Bool:
return true
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
return true
case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
return true
case reflect.Float32, reflect.Float64:
return true
case reflect.String:
return true
case reflect.Uintptr:
return true
case reflect.Array:
return true
}
return false
}
// Len returns the number of values in the slice. It is part of the
// sort.Interface implementation.
func (s *valuesSorter) Len() int {
return len(s.values)
}
// Swap swaps the values at the passed indices. It is part of the
// sort.Interface implementation.
func (s *valuesSorter) Swap(i, j int) {
s.values[i], s.values[j] = s.values[j], s.values[i]
if s.strings != nil {
s.strings[i], s.strings[j] = s.strings[j], s.strings[i]
}
}
// valueSortLess returns whether the first value should sort before the second
// value. It is used by valueSorter.Less as part of the sort.Interface
// implementation.
func valueSortLess(a, b reflect.Value) bool {
switch a.Kind() {
case reflect.Bool:
return !a.Bool() && b.Bool()
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
return a.Int() < b.Int()
case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
return a.Uint() < b.Uint()
case reflect.Float32, reflect.Float64:
return a.Float() < b.Float()
case reflect.String:
return a.String() < b.String()
case reflect.Uintptr:
return a.Uint() < b.Uint()
case reflect.Array:
// Compare the contents of both arrays.
l := a.Len()
for i := 0; i < l; i++ {
av := a.Index(i)
bv := b.Index(i)
if av.Interface() == bv.Interface() {
continue
}
return valueSortLess(av, bv)
}
}
return a.String() < b.String()
}
// Less returns whether the value at index i should sort before the
// value at index j. It is part of the sort.Interface implementation.
func (s *valuesSorter) Less(i, j int) bool {
if s.strings == nil {
return valueSortLess(s.values[i], s.values[j])
}
return s.strings[i] < s.strings[j]
}
// sortValues is a sort function that handles both native types and any type that
// can be converted to error or Stringer. Other inputs are sorted according to
// their Value.String() value to ensure display stability.
func sortValues(values []reflect.Value, cs *ConfigState) {
if len(values) == 0 {
return
}
sort.Sort(newValuesSorter(values, cs))
}
-306
View File
@@ -1,306 +0,0 @@
/*
* Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
package spew
import (
"bytes"
"fmt"
"io"
"os"
)
// ConfigState houses the configuration options used by spew to format and
// display values. There is a global instance, Config, that is used to control
// all top-level Formatter and Dump functionality. Each ConfigState instance
// provides methods equivalent to the top-level functions.
//
// The zero value for ConfigState provides no indentation. You would typically
// want to set it to a space or a tab.
//
// Alternatively, you can use NewDefaultConfig to get a ConfigState instance
// with default settings. See the documentation of NewDefaultConfig for default
// values.
type ConfigState struct {
// Indent specifies the string to use for each indentation level. The
// global config instance that all top-level functions use set this to a
// single space by default. If you would like more indentation, you might
// set this to a tab with "\t" or perhaps two spaces with " ".
Indent string
// MaxDepth controls the maximum number of levels to descend into nested
// data structures. The default, 0, means there is no limit.
//
// NOTE: Circular data structures are properly detected, so it is not
// necessary to set this value unless you specifically want to limit deeply
// nested data structures.
MaxDepth int
// DisableMethods specifies whether or not error and Stringer interfaces are
// invoked for types that implement them.
DisableMethods bool
// DisablePointerMethods specifies whether or not to check for and invoke
// error and Stringer interfaces on types which only accept a pointer
// receiver when the current type is not a pointer.
//
// NOTE: This might be an unsafe action since calling one of these methods
// with a pointer receiver could technically mutate the value, however,
// in practice, types which choose to satisify an error or Stringer
// interface with a pointer receiver should not be mutating their state
// inside these interface methods. As a result, this option relies on
// access to the unsafe package, so it will not have any effect when
// running in environments without access to the unsafe package such as
// Google App Engine or with the "safe" build tag specified.
DisablePointerMethods bool
// DisablePointerAddresses specifies whether to disable the printing of
// pointer addresses. This is useful when diffing data structures in tests.
DisablePointerAddresses bool
// DisableCapacities specifies whether to disable the printing of capacities
// for arrays, slices, maps and channels. This is useful when diffing
// data structures in tests.
DisableCapacities bool
// ContinueOnMethod specifies whether or not recursion should continue once
// a custom error or Stringer interface is invoked. The default, false,
// means it will print the results of invoking the custom error or Stringer
// interface and return immediately instead of continuing to recurse into
// the internals of the data type.
//
// NOTE: This flag does not have any effect if method invocation is disabled
// via the DisableMethods or DisablePointerMethods options.
ContinueOnMethod bool
// SortKeys specifies map keys should be sorted before being printed. Use
// this to have a more deterministic, diffable output. Note that only
// native types (bool, int, uint, floats, uintptr and string) and types
// that support the error or Stringer interfaces (if methods are
// enabled) are supported, with other types sorted according to the
// reflect.Value.String() output which guarantees display stability.
SortKeys bool
// SpewKeys specifies that, as a last resort attempt, map keys should
// be spewed to strings and sorted by those strings. This is only
// considered if SortKeys is true.
SpewKeys bool
}
// Config is the active configuration of the top-level functions.
// The configuration can be changed by modifying the contents of spew.Config.
var Config = ConfigState{Indent: " "}
// Errorf is a wrapper for fmt.Errorf that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the formatted string as a value that satisfies error. See NewFormatter
// for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Errorf(format, c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Errorf(format string, a ...interface{}) (err error) {
return fmt.Errorf(format, c.convertArgs(a)...)
}
// Fprint is a wrapper for fmt.Fprint that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Fprint(w, c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Fprint(w io.Writer, a ...interface{}) (n int, err error) {
return fmt.Fprint(w, c.convertArgs(a)...)
}
// Fprintf is a wrapper for fmt.Fprintf that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Fprintf(w, format, c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
return fmt.Fprintf(w, format, c.convertArgs(a)...)
}
// Fprintln is a wrapper for fmt.Fprintln that treats each argument as if it
// passed with a Formatter interface returned by c.NewFormatter. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Fprintln(w, c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
return fmt.Fprintln(w, c.convertArgs(a)...)
}
// Print is a wrapper for fmt.Print that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Print(c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Print(a ...interface{}) (n int, err error) {
return fmt.Print(c.convertArgs(a)...)
}
// Printf is a wrapper for fmt.Printf that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Printf(format, c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Printf(format string, a ...interface{}) (n int, err error) {
return fmt.Printf(format, c.convertArgs(a)...)
}
// Println is a wrapper for fmt.Println that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Println(c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Println(a ...interface{}) (n int, err error) {
return fmt.Println(c.convertArgs(a)...)
}
// Sprint is a wrapper for fmt.Sprint that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the resulting string. See NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Sprint(c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Sprint(a ...interface{}) string {
return fmt.Sprint(c.convertArgs(a)...)
}
// Sprintf is a wrapper for fmt.Sprintf that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the resulting string. See NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Sprintf(format, c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Sprintf(format string, a ...interface{}) string {
return fmt.Sprintf(format, c.convertArgs(a)...)
}
// Sprintln is a wrapper for fmt.Sprintln that treats each argument as if it
// were passed with a Formatter interface returned by c.NewFormatter. It
// returns the resulting string. See NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Sprintln(c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Sprintln(a ...interface{}) string {
return fmt.Sprintln(c.convertArgs(a)...)
}
/*
NewFormatter returns a custom formatter that satisfies the fmt.Formatter
interface. As a result, it integrates cleanly with standard fmt package
printing functions. The formatter is useful for inline printing of smaller data
types similar to the standard %v format specifier.
The custom formatter only responds to the %v (most compact), %+v (adds pointer
addresses), %#v (adds types), and %#+v (adds types and pointer addresses) verb
combinations. Any other verbs such as %x and %q will be sent to the the
standard fmt package for formatting. In addition, the custom formatter ignores
the width and precision arguments (however they will still work on the format
specifiers not handled by the custom formatter).
Typically this function shouldn't be called directly. It is much easier to make
use of the custom formatter by calling one of the convenience functions such as
c.Printf, c.Println, or c.Printf.
*/
func (c *ConfigState) NewFormatter(v interface{}) fmt.Formatter {
return newFormatter(c, v)
}
// Fdump formats and displays the passed arguments to io.Writer w. It formats
// exactly the same as Dump.
func (c *ConfigState) Fdump(w io.Writer, a ...interface{}) {
fdump(c, w, a...)
}
/*
Dump displays the passed parameters to standard out with newlines, customizable
indentation, and additional debug information such as complete types and all
pointer addresses used to indirect to the final value. It provides the
following features over the built-in printing facilities provided by the fmt
package:
- Pointers are dereferenced and followed
- Circular data structures are detected and handled properly
- Custom Stringer/error interfaces are optionally invoked, including
on unexported types
- Custom types which only implement the Stringer/error interfaces via
a pointer receiver are optionally invoked when passing non-pointer
variables
- Byte arrays and slices are dumped like the hexdump -C command which
includes offsets, byte values in hex, and ASCII output
The configuration options are controlled by modifying the public members
of c. See ConfigState for options documentation.
See Fdump if you would prefer dumping to an arbitrary io.Writer or Sdump to
get the formatted result as a string.
*/
func (c *ConfigState) Dump(a ...interface{}) {
fdump(c, os.Stdout, a...)
}
// Sdump returns a string with the passed arguments formatted exactly the same
// as Dump.
func (c *ConfigState) Sdump(a ...interface{}) string {
var buf bytes.Buffer
fdump(c, &buf, a...)
return buf.String()
}
// convertArgs accepts a slice of arguments and returns a slice of the same
// length with each argument converted to a spew Formatter interface using
// the ConfigState associated with s.
func (c *ConfigState) convertArgs(args []interface{}) (formatters []interface{}) {
formatters = make([]interface{}, len(args))
for index, arg := range args {
formatters[index] = newFormatter(c, arg)
}
return formatters
}
// NewDefaultConfig returns a ConfigState with the following default settings.
//
// Indent: " "
// MaxDepth: 0
// DisableMethods: false
// DisablePointerMethods: false
// ContinueOnMethod: false
// SortKeys: false
func NewDefaultConfig() *ConfigState {
return &ConfigState{Indent: " "}
}
-217
View File
@@ -1,217 +0,0 @@
/*
* Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
/*
Package spew implements a deep pretty printer for Go data structures to aid in
debugging.
A quick overview of the additional features spew provides over the built-in
printing facilities for Go data types are as follows:
- Pointers are dereferenced and followed
- Circular data structures are detected and handled properly
- Custom Stringer/error interfaces are optionally invoked, including
on unexported types
- Custom types which only implement the Stringer/error interfaces via
a pointer receiver are optionally invoked when passing non-pointer
variables
- Byte arrays and slices are dumped like the hexdump -C command which
includes offsets, byte values in hex, and ASCII output (only when using
Dump style)
There are two different approaches spew allows for dumping Go data structures:
- Dump style which prints with newlines, customizable indentation,
and additional debug information such as types and all pointer addresses
used to indirect to the final value
- A custom Formatter interface that integrates cleanly with the standard fmt
package and replaces %v, %+v, %#v, and %#+v to provide inline printing
similar to the default %v while providing the additional functionality
outlined above and passing unsupported format verbs such as %x and %q
along to fmt
# Quick Start
This section demonstrates how to quickly get started with spew. See the
sections below for further details on formatting and configuration options.
To dump a variable with full newlines, indentation, type, and pointer
information use Dump, Fdump, or Sdump:
spew.Dump(myVar1, myVar2, ...)
spew.Fdump(someWriter, myVar1, myVar2, ...)
str := spew.Sdump(myVar1, myVar2, ...)
Alternatively, if you would prefer to use format strings with a compacted inline
printing style, use the convenience wrappers Printf, Fprintf, etc with
%v (most compact), %+v (adds pointer addresses), %#v (adds types), or
%#+v (adds types and pointer addresses):
spew.Printf("myVar1: %v -- myVar2: %+v", myVar1, myVar2)
spew.Printf("myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
spew.Fprintf(someWriter, "myVar1: %v -- myVar2: %+v", myVar1, myVar2)
spew.Fprintf(someWriter, "myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
# Configuration Options
Configuration of spew is handled by fields in the ConfigState type. For
convenience, all of the top-level functions use a global state available
via the spew.Config global.
It is also possible to create a ConfigState instance that provides methods
equivalent to the top-level functions. This allows concurrent configuration
options. See the ConfigState documentation for more details.
The following configuration options are available:
- Indent
String to use for each indentation level for Dump functions.
It is a single space by default. A popular alternative is "\t".
- MaxDepth
Maximum number of levels to descend into nested data structures.
There is no limit by default.
- DisableMethods
Disables invocation of error and Stringer interface methods.
Method invocation is enabled by default.
- DisablePointerMethods
Disables invocation of error and Stringer interface methods on types
which only accept pointer receivers from non-pointer variables.
Pointer method invocation is enabled by default.
- DisablePointerAddresses
DisablePointerAddresses specifies whether to disable the printing of
pointer addresses. This is useful when diffing data structures in tests.
- DisableCapacities
DisableCapacities specifies whether to disable the printing of
capacities for arrays, slices, maps and channels. This is useful when
diffing data structures in tests.
- ContinueOnMethod
Enables recursion into types after invoking error and Stringer interface
methods. Recursion after method invocation is disabled by default.
- SortKeys
Specifies map keys should be sorted before being printed. Use
this to have a more deterministic, diffable output. Note that
only native types (bool, int, uint, floats, uintptr and string)
and types which implement error or Stringer interfaces are
supported with other types sorted according to the
reflect.Value.String() output which guarantees display
stability. Natural map order is used by default.
- SpewKeys
Specifies that, as a last resort attempt, map keys should be
spewed to strings and sorted by those strings. This is only
considered if SortKeys is true.
# Dump Usage
Simply call spew.Dump with a list of variables you want to dump:
spew.Dump(myVar1, myVar2, ...)
You may also call spew.Fdump if you would prefer to output to an arbitrary
io.Writer. For example, to dump to standard error:
spew.Fdump(os.Stderr, myVar1, myVar2, ...)
A third option is to call spew.Sdump to get the formatted output as a string:
str := spew.Sdump(myVar1, myVar2, ...)
# Sample Dump Output
See the Dump example for details on the setup of the types and variables being
shown here.
(main.Foo) {
unexportedField: (*main.Bar)(0xf84002e210)({
flag: (main.Flag) flagTwo,
data: (uintptr) <nil>
}),
ExportedField: (map[interface {}]interface {}) (len=1) {
(string) (len=3) "one": (bool) true
}
}
Byte (and uint8) arrays and slices are displayed uniquely like the hexdump -C
command as shown.
([]uint8) (len=32 cap=32) {
00000000 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 |............... |
00000010 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 |!"#$%&'()*+,-./0|
00000020 31 32 |12|
}
# Custom Formatter
Spew provides a custom formatter that implements the fmt.Formatter interface
so that it integrates cleanly with standard fmt package printing functions. The
formatter is useful for inline printing of smaller data types similar to the
standard %v format specifier.
The custom formatter only responds to the %v (most compact), %+v (adds pointer
addresses), %#v (adds types), or %#+v (adds types and pointer addresses) verb
combinations. Any other verbs such as %x and %q will be sent to the the
standard fmt package for formatting. In addition, the custom formatter ignores
the width and precision arguments (however they will still work on the format
specifiers not handled by the custom formatter).
# Custom Formatter Usage
The simplest way to make use of the spew custom formatter is to call one of the
convenience functions such as spew.Printf, spew.Println, or spew.Printf. The
functions have syntax you are most likely already familiar with:
spew.Printf("myVar1: %v -- myVar2: %+v", myVar1, myVar2)
spew.Printf("myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
spew.Println(myVar, myVar2)
spew.Fprintf(os.Stderr, "myVar1: %v -- myVar2: %+v", myVar1, myVar2)
spew.Fprintf(os.Stderr, "myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
See the Index for the full list convenience functions.
# Sample Formatter Output
Double pointer to a uint8:
%v: <**>5
%+v: <**>(0xf8400420d0->0xf8400420c8)5
%#v: (**uint8)5
%#+v: (**uint8)(0xf8400420d0->0xf8400420c8)5
Pointer to circular struct with a uint8 field and a pointer to itself:
%v: <*>{1 <*><shown>}
%+v: <*>(0xf84003e260){ui8:1 c:<*>(0xf84003e260)<shown>}
%#v: (*main.circular){ui8:(uint8)1 c:(*main.circular)<shown>}
%#+v: (*main.circular)(0xf84003e260){ui8:(uint8)1 c:(*main.circular)(0xf84003e260)<shown>}
See the Printf example for details on the setup of variables being shown
here.
# Errors
Since it is possible for custom Stringer/error interfaces to panic, spew
detects them and handles them internally by printing the panic information
inline with the output. Since spew is intended to provide deep pretty printing
capabilities on structures, it intentionally does not return any errors.
*/
package spew
-509
View File
@@ -1,509 +0,0 @@
/*
* Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
package spew
import (
"bytes"
"encoding/hex"
"fmt"
"io"
"os"
"reflect"
"regexp"
"strconv"
"strings"
)
var (
// uint8Type is a reflect.Type representing a uint8. It is used to
// convert cgo types to uint8 slices for hexdumping.
uint8Type = reflect.TypeOf(uint8(0))
// cCharRE is a regular expression that matches a cgo char.
// It is used to detect character arrays to hexdump them.
cCharRE = regexp.MustCompile(`^.*\._Ctype_char$`)
// cUnsignedCharRE is a regular expression that matches a cgo unsigned
// char. It is used to detect unsigned character arrays to hexdump
// them.
cUnsignedCharRE = regexp.MustCompile(`^.*\._Ctype_unsignedchar$`)
// cUint8tCharRE is a regular expression that matches a cgo uint8_t.
// It is used to detect uint8_t arrays to hexdump them.
cUint8tCharRE = regexp.MustCompile(`^.*\._Ctype_uint8_t$`)
)
// dumpState contains information about the state of a dump operation.
type dumpState struct {
w io.Writer
depth int
pointers map[uintptr]int
ignoreNextType bool
ignoreNextIndent bool
cs *ConfigState
}
// indent performs indentation according to the depth level and cs.Indent
// option.
func (d *dumpState) indent() {
if d.ignoreNextIndent {
d.ignoreNextIndent = false
return
}
d.w.Write(bytes.Repeat([]byte(d.cs.Indent), d.depth))
}
// unpackValue returns values inside of non-nil interfaces when possible.
// This is useful for data types like structs, arrays, slices, and maps which
// can contain varying types packed inside an interface.
func (d *dumpState) unpackValue(v reflect.Value) reflect.Value {
if v.Kind() == reflect.Interface && !v.IsNil() {
v = v.Elem()
}
return v
}
// dumpPtr handles formatting of pointers by indirecting them as necessary.
func (d *dumpState) dumpPtr(v reflect.Value) {
// Remove pointers at or below the current depth from map used to detect
// circular refs.
for k, depth := range d.pointers {
if depth >= d.depth {
delete(d.pointers, k)
}
}
// Keep list of all dereferenced pointers to show later.
pointerChain := make([]uintptr, 0)
// Figure out how many levels of indirection there are by dereferencing
// pointers and unpacking interfaces down the chain while detecting circular
// references.
nilFound := false
cycleFound := false
indirects := 0
ve := v
for ve.Kind() == reflect.Ptr {
if ve.IsNil() {
nilFound = true
break
}
indirects++
addr := ve.Pointer()
pointerChain = append(pointerChain, addr)
if pd, ok := d.pointers[addr]; ok && pd < d.depth {
cycleFound = true
indirects--
break
}
d.pointers[addr] = d.depth
ve = ve.Elem()
if ve.Kind() == reflect.Interface {
if ve.IsNil() {
nilFound = true
break
}
ve = ve.Elem()
}
}
// Display type information.
d.w.Write(openParenBytes)
d.w.Write(bytes.Repeat(asteriskBytes, indirects))
d.w.Write([]byte(ve.Type().String()))
d.w.Write(closeParenBytes)
// Display pointer information.
if !d.cs.DisablePointerAddresses && len(pointerChain) > 0 {
d.w.Write(openParenBytes)
for i, addr := range pointerChain {
if i > 0 {
d.w.Write(pointerChainBytes)
}
printHexPtr(d.w, addr)
}
d.w.Write(closeParenBytes)
}
// Display dereferenced value.
d.w.Write(openParenBytes)
switch {
case nilFound:
d.w.Write(nilAngleBytes)
case cycleFound:
d.w.Write(circularBytes)
default:
d.ignoreNextType = true
d.dump(ve)
}
d.w.Write(closeParenBytes)
}
// dumpSlice handles formatting of arrays and slices. Byte (uint8 under
// reflection) arrays and slices are dumped in hexdump -C fashion.
func (d *dumpState) dumpSlice(v reflect.Value) {
// Determine whether this type should be hex dumped or not. Also,
// for types which should be hexdumped, try to use the underlying data
// first, then fall back to trying to convert them to a uint8 slice.
var buf []uint8
doConvert := false
doHexDump := false
numEntries := v.Len()
if numEntries > 0 {
vt := v.Index(0).Type()
vts := vt.String()
switch {
// C types that need to be converted.
case cCharRE.MatchString(vts):
fallthrough
case cUnsignedCharRE.MatchString(vts):
fallthrough
case cUint8tCharRE.MatchString(vts):
doConvert = true
// Try to use existing uint8 slices and fall back to converting
// and copying if that fails.
case vt.Kind() == reflect.Uint8:
// We need an addressable interface to convert the type
// to a byte slice. However, the reflect package won't
// give us an interface on certain things like
// unexported struct fields in order to enforce
// visibility rules. We use unsafe, when available, to
// bypass these restrictions since this package does not
// mutate the values.
vs := v
if !vs.CanInterface() || !vs.CanAddr() {
vs = unsafeReflectValue(vs)
}
if !UnsafeDisabled {
vs = vs.Slice(0, numEntries)
// Use the existing uint8 slice if it can be
// type asserted.
iface := vs.Interface()
if slice, ok := iface.([]uint8); ok {
buf = slice
doHexDump = true
break
}
}
// The underlying data needs to be converted if it can't
// be type asserted to a uint8 slice.
doConvert = true
}
// Copy and convert the underlying type if needed.
if doConvert && vt.ConvertibleTo(uint8Type) {
// Convert and copy each element into a uint8 byte
// slice.
buf = make([]uint8, numEntries)
for i := 0; i < numEntries; i++ {
vv := v.Index(i)
buf[i] = uint8(vv.Convert(uint8Type).Uint())
}
doHexDump = true
}
}
// Hexdump the entire slice as needed.
if doHexDump {
indent := strings.Repeat(d.cs.Indent, d.depth)
str := indent + hex.Dump(buf)
str = strings.Replace(str, "\n", "\n"+indent, -1)
str = strings.TrimRight(str, d.cs.Indent)
d.w.Write([]byte(str))
return
}
// Recursively call dump for each item.
for i := 0; i < numEntries; i++ {
d.dump(d.unpackValue(v.Index(i)))
if i < (numEntries - 1) {
d.w.Write(commaNewlineBytes)
} else {
d.w.Write(newlineBytes)
}
}
}
// dump is the main workhorse for dumping a value. It uses the passed reflect
// value to figure out what kind of object we are dealing with and formats it
// appropriately. It is a recursive function, however circular data structures
// are detected and handled properly.
func (d *dumpState) dump(v reflect.Value) {
// Handle invalid reflect values immediately.
kind := v.Kind()
if kind == reflect.Invalid {
d.w.Write(invalidAngleBytes)
return
}
// Handle pointers specially.
if kind == reflect.Ptr {
d.indent()
d.dumpPtr(v)
return
}
// Print type information unless already handled elsewhere.
if !d.ignoreNextType {
d.indent()
d.w.Write(openParenBytes)
d.w.Write([]byte(v.Type().String()))
d.w.Write(closeParenBytes)
d.w.Write(spaceBytes)
}
d.ignoreNextType = false
// Display length and capacity if the built-in len and cap functions
// work with the value's kind and the len/cap itself is non-zero.
valueLen, valueCap := 0, 0
switch v.Kind() {
case reflect.Array, reflect.Slice, reflect.Chan:
valueLen, valueCap = v.Len(), v.Cap()
case reflect.Map, reflect.String:
valueLen = v.Len()
}
if valueLen != 0 || !d.cs.DisableCapacities && valueCap != 0 {
d.w.Write(openParenBytes)
if valueLen != 0 {
d.w.Write(lenEqualsBytes)
printInt(d.w, int64(valueLen), 10)
}
if !d.cs.DisableCapacities && valueCap != 0 {
if valueLen != 0 {
d.w.Write(spaceBytes)
}
d.w.Write(capEqualsBytes)
printInt(d.w, int64(valueCap), 10)
}
d.w.Write(closeParenBytes)
d.w.Write(spaceBytes)
}
// Call Stringer/error interfaces if they exist and the handle methods flag
// is enabled
if !d.cs.DisableMethods {
if (kind != reflect.Invalid) && (kind != reflect.Interface) {
if handled := handleMethods(d.cs, d.w, v); handled {
return
}
}
}
switch kind {
case reflect.Invalid:
// Do nothing. We should never get here since invalid has already
// been handled above.
case reflect.Bool:
printBool(d.w, v.Bool())
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
printInt(d.w, v.Int(), 10)
case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
printUint(d.w, v.Uint(), 10)
case reflect.Float32:
printFloat(d.w, v.Float(), 32)
case reflect.Float64:
printFloat(d.w, v.Float(), 64)
case reflect.Complex64:
printComplex(d.w, v.Complex(), 32)
case reflect.Complex128:
printComplex(d.w, v.Complex(), 64)
case reflect.Slice:
if v.IsNil() {
d.w.Write(nilAngleBytes)
break
}
fallthrough
case reflect.Array:
d.w.Write(openBraceNewlineBytes)
d.depth++
if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) {
d.indent()
d.w.Write(maxNewlineBytes)
} else {
d.dumpSlice(v)
}
d.depth--
d.indent()
d.w.Write(closeBraceBytes)
case reflect.String:
d.w.Write([]byte(strconv.Quote(v.String())))
case reflect.Interface:
// The only time we should get here is for nil interfaces due to
// unpackValue calls.
if v.IsNil() {
d.w.Write(nilAngleBytes)
}
case reflect.Ptr:
// Do nothing. We should never get here since pointers have already
// been handled above.
case reflect.Map:
// nil maps should be indicated as different than empty maps
if v.IsNil() {
d.w.Write(nilAngleBytes)
break
}
d.w.Write(openBraceNewlineBytes)
d.depth++
if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) {
d.indent()
d.w.Write(maxNewlineBytes)
} else {
numEntries := v.Len()
keys := v.MapKeys()
if d.cs.SortKeys {
sortValues(keys, d.cs)
}
for i, key := range keys {
d.dump(d.unpackValue(key))
d.w.Write(colonSpaceBytes)
d.ignoreNextIndent = true
d.dump(d.unpackValue(v.MapIndex(key)))
if i < (numEntries - 1) {
d.w.Write(commaNewlineBytes)
} else {
d.w.Write(newlineBytes)
}
}
}
d.depth--
d.indent()
d.w.Write(closeBraceBytes)
case reflect.Struct:
d.w.Write(openBraceNewlineBytes)
d.depth++
if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) {
d.indent()
d.w.Write(maxNewlineBytes)
} else {
vt := v.Type()
numFields := v.NumField()
for i := 0; i < numFields; i++ {
d.indent()
vtf := vt.Field(i)
d.w.Write([]byte(vtf.Name))
d.w.Write(colonSpaceBytes)
d.ignoreNextIndent = true
d.dump(d.unpackValue(v.Field(i)))
if i < (numFields - 1) {
d.w.Write(commaNewlineBytes)
} else {
d.w.Write(newlineBytes)
}
}
}
d.depth--
d.indent()
d.w.Write(closeBraceBytes)
case reflect.Uintptr:
printHexPtr(d.w, uintptr(v.Uint()))
case reflect.UnsafePointer, reflect.Chan, reflect.Func:
printHexPtr(d.w, v.Pointer())
// There were not any other types at the time this code was written, but
// fall back to letting the default fmt package handle it in case any new
// types are added.
default:
if v.CanInterface() {
fmt.Fprintf(d.w, "%v", v.Interface())
} else {
fmt.Fprintf(d.w, "%v", v.String())
}
}
}
// fdump is a helper function to consolidate the logic from the various public
// methods which take varying writers and config states.
func fdump(cs *ConfigState, w io.Writer, a ...interface{}) {
for _, arg := range a {
if arg == nil {
w.Write(interfaceBytes)
w.Write(spaceBytes)
w.Write(nilAngleBytes)
w.Write(newlineBytes)
continue
}
d := dumpState{w: w, cs: cs}
d.pointers = make(map[uintptr]int)
d.dump(reflect.ValueOf(arg))
d.w.Write(newlineBytes)
}
}
// Fdump formats and displays the passed arguments to io.Writer w. It formats
// exactly the same as Dump.
func Fdump(w io.Writer, a ...interface{}) {
fdump(&Config, w, a...)
}
// Sdump returns a string with the passed arguments formatted exactly the same
// as Dump.
func Sdump(a ...interface{}) string {
var buf bytes.Buffer
fdump(&Config, &buf, a...)
return buf.String()
}
/*
Dump displays the passed parameters to standard out with newlines, customizable
indentation, and additional debug information such as complete types and all
pointer addresses used to indirect to the final value. It provides the
following features over the built-in printing facilities provided by the fmt
package:
- Pointers are dereferenced and followed
- Circular data structures are detected and handled properly
- Custom Stringer/error interfaces are optionally invoked, including
on unexported types
- Custom types which only implement the Stringer/error interfaces via
a pointer receiver are optionally invoked when passing non-pointer
variables
- Byte arrays and slices are dumped like the hexdump -C command which
includes offsets, byte values in hex, and ASCII output
The configuration options are controlled by an exported package global,
spew.Config. See ConfigState for options documentation.
See Fdump if you would prefer dumping to an arbitrary io.Writer or Sdump to
get the formatted result as a string.
*/
func Dump(a ...interface{}) {
fdump(&Config, os.Stdout, a...)
}
-419
View File
@@ -1,419 +0,0 @@
/*
* Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
package spew
import (
"bytes"
"fmt"
"reflect"
"strconv"
"strings"
)
// supportedFlags is a list of all the character flags supported by fmt package.
const supportedFlags = "0-+# "
// formatState implements the fmt.Formatter interface and contains information
// about the state of a formatting operation. The NewFormatter function can
// be used to get a new Formatter which can be used directly as arguments
// in standard fmt package printing calls.
type formatState struct {
value interface{}
fs fmt.State
depth int
pointers map[uintptr]int
ignoreNextType bool
cs *ConfigState
}
// buildDefaultFormat recreates the original format string without precision
// and width information to pass in to fmt.Sprintf in the case of an
// unrecognized type. Unless new types are added to the language, this
// function won't ever be called.
func (f *formatState) buildDefaultFormat() (format string) {
buf := bytes.NewBuffer(percentBytes)
for _, flag := range supportedFlags {
if f.fs.Flag(int(flag)) {
buf.WriteRune(flag)
}
}
buf.WriteRune('v')
format = buf.String()
return format
}
// constructOrigFormat recreates the original format string including precision
// and width information to pass along to the standard fmt package. This allows
// automatic deferral of all format strings this package doesn't support.
func (f *formatState) constructOrigFormat(verb rune) (format string) {
buf := bytes.NewBuffer(percentBytes)
for _, flag := range supportedFlags {
if f.fs.Flag(int(flag)) {
buf.WriteRune(flag)
}
}
if width, ok := f.fs.Width(); ok {
buf.WriteString(strconv.Itoa(width))
}
if precision, ok := f.fs.Precision(); ok {
buf.Write(precisionBytes)
buf.WriteString(strconv.Itoa(precision))
}
buf.WriteRune(verb)
format = buf.String()
return format
}
// unpackValue returns values inside of non-nil interfaces when possible and
// ensures that types for values which have been unpacked from an interface
// are displayed when the show types flag is also set.
// This is useful for data types like structs, arrays, slices, and maps which
// can contain varying types packed inside an interface.
func (f *formatState) unpackValue(v reflect.Value) reflect.Value {
if v.Kind() == reflect.Interface {
f.ignoreNextType = false
if !v.IsNil() {
v = v.Elem()
}
}
return v
}
// formatPtr handles formatting of pointers by indirecting them as necessary.
func (f *formatState) formatPtr(v reflect.Value) {
// Display nil if top level pointer is nil.
showTypes := f.fs.Flag('#')
if v.IsNil() && (!showTypes || f.ignoreNextType) {
f.fs.Write(nilAngleBytes)
return
}
// Remove pointers at or below the current depth from map used to detect
// circular refs.
for k, depth := range f.pointers {
if depth >= f.depth {
delete(f.pointers, k)
}
}
// Keep list of all dereferenced pointers to possibly show later.
pointerChain := make([]uintptr, 0)
// Figure out how many levels of indirection there are by derferencing
// pointers and unpacking interfaces down the chain while detecting circular
// references.
nilFound := false
cycleFound := false
indirects := 0
ve := v
for ve.Kind() == reflect.Ptr {
if ve.IsNil() {
nilFound = true
break
}
indirects++
addr := ve.Pointer()
pointerChain = append(pointerChain, addr)
if pd, ok := f.pointers[addr]; ok && pd < f.depth {
cycleFound = true
indirects--
break
}
f.pointers[addr] = f.depth
ve = ve.Elem()
if ve.Kind() == reflect.Interface {
if ve.IsNil() {
nilFound = true
break
}
ve = ve.Elem()
}
}
// Display type or indirection level depending on flags.
if showTypes && !f.ignoreNextType {
f.fs.Write(openParenBytes)
f.fs.Write(bytes.Repeat(asteriskBytes, indirects))
f.fs.Write([]byte(ve.Type().String()))
f.fs.Write(closeParenBytes)
} else {
if nilFound || cycleFound {
indirects += strings.Count(ve.Type().String(), "*")
}
f.fs.Write(openAngleBytes)
f.fs.Write([]byte(strings.Repeat("*", indirects)))
f.fs.Write(closeAngleBytes)
}
// Display pointer information depending on flags.
if f.fs.Flag('+') && (len(pointerChain) > 0) {
f.fs.Write(openParenBytes)
for i, addr := range pointerChain {
if i > 0 {
f.fs.Write(pointerChainBytes)
}
printHexPtr(f.fs, addr)
}
f.fs.Write(closeParenBytes)
}
// Display dereferenced value.
switch {
case nilFound:
f.fs.Write(nilAngleBytes)
case cycleFound:
f.fs.Write(circularShortBytes)
default:
f.ignoreNextType = true
f.format(ve)
}
}
// format is the main workhorse for providing the Formatter interface. It
// uses the passed reflect value to figure out what kind of object we are
// dealing with and formats it appropriately. It is a recursive function,
// however circular data structures are detected and handled properly.
func (f *formatState) format(v reflect.Value) {
// Handle invalid reflect values immediately.
kind := v.Kind()
if kind == reflect.Invalid {
f.fs.Write(invalidAngleBytes)
return
}
// Handle pointers specially.
if kind == reflect.Ptr {
f.formatPtr(v)
return
}
// Print type information unless already handled elsewhere.
if !f.ignoreNextType && f.fs.Flag('#') {
f.fs.Write(openParenBytes)
f.fs.Write([]byte(v.Type().String()))
f.fs.Write(closeParenBytes)
}
f.ignoreNextType = false
// Call Stringer/error interfaces if they exist and the handle methods
// flag is enabled.
if !f.cs.DisableMethods {
if (kind != reflect.Invalid) && (kind != reflect.Interface) {
if handled := handleMethods(f.cs, f.fs, v); handled {
return
}
}
}
switch kind {
case reflect.Invalid:
// Do nothing. We should never get here since invalid has already
// been handled above.
case reflect.Bool:
printBool(f.fs, v.Bool())
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
printInt(f.fs, v.Int(), 10)
case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
printUint(f.fs, v.Uint(), 10)
case reflect.Float32:
printFloat(f.fs, v.Float(), 32)
case reflect.Float64:
printFloat(f.fs, v.Float(), 64)
case reflect.Complex64:
printComplex(f.fs, v.Complex(), 32)
case reflect.Complex128:
printComplex(f.fs, v.Complex(), 64)
case reflect.Slice:
if v.IsNil() {
f.fs.Write(nilAngleBytes)
break
}
fallthrough
case reflect.Array:
f.fs.Write(openBracketBytes)
f.depth++
if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) {
f.fs.Write(maxShortBytes)
} else {
numEntries := v.Len()
for i := 0; i < numEntries; i++ {
if i > 0 {
f.fs.Write(spaceBytes)
}
f.ignoreNextType = true
f.format(f.unpackValue(v.Index(i)))
}
}
f.depth--
f.fs.Write(closeBracketBytes)
case reflect.String:
f.fs.Write([]byte(v.String()))
case reflect.Interface:
// The only time we should get here is for nil interfaces due to
// unpackValue calls.
if v.IsNil() {
f.fs.Write(nilAngleBytes)
}
case reflect.Ptr:
// Do nothing. We should never get here since pointers have already
// been handled above.
case reflect.Map:
// nil maps should be indicated as different than empty maps
if v.IsNil() {
f.fs.Write(nilAngleBytes)
break
}
f.fs.Write(openMapBytes)
f.depth++
if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) {
f.fs.Write(maxShortBytes)
} else {
keys := v.MapKeys()
if f.cs.SortKeys {
sortValues(keys, f.cs)
}
for i, key := range keys {
if i > 0 {
f.fs.Write(spaceBytes)
}
f.ignoreNextType = true
f.format(f.unpackValue(key))
f.fs.Write(colonBytes)
f.ignoreNextType = true
f.format(f.unpackValue(v.MapIndex(key)))
}
}
f.depth--
f.fs.Write(closeMapBytes)
case reflect.Struct:
numFields := v.NumField()
f.fs.Write(openBraceBytes)
f.depth++
if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) {
f.fs.Write(maxShortBytes)
} else {
vt := v.Type()
for i := 0; i < numFields; i++ {
if i > 0 {
f.fs.Write(spaceBytes)
}
vtf := vt.Field(i)
if f.fs.Flag('+') || f.fs.Flag('#') {
f.fs.Write([]byte(vtf.Name))
f.fs.Write(colonBytes)
}
f.format(f.unpackValue(v.Field(i)))
}
}
f.depth--
f.fs.Write(closeBraceBytes)
case reflect.Uintptr:
printHexPtr(f.fs, uintptr(v.Uint()))
case reflect.UnsafePointer, reflect.Chan, reflect.Func:
printHexPtr(f.fs, v.Pointer())
// There were not any other types at the time this code was written, but
// fall back to letting the default fmt package handle it if any get added.
default:
format := f.buildDefaultFormat()
if v.CanInterface() {
fmt.Fprintf(f.fs, format, v.Interface())
} else {
fmt.Fprintf(f.fs, format, v.String())
}
}
}
// Format satisfies the fmt.Formatter interface. See NewFormatter for usage
// details.
func (f *formatState) Format(fs fmt.State, verb rune) {
f.fs = fs
// Use standard formatting for verbs that are not v.
if verb != 'v' {
format := f.constructOrigFormat(verb)
fmt.Fprintf(fs, format, f.value)
return
}
if f.value == nil {
if fs.Flag('#') {
fs.Write(interfaceBytes)
}
fs.Write(nilAngleBytes)
return
}
f.format(reflect.ValueOf(f.value))
}
// newFormatter is a helper function to consolidate the logic from the various
// public methods which take varying config states.
func newFormatter(cs *ConfigState, v interface{}) fmt.Formatter {
fs := &formatState{value: v, cs: cs}
fs.pointers = make(map[uintptr]int)
return fs
}
/*
NewFormatter returns a custom formatter that satisfies the fmt.Formatter
interface. As a result, it integrates cleanly with standard fmt package
printing functions. The formatter is useful for inline printing of smaller data
types similar to the standard %v format specifier.
The custom formatter only responds to the %v (most compact), %+v (adds pointer
addresses), %#v (adds types), or %#+v (adds types and pointer addresses) verb
combinations. Any other verbs such as %x and %q will be sent to the the
standard fmt package for formatting. In addition, the custom formatter ignores
the width and precision arguments (however they will still work on the format
specifiers not handled by the custom formatter).
Typically this function shouldn't be called directly. It is much easier to make
use of the custom formatter by calling one of the convenience functions such as
Printf, Println, or Fprintf.
*/
func NewFormatter(v interface{}) fmt.Formatter {
return newFormatter(&Config, v)
}
-148
View File
@@ -1,148 +0,0 @@
/*
* Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
package spew
import (
"fmt"
"io"
)
// Errorf is a wrapper for fmt.Errorf that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the formatted string as a value that satisfies error. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Errorf(format, spew.NewFormatter(a), spew.NewFormatter(b))
func Errorf(format string, a ...interface{}) (err error) {
return fmt.Errorf(format, convertArgs(a)...)
}
// Fprint is a wrapper for fmt.Fprint that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Fprint(w, spew.NewFormatter(a), spew.NewFormatter(b))
func Fprint(w io.Writer, a ...interface{}) (n int, err error) {
return fmt.Fprint(w, convertArgs(a)...)
}
// Fprintf is a wrapper for fmt.Fprintf that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Fprintf(w, format, spew.NewFormatter(a), spew.NewFormatter(b))
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
return fmt.Fprintf(w, format, convertArgs(a)...)
}
// Fprintln is a wrapper for fmt.Fprintln that treats each argument as if it
// passed with a default Formatter interface returned by NewFormatter. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Fprintln(w, spew.NewFormatter(a), spew.NewFormatter(b))
func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
return fmt.Fprintln(w, convertArgs(a)...)
}
// Print is a wrapper for fmt.Print that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Print(spew.NewFormatter(a), spew.NewFormatter(b))
func Print(a ...interface{}) (n int, err error) {
return fmt.Print(convertArgs(a)...)
}
// Printf is a wrapper for fmt.Printf that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Printf(format, spew.NewFormatter(a), spew.NewFormatter(b))
func Printf(format string, a ...interface{}) (n int, err error) {
return fmt.Printf(format, convertArgs(a)...)
}
// Println is a wrapper for fmt.Println that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Println(spew.NewFormatter(a), spew.NewFormatter(b))
func Println(a ...interface{}) (n int, err error) {
return fmt.Println(convertArgs(a)...)
}
// Sprint is a wrapper for fmt.Sprint that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the resulting string. See NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Sprint(spew.NewFormatter(a), spew.NewFormatter(b))
func Sprint(a ...interface{}) string {
return fmt.Sprint(convertArgs(a)...)
}
// Sprintf is a wrapper for fmt.Sprintf that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the resulting string. See NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Sprintf(format, spew.NewFormatter(a), spew.NewFormatter(b))
func Sprintf(format string, a ...interface{}) string {
return fmt.Sprintf(format, convertArgs(a)...)
}
// Sprintln is a wrapper for fmt.Sprintln that treats each argument as if it
// were passed with a default Formatter interface returned by NewFormatter. It
// returns the resulting string. See NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Sprintln(spew.NewFormatter(a), spew.NewFormatter(b))
func Sprintln(a ...interface{}) string {
return fmt.Sprintln(convertArgs(a)...)
}
// convertArgs accepts a slice of arguments and returns a slice of the same
// length with each argument converted to a default spew Formatter interface.
func convertArgs(args []interface{}) (formatters []interface{}) {
formatters = make([]interface{}, len(args))
for index, arg := range args {
formatters[index] = NewFormatter(arg)
}
return formatters
}
-27
View File
@@ -1,27 +0,0 @@
Copyright (c) 2013, Patrick Mezard
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
The names of its contributors may not be used to endorse or promote
products derived from this software without specific prior written
permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-775
View File
@@ -1,775 +0,0 @@
// Package difflib is a partial port of Python difflib module.
//
// It provides tools to compare sequences of strings and generate textual diffs.
//
// The following class and functions have been ported:
//
// - SequenceMatcher
//
// - unified_diff
//
// - context_diff
//
// Getting unified diffs was the main goal of the port. Keep in mind this code
// is mostly suitable to output text differences in a human friendly way, there
// are no guarantees generated diffs are consumable by patch(1).
package difflib
import (
"bufio"
"bytes"
"fmt"
"io"
"strings"
)
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func calculateRatio(matches, length int) float64 {
if length > 0 {
return 2.0 * float64(matches) / float64(length)
}
return 1.0
}
type Match struct {
A int
B int
Size int
}
type OpCode struct {
Tag byte
I1 int
I2 int
J1 int
J2 int
}
// SequenceMatcher compares sequence of strings. The basic
// algorithm predates, and is a little fancier than, an algorithm
// published in the late 1980's by Ratcliff and Obershelp under the
// hyperbolic name "gestalt pattern matching". The basic idea is to find
// the longest contiguous matching subsequence that contains no "junk"
// elements (R-O doesn't address junk). The same idea is then applied
// recursively to the pieces of the sequences to the left and to the right
// of the matching subsequence. This does not yield minimal edit
// sequences, but does tend to yield matches that "look right" to people.
//
// SequenceMatcher tries to compute a "human-friendly diff" between two
// sequences. Unlike e.g. UNIX(tm) diff, the fundamental notion is the
// longest *contiguous* & junk-free matching subsequence. That's what
// catches peoples' eyes. The Windows(tm) windiff has another interesting
// notion, pairing up elements that appear uniquely in each sequence.
// That, and the method here, appear to yield more intuitive difference
// reports than does diff. This method appears to be the least vulnerable
// to synching up on blocks of "junk lines", though (like blank lines in
// ordinary text files, or maybe "<P>" lines in HTML files). That may be
// because this is the only method of the 3 that has a *concept* of
// "junk" <wink>.
//
// Timing: Basic R-O is cubic time worst case and quadratic time expected
// case. SequenceMatcher is quadratic time for the worst case and has
// expected-case behavior dependent in a complicated way on how many
// elements the sequences have in common; best case time is linear.
type SequenceMatcher struct {
a []string
b []string
b2j map[string][]int
IsJunk func(string) bool
autoJunk bool
bJunk map[string]struct{}
matchingBlocks []Match
fullBCount map[string]int
bPopular map[string]struct{}
opCodes []OpCode
}
func NewMatcher(a, b []string) *SequenceMatcher {
m := SequenceMatcher{autoJunk: true}
m.SetSeqs(a, b)
return &m
}
func NewMatcherWithJunk(a, b []string, autoJunk bool,
isJunk func(string) bool) *SequenceMatcher {
m := SequenceMatcher{IsJunk: isJunk, autoJunk: autoJunk}
m.SetSeqs(a, b)
return &m
}
// Set two sequences to be compared.
func (m *SequenceMatcher) SetSeqs(a, b []string) {
m.SetSeq1(a)
m.SetSeq2(b)
}
// Set the first sequence to be compared. The second sequence to be compared is
// not changed.
//
// SequenceMatcher computes and caches detailed information about the second
// sequence, so if you want to compare one sequence S against many sequences,
// use .SetSeq2(s) once and call .SetSeq1(x) repeatedly for each of the other
// sequences.
//
// See also SetSeqs() and SetSeq2().
func (m *SequenceMatcher) SetSeq1(a []string) {
if &a == &m.a {
return
}
m.a = a
m.matchingBlocks = nil
m.opCodes = nil
}
// Set the second sequence to be compared. The first sequence to be compared is
// not changed.
func (m *SequenceMatcher) SetSeq2(b []string) {
if &b == &m.b {
return
}
m.b = b
m.matchingBlocks = nil
m.opCodes = nil
m.fullBCount = nil
m.chainB()
}
func (m *SequenceMatcher) chainB() {
// Populate line -> index mapping
b2j := map[string][]int{}
for i, s := range m.b {
indices := b2j[s]
indices = append(indices, i)
b2j[s] = indices
}
// Purge junk elements
m.bJunk = map[string]struct{}{}
if m.IsJunk != nil {
junk := m.bJunk
for s, _ := range b2j {
if m.IsJunk(s) {
junk[s] = struct{}{}
}
}
for s, _ := range junk {
delete(b2j, s)
}
}
// Purge remaining popular elements
popular := map[string]struct{}{}
n := len(m.b)
if m.autoJunk && n >= 200 {
ntest := n/100 + 1
for s, indices := range b2j {
if len(indices) > ntest {
popular[s] = struct{}{}
}
}
for s, _ := range popular {
delete(b2j, s)
}
}
m.bPopular = popular
m.b2j = b2j
}
func (m *SequenceMatcher) isBJunk(s string) bool {
_, ok := m.bJunk[s]
return ok
}
// Find longest matching block in a[alo:ahi] and b[blo:bhi].
//
// If IsJunk is not defined:
//
// Return (i,j,k) such that a[i:i+k] is equal to b[j:j+k], where
//
// alo <= i <= i+k <= ahi
// blo <= j <= j+k <= bhi
//
// and for all (i',j',k') meeting those conditions,
//
// k >= k'
// i <= i'
// and if i == i', j <= j'
//
// In other words, of all maximal matching blocks, return one that
// starts earliest in a, and of all those maximal matching blocks that
// start earliest in a, return the one that starts earliest in b.
//
// If IsJunk is defined, first the longest matching block is
// determined as above, but with the additional restriction that no
// junk element appears in the block. Then that block is extended as
// far as possible by matching (only) junk elements on both sides. So
// the resulting block never matches on junk except as identical junk
// happens to be adjacent to an "interesting" match.
//
// If no blocks match, return (alo, blo, 0).
func (m *SequenceMatcher) findLongestMatch(alo, ahi, blo, bhi int) Match {
// CAUTION: stripping common prefix or suffix would be incorrect.
// E.g.,
// ab
// acab
// Longest matching block is "ab", but if common prefix is
// stripped, it's "a" (tied with "b"). UNIX(tm) diff does so
// strip, so ends up claiming that ab is changed to acab by
// inserting "ca" in the middle. That's minimal but unintuitive:
// "it's obvious" that someone inserted "ac" at the front.
// Windiff ends up at the same place as diff, but by pairing up
// the unique 'b's and then matching the first two 'a's.
besti, bestj, bestsize := alo, blo, 0
// find longest junk-free match
// during an iteration of the loop, j2len[j] = length of longest
// junk-free match ending with a[i-1] and b[j]
j2len := map[int]int{}
for i := alo; i != ahi; i++ {
// look at all instances of a[i] in b; note that because
// b2j has no junk keys, the loop is skipped if a[i] is junk
newj2len := map[int]int{}
for _, j := range m.b2j[m.a[i]] {
// a[i] matches b[j]
if j < blo {
continue
}
if j >= bhi {
break
}
k := j2len[j-1] + 1
newj2len[j] = k
if k > bestsize {
besti, bestj, bestsize = i-k+1, j-k+1, k
}
}
j2len = newj2len
}
// Extend the best by non-junk elements on each end. In particular,
// "popular" non-junk elements aren't in b2j, which greatly speeds
// the inner loop above, but also means "the best" match so far
// doesn't contain any junk *or* popular non-junk elements.
for besti > alo && bestj > blo && !m.isBJunk(m.b[bestj-1]) &&
m.a[besti-1] == m.b[bestj-1] {
besti, bestj, bestsize = besti-1, bestj-1, bestsize+1
}
for besti+bestsize < ahi && bestj+bestsize < bhi &&
!m.isBJunk(m.b[bestj+bestsize]) &&
m.a[besti+bestsize] == m.b[bestj+bestsize] {
bestsize += 1
}
// Now that we have a wholly interesting match (albeit possibly
// empty!), we may as well suck up the matching junk on each
// side of it too. Can't think of a good reason not to, and it
// saves post-processing the (possibly considerable) expense of
// figuring out what to do with it. In the case of an empty
// interesting match, this is clearly the right thing to do,
// because no other kind of match is possible in the regions.
for besti > alo && bestj > blo && m.isBJunk(m.b[bestj-1]) &&
m.a[besti-1] == m.b[bestj-1] {
besti, bestj, bestsize = besti-1, bestj-1, bestsize+1
}
for besti+bestsize < ahi && bestj+bestsize < bhi &&
m.isBJunk(m.b[bestj+bestsize]) &&
m.a[besti+bestsize] == m.b[bestj+bestsize] {
bestsize += 1
}
return Match{A: besti, B: bestj, Size: bestsize}
}
// Return list of triples describing matching subsequences.
//
// Each triple is of the form (i, j, n), and means that
// a[i:i+n] == b[j:j+n]. The triples are monotonically increasing in
// i and in j. It's also guaranteed that if (i, j, n) and (i', j', n') are
// adjacent triples in the list, and the second is not the last triple in the
// list, then i+n != i' or j+n != j'. IOW, adjacent triples never describe
// adjacent equal blocks.
//
// The last triple is a dummy, (len(a), len(b), 0), and is the only
// triple with n==0.
func (m *SequenceMatcher) GetMatchingBlocks() []Match {
if m.matchingBlocks != nil {
return m.matchingBlocks
}
var matchBlocks func(alo, ahi, blo, bhi int, matched []Match) []Match
matchBlocks = func(alo, ahi, blo, bhi int, matched []Match) []Match {
match := m.findLongestMatch(alo, ahi, blo, bhi)
i, j, k := match.A, match.B, match.Size
if match.Size > 0 {
if alo < i && blo < j {
matched = matchBlocks(alo, i, blo, j, matched)
}
matched = append(matched, match)
if i+k < ahi && j+k < bhi {
matched = matchBlocks(i+k, ahi, j+k, bhi, matched)
}
}
return matched
}
matched := matchBlocks(0, len(m.a), 0, len(m.b), nil)
// It's possible that we have adjacent equal blocks in the
// matching_blocks list now.
nonAdjacent := []Match{}
i1, j1, k1 := 0, 0, 0
for _, b := range matched {
// Is this block adjacent to i1, j1, k1?
i2, j2, k2 := b.A, b.B, b.Size
if i1+k1 == i2 && j1+k1 == j2 {
// Yes, so collapse them -- this just increases the length of
// the first block by the length of the second, and the first
// block so lengthened remains the block to compare against.
k1 += k2
} else {
// Not adjacent. Remember the first block (k1==0 means it's
// the dummy we started with), and make the second block the
// new block to compare against.
if k1 > 0 {
nonAdjacent = append(nonAdjacent, Match{i1, j1, k1})
}
i1, j1, k1 = i2, j2, k2
}
}
if k1 > 0 {
nonAdjacent = append(nonAdjacent, Match{i1, j1, k1})
}
nonAdjacent = append(nonAdjacent, Match{len(m.a), len(m.b), 0})
m.matchingBlocks = nonAdjacent
return m.matchingBlocks
}
// Return list of 5-tuples describing how to turn a into b.
//
// Each tuple is of the form (tag, i1, i2, j1, j2). The first tuple
// has i1 == j1 == 0, and remaining tuples have i1 == the i2 from the
// tuple preceding it, and likewise for j1 == the previous j2.
//
// The tags are characters, with these meanings:
//
// 'r' (replace): a[i1:i2] should be replaced by b[j1:j2]
//
// 'd' (delete): a[i1:i2] should be deleted, j1==j2 in this case.
//
// 'i' (insert): b[j1:j2] should be inserted at a[i1:i1], i1==i2 in this case.
//
// 'e' (equal): a[i1:i2] == b[j1:j2]
func (m *SequenceMatcher) GetOpCodes() []OpCode {
if m.opCodes != nil {
return m.opCodes
}
i, j := 0, 0
matching := m.GetMatchingBlocks()
opCodes := make([]OpCode, 0, len(matching))
for _, m := range matching {
// invariant: we've pumped out correct diffs to change
// a[:i] into b[:j], and the next matching block is
// a[ai:ai+size] == b[bj:bj+size]. So we need to pump
// out a diff to change a[i:ai] into b[j:bj], pump out
// the matching block, and move (i,j) beyond the match
ai, bj, size := m.A, m.B, m.Size
tag := byte(0)
if i < ai && j < bj {
tag = 'r'
} else if i < ai {
tag = 'd'
} else if j < bj {
tag = 'i'
}
if tag > 0 {
opCodes = append(opCodes, OpCode{tag, i, ai, j, bj})
}
i, j = ai+size, bj+size
// the list of matching blocks is terminated by a
// sentinel with size 0
if size > 0 {
opCodes = append(opCodes, OpCode{'e', ai, i, bj, j})
}
}
m.opCodes = opCodes
return m.opCodes
}
// Isolate change clusters by eliminating ranges with no changes.
//
// Return a generator of groups with up to n lines of context.
// Each group is in the same format as returned by GetOpCodes().
func (m *SequenceMatcher) GetGroupedOpCodes(n int) [][]OpCode {
if n < 0 {
n = 3
}
codes := m.GetOpCodes()
if len(codes) == 0 {
codes = []OpCode{OpCode{'e', 0, 1, 0, 1}}
}
// Fixup leading and trailing groups if they show no changes.
if codes[0].Tag == 'e' {
c := codes[0]
i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2
codes[0] = OpCode{c.Tag, max(i1, i2-n), i2, max(j1, j2-n), j2}
}
if codes[len(codes)-1].Tag == 'e' {
c := codes[len(codes)-1]
i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2
codes[len(codes)-1] = OpCode{c.Tag, i1, min(i2, i1+n), j1, min(j2, j1+n)}
}
nn := n + n
groups := [][]OpCode{}
group := []OpCode{}
for _, c := range codes {
i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2
// End the current group and start a new one whenever
// there is a large range with no changes.
if c.Tag == 'e' && i2-i1 > nn {
group = append(group, OpCode{c.Tag, i1, min(i2, i1+n),
j1, min(j2, j1+n)})
groups = append(groups, group)
group = []OpCode{}
i1, j1 = max(i1, i2-n), max(j1, j2-n)
}
group = append(group, OpCode{c.Tag, i1, i2, j1, j2})
}
if len(group) > 0 && !(len(group) == 1 && group[0].Tag == 'e') {
groups = append(groups, group)
}
return groups
}
// Return a measure of the sequences' similarity (float in [0,1]).
//
// Where T is the total number of elements in both sequences, and
// M is the number of matches, this is 2.0*M / T.
// Note that this is 1 if the sequences are identical, and 0 if
// they have nothing in common.
//
// .Ratio() is expensive to compute if you haven't already computed
// .GetMatchingBlocks() or .GetOpCodes(), in which case you may
// want to try .QuickRatio() or .RealQuickRation() first to get an
// upper bound.
func (m *SequenceMatcher) Ratio() float64 {
matches := 0
for _, m := range m.GetMatchingBlocks() {
matches += m.Size
}
return calculateRatio(matches, len(m.a)+len(m.b))
}
// Return an upper bound on ratio() relatively quickly.
//
// This isn't defined beyond that it is an upper bound on .Ratio(), and
// is faster to compute.
func (m *SequenceMatcher) QuickRatio() float64 {
// viewing a and b as multisets, set matches to the cardinality
// of their intersection; this counts the number of matches
// without regard to order, so is clearly an upper bound
if m.fullBCount == nil {
m.fullBCount = map[string]int{}
for _, s := range m.b {
m.fullBCount[s] = m.fullBCount[s] + 1
}
}
// avail[x] is the number of times x appears in 'b' less the
// number of times we've seen it in 'a' so far ... kinda
avail := map[string]int{}
matches := 0
for _, s := range m.a {
n, ok := avail[s]
if !ok {
n = m.fullBCount[s]
}
avail[s] = n - 1
if n > 0 {
matches += 1
}
}
return calculateRatio(matches, len(m.a)+len(m.b))
}
// Return an upper bound on ratio() very quickly.
//
// This isn't defined beyond that it is an upper bound on .Ratio(), and
// is faster to compute than either .Ratio() or .QuickRatio().
func (m *SequenceMatcher) RealQuickRatio() float64 {
la, lb := len(m.a), len(m.b)
return calculateRatio(min(la, lb), la+lb)
}
// Convert range to the "ed" format
func formatRangeUnified(start, stop int) string {
// Per the diff spec at http://www.unix.org/single_unix_specification/
beginning := start + 1 // lines start numbering with one
length := stop - start
if length == 1 {
return fmt.Sprintf("%d", beginning)
}
if length == 0 {
beginning -= 1 // empty ranges begin at line just before the range
}
return fmt.Sprintf("%d,%d", beginning, length)
}
// Unified diff parameters
type UnifiedDiff struct {
A []string // First sequence lines
FromFile string // First file name
FromDate string // First file time
B []string // Second sequence lines
ToFile string // Second file name
ToDate string // Second file time
Eol string // Headers end of line, defaults to LF
Context int // Number of context lines
}
// Compare two sequences of lines; generate the delta as a unified diff.
//
// Unified diffs are a compact way of showing line changes and a few
// lines of context. The number of context lines is set by 'n' which
// defaults to three.
//
// By default, the diff control lines (those with ---, +++, or @@) are
// created with a trailing newline. This is helpful so that inputs
// created from file.readlines() result in diffs that are suitable for
// file.writelines() since both the inputs and outputs have trailing
// newlines.
//
// For inputs that do not have trailing newlines, set the lineterm
// argument to "" so that the output will be uniformly newline free.
//
// The unidiff format normally has a header for filenames and modification
// times. Any or all of these may be specified using strings for
// 'fromfile', 'tofile', 'fromfiledate', and 'tofiledate'.
// The modification times are normally expressed in the ISO 8601 format.
func WriteUnifiedDiff(writer io.Writer, diff UnifiedDiff) error {
buf := bufio.NewWriter(writer)
defer buf.Flush()
wf := func(format string, args ...interface{}) error {
_, err := buf.WriteString(fmt.Sprintf(format, args...))
return err
}
ws := func(s string) error {
_, err := buf.WriteString(s)
return err
}
if len(diff.Eol) == 0 {
diff.Eol = "\n"
}
started := false
m := NewMatcher(diff.A, diff.B)
for _, g := range m.GetGroupedOpCodes(diff.Context) {
if !started {
started = true
fromDate := ""
if len(diff.FromDate) > 0 {
fromDate = "\t" + diff.FromDate
}
toDate := ""
if len(diff.ToDate) > 0 {
toDate = "\t" + diff.ToDate
}
if diff.FromFile != "" || diff.ToFile != "" {
err := wf("--- %s%s%s", diff.FromFile, fromDate, diff.Eol)
if err != nil {
return err
}
err = wf("+++ %s%s%s", diff.ToFile, toDate, diff.Eol)
if err != nil {
return err
}
}
}
first, last := g[0], g[len(g)-1]
range1 := formatRangeUnified(first.I1, last.I2)
range2 := formatRangeUnified(first.J1, last.J2)
if err := wf("@@ -%s +%s @@%s", range1, range2, diff.Eol); err != nil {
return err
}
for _, c := range g {
i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2
if c.Tag == 'e' {
for _, line := range diff.A[i1:i2] {
if err := ws(" " + line); err != nil {
return err
}
}
continue
}
if c.Tag == 'r' || c.Tag == 'd' {
for _, line := range diff.A[i1:i2] {
if err := ws("-" + line); err != nil {
return err
}
}
}
if c.Tag == 'r' || c.Tag == 'i' {
for _, line := range diff.B[j1:j2] {
if err := ws("+" + line); err != nil {
return err
}
}
}
}
}
return nil
}
// Like WriteUnifiedDiff but returns the diff a string.
func GetUnifiedDiffString(diff UnifiedDiff) (string, error) {
w := &bytes.Buffer{}
err := WriteUnifiedDiff(w, diff)
return string(w.Bytes()), err
}
// Convert range to the "ed" format.
func formatRangeContext(start, stop int) string {
// Per the diff spec at http://www.unix.org/single_unix_specification/
beginning := start + 1 // lines start numbering with one
length := stop - start
if length == 0 {
beginning -= 1 // empty ranges begin at line just before the range
}
if length <= 1 {
return fmt.Sprintf("%d", beginning)
}
return fmt.Sprintf("%d,%d", beginning, beginning+length-1)
}
type ContextDiff UnifiedDiff
// Compare two sequences of lines; generate the delta as a context diff.
//
// Context diffs are a compact way of showing line changes and a few
// lines of context. The number of context lines is set by diff.Context
// which defaults to three.
//
// By default, the diff control lines (those with *** or ---) are
// created with a trailing newline.
//
// For inputs that do not have trailing newlines, set the diff.Eol
// argument to "" so that the output will be uniformly newline free.
//
// The context diff format normally has a header for filenames and
// modification times. Any or all of these may be specified using
// strings for diff.FromFile, diff.ToFile, diff.FromDate, diff.ToDate.
// The modification times are normally expressed in the ISO 8601 format.
// If not specified, the strings default to blanks.
func WriteContextDiff(writer io.Writer, diff ContextDiff) error {
buf := bufio.NewWriter(writer)
defer buf.Flush()
var diffErr error
wf := func(format string, args ...interface{}) {
_, err := buf.WriteString(fmt.Sprintf(format, args...))
if diffErr == nil && err != nil {
diffErr = err
}
}
ws := func(s string) {
_, err := buf.WriteString(s)
if diffErr == nil && err != nil {
diffErr = err
}
}
if len(diff.Eol) == 0 {
diff.Eol = "\n"
}
prefix := map[byte]string{
'i': "+ ",
'd': "- ",
'r': "! ",
'e': " ",
}
started := false
m := NewMatcher(diff.A, diff.B)
for _, g := range m.GetGroupedOpCodes(diff.Context) {
if !started {
started = true
fromDate := ""
if len(diff.FromDate) > 0 {
fromDate = "\t" + diff.FromDate
}
toDate := ""
if len(diff.ToDate) > 0 {
toDate = "\t" + diff.ToDate
}
if diff.FromFile != "" || diff.ToFile != "" {
wf("*** %s%s%s", diff.FromFile, fromDate, diff.Eol)
wf("--- %s%s%s", diff.ToFile, toDate, diff.Eol)
}
}
first, last := g[0], g[len(g)-1]
ws("***************" + diff.Eol)
range1 := formatRangeContext(first.I1, last.I2)
wf("*** %s ****%s", range1, diff.Eol)
for _, c := range g {
if c.Tag == 'r' || c.Tag == 'd' {
for _, cc := range g {
if cc.Tag == 'i' {
continue
}
for _, line := range diff.A[cc.I1:cc.I2] {
ws(prefix[cc.Tag] + line)
}
}
break
}
}
range2 := formatRangeContext(first.J1, last.J2)
wf("--- %s ----%s", range2, diff.Eol)
for _, c := range g {
if c.Tag == 'r' || c.Tag == 'i' {
for _, cc := range g {
if cc.Tag == 'd' {
continue
}
for _, line := range diff.B[cc.J1:cc.J2] {
ws(prefix[cc.Tag] + line)
}
}
break
}
}
}
return diffErr
}
// Like WriteContextDiff but returns the diff a string.
func GetContextDiffString(diff ContextDiff) (string, error) {
w := &bytes.Buffer{}
err := WriteContextDiff(w, diff)
return string(w.Bytes()), err
}
// Split a string on "\n" while preserving them. The output can be used
// as input for UnifiedDiff and ContextDiff structures.
func SplitLines(s string) []string {
lines := strings.SplitAfter(s, "\n")
lines[len(lines)-1] += "\n"
return lines
}
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-489
View File
@@ -1,489 +0,0 @@
package assert
import (
"bytes"
"fmt"
"reflect"
"time"
)
// Deprecated: CompareType has only ever been for internal use and has accidentally been published since v1.6.0. Do not use it.
type CompareType = compareResult
type compareResult int
const (
compareLess compareResult = iota - 1
compareEqual
compareGreater
)
var (
intType = reflect.TypeOf(int(1))
int8Type = reflect.TypeOf(int8(1))
int16Type = reflect.TypeOf(int16(1))
int32Type = reflect.TypeOf(int32(1))
int64Type = reflect.TypeOf(int64(1))
uintType = reflect.TypeOf(uint(1))
uint8Type = reflect.TypeOf(uint8(1))
uint16Type = reflect.TypeOf(uint16(1))
uint32Type = reflect.TypeOf(uint32(1))
uint64Type = reflect.TypeOf(uint64(1))
uintptrType = reflect.TypeOf(uintptr(1))
float32Type = reflect.TypeOf(float32(1))
float64Type = reflect.TypeOf(float64(1))
stringType = reflect.TypeOf("")
timeType = reflect.TypeOf(time.Time{})
bytesType = reflect.TypeOf([]byte{})
)
func compare(obj1, obj2 interface{}, kind reflect.Kind) (compareResult, bool) {
obj1Value := reflect.ValueOf(obj1)
obj2Value := reflect.ValueOf(obj2)
// throughout this switch we try and avoid calling .Convert() if possible,
// as this has a pretty big performance impact
switch kind {
case reflect.Int:
{
intobj1, ok := obj1.(int)
if !ok {
intobj1 = obj1Value.Convert(intType).Interface().(int)
}
intobj2, ok := obj2.(int)
if !ok {
intobj2 = obj2Value.Convert(intType).Interface().(int)
}
if intobj1 > intobj2 {
return compareGreater, true
}
if intobj1 == intobj2 {
return compareEqual, true
}
if intobj1 < intobj2 {
return compareLess, true
}
}
case reflect.Int8:
{
int8obj1, ok := obj1.(int8)
if !ok {
int8obj1 = obj1Value.Convert(int8Type).Interface().(int8)
}
int8obj2, ok := obj2.(int8)
if !ok {
int8obj2 = obj2Value.Convert(int8Type).Interface().(int8)
}
if int8obj1 > int8obj2 {
return compareGreater, true
}
if int8obj1 == int8obj2 {
return compareEqual, true
}
if int8obj1 < int8obj2 {
return compareLess, true
}
}
case reflect.Int16:
{
int16obj1, ok := obj1.(int16)
if !ok {
int16obj1 = obj1Value.Convert(int16Type).Interface().(int16)
}
int16obj2, ok := obj2.(int16)
if !ok {
int16obj2 = obj2Value.Convert(int16Type).Interface().(int16)
}
if int16obj1 > int16obj2 {
return compareGreater, true
}
if int16obj1 == int16obj2 {
return compareEqual, true
}
if int16obj1 < int16obj2 {
return compareLess, true
}
}
case reflect.Int32:
{
int32obj1, ok := obj1.(int32)
if !ok {
int32obj1 = obj1Value.Convert(int32Type).Interface().(int32)
}
int32obj2, ok := obj2.(int32)
if !ok {
int32obj2 = obj2Value.Convert(int32Type).Interface().(int32)
}
if int32obj1 > int32obj2 {
return compareGreater, true
}
if int32obj1 == int32obj2 {
return compareEqual, true
}
if int32obj1 < int32obj2 {
return compareLess, true
}
}
case reflect.Int64:
{
int64obj1, ok := obj1.(int64)
if !ok {
int64obj1 = obj1Value.Convert(int64Type).Interface().(int64)
}
int64obj2, ok := obj2.(int64)
if !ok {
int64obj2 = obj2Value.Convert(int64Type).Interface().(int64)
}
if int64obj1 > int64obj2 {
return compareGreater, true
}
if int64obj1 == int64obj2 {
return compareEqual, true
}
if int64obj1 < int64obj2 {
return compareLess, true
}
}
case reflect.Uint:
{
uintobj1, ok := obj1.(uint)
if !ok {
uintobj1 = obj1Value.Convert(uintType).Interface().(uint)
}
uintobj2, ok := obj2.(uint)
if !ok {
uintobj2 = obj2Value.Convert(uintType).Interface().(uint)
}
if uintobj1 > uintobj2 {
return compareGreater, true
}
if uintobj1 == uintobj2 {
return compareEqual, true
}
if uintobj1 < uintobj2 {
return compareLess, true
}
}
case reflect.Uint8:
{
uint8obj1, ok := obj1.(uint8)
if !ok {
uint8obj1 = obj1Value.Convert(uint8Type).Interface().(uint8)
}
uint8obj2, ok := obj2.(uint8)
if !ok {
uint8obj2 = obj2Value.Convert(uint8Type).Interface().(uint8)
}
if uint8obj1 > uint8obj2 {
return compareGreater, true
}
if uint8obj1 == uint8obj2 {
return compareEqual, true
}
if uint8obj1 < uint8obj2 {
return compareLess, true
}
}
case reflect.Uint16:
{
uint16obj1, ok := obj1.(uint16)
if !ok {
uint16obj1 = obj1Value.Convert(uint16Type).Interface().(uint16)
}
uint16obj2, ok := obj2.(uint16)
if !ok {
uint16obj2 = obj2Value.Convert(uint16Type).Interface().(uint16)
}
if uint16obj1 > uint16obj2 {
return compareGreater, true
}
if uint16obj1 == uint16obj2 {
return compareEqual, true
}
if uint16obj1 < uint16obj2 {
return compareLess, true
}
}
case reflect.Uint32:
{
uint32obj1, ok := obj1.(uint32)
if !ok {
uint32obj1 = obj1Value.Convert(uint32Type).Interface().(uint32)
}
uint32obj2, ok := obj2.(uint32)
if !ok {
uint32obj2 = obj2Value.Convert(uint32Type).Interface().(uint32)
}
if uint32obj1 > uint32obj2 {
return compareGreater, true
}
if uint32obj1 == uint32obj2 {
return compareEqual, true
}
if uint32obj1 < uint32obj2 {
return compareLess, true
}
}
case reflect.Uint64:
{
uint64obj1, ok := obj1.(uint64)
if !ok {
uint64obj1 = obj1Value.Convert(uint64Type).Interface().(uint64)
}
uint64obj2, ok := obj2.(uint64)
if !ok {
uint64obj2 = obj2Value.Convert(uint64Type).Interface().(uint64)
}
if uint64obj1 > uint64obj2 {
return compareGreater, true
}
if uint64obj1 == uint64obj2 {
return compareEqual, true
}
if uint64obj1 < uint64obj2 {
return compareLess, true
}
}
case reflect.Float32:
{
float32obj1, ok := obj1.(float32)
if !ok {
float32obj1 = obj1Value.Convert(float32Type).Interface().(float32)
}
float32obj2, ok := obj2.(float32)
if !ok {
float32obj2 = obj2Value.Convert(float32Type).Interface().(float32)
}
if float32obj1 > float32obj2 {
return compareGreater, true
}
if float32obj1 == float32obj2 {
return compareEqual, true
}
if float32obj1 < float32obj2 {
return compareLess, true
}
}
case reflect.Float64:
{
float64obj1, ok := obj1.(float64)
if !ok {
float64obj1 = obj1Value.Convert(float64Type).Interface().(float64)
}
float64obj2, ok := obj2.(float64)
if !ok {
float64obj2 = obj2Value.Convert(float64Type).Interface().(float64)
}
if float64obj1 > float64obj2 {
return compareGreater, true
}
if float64obj1 == float64obj2 {
return compareEqual, true
}
if float64obj1 < float64obj2 {
return compareLess, true
}
}
case reflect.String:
{
stringobj1, ok := obj1.(string)
if !ok {
stringobj1 = obj1Value.Convert(stringType).Interface().(string)
}
stringobj2, ok := obj2.(string)
if !ok {
stringobj2 = obj2Value.Convert(stringType).Interface().(string)
}
if stringobj1 > stringobj2 {
return compareGreater, true
}
if stringobj1 == stringobj2 {
return compareEqual, true
}
if stringobj1 < stringobj2 {
return compareLess, true
}
}
// Check for known struct types we can check for compare results.
case reflect.Struct:
{
// All structs enter here. We're not interested in most types.
if !obj1Value.CanConvert(timeType) {
break
}
// time.Time can be compared!
timeObj1, ok := obj1.(time.Time)
if !ok {
timeObj1 = obj1Value.Convert(timeType).Interface().(time.Time)
}
timeObj2, ok := obj2.(time.Time)
if !ok {
timeObj2 = obj2Value.Convert(timeType).Interface().(time.Time)
}
if timeObj1.Before(timeObj2) {
return compareLess, true
}
if timeObj1.Equal(timeObj2) {
return compareEqual, true
}
return compareGreater, true
}
case reflect.Slice:
{
// We only care about the []byte type.
if !obj1Value.CanConvert(bytesType) {
break
}
// []byte can be compared!
bytesObj1, ok := obj1.([]byte)
if !ok {
bytesObj1 = obj1Value.Convert(bytesType).Interface().([]byte)
}
bytesObj2, ok := obj2.([]byte)
if !ok {
bytesObj2 = obj2Value.Convert(bytesType).Interface().([]byte)
}
return compareResult(bytes.Compare(bytesObj1, bytesObj2)), true
}
case reflect.Uintptr:
{
uintptrObj1, ok := obj1.(uintptr)
if !ok {
uintptrObj1 = obj1Value.Convert(uintptrType).Interface().(uintptr)
}
uintptrObj2, ok := obj2.(uintptr)
if !ok {
uintptrObj2 = obj2Value.Convert(uintptrType).Interface().(uintptr)
}
if uintptrObj1 > uintptrObj2 {
return compareGreater, true
}
if uintptrObj1 == uintptrObj2 {
return compareEqual, true
}
if uintptrObj1 < uintptrObj2 {
return compareLess, true
}
}
}
return compareEqual, false
}
// Greater asserts that the first element is greater than the second
//
// assert.Greater(t, 2, 1)
// assert.Greater(t, float64(2), float64(1))
// assert.Greater(t, "b", "a")
func Greater(t TestingT, e1 interface{}, e2 interface{}, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return compareTwoValues(t, e1, e2, []compareResult{compareGreater}, "\"%v\" is not greater than \"%v\"", msgAndArgs...)
}
// GreaterOrEqual asserts that the first element is greater than or equal to the second
//
// assert.GreaterOrEqual(t, 2, 1)
// assert.GreaterOrEqual(t, 2, 2)
// assert.GreaterOrEqual(t, "b", "a")
// assert.GreaterOrEqual(t, "b", "b")
func GreaterOrEqual(t TestingT, e1 interface{}, e2 interface{}, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return compareTwoValues(t, e1, e2, []compareResult{compareGreater, compareEqual}, "\"%v\" is not greater than or equal to \"%v\"", msgAndArgs...)
}
// Less asserts that the first element is less than the second
//
// assert.Less(t, 1, 2)
// assert.Less(t, float64(1), float64(2))
// assert.Less(t, "a", "b")
func Less(t TestingT, e1 interface{}, e2 interface{}, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return compareTwoValues(t, e1, e2, []compareResult{compareLess}, "\"%v\" is not less than \"%v\"", msgAndArgs...)
}
// LessOrEqual asserts that the first element is less than or equal to the second
//
// assert.LessOrEqual(t, 1, 2)
// assert.LessOrEqual(t, 2, 2)
// assert.LessOrEqual(t, "a", "b")
// assert.LessOrEqual(t, "b", "b")
func LessOrEqual(t TestingT, e1 interface{}, e2 interface{}, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return compareTwoValues(t, e1, e2, []compareResult{compareLess, compareEqual}, "\"%v\" is not less than or equal to \"%v\"", msgAndArgs...)
}
// Positive asserts that the specified element is positive
//
// assert.Positive(t, 1)
// assert.Positive(t, 1.23)
func Positive(t TestingT, e interface{}, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
zero := reflect.Zero(reflect.TypeOf(e))
return compareTwoValues(t, e, zero.Interface(), []compareResult{compareGreater}, "\"%v\" is not positive", msgAndArgs...)
}
// Negative asserts that the specified element is negative
//
// assert.Negative(t, -1)
// assert.Negative(t, -1.23)
func Negative(t TestingT, e interface{}, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
zero := reflect.Zero(reflect.TypeOf(e))
return compareTwoValues(t, e, zero.Interface(), []compareResult{compareLess}, "\"%v\" is not negative", msgAndArgs...)
}
func compareTwoValues(t TestingT, e1 interface{}, e2 interface{}, allowedComparesResults []compareResult, failMessage string, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
e1Kind := reflect.ValueOf(e1).Kind()
e2Kind := reflect.ValueOf(e2).Kind()
if e1Kind != e2Kind {
return Fail(t, "Elements should be the same type", msgAndArgs...)
}
compareResult, isComparable := compare(e1, e2, e1Kind)
if !isComparable {
return Fail(t, fmt.Sprintf("Can not compare type \"%s\"", reflect.TypeOf(e1)), msgAndArgs...)
}
if !containsValue(allowedComparesResults, compareResult) {
return Fail(t, fmt.Sprintf(failMessage, e1, e2), msgAndArgs...)
}
return true
}
func containsValue(values []compareResult, value compareResult) bool {
for _, v := range values {
if v == value {
return true
}
}
return false
}
-841
View File
@@ -1,841 +0,0 @@
// Code generated with github.com/stretchr/testify/_codegen; DO NOT EDIT.
package assert
import (
http "net/http"
url "net/url"
time "time"
)
// Conditionf uses a Comparison to assert a complex condition.
func Conditionf(t TestingT, comp Comparison, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Condition(t, comp, append([]interface{}{msg}, args...)...)
}
// Containsf asserts that the specified string, list(array, slice...) or map contains the
// specified substring or element.
//
// assert.Containsf(t, "Hello World", "World", "error message %s", "formatted")
// assert.Containsf(t, ["Hello", "World"], "World", "error message %s", "formatted")
// assert.Containsf(t, {"Hello": "World"}, "Hello", "error message %s", "formatted")
func Containsf(t TestingT, s interface{}, contains interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Contains(t, s, contains, append([]interface{}{msg}, args...)...)
}
// DirExistsf checks whether a directory exists in the given path. It also fails
// if the path is a file rather a directory or there is an error checking whether it exists.
func DirExistsf(t TestingT, path string, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return DirExists(t, path, append([]interface{}{msg}, args...)...)
}
// ElementsMatchf asserts that the specified listA(array, slice...) is equal to specified
// listB(array, slice...) ignoring the order of the elements. If there are duplicate elements,
// the number of appearances of each of them in both lists should match.
//
// assert.ElementsMatchf(t, [1, 3, 2, 3], [1, 3, 3, 2], "error message %s", "formatted")
func ElementsMatchf(t TestingT, listA interface{}, listB interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return ElementsMatch(t, listA, listB, append([]interface{}{msg}, args...)...)
}
// Emptyf asserts that the specified object is empty. I.e. nil, "", false, 0 or either
// a slice or a channel with len == 0.
//
// assert.Emptyf(t, obj, "error message %s", "formatted")
func Emptyf(t TestingT, object interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Empty(t, object, append([]interface{}{msg}, args...)...)
}
// Equalf asserts that two objects are equal.
//
// assert.Equalf(t, 123, 123, "error message %s", "formatted")
//
// Pointer variable equality is determined based on the equality of the
// referenced values (as opposed to the memory addresses). Function equality
// cannot be determined and will always fail.
func Equalf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Equal(t, expected, actual, append([]interface{}{msg}, args...)...)
}
// EqualErrorf asserts that a function returned an error (i.e. not `nil`)
// and that it is equal to the provided error.
//
// actualObj, err := SomeFunction()
// assert.EqualErrorf(t, err, expectedErrorString, "error message %s", "formatted")
func EqualErrorf(t TestingT, theError error, errString string, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return EqualError(t, theError, errString, append([]interface{}{msg}, args...)...)
}
// EqualExportedValuesf asserts that the types of two objects are equal and their public
// fields are also equal. This is useful for comparing structs that have private fields
// that could potentially differ.
//
// type S struct {
// Exported int
// notExported int
// }
// assert.EqualExportedValuesf(t, S{1, 2}, S{1, 3}, "error message %s", "formatted") => true
// assert.EqualExportedValuesf(t, S{1, 2}, S{2, 3}, "error message %s", "formatted") => false
func EqualExportedValuesf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return EqualExportedValues(t, expected, actual, append([]interface{}{msg}, args...)...)
}
// EqualValuesf asserts that two objects are equal or convertible to the larger
// type and equal.
//
// assert.EqualValuesf(t, uint32(123), int32(123), "error message %s", "formatted")
func EqualValuesf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return EqualValues(t, expected, actual, append([]interface{}{msg}, args...)...)
}
// Errorf asserts that a function returned an error (i.e. not `nil`).
//
// actualObj, err := SomeFunction()
// if assert.Errorf(t, err, "error message %s", "formatted") {
// assert.Equal(t, expectedErrorf, err)
// }
func Errorf(t TestingT, err error, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Error(t, err, append([]interface{}{msg}, args...)...)
}
// ErrorAsf asserts that at least one of the errors in err's chain matches target, and if so, sets target to that error value.
// This is a wrapper for errors.As.
func ErrorAsf(t TestingT, err error, target interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return ErrorAs(t, err, target, append([]interface{}{msg}, args...)...)
}
// ErrorContainsf asserts that a function returned an error (i.e. not `nil`)
// and that the error contains the specified substring.
//
// actualObj, err := SomeFunction()
// assert.ErrorContainsf(t, err, expectedErrorSubString, "error message %s", "formatted")
func ErrorContainsf(t TestingT, theError error, contains string, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return ErrorContains(t, theError, contains, append([]interface{}{msg}, args...)...)
}
// ErrorIsf asserts that at least one of the errors in err's chain matches target.
// This is a wrapper for errors.Is.
func ErrorIsf(t TestingT, err error, target error, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return ErrorIs(t, err, target, append([]interface{}{msg}, args...)...)
}
// Eventuallyf asserts that given condition will be met in waitFor time,
// periodically checking target function each tick.
//
// assert.Eventuallyf(t, func() bool { return true; }, time.Second, 10*time.Millisecond, "error message %s", "formatted")
func Eventuallyf(t TestingT, condition func() bool, waitFor time.Duration, tick time.Duration, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Eventually(t, condition, waitFor, tick, append([]interface{}{msg}, args...)...)
}
// EventuallyWithTf asserts that given condition will be met in waitFor time,
// periodically checking target function each tick. In contrast to Eventually,
// it supplies a CollectT to the condition function, so that the condition
// function can use the CollectT to call other assertions.
// The condition is considered "met" if no errors are raised in a tick.
// The supplied CollectT collects all errors from one tick (if there are any).
// If the condition is not met before waitFor, the collected errors of
// the last tick are copied to t.
//
// externalValue := false
// go func() {
// time.Sleep(8*time.Second)
// externalValue = true
// }()
// assert.EventuallyWithTf(t, func(c *assert.CollectT, "error message %s", "formatted") {
// // add assertions as needed; any assertion failure will fail the current tick
// assert.True(c, externalValue, "expected 'externalValue' to be true")
// }, 10*time.Second, 1*time.Second, "external state has not changed to 'true'; still false")
func EventuallyWithTf(t TestingT, condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return EventuallyWithT(t, condition, waitFor, tick, append([]interface{}{msg}, args...)...)
}
// Exactlyf asserts that two objects are equal in value and type.
//
// assert.Exactlyf(t, int32(123), int64(123), "error message %s", "formatted")
func Exactlyf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Exactly(t, expected, actual, append([]interface{}{msg}, args...)...)
}
// Failf reports a failure through
func Failf(t TestingT, failureMessage string, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Fail(t, failureMessage, append([]interface{}{msg}, args...)...)
}
// FailNowf fails test
func FailNowf(t TestingT, failureMessage string, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return FailNow(t, failureMessage, append([]interface{}{msg}, args...)...)
}
// Falsef asserts that the specified value is false.
//
// assert.Falsef(t, myBool, "error message %s", "formatted")
func Falsef(t TestingT, value bool, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return False(t, value, append([]interface{}{msg}, args...)...)
}
// FileExistsf checks whether a file exists in the given path. It also fails if
// the path points to a directory or there is an error when trying to check the file.
func FileExistsf(t TestingT, path string, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return FileExists(t, path, append([]interface{}{msg}, args...)...)
}
// Greaterf asserts that the first element is greater than the second
//
// assert.Greaterf(t, 2, 1, "error message %s", "formatted")
// assert.Greaterf(t, float64(2), float64(1), "error message %s", "formatted")
// assert.Greaterf(t, "b", "a", "error message %s", "formatted")
func Greaterf(t TestingT, e1 interface{}, e2 interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Greater(t, e1, e2, append([]interface{}{msg}, args...)...)
}
// GreaterOrEqualf asserts that the first element is greater than or equal to the second
//
// assert.GreaterOrEqualf(t, 2, 1, "error message %s", "formatted")
// assert.GreaterOrEqualf(t, 2, 2, "error message %s", "formatted")
// assert.GreaterOrEqualf(t, "b", "a", "error message %s", "formatted")
// assert.GreaterOrEqualf(t, "b", "b", "error message %s", "formatted")
func GreaterOrEqualf(t TestingT, e1 interface{}, e2 interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return GreaterOrEqual(t, e1, e2, append([]interface{}{msg}, args...)...)
}
// HTTPBodyContainsf asserts that a specified handler returns a
// body that contains a string.
//
// assert.HTTPBodyContainsf(t, myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky", "error message %s", "formatted")
//
// Returns whether the assertion was successful (true) or not (false).
func HTTPBodyContainsf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return HTTPBodyContains(t, handler, method, url, values, str, append([]interface{}{msg}, args...)...)
}
// HTTPBodyNotContainsf asserts that a specified handler returns a
// body that does not contain a string.
//
// assert.HTTPBodyNotContainsf(t, myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky", "error message %s", "formatted")
//
// Returns whether the assertion was successful (true) or not (false).
func HTTPBodyNotContainsf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return HTTPBodyNotContains(t, handler, method, url, values, str, append([]interface{}{msg}, args...)...)
}
// HTTPErrorf asserts that a specified handler returns an error status code.
//
// assert.HTTPErrorf(t, myHandler, "POST", "/a/b/c", url.Values{"a": []string{"b", "c"}}
//
// Returns whether the assertion was successful (true) or not (false).
func HTTPErrorf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return HTTPError(t, handler, method, url, values, append([]interface{}{msg}, args...)...)
}
// HTTPRedirectf asserts that a specified handler returns a redirect status code.
//
// assert.HTTPRedirectf(t, myHandler, "GET", "/a/b/c", url.Values{"a": []string{"b", "c"}}
//
// Returns whether the assertion was successful (true) or not (false).
func HTTPRedirectf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return HTTPRedirect(t, handler, method, url, values, append([]interface{}{msg}, args...)...)
}
// HTTPStatusCodef asserts that a specified handler returns a specified status code.
//
// assert.HTTPStatusCodef(t, myHandler, "GET", "/notImplemented", nil, 501, "error message %s", "formatted")
//
// Returns whether the assertion was successful (true) or not (false).
func HTTPStatusCodef(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, statuscode int, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return HTTPStatusCode(t, handler, method, url, values, statuscode, append([]interface{}{msg}, args...)...)
}
// HTTPSuccessf asserts that a specified handler returns a success status code.
//
// assert.HTTPSuccessf(t, myHandler, "POST", "http://www.google.com", nil, "error message %s", "formatted")
//
// Returns whether the assertion was successful (true) or not (false).
func HTTPSuccessf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return HTTPSuccess(t, handler, method, url, values, append([]interface{}{msg}, args...)...)
}
// Implementsf asserts that an object is implemented by the specified interface.
//
// assert.Implementsf(t, (*MyInterface)(nil), new(MyObject), "error message %s", "formatted")
func Implementsf(t TestingT, interfaceObject interface{}, object interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Implements(t, interfaceObject, object, append([]interface{}{msg}, args...)...)
}
// InDeltaf asserts that the two numerals are within delta of each other.
//
// assert.InDeltaf(t, math.Pi, 22/7.0, 0.01, "error message %s", "formatted")
func InDeltaf(t TestingT, expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return InDelta(t, expected, actual, delta, append([]interface{}{msg}, args...)...)
}
// InDeltaMapValuesf is the same as InDelta, but it compares all values between two maps. Both maps must have exactly the same keys.
func InDeltaMapValuesf(t TestingT, expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return InDeltaMapValues(t, expected, actual, delta, append([]interface{}{msg}, args...)...)
}
// InDeltaSlicef is the same as InDelta, except it compares two slices.
func InDeltaSlicef(t TestingT, expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return InDeltaSlice(t, expected, actual, delta, append([]interface{}{msg}, args...)...)
}
// InEpsilonf asserts that expected and actual have a relative error less than epsilon
func InEpsilonf(t TestingT, expected interface{}, actual interface{}, epsilon float64, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return InEpsilon(t, expected, actual, epsilon, append([]interface{}{msg}, args...)...)
}
// InEpsilonSlicef is the same as InEpsilon, except it compares each value from two slices.
func InEpsilonSlicef(t TestingT, expected interface{}, actual interface{}, epsilon float64, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return InEpsilonSlice(t, expected, actual, epsilon, append([]interface{}{msg}, args...)...)
}
// IsDecreasingf asserts that the collection is decreasing
//
// assert.IsDecreasingf(t, []int{2, 1, 0}, "error message %s", "formatted")
// assert.IsDecreasingf(t, []float{2, 1}, "error message %s", "formatted")
// assert.IsDecreasingf(t, []string{"b", "a"}, "error message %s", "formatted")
func IsDecreasingf(t TestingT, object interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return IsDecreasing(t, object, append([]interface{}{msg}, args...)...)
}
// IsIncreasingf asserts that the collection is increasing
//
// assert.IsIncreasingf(t, []int{1, 2, 3}, "error message %s", "formatted")
// assert.IsIncreasingf(t, []float{1, 2}, "error message %s", "formatted")
// assert.IsIncreasingf(t, []string{"a", "b"}, "error message %s", "formatted")
func IsIncreasingf(t TestingT, object interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return IsIncreasing(t, object, append([]interface{}{msg}, args...)...)
}
// IsNonDecreasingf asserts that the collection is not decreasing
//
// assert.IsNonDecreasingf(t, []int{1, 1, 2}, "error message %s", "formatted")
// assert.IsNonDecreasingf(t, []float{1, 2}, "error message %s", "formatted")
// assert.IsNonDecreasingf(t, []string{"a", "b"}, "error message %s", "formatted")
func IsNonDecreasingf(t TestingT, object interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return IsNonDecreasing(t, object, append([]interface{}{msg}, args...)...)
}
// IsNonIncreasingf asserts that the collection is not increasing
//
// assert.IsNonIncreasingf(t, []int{2, 1, 1}, "error message %s", "formatted")
// assert.IsNonIncreasingf(t, []float{2, 1}, "error message %s", "formatted")
// assert.IsNonIncreasingf(t, []string{"b", "a"}, "error message %s", "formatted")
func IsNonIncreasingf(t TestingT, object interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return IsNonIncreasing(t, object, append([]interface{}{msg}, args...)...)
}
// IsTypef asserts that the specified objects are of the same type.
func IsTypef(t TestingT, expectedType interface{}, object interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return IsType(t, expectedType, object, append([]interface{}{msg}, args...)...)
}
// JSONEqf asserts that two JSON strings are equivalent.
//
// assert.JSONEqf(t, `{"hello": "world", "foo": "bar"}`, `{"foo": "bar", "hello": "world"}`, "error message %s", "formatted")
func JSONEqf(t TestingT, expected string, actual string, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return JSONEq(t, expected, actual, append([]interface{}{msg}, args...)...)
}
// Lenf asserts that the specified object has specific length.
// Lenf also fails if the object has a type that len() not accept.
//
// assert.Lenf(t, mySlice, 3, "error message %s", "formatted")
func Lenf(t TestingT, object interface{}, length int, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Len(t, object, length, append([]interface{}{msg}, args...)...)
}
// Lessf asserts that the first element is less than the second
//
// assert.Lessf(t, 1, 2, "error message %s", "formatted")
// assert.Lessf(t, float64(1), float64(2), "error message %s", "formatted")
// assert.Lessf(t, "a", "b", "error message %s", "formatted")
func Lessf(t TestingT, e1 interface{}, e2 interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Less(t, e1, e2, append([]interface{}{msg}, args...)...)
}
// LessOrEqualf asserts that the first element is less than or equal to the second
//
// assert.LessOrEqualf(t, 1, 2, "error message %s", "formatted")
// assert.LessOrEqualf(t, 2, 2, "error message %s", "formatted")
// assert.LessOrEqualf(t, "a", "b", "error message %s", "formatted")
// assert.LessOrEqualf(t, "b", "b", "error message %s", "formatted")
func LessOrEqualf(t TestingT, e1 interface{}, e2 interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return LessOrEqual(t, e1, e2, append([]interface{}{msg}, args...)...)
}
// Negativef asserts that the specified element is negative
//
// assert.Negativef(t, -1, "error message %s", "formatted")
// assert.Negativef(t, -1.23, "error message %s", "formatted")
func Negativef(t TestingT, e interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Negative(t, e, append([]interface{}{msg}, args...)...)
}
// Neverf asserts that the given condition doesn't satisfy in waitFor time,
// periodically checking the target function each tick.
//
// assert.Neverf(t, func() bool { return false; }, time.Second, 10*time.Millisecond, "error message %s", "formatted")
func Neverf(t TestingT, condition func() bool, waitFor time.Duration, tick time.Duration, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Never(t, condition, waitFor, tick, append([]interface{}{msg}, args...)...)
}
// Nilf asserts that the specified object is nil.
//
// assert.Nilf(t, err, "error message %s", "formatted")
func Nilf(t TestingT, object interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Nil(t, object, append([]interface{}{msg}, args...)...)
}
// NoDirExistsf checks whether a directory does not exist in the given path.
// It fails if the path points to an existing _directory_ only.
func NoDirExistsf(t TestingT, path string, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NoDirExists(t, path, append([]interface{}{msg}, args...)...)
}
// NoErrorf asserts that a function returned no error (i.e. `nil`).
//
// actualObj, err := SomeFunction()
// if assert.NoErrorf(t, err, "error message %s", "formatted") {
// assert.Equal(t, expectedObj, actualObj)
// }
func NoErrorf(t TestingT, err error, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NoError(t, err, append([]interface{}{msg}, args...)...)
}
// NoFileExistsf checks whether a file does not exist in a given path. It fails
// if the path points to an existing _file_ only.
func NoFileExistsf(t TestingT, path string, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NoFileExists(t, path, append([]interface{}{msg}, args...)...)
}
// NotContainsf asserts that the specified string, list(array, slice...) or map does NOT contain the
// specified substring or element.
//
// assert.NotContainsf(t, "Hello World", "Earth", "error message %s", "formatted")
// assert.NotContainsf(t, ["Hello", "World"], "Earth", "error message %s", "formatted")
// assert.NotContainsf(t, {"Hello": "World"}, "Earth", "error message %s", "formatted")
func NotContainsf(t TestingT, s interface{}, contains interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotContains(t, s, contains, append([]interface{}{msg}, args...)...)
}
// NotElementsMatchf asserts that the specified listA(array, slice...) is NOT equal to specified
// listB(array, slice...) ignoring the order of the elements. If there are duplicate elements,
// the number of appearances of each of them in both lists should not match.
// This is an inverse of ElementsMatch.
//
// assert.NotElementsMatchf(t, [1, 1, 2, 3], [1, 1, 2, 3], "error message %s", "formatted") -> false
//
// assert.NotElementsMatchf(t, [1, 1, 2, 3], [1, 2, 3], "error message %s", "formatted") -> true
//
// assert.NotElementsMatchf(t, [1, 2, 3], [1, 2, 4], "error message %s", "formatted") -> true
func NotElementsMatchf(t TestingT, listA interface{}, listB interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotElementsMatch(t, listA, listB, append([]interface{}{msg}, args...)...)
}
// NotEmptyf asserts that the specified object is NOT empty. I.e. not nil, "", false, 0 or either
// a slice or a channel with len == 0.
//
// if assert.NotEmptyf(t, obj, "error message %s", "formatted") {
// assert.Equal(t, "two", obj[1])
// }
func NotEmptyf(t TestingT, object interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotEmpty(t, object, append([]interface{}{msg}, args...)...)
}
// NotEqualf asserts that the specified values are NOT equal.
//
// assert.NotEqualf(t, obj1, obj2, "error message %s", "formatted")
//
// Pointer variable equality is determined based on the equality of the
// referenced values (as opposed to the memory addresses).
func NotEqualf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotEqual(t, expected, actual, append([]interface{}{msg}, args...)...)
}
// NotEqualValuesf asserts that two objects are not equal even when converted to the same type
//
// assert.NotEqualValuesf(t, obj1, obj2, "error message %s", "formatted")
func NotEqualValuesf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotEqualValues(t, expected, actual, append([]interface{}{msg}, args...)...)
}
// NotErrorAsf asserts that none of the errors in err's chain matches target,
// but if so, sets target to that error value.
func NotErrorAsf(t TestingT, err error, target interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotErrorAs(t, err, target, append([]interface{}{msg}, args...)...)
}
// NotErrorIsf asserts that none of the errors in err's chain matches target.
// This is a wrapper for errors.Is.
func NotErrorIsf(t TestingT, err error, target error, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotErrorIs(t, err, target, append([]interface{}{msg}, args...)...)
}
// NotImplementsf asserts that an object does not implement the specified interface.
//
// assert.NotImplementsf(t, (*MyInterface)(nil), new(MyObject), "error message %s", "formatted")
func NotImplementsf(t TestingT, interfaceObject interface{}, object interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotImplements(t, interfaceObject, object, append([]interface{}{msg}, args...)...)
}
// NotNilf asserts that the specified object is not nil.
//
// assert.NotNilf(t, err, "error message %s", "formatted")
func NotNilf(t TestingT, object interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotNil(t, object, append([]interface{}{msg}, args...)...)
}
// NotPanicsf asserts that the code inside the specified PanicTestFunc does NOT panic.
//
// assert.NotPanicsf(t, func(){ RemainCalm() }, "error message %s", "formatted")
func NotPanicsf(t TestingT, f PanicTestFunc, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotPanics(t, f, append([]interface{}{msg}, args...)...)
}
// NotRegexpf asserts that a specified regexp does not match a string.
//
// assert.NotRegexpf(t, regexp.MustCompile("starts"), "it's starting", "error message %s", "formatted")
// assert.NotRegexpf(t, "^start", "it's not starting", "error message %s", "formatted")
func NotRegexpf(t TestingT, rx interface{}, str interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotRegexp(t, rx, str, append([]interface{}{msg}, args...)...)
}
// NotSamef asserts that two pointers do not reference the same object.
//
// assert.NotSamef(t, ptr1, ptr2, "error message %s", "formatted")
//
// Both arguments must be pointer variables. Pointer variable sameness is
// determined based on the equality of both type and value.
func NotSamef(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotSame(t, expected, actual, append([]interface{}{msg}, args...)...)
}
// NotSubsetf asserts that the specified list(array, slice...) or map does NOT
// contain all elements given in the specified subset list(array, slice...) or
// map.
//
// assert.NotSubsetf(t, [1, 3, 4], [1, 2], "error message %s", "formatted")
// assert.NotSubsetf(t, {"x": 1, "y": 2}, {"z": 3}, "error message %s", "formatted")
func NotSubsetf(t TestingT, list interface{}, subset interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotSubset(t, list, subset, append([]interface{}{msg}, args...)...)
}
// NotZerof asserts that i is not the zero value for its type.
func NotZerof(t TestingT, i interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return NotZero(t, i, append([]interface{}{msg}, args...)...)
}
// Panicsf asserts that the code inside the specified PanicTestFunc panics.
//
// assert.Panicsf(t, func(){ GoCrazy() }, "error message %s", "formatted")
func Panicsf(t TestingT, f PanicTestFunc, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Panics(t, f, append([]interface{}{msg}, args...)...)
}
// PanicsWithErrorf asserts that the code inside the specified PanicTestFunc
// panics, and that the recovered panic value is an error that satisfies the
// EqualError comparison.
//
// assert.PanicsWithErrorf(t, "crazy error", func(){ GoCrazy() }, "error message %s", "formatted")
func PanicsWithErrorf(t TestingT, errString string, f PanicTestFunc, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return PanicsWithError(t, errString, f, append([]interface{}{msg}, args...)...)
}
// PanicsWithValuef asserts that the code inside the specified PanicTestFunc panics, and that
// the recovered panic value equals the expected panic value.
//
// assert.PanicsWithValuef(t, "crazy error", func(){ GoCrazy() }, "error message %s", "formatted")
func PanicsWithValuef(t TestingT, expected interface{}, f PanicTestFunc, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return PanicsWithValue(t, expected, f, append([]interface{}{msg}, args...)...)
}
// Positivef asserts that the specified element is positive
//
// assert.Positivef(t, 1, "error message %s", "formatted")
// assert.Positivef(t, 1.23, "error message %s", "formatted")
func Positivef(t TestingT, e interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Positive(t, e, append([]interface{}{msg}, args...)...)
}
// Regexpf asserts that a specified regexp matches a string.
//
// assert.Regexpf(t, regexp.MustCompile("start"), "it's starting", "error message %s", "formatted")
// assert.Regexpf(t, "start...$", "it's not starting", "error message %s", "formatted")
func Regexpf(t TestingT, rx interface{}, str interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Regexp(t, rx, str, append([]interface{}{msg}, args...)...)
}
// Samef asserts that two pointers reference the same object.
//
// assert.Samef(t, ptr1, ptr2, "error message %s", "formatted")
//
// Both arguments must be pointer variables. Pointer variable sameness is
// determined based on the equality of both type and value.
func Samef(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Same(t, expected, actual, append([]interface{}{msg}, args...)...)
}
// Subsetf asserts that the specified list(array, slice...) or map contains all
// elements given in the specified subset list(array, slice...) or map.
//
// assert.Subsetf(t, [1, 2, 3], [1, 2], "error message %s", "formatted")
// assert.Subsetf(t, {"x": 1, "y": 2}, {"x": 1}, "error message %s", "formatted")
func Subsetf(t TestingT, list interface{}, subset interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Subset(t, list, subset, append([]interface{}{msg}, args...)...)
}
// Truef asserts that the specified value is true.
//
// assert.Truef(t, myBool, "error message %s", "formatted")
func Truef(t TestingT, value bool, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return True(t, value, append([]interface{}{msg}, args...)...)
}
// WithinDurationf asserts that the two times are within duration delta of each other.
//
// assert.WithinDurationf(t, time.Now(), time.Now(), 10*time.Second, "error message %s", "formatted")
func WithinDurationf(t TestingT, expected time.Time, actual time.Time, delta time.Duration, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return WithinDuration(t, expected, actual, delta, append([]interface{}{msg}, args...)...)
}
// WithinRangef asserts that a time is within a time range (inclusive).
//
// assert.WithinRangef(t, time.Now(), time.Now().Add(-time.Second), time.Now().Add(time.Second), "error message %s", "formatted")
func WithinRangef(t TestingT, actual time.Time, start time.Time, end time.Time, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return WithinRange(t, actual, start, end, append([]interface{}{msg}, args...)...)
}
// YAMLEqf asserts that two YAML strings are equivalent.
func YAMLEqf(t TestingT, expected string, actual string, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return YAMLEq(t, expected, actual, append([]interface{}{msg}, args...)...)
}
// Zerof asserts that i is the zero value for its type.
func Zerof(t TestingT, i interface{}, msg string, args ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Zero(t, i, append([]interface{}{msg}, args...)...)
}
-5
View File
@@ -1,5 +0,0 @@
{{.CommentFormat}}
func {{.DocInfo.Name}}f(t TestingT, {{.ParamsFormat}}) bool {
if h, ok := t.(tHelper); ok { h.Helper() }
return {{.DocInfo.Name}}(t, {{.ForwardedParamsFormat}})
}
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
{{.CommentWithoutT "a"}}
func (a *Assertions) {{.DocInfo.Name}}({{.Params}}) bool {
if h, ok := a.t.(tHelper); ok { h.Helper() }
return {{.DocInfo.Name}}(a.t, {{.ForwardedParams}})
}
-81
View File
@@ -1,81 +0,0 @@
package assert
import (
"fmt"
"reflect"
)
// isOrdered checks that collection contains orderable elements.
func isOrdered(t TestingT, object interface{}, allowedComparesResults []compareResult, failMessage string, msgAndArgs ...interface{}) bool {
objKind := reflect.TypeOf(object).Kind()
if objKind != reflect.Slice && objKind != reflect.Array {
return false
}
objValue := reflect.ValueOf(object)
objLen := objValue.Len()
if objLen <= 1 {
return true
}
value := objValue.Index(0)
valueInterface := value.Interface()
firstValueKind := value.Kind()
for i := 1; i < objLen; i++ {
prevValue := value
prevValueInterface := valueInterface
value = objValue.Index(i)
valueInterface = value.Interface()
compareResult, isComparable := compare(prevValueInterface, valueInterface, firstValueKind)
if !isComparable {
return Fail(t, fmt.Sprintf("Can not compare type \"%s\" and \"%s\"", reflect.TypeOf(value), reflect.TypeOf(prevValue)), msgAndArgs...)
}
if !containsValue(allowedComparesResults, compareResult) {
return Fail(t, fmt.Sprintf(failMessage, prevValue, value), msgAndArgs...)
}
}
return true
}
// IsIncreasing asserts that the collection is increasing
//
// assert.IsIncreasing(t, []int{1, 2, 3})
// assert.IsIncreasing(t, []float{1, 2})
// assert.IsIncreasing(t, []string{"a", "b"})
func IsIncreasing(t TestingT, object interface{}, msgAndArgs ...interface{}) bool {
return isOrdered(t, object, []compareResult{compareLess}, "\"%v\" is not less than \"%v\"", msgAndArgs...)
}
// IsNonIncreasing asserts that the collection is not increasing
//
// assert.IsNonIncreasing(t, []int{2, 1, 1})
// assert.IsNonIncreasing(t, []float{2, 1})
// assert.IsNonIncreasing(t, []string{"b", "a"})
func IsNonIncreasing(t TestingT, object interface{}, msgAndArgs ...interface{}) bool {
return isOrdered(t, object, []compareResult{compareEqual, compareGreater}, "\"%v\" is not greater than or equal to \"%v\"", msgAndArgs...)
}
// IsDecreasing asserts that the collection is decreasing
//
// assert.IsDecreasing(t, []int{2, 1, 0})
// assert.IsDecreasing(t, []float{2, 1})
// assert.IsDecreasing(t, []string{"b", "a"})
func IsDecreasing(t TestingT, object interface{}, msgAndArgs ...interface{}) bool {
return isOrdered(t, object, []compareResult{compareGreater}, "\"%v\" is not greater than \"%v\"", msgAndArgs...)
}
// IsNonDecreasing asserts that the collection is not decreasing
//
// assert.IsNonDecreasing(t, []int{1, 1, 2})
// assert.IsNonDecreasing(t, []float{1, 2})
// assert.IsNonDecreasing(t, []string{"a", "b"})
func IsNonDecreasing(t TestingT, object interface{}, msgAndArgs ...interface{}) bool {
return isOrdered(t, object, []compareResult{compareLess, compareEqual}, "\"%v\" is not less than or equal to \"%v\"", msgAndArgs...)
}
File diff suppressed because it is too large Load Diff
-46
View File
@@ -1,46 +0,0 @@
// Package assert provides a set of comprehensive testing tools for use with the normal Go testing system.
//
// # Example Usage
//
// The following is a complete example using assert in a standard test function:
//
// import (
// "testing"
// "github.com/stretchr/testify/assert"
// )
//
// func TestSomething(t *testing.T) {
//
// var a string = "Hello"
// var b string = "Hello"
//
// assert.Equal(t, a, b, "The two words should be the same.")
//
// }
//
// if you assert many times, use the format below:
//
// import (
// "testing"
// "github.com/stretchr/testify/assert"
// )
//
// func TestSomething(t *testing.T) {
// assert := assert.New(t)
//
// var a string = "Hello"
// var b string = "Hello"
//
// assert.Equal(a, b, "The two words should be the same.")
// }
//
// # Assertions
//
// Assertions allow you to easily write test code, and are global funcs in the `assert` package.
// All assertion functions take, as the first argument, the `*testing.T` object provided by the
// testing framework. This allows the assertion funcs to write the failings and other details to
// the correct place.
//
// Every assertion function also takes an optional string message as the final argument,
// allowing custom error messages to be appended to the message the assertion method outputs.
package assert
-10
View File
@@ -1,10 +0,0 @@
package assert
import (
"errors"
)
// AnError is an error instance useful for testing. If the code does not care
// about error specifics, and only needs to return the error for example, this
// error should be used to make the test code more readable.
var AnError = errors.New("assert.AnError general error for testing")
-16
View File
@@ -1,16 +0,0 @@
package assert
// Assertions provides assertion methods around the
// TestingT interface.
type Assertions struct {
t TestingT
}
// New makes a new Assertions object for the specified TestingT.
func New(t TestingT) *Assertions {
return &Assertions{
t: t,
}
}
//go:generate sh -c "cd ../_codegen && go build && cd - && ../_codegen/_codegen -output-package=assert -template=assertion_forward.go.tmpl -include-format-funcs"
-165
View File
@@ -1,165 +0,0 @@
package assert
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
)
// httpCode is a helper that returns HTTP code of the response. It returns -1 and
// an error if building a new request fails.
func httpCode(handler http.HandlerFunc, method, url string, values url.Values) (int, error) {
w := httptest.NewRecorder()
req, err := http.NewRequest(method, url, http.NoBody)
if err != nil {
return -1, err
}
req.URL.RawQuery = values.Encode()
handler(w, req)
return w.Code, nil
}
// HTTPSuccess asserts that a specified handler returns a success status code.
//
// assert.HTTPSuccess(t, myHandler, "POST", "http://www.google.com", nil)
//
// Returns whether the assertion was successful (true) or not (false).
func HTTPSuccess(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
code, err := httpCode(handler, method, url, values)
if err != nil {
Fail(t, fmt.Sprintf("Failed to build test request, got error: %s", err), msgAndArgs...)
}
isSuccessCode := code >= http.StatusOK && code <= http.StatusPartialContent
if !isSuccessCode {
Fail(t, fmt.Sprintf("Expected HTTP success status code for %q but received %d", url+"?"+values.Encode(), code), msgAndArgs...)
}
return isSuccessCode
}
// HTTPRedirect asserts that a specified handler returns a redirect status code.
//
// assert.HTTPRedirect(t, myHandler, "GET", "/a/b/c", url.Values{"a": []string{"b", "c"}}
//
// Returns whether the assertion was successful (true) or not (false).
func HTTPRedirect(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
code, err := httpCode(handler, method, url, values)
if err != nil {
Fail(t, fmt.Sprintf("Failed to build test request, got error: %s", err), msgAndArgs...)
}
isRedirectCode := code >= http.StatusMultipleChoices && code <= http.StatusTemporaryRedirect
if !isRedirectCode {
Fail(t, fmt.Sprintf("Expected HTTP redirect status code for %q but received %d", url+"?"+values.Encode(), code), msgAndArgs...)
}
return isRedirectCode
}
// HTTPError asserts that a specified handler returns an error status code.
//
// assert.HTTPError(t, myHandler, "POST", "/a/b/c", url.Values{"a": []string{"b", "c"}}
//
// Returns whether the assertion was successful (true) or not (false).
func HTTPError(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
code, err := httpCode(handler, method, url, values)
if err != nil {
Fail(t, fmt.Sprintf("Failed to build test request, got error: %s", err), msgAndArgs...)
}
isErrorCode := code >= http.StatusBadRequest
if !isErrorCode {
Fail(t, fmt.Sprintf("Expected HTTP error status code for %q but received %d", url+"?"+values.Encode(), code), msgAndArgs...)
}
return isErrorCode
}
// HTTPStatusCode asserts that a specified handler returns a specified status code.
//
// assert.HTTPStatusCode(t, myHandler, "GET", "/notImplemented", nil, 501)
//
// Returns whether the assertion was successful (true) or not (false).
func HTTPStatusCode(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, statuscode int, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
code, err := httpCode(handler, method, url, values)
if err != nil {
Fail(t, fmt.Sprintf("Failed to build test request, got error: %s", err), msgAndArgs...)
}
successful := code == statuscode
if !successful {
Fail(t, fmt.Sprintf("Expected HTTP status code %d for %q but received %d", statuscode, url+"?"+values.Encode(), code), msgAndArgs...)
}
return successful
}
// HTTPBody is a helper that returns HTTP body of the response. It returns
// empty string if building a new request fails.
func HTTPBody(handler http.HandlerFunc, method, url string, values url.Values) string {
w := httptest.NewRecorder()
if len(values) > 0 {
url += "?" + values.Encode()
}
req, err := http.NewRequest(method, url, http.NoBody)
if err != nil {
return ""
}
handler(w, req)
return w.Body.String()
}
// HTTPBodyContains asserts that a specified handler returns a
// body that contains a string.
//
// assert.HTTPBodyContains(t, myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky")
//
// Returns whether the assertion was successful (true) or not (false).
func HTTPBodyContains(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, str interface{}, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
body := HTTPBody(handler, method, url, values)
contains := strings.Contains(body, fmt.Sprint(str))
if !contains {
Fail(t, fmt.Sprintf("Expected response body for \"%s\" to contain \"%s\" but found \"%s\"", url+"?"+values.Encode(), str, body), msgAndArgs...)
}
return contains
}
// HTTPBodyNotContains asserts that a specified handler returns a
// body that does not contain a string.
//
// assert.HTTPBodyNotContains(t, myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky")
//
// Returns whether the assertion was successful (true) or not (false).
func HTTPBodyNotContains(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, str interface{}, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
body := HTTPBody(handler, method, url, values)
contains := strings.Contains(body, fmt.Sprint(str))
if contains {
Fail(t, fmt.Sprintf("Expected response body for \"%s\" to NOT contain \"%s\" but found \"%s\"", url+"?"+values.Encode(), str, body), msgAndArgs...)
}
return !contains
}
-25
View File
@@ -1,25 +0,0 @@
//go:build testify_yaml_custom && !testify_yaml_fail && !testify_yaml_default
// +build testify_yaml_custom,!testify_yaml_fail,!testify_yaml_default
// Package yaml is an implementation of YAML functions that calls a pluggable implementation.
//
// This implementation is selected with the testify_yaml_custom build tag.
//
// go test -tags testify_yaml_custom
//
// This implementation can be used at build time to replace the default implementation
// to avoid linking with [gopkg.in/yaml.v3].
//
// In your test package:
//
// import assertYaml "github.com/stretchr/testify/assert/yaml"
//
// func init() {
// assertYaml.Unmarshal = func (in []byte, out interface{}) error {
// // ...
// return nil
// }
// }
package yaml
var Unmarshal func(in []byte, out interface{}) error
-37
View File
@@ -1,37 +0,0 @@
//go:build !testify_yaml_fail && !testify_yaml_custom
// +build !testify_yaml_fail,!testify_yaml_custom
// Package yaml is just an indirection to handle YAML deserialization.
//
// This package is just an indirection that allows the builder to override the
// indirection with an alternative implementation of this package that uses
// another implementation of YAML deserialization. This allows to not either not
// use YAML deserialization at all, or to use another implementation than
// [gopkg.in/yaml.v3] (for example for license compatibility reasons, see [PR #1120]).
//
// Alternative implementations are selected using build tags:
//
// - testify_yaml_fail: [Unmarshal] always fails with an error
// - testify_yaml_custom: [Unmarshal] is a variable. Caller must initialize it
// before calling any of [github.com/stretchr/testify/assert.YAMLEq] or
// [github.com/stretchr/testify/assert.YAMLEqf].
//
// Usage:
//
// go test -tags testify_yaml_fail
//
// You can check with "go list" which implementation is linked:
//
// go list -f '{{.Imports}}' github.com/stretchr/testify/assert/yaml
// go list -tags testify_yaml_fail -f '{{.Imports}}' github.com/stretchr/testify/assert/yaml
// go list -tags testify_yaml_custom -f '{{.Imports}}' github.com/stretchr/testify/assert/yaml
//
// [PR #1120]: https://github.com/stretchr/testify/pull/1120
package yaml
import goyaml "gopkg.in/yaml.v3"
// Unmarshal is just a wrapper of [gopkg.in/yaml.v3.Unmarshal].
func Unmarshal(in []byte, out interface{}) error {
return goyaml.Unmarshal(in, out)
}
-18
View File
@@ -1,18 +0,0 @@
//go:build testify_yaml_fail && !testify_yaml_custom && !testify_yaml_default
// +build testify_yaml_fail,!testify_yaml_custom,!testify_yaml_default
// Package yaml is an implementation of YAML functions that always fail.
//
// This implementation can be used at build time to replace the default implementation
// to avoid linking with [gopkg.in/yaml.v3]:
//
// go test -tags testify_yaml_fail
package yaml
import "errors"
var errNotImplemented = errors.New("YAML functions are not available (see https://pkg.go.dev/github.com/stretchr/testify/assert/yaml)")
func Unmarshal([]byte, interface{}) error {
return errNotImplemented
}

Some files were not shown because too many files have changed in this diff Show More