mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
+137
-5
@@ -102,7 +102,13 @@ testData:
|
|||||||
cookieDomain: "" # Explicit domain for session cookies (e.g., ".example.com" for multi-subdomain setups)
|
cookieDomain: "" # Explicit domain for session cookies (e.g., ".example.com" for multi-subdomain setups)
|
||||||
overrideScopes: false # When true, replaces default scopes instead of appending (default: false)
|
overrideScopes: false # When true, replaces default scopes instead of appending (default: false)
|
||||||
refreshGracePeriodSeconds: 60 # Seconds before token expiry to attempt proactive refresh (default: 60)
|
refreshGracePeriodSeconds: 60 # Seconds before token expiry to attempt proactive refresh (default: 60)
|
||||||
|
|
||||||
|
# Auth0 / Custom API Audience Configuration
|
||||||
|
audience: "" # Custom audience for access token validation (default: clientID)
|
||||||
|
strictAudienceValidation: false # Reject sessions with audience mismatch (prevents token confusion attacks)
|
||||||
|
allowOpaqueTokens: false # Enable opaque (non-JWT) access token support via RFC 7662 introspection
|
||||||
|
requireTokenIntrospection: false # Force introspection for opaque tokens (requires introspection endpoint)
|
||||||
|
|
||||||
# Security Headers Configuration (enabled by default with 'default' profile)
|
# Security Headers Configuration (enabled by default with 'default' profile)
|
||||||
securityHeaders:
|
securityHeaders:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -312,6 +318,20 @@ testData:
|
|||||||
# clientSecret: your-auth0-client-secret # Store securely
|
# clientSecret: your-auth0-client-secret # Store securely
|
||||||
# callbackURL: /oauth2/callback
|
# callbackURL: /oauth2/callback
|
||||||
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-auth0"
|
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-auth0"
|
||||||
|
#
|
||||||
|
# # Auth0 Audience Configuration (for custom APIs)
|
||||||
|
# # Scenario 1 (Recommended): Custom API with JWT access tokens
|
||||||
|
# audience: "https://my-api.example.com" # Your API identifier from Auth0 dashboard
|
||||||
|
# strictAudienceValidation: true # Enforce proper audience validation for security
|
||||||
|
#
|
||||||
|
# # Scenario 2 (Backward Compatible): Default audience (uses client_id)
|
||||||
|
# # audience: "" # Leave empty or omit to use client_id as audience
|
||||||
|
# # strictAudienceValidation: false # Allows fallback to ID token validation (logs warnings)
|
||||||
|
#
|
||||||
|
# # Scenario 3: Opaque (non-JWT) access tokens
|
||||||
|
# # allowOpaqueTokens: true # Enable opaque token support
|
||||||
|
# # requireTokenIntrospection: true # Require RFC 7662 token introspection
|
||||||
|
#
|
||||||
# scopes: # Defaults ["openid", "profile", "email"]. Add custom scopes if your Auth0 Rules/Actions require them.
|
# scopes: # Defaults ["openid", "profile", "email"]. Add custom scopes if your Auth0 Rules/Actions require them.
|
||||||
# - read:custom_data # Example custom scope
|
# - read:custom_data # Example custom scope
|
||||||
# allowedRolesAndGroups: # Based on claims added via Auth0 Rules or Actions (e.g. namespaced claims)
|
# allowedRolesAndGroups: # Based on claims added via Auth0 Rules or Actions (e.g. namespaced claims)
|
||||||
@@ -319,7 +339,7 @@ testData:
|
|||||||
# - editor
|
# - editor
|
||||||
# # Use Auth0 Rules or Actions to add custom claims (roles, permissions) to the ID Token.
|
# # 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".
|
# # Ensure postLogoutRedirectURI is in Auth0 app's "Allowed Logout URLs".
|
||||||
# # See README.md "Provider Configuration Recommendations" for Auth0.
|
# # For detailed Auth0 audience configuration, see AUTH0_AUDIENCE_GUIDE.md
|
||||||
|
|
||||||
# --- Generic OIDC Provider Example ---
|
# --- Generic OIDC Provider Example ---
|
||||||
# testDataGenericOIDC:
|
# testDataGenericOIDC:
|
||||||
@@ -588,16 +608,128 @@ configuration:
|
|||||||
type: integer
|
type: integer
|
||||||
description: |
|
description: |
|
||||||
The number of seconds before a token expires to attempt proactive refresh.
|
The number of seconds before a token expires to attempt proactive refresh.
|
||||||
|
|
||||||
When a request is made and the access token will expire within this grace period,
|
When a request is made and the access token will expire within this grace period,
|
||||||
the middleware will attempt to refresh the token proactively. This helps prevent
|
the middleware will attempt to refresh the token proactively. This helps prevent
|
||||||
authentication interruptions for active users.
|
authentication interruptions for active users.
|
||||||
|
|
||||||
Setting this to 0 disables proactive refresh (tokens are only refreshed after expiry).
|
Setting this to 0 disables proactive refresh (tokens are only refreshed after expiry).
|
||||||
|
|
||||||
Default: 60 (1 minute before expiry)
|
Default: 60 (1 minute before expiry)
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
|
audience:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Custom audience value for access token validation.
|
||||||
|
|
||||||
|
This configures the expected audience claim in access tokens. Per OAuth 2.0 and OIDC
|
||||||
|
specifications:
|
||||||
|
- ID tokens always have aud=client_id (per OIDC Core 1.0)
|
||||||
|
- Access tokens can have custom audiences (e.g., API identifiers)
|
||||||
|
|
||||||
|
Auth0 Scenarios:
|
||||||
|
1. Custom API audience (recommended): Set to your API identifier from Auth0
|
||||||
|
Example: "https://my-api.example.com"
|
||||||
|
Result: Access tokens will contain this audience
|
||||||
|
|
||||||
|
2. Default audience: Leave empty or omit (uses client_id)
|
||||||
|
Result: Access tokens may not contain client_id, triggering warnings
|
||||||
|
|
||||||
|
3. Opaque tokens: Use with allowOpaqueTokens=true for non-JWT tokens
|
||||||
|
|
||||||
|
When configured and different from client_id, the middleware automatically adds
|
||||||
|
the audience parameter to the authorize endpoint request.
|
||||||
|
|
||||||
|
Default: "" (uses client_id as audience)
|
||||||
|
See: AUTH0_AUDIENCE_GUIDE.md for detailed configuration
|
||||||
|
required: false
|
||||||
|
|
||||||
|
strictAudienceValidation:
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
Enforce strict audience validation for access tokens.
|
||||||
|
|
||||||
|
When enabled, sessions are rejected if access token validation fails due to
|
||||||
|
audience mismatch. This prevents falling back to ID token validation, addressing
|
||||||
|
potential token confusion attacks where tokens intended for different APIs could
|
||||||
|
be used to grant access.
|
||||||
|
|
||||||
|
Auth0 Scenario 2 Protection:
|
||||||
|
- When true: Rejects sessions with mismatched access token audience
|
||||||
|
- When false: Logs security warnings but allows fallback to ID token (backward compatible)
|
||||||
|
|
||||||
|
Security Recommendation:
|
||||||
|
- Production environments: Set to true for maximum security
|
||||||
|
- Development/testing: Can use false with monitoring of security warnings
|
||||||
|
|
||||||
|
This setting addresses security concerns where access tokens without proper
|
||||||
|
audience claims could bypass API-specific authorization checks.
|
||||||
|
|
||||||
|
Default: false (backward compatible)
|
||||||
|
See: https://github.com/lukaszraczylo/traefikoidc/issues/74
|
||||||
|
required: false
|
||||||
|
|
||||||
|
allowOpaqueTokens:
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
Enable acceptance of opaque (non-JWT) access tokens.
|
||||||
|
|
||||||
|
When enabled, the middleware accepts access tokens that are not in JWT format
|
||||||
|
(3-part base64 structure). Opaque tokens are validated using OAuth 2.0 Token
|
||||||
|
Introspection (RFC 7662) if the provider exposes an introspection endpoint.
|
||||||
|
|
||||||
|
Auth0 Scenario 3:
|
||||||
|
Some Auth0 configurations issue opaque access tokens when no default API is
|
||||||
|
configured. This setting allows those tokens to be validated.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Provider must support introspection_endpoint in OIDC discovery
|
||||||
|
- Client must have appropriate introspection permissions
|
||||||
|
|
||||||
|
Validation Process:
|
||||||
|
1. Detects opaque token (not 3-part JWT structure)
|
||||||
|
2. Calls provider's introspection endpoint with client credentials
|
||||||
|
3. Validates response (active status, expiration, audience if present)
|
||||||
|
4. Caches result for 5 minutes or token expiry (whichever is shorter)
|
||||||
|
5. Falls back to ID token validation if introspection unavailable
|
||||||
|
(unless requireTokenIntrospection=true)
|
||||||
|
|
||||||
|
Default: false (only JWT access tokens accepted)
|
||||||
|
See: AUTH0_AUDIENCE_GUIDE.md for configuration examples
|
||||||
|
required: false
|
||||||
|
|
||||||
|
requireTokenIntrospection:
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
Require token introspection for all opaque access tokens.
|
||||||
|
|
||||||
|
When enabled with allowOpaqueTokens=true, opaque tokens are rejected if:
|
||||||
|
- Introspection endpoint is not available from provider metadata
|
||||||
|
- Introspection request fails
|
||||||
|
- Introspection response indicates token is not active
|
||||||
|
|
||||||
|
Security Levels:
|
||||||
|
- requireTokenIntrospection=true + allowOpaqueTokens=true:
|
||||||
|
Maximum security - rejects opaque tokens without successful introspection
|
||||||
|
|
||||||
|
- requireTokenIntrospection=false + allowOpaqueTokens=true:
|
||||||
|
Backward compatible - falls back to ID token validation if introspection fails
|
||||||
|
|
||||||
|
- requireTokenIntrospection=true + allowOpaqueTokens=false:
|
||||||
|
No effect - opaque tokens are already rejected
|
||||||
|
|
||||||
|
Recommended Configuration:
|
||||||
|
When accepting opaque tokens, always set this to true for maximum security:
|
||||||
|
```yaml
|
||||||
|
allowOpaqueTokens: true
|
||||||
|
requireTokenIntrospection: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Default: false (allows fallback to ID token)
|
||||||
|
See: RFC 7662 OAuth 2.0 Token Introspection specification
|
||||||
|
required: false
|
||||||
|
|
||||||
headers:
|
headers:
|
||||||
type: array
|
type: array
|
||||||
description: |
|
description: |
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
# Auth0 Audience Validation Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide explains how to configure audience validation for Auth0 and other OIDC providers that support custom API audiences. It covers three common Auth0 scenarios and how to configure the middleware for maximum security.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Understanding Audiences](#understanding-audiences)
|
||||||
|
2. [The Three Auth0 Scenarios](#the-three-auth0-scenarios)
|
||||||
|
3. [Configuration Options](#configuration-options)
|
||||||
|
4. [Security Recommendations](#security-recommendations)
|
||||||
|
5. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Understanding Audiences
|
||||||
|
|
||||||
|
### What is an Audience?
|
||||||
|
|
||||||
|
The **audience** (`aud`) claim in a JWT identifies the intended recipient of the token. Per OAuth 2.0 and OIDC specifications:
|
||||||
|
|
||||||
|
- **ID Tokens**: MUST have `aud = client_id` (per OIDC Core 1.0 spec)
|
||||||
|
- **Access Tokens**: Can have custom audiences (e.g., API identifiers)
|
||||||
|
|
||||||
|
### Why Does This Matter?
|
||||||
|
|
||||||
|
Proper audience validation prevents **token confusion attacks** where a token intended for one API is used to access another API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Three Auth0 Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Custom API Audience ✅ **RECOMMENDED**
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```yaml
|
||||||
|
audience: "https://my-api.example.com" # Your API identifier from Auth0
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Happens:**
|
||||||
|
1. Authorization request includes `audience` parameter
|
||||||
|
2. Auth0 issues:
|
||||||
|
- **ID Token**: `aud = client_id`
|
||||||
|
- **Access Token**: `aud = ["https://issuer/userinfo", "https://my-api.example.com"]`
|
||||||
|
3. Middleware validates:
|
||||||
|
- ID tokens against `client_id`
|
||||||
|
- Access tokens against custom audience
|
||||||
|
|
||||||
|
**Result:** ✅ Fully secure, OIDC compliant
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 2: Default Audience (No Custom API) ⚠️ **USE WITH CAUTION**
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```yaml
|
||||||
|
# audience not specified (defaults to client_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Happens:**
|
||||||
|
1. Authorization request WITHOUT `audience` parameter
|
||||||
|
2. Auth0 issues:
|
||||||
|
- **ID Token**: `aud = client_id`
|
||||||
|
- **Access Token**: `aud = ["https://issuer/userinfo", "default_api"]` (no `client_id`)
|
||||||
|
3. Access token validation fails (audience mismatch)
|
||||||
|
4. Middleware falls back to ID token validation
|
||||||
|
|
||||||
|
**Security Warning:**
|
||||||
|
```
|
||||||
|
⚠️⚠️⚠️ SECURITY WARNING: Falling back to ID token validation despite access token audience mismatch!
|
||||||
|
⚠️ This could allow tokens intended for different APIs to grant access
|
||||||
|
⚠️ Set strictAudienceValidation=true to enforce proper audience validation
|
||||||
|
⚠️ See: https://github.com/lukaszraczylo/traefikoidc/issues/74
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommended Fix:**
|
||||||
|
```yaml
|
||||||
|
strictAudienceValidation: true # Reject sessions with audience mismatch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
- Default: ⚠️ Works but logs security warnings
|
||||||
|
- With strict mode: ✅ Secure (rejects mismatched tokens)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 3: Opaque Access Tokens ✅ **SUPPORTED**
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```yaml
|
||||||
|
allowOpaqueTokens: true # Enable opaque token support
|
||||||
|
requireTokenIntrospection: true # Require introspection (recommended)
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Happens:**
|
||||||
|
1. Auth0 issues opaque (non-JWT) access token
|
||||||
|
2. Middleware detects opaque token (not 3 parts separated by dots)
|
||||||
|
3. Uses OAuth 2.0 Token Introspection (RFC 7662) to validate
|
||||||
|
4. Falls back to ID token if introspection unavailable (unless `requireTokenIntrospection=true`)
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Provider must support `introspection_endpoint` in OIDC discovery
|
||||||
|
- Client must have introspection permissions
|
||||||
|
|
||||||
|
**Result:** ✅ Secure with introspection, ⚠️ risky without
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
### Audience Settings
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `audience` | string | `client_id` | Expected audience for access tokens |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```yaml
|
||||||
|
# .traefik.yml
|
||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
oidc-auth:
|
||||||
|
plugin:
|
||||||
|
traefikoidc:
|
||||||
|
audience: "https://my-api.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Security Mode Settings
|
||||||
|
|
||||||
|
#### `strictAudienceValidation`
|
||||||
|
|
||||||
|
**Type:** boolean
|
||||||
|
**Default:** `false`
|
||||||
|
**Recommended:** `true` for production
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- When `true`: Rejects sessions if access token audience doesn't match (prevents Scenario 2)
|
||||||
|
- When `false`: Logs warnings but allows fallback to ID token (backward compatible)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```yaml
|
||||||
|
strictAudienceValidation: true
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- ✅ Always use in production environments
|
||||||
|
- ✅ When you have custom API audiences configured in Auth0
|
||||||
|
- ⚠️ May break existing deployments relying on Scenario 2 behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `allowOpaqueTokens`
|
||||||
|
|
||||||
|
**Type:** boolean
|
||||||
|
**Default:** `false`
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- When `true`: Accepts opaque (non-JWT) access tokens
|
||||||
|
- When `false`: Only accepts JWT access tokens
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```yaml
|
||||||
|
allowOpaqueTokens: true
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- ✅ When Auth0 issues opaque tokens (no default API configured)
|
||||||
|
- ✅ When using Auth0 Management API tokens
|
||||||
|
- ⚠️ Requires introspection endpoint for security
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `requireTokenIntrospection`
|
||||||
|
|
||||||
|
**Type:** boolean
|
||||||
|
**Default:** `false`
|
||||||
|
**Recommended:** `true` when `allowOpaqueTokens=true`
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- When `true`: Rejects opaque tokens if introspection fails or endpoint unavailable
|
||||||
|
- When `false`: Falls back to ID token validation for opaque tokens
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```yaml
|
||||||
|
allowOpaqueTokens: true
|
||||||
|
requireTokenIntrospection: true
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- ✅ Always use when `allowOpaqueTokens=true` for maximum security
|
||||||
|
- ⚠️ Requires provider to expose introspection endpoint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Recommendations
|
||||||
|
|
||||||
|
### Recommended Configuration for Auth0
|
||||||
|
|
||||||
|
**For APIs with custom audiences (Scenario 1):**
|
||||||
|
```yaml
|
||||||
|
audience: "https://my-api.example.com"
|
||||||
|
strictAudienceValidation: true
|
||||||
|
allowOpaqueTokens: false
|
||||||
|
```
|
||||||
|
|
||||||
|
**For default Auth0 setup (Scenario 2):**
|
||||||
|
```yaml
|
||||||
|
# Don't set audience (defaults to client_id)
|
||||||
|
strictAudienceValidation: true # Enforce proper configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
**For opaque tokens (Scenario 3):**
|
||||||
|
```yaml
|
||||||
|
allowOpaqueTokens: true
|
||||||
|
requireTokenIntrospection: true
|
||||||
|
strictAudienceValidation: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
|
||||||
|
1. ✅ **Always set `strictAudienceValidation: true` in production**
|
||||||
|
2. ✅ **Configure custom API audiences in Auth0 dashboard**
|
||||||
|
3. ✅ **Use `requireTokenIntrospection: true` if accepting opaque tokens**
|
||||||
|
4. ✅ **Monitor logs for security warnings**
|
||||||
|
5. ❌ **Don't rely on Scenario 2 fallback behavior**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Access token validation failed due to audience mismatch"
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
⚠️ SCENARIO 2 DETECTED: Access token validation failed due to audience mismatch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** Access token audience doesn't match configured audience
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. **Configure correct audience:**
|
||||||
|
```yaml
|
||||||
|
audience: "https://your-api-identifier" # From Auth0 API settings
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update Auth0 authorization request:**
|
||||||
|
- Ensure `audience` parameter is included in authorize URL
|
||||||
|
- Middleware automatically adds this when `audience != client_id`
|
||||||
|
|
||||||
|
3. **Accept the behavior (not recommended):**
|
||||||
|
```yaml
|
||||||
|
strictAudienceValidation: false # Logs warnings but allows
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### "Opaque token detected but allowOpaqueTokens=false"
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
⚠️ Opaque access token detected but allowOpaqueTokens=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** Auth0 issued non-JWT access token but middleware not configured to accept them
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. **Enable opaque tokens:**
|
||||||
|
```yaml
|
||||||
|
allowOpaqueTokens: true
|
||||||
|
requireTokenIntrospection: true
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure Auth0 to issue JWT access tokens:**
|
||||||
|
- Create an API in Auth0 dashboard
|
||||||
|
- Set API identifier as `audience` in configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### "Introspection endpoint not available"
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
⚠️ Opaque tokens enabled but no introspection endpoint available from provider
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** Auth0 provider metadata doesn't include `introspection_endpoint`
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. **Check provider discovery:**
|
||||||
|
```bash
|
||||||
|
curl https://YOUR_DOMAIN/.well-known/openid-configuration
|
||||||
|
```
|
||||||
|
Look for `introspection_endpoint`
|
||||||
|
|
||||||
|
2. **Disable required introspection (less secure):**
|
||||||
|
```yaml
|
||||||
|
allowOpaqueTokens: true
|
||||||
|
requireTokenIntrospection: false # Falls back to ID token
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use JWT access tokens instead** (recommended)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### "Token introspection required but endpoint not available"
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
❌ SECURITY: Opaque token rejected (introspection required but failed)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** `requireTokenIntrospection=true` but provider doesn't support it
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. **Disable required introspection:**
|
||||||
|
```yaml
|
||||||
|
requireTokenIntrospection: false
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure Auth0 to issue JWT tokens** (better solution)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Topics
|
||||||
|
|
||||||
|
### Token Type Detection
|
||||||
|
|
||||||
|
The middleware uses a sophisticated 6-step detection algorithm:
|
||||||
|
|
||||||
|
1. **RFC 9068 `typ` header**: `at+jwt` → Access Token
|
||||||
|
2. **Explicit type claims**: `token_use`, `token_type`
|
||||||
|
3. **`scope` claim**: Present → Access Token
|
||||||
|
4. **`nonce` claim**: Present → ID Token (OIDC spec)
|
||||||
|
5. **Audience check**: `aud == client_id` only → ID Token
|
||||||
|
6. **Default**: Access Token
|
||||||
|
|
||||||
|
### OAuth 2.0 Token Introspection (RFC 7662)
|
||||||
|
|
||||||
|
When opaque tokens are detected:
|
||||||
|
|
||||||
|
1. Middleware calls provider's `introspection_endpoint`
|
||||||
|
2. Authenticates using client credentials
|
||||||
|
3. Receives response with `active` status and claims
|
||||||
|
4. Caches result for 5 minutes (configurable via TTL)
|
||||||
|
5. Validates expiration, not-before, and audience if present
|
||||||
|
|
||||||
|
**Cache behavior:**
|
||||||
|
- Cache key: Token hash
|
||||||
|
- TTL: 5 minutes or token expiry (whichever is shorter)
|
||||||
|
- Reduces introspection requests for frequently used tokens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference Links
|
||||||
|
|
||||||
|
- [GitHub Issue #74](https://github.com/lukaszraczylo/traefikoidc/issues/74) - Original Auth0 audience discussion
|
||||||
|
- [OIDC Core 1.0 Spec](https://openid.net/specs/openid-connect-core-1_0.html) - ID Token requirements
|
||||||
|
- [OAuth 2.0 RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) - OAuth 2.0 specification
|
||||||
|
- [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) - OAuth 2.0 Token Introspection
|
||||||
|
- [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068) - JWT Access Token Profile
|
||||||
|
- [Auth0 API Authorization](https://auth0.com/docs/secure/tokens/access-tokens) - Auth0 audience documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### From Previous Versions
|
||||||
|
|
||||||
|
**If you're upgrading from a version without these features:**
|
||||||
|
|
||||||
|
1. **No action required for default behavior** - backward compatible
|
||||||
|
2. **Recommended: Enable strict mode gradually**
|
||||||
|
```yaml
|
||||||
|
# Step 1: Enable and monitor logs
|
||||||
|
strictAudienceValidation: false # Default
|
||||||
|
|
||||||
|
# Step 2: After confirming no warnings, enable
|
||||||
|
strictAudienceValidation: true
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **For opaque tokens: Enable explicitly**
|
||||||
|
```yaml
|
||||||
|
allowOpaqueTokens: true
|
||||||
|
requireTokenIntrospection: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Your Configuration
|
||||||
|
|
||||||
|
1. **Check logs for warnings:**
|
||||||
|
```bash
|
||||||
|
# Look for Scenario 2 warnings
|
||||||
|
grep "SCENARIO 2 DETECTED" /var/log/traefik.log
|
||||||
|
|
||||||
|
# Look for opaque token warnings
|
||||||
|
grep "Opaque" /var/log/traefik.log
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Test with curl:**
|
||||||
|
```bash
|
||||||
|
# Get token from Auth0
|
||||||
|
ACCESS_TOKEN="your_access_token"
|
||||||
|
|
||||||
|
# Test request
|
||||||
|
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||||
|
https://your-app.example.com/api
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Monitor for security warnings in production logs**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
- GitHub Issues: https://github.com/lukaszraczylo/traefikoidc/issues
|
||||||
|
- Security issues: See SECURITY.md for responsible disclosure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2025-01-09
|
||||||
|
**Version:** 0.7.8+
|
||||||
@@ -126,6 +126,10 @@ The middleware supports the following configuration options:
|
|||||||
| `enablePKCE` | Enables PKCE (Proof Key for Code Exchange) for authorization code flow | `false` | `true`, `false` |
|
| `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` |
|
| `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` |
|
| `cookieDomain` | Explicit domain for session cookies (important for multi-subdomain setups) | auto-detected | `.example.com`, `app.example.com` |
|
||||||
|
| `audience` | Custom audience for access token validation (for Auth0 custom APIs, etc.) | `clientID` | `https://my-api.example.com` |
|
||||||
|
| `strictAudienceValidation` | Reject sessions with access token audience mismatch (prevents token confusion attacks) | `false` | `true` |
|
||||||
|
| `allowOpaqueTokens` | Enable opaque (non-JWT) access token support via RFC 7662 introspection | `false` | `true` |
|
||||||
|
| `requireTokenIntrospection` | Require introspection for opaque tokens (force validation, no fallback) | `false` | `true` |
|
||||||
| `headers` | Custom HTTP headers with templates that can access OIDC claims and tokens | none | See "Templated Headers" section |
|
| `headers` | Custom HTTP headers with templates that can access OIDC claims and tokens | none | See "Templated Headers" section |
|
||||||
| `securityHeaders` | Configure security headers including CSP, HSTS, CORS, and custom headers | enabled with default profile | See "Security Headers Configuration" section |
|
| `securityHeaders` | Configure security headers including CSP, HSTS, CORS, and custom headers | enabled with default profile | See "Security Headers Configuration" section |
|
||||||
|
|
||||||
@@ -202,6 +206,103 @@ scopes: []
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
## Auth0 Audience Validation & Security
|
||||||
|
|
||||||
|
The middleware provides comprehensive support for Auth0 audience validation to prevent token confusion attacks. Auth0 can issue tokens in three different scenarios, each requiring specific configuration.
|
||||||
|
|
||||||
|
### Understanding Token Audiences
|
||||||
|
|
||||||
|
Per OAuth 2.0 and OIDC specifications:
|
||||||
|
- **ID Tokens**: MUST have `aud = client_id` (OIDC Core 1.0 spec)
|
||||||
|
- **Access Tokens**: Can have custom audiences (e.g., API identifiers)
|
||||||
|
|
||||||
|
Proper audience validation prevents **token confusion attacks** where a token intended for one API is used to access another API.
|
||||||
|
|
||||||
|
### Auth0 Scenarios
|
||||||
|
|
||||||
|
#### Scenario 1: Custom API Audience ✅ (RECOMMENDED)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```yaml
|
||||||
|
audience: "https://my-api.example.com" # Your API identifier from Auth0
|
||||||
|
strictAudienceValidation: true # Enforce strict validation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result**: Fully secure, OIDC compliant with proper access token audience validation.
|
||||||
|
|
||||||
|
#### Scenario 2: Default Audience ⚠️ (USE WITH CAUTION)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```yaml
|
||||||
|
# audience not specified (defaults to client_id)
|
||||||
|
strictAudienceValidation: true # Recommended: reject mismatched tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior**: Access tokens may not contain client_id in audience, triggering security warnings. Set `strictAudienceValidation: true` to reject such sessions.
|
||||||
|
|
||||||
|
#### Scenario 3: Opaque Access Tokens ✅ (SUPPORTED)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```yaml
|
||||||
|
allowOpaqueTokens: true # Enable opaque token support
|
||||||
|
requireTokenIntrospection: true # Require introspection (recommended)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result**: Secure with OAuth 2.0 Token Introspection (RFC 7662).
|
||||||
|
|
||||||
|
### Security Configuration Options
|
||||||
|
|
||||||
|
| Option | Purpose | Recommended Value |
|
||||||
|
|--------|---------|-------------------|
|
||||||
|
| `audience` | Expected audience for access tokens | Your API identifier or leave empty |
|
||||||
|
| `strictAudienceValidation` | Reject sessions with audience mismatch | `true` for production |
|
||||||
|
| `allowOpaqueTokens` | Accept non-JWT access tokens | `true` if provider issues opaque tokens |
|
||||||
|
| `requireTokenIntrospection` | Force introspection for opaque tokens | `true` when `allowOpaqueTokens=true` |
|
||||||
|
|
||||||
|
### Complete Auth0 Configuration Examples
|
||||||
|
|
||||||
|
**Production Configuration (Scenario 1):**
|
||||||
|
```yaml
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: oidc-auth0-secure
|
||||||
|
spec:
|
||||||
|
plugin:
|
||||||
|
traefikoidc:
|
||||||
|
providerURL: https://your-auth0-domain.auth0.com
|
||||||
|
clientID: your-auth0-client-id
|
||||||
|
clientSecret: your-auth0-client-secret
|
||||||
|
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
|
||||||
|
callbackURL: /oauth2/callback
|
||||||
|
audience: "https://my-api.example.com"
|
||||||
|
strictAudienceValidation: true
|
||||||
|
allowedRolesAndGroups:
|
||||||
|
- "https://your-app.com/roles:admin"
|
||||||
|
- editor
|
||||||
|
```
|
||||||
|
|
||||||
|
**Opaque Token Configuration (Scenario 3):**
|
||||||
|
```yaml
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: oidc-auth0-opaque
|
||||||
|
spec:
|
||||||
|
plugin:
|
||||||
|
traefikoidc:
|
||||||
|
providerURL: https://your-auth0-domain.auth0.com
|
||||||
|
clientID: your-auth0-client-id
|
||||||
|
clientSecret: your-auth0-client-secret
|
||||||
|
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
|
||||||
|
callbackURL: /oauth2/callback
|
||||||
|
allowOpaqueTokens: true
|
||||||
|
requireTokenIntrospection: true
|
||||||
|
strictAudienceValidation: true
|
||||||
|
```
|
||||||
|
|
||||||
|
For detailed Auth0 configuration including all three scenarios, troubleshooting, and security best practices, see **[AUTH0_AUDIENCE_GUIDE.md](AUTH0_AUDIENCE_GUIDE.md)**.
|
||||||
|
|
||||||
## Security Headers Configuration
|
## Security Headers Configuration
|
||||||
|
|
||||||
The middleware includes comprehensive security headers support to protect your applications against common web vulnerabilities. Security headers are applied to all authenticated responses.
|
The middleware includes comprehensive security headers support to protect your applications against common web vulnerabilities. Security headers are applied to all authenticated responses.
|
||||||
@@ -741,6 +842,11 @@ spec:
|
|||||||
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
|
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
|
||||||
callbackURL: /oauth2/callback
|
callbackURL: /oauth2/callback
|
||||||
logoutURL: /oauth2/logout
|
logoutURL: /oauth2/logout
|
||||||
|
|
||||||
|
# Audience configuration for custom APIs
|
||||||
|
audience: "https://my-api.example.com" # Your API identifier from Auth0
|
||||||
|
strictAudienceValidation: true # Enforce proper audience validation
|
||||||
|
|
||||||
scopes:
|
scopes:
|
||||||
- read:custom_data # Custom scopes as needed
|
- read:custom_data # Custom scopes as needed
|
||||||
allowedRolesAndGroups:
|
allowedRolesAndGroups:
|
||||||
@@ -749,6 +855,8 @@ spec:
|
|||||||
postLogoutRedirectURI: /logged-out-page # Must be in Auth0 Allowed Logout URLs
|
postLogoutRedirectURI: /logged-out-page # Must be in Auth0 Allowed Logout URLs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note**: For detailed Auth0 audience configuration including opaque tokens and all security scenarios, see [AUTH0_AUDIENCE_GUIDE.md](AUTH0_AUDIENCE_GUIDE.md).
|
||||||
|
|
||||||
### Okta Configuration
|
### Okta Configuration
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -415,10 +415,11 @@ func TestAudienceIntegrationAuth0Scenario(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Auth0 token with clientID instead of API audience should fail", func(t *testing.T) {
|
t.Run("Auth0 ACCESS token with clientID instead of API audience should fail", func(t *testing.T) {
|
||||||
jwt, err := createTestJWT(rsaPrivateKey, "RS256", "auth0-key-id", map[string]interface{}{
|
jwt, err := createTestJWT(rsaPrivateKey, "RS256", "auth0-key-id", map[string]interface{}{
|
||||||
"iss": config.ProviderURL,
|
"iss": config.ProviderURL,
|
||||||
"aud": config.ClientID, // Using clientID instead of API audience
|
"aud": config.ClientID, // Using clientID instead of API audience
|
||||||
|
"scope": "openid profile email", // Mark as access token
|
||||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||||
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
||||||
"sub": "auth0|123456",
|
"sub": "auth0|123456",
|
||||||
@@ -431,7 +432,7 @@ func TestAudienceIntegrationAuth0Scenario(t *testing.T) {
|
|||||||
|
|
||||||
err = tOidc.VerifyToken(jwt)
|
err = tOidc.VerifyToken(jwt)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Auth0 token with wrong audience should have been rejected")
|
t.Error("Auth0 access token with wrong audience should have been rejected")
|
||||||
} else if !strings.Contains(err.Error(), "invalid audience") {
|
} else if !strings.Contains(err.Error(), "invalid audience") {
|
||||||
t.Errorf("Expected 'invalid audience' error, got: %v", err)
|
t.Errorf("Expected 'invalid audience' error, got: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
// Package traefikoidc provides OIDC authentication middleware for Traefik.
|
||||||
|
// This file contains tests for Auth0-specific audience validation scenarios.
|
||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAuth0Scenario1WithCustomAudience tests Auth0 scenario 1:
|
||||||
|
// - Custom audience configured in plugin
|
||||||
|
// - Authorize endpoint called WITH audience parameter
|
||||||
|
// - ID token: aud = client_id
|
||||||
|
// - Access token: aud = [userinfo, custom_audience]
|
||||||
|
// Expected: Both tokens validate correctly
|
||||||
|
func TestAuth0Scenario1WithCustomAudience(t *testing.T) {
|
||||||
|
ts := NewTestSuite(t)
|
||||||
|
ts.Setup()
|
||||||
|
|
||||||
|
customAudience := "https://my-api.example.com"
|
||||||
|
ts.tOidc.audience = customAudience
|
||||||
|
|
||||||
|
// Create ID token with aud = client_id (OIDC standard)
|
||||||
|
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
"iss": "https://test-issuer.com",
|
||||||
|
"aud": "test-client-id", // ID token always has client_id
|
||||||
|
"nonce": "test-nonce-scenario1", // ID tokens have nonce per OIDC spec
|
||||||
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||||
|
"iat": float64(time.Now().Unix()),
|
||||||
|
"sub": "test-user",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"jti": "id-token-jti",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create ID token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create access token with aud = [userinfo, custom_audience]
|
||||||
|
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
"iss": "https://test-issuer.com",
|
||||||
|
"aud": []interface{}{
|
||||||
|
"https://test-issuer.com/userinfo",
|
||||||
|
customAudience, // Custom API audience
|
||||||
|
},
|
||||||
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||||
|
"iat": float64(time.Now().Unix()),
|
||||||
|
"sub": "test-user",
|
||||||
|
"scope": "openid profile email read:data", // Access tokens have scope
|
||||||
|
"jti": "access-token-jti",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create access token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ID token validates against client_id
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
|
err = ts.tOidc.VerifyToken(idToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ID token validation failed (should validate against client_id): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access token validates against custom audience
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
|
err = ts.tOidc.VerifyToken(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Access token validation failed (should validate against custom audience): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify buildAuthURL includes audience parameter (URL-encoded)
|
||||||
|
authURL := ts.tOidc.buildAuthURL("https://example.com/callback", "state", "nonce", "")
|
||||||
|
if !strings.Contains(authURL, "audience=") {
|
||||||
|
t.Errorf("Auth URL should contain audience parameter when custom audience is configured, got: %s", authURL)
|
||||||
|
}
|
||||||
|
// Verify the audience is properly URL-encoded (contains %3A for :, %2F for /)
|
||||||
|
if !strings.Contains(authURL, "audience=https%3A%2F%2Fmy-api.example.com") {
|
||||||
|
t.Errorf("Auth URL should contain URL-encoded custom audience, got: %s", authURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuth0Scenario2DefaultAudience tests Auth0 scenario 2:
|
||||||
|
// - No custom audience configured (defaults to client_id)
|
||||||
|
// - Authorize endpoint called WITHOUT audience parameter
|
||||||
|
// - ID token: aud = client_id
|
||||||
|
// - Access token: aud = [userinfo, default_audience] (no client_id)
|
||||||
|
// Expected: ID token validates, access token falls back to ID token validation
|
||||||
|
func TestAuth0Scenario2DefaultAudience(t *testing.T) {
|
||||||
|
ts := NewTestSuite(t)
|
||||||
|
ts.Setup()
|
||||||
|
|
||||||
|
// No custom audience - defaults to client_id
|
||||||
|
ts.tOidc.audience = ts.tOidc.clientID
|
||||||
|
|
||||||
|
// Create ID token with aud = client_id
|
||||||
|
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
"iss": "https://test-issuer.com",
|
||||||
|
"aud": "test-client-id",
|
||||||
|
"nonce": "test-nonce-scenario2", // ID tokens have nonce per OIDC spec
|
||||||
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||||
|
"iat": float64(time.Now().Unix()),
|
||||||
|
"sub": "test-user",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"jti": "id-token-jti-2",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create ID token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create access token with aud = [userinfo, some_default_audience]
|
||||||
|
// This represents Auth0's default audience behavior
|
||||||
|
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
"iss": "https://test-issuer.com",
|
||||||
|
"aud": []interface{}{
|
||||||
|
"https://test-issuer.com/userinfo",
|
||||||
|
"https://test-issuer.com/api/v2/", // Default Auth0 Management API
|
||||||
|
},
|
||||||
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||||
|
"iat": float64(time.Now().Unix()),
|
||||||
|
"sub": "test-user",
|
||||||
|
"scope": "openid profile email",
|
||||||
|
"jti": "access-token-jti-2",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create access token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ID token validates
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
|
err = ts.tOidc.VerifyToken(idToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ID token validation failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access token won't have client_id in aud, so it will fail validation
|
||||||
|
// This is expected for scenario 2 - the session validation relies on ID token
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
|
err = ts.tOidc.VerifyToken(accessToken)
|
||||||
|
if err == nil {
|
||||||
|
t.Logf("Access token validation passed (unexpected but OK if client_id is in aud array)")
|
||||||
|
} else {
|
||||||
|
// Expected failure - access token doesn't have client_id in aud
|
||||||
|
t.Logf("Access token validation failed as expected (aud doesn't contain client_id): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify buildAuthURL does NOT include audience parameter (since audience == client_id)
|
||||||
|
authURL := ts.tOidc.buildAuthURL("https://example.com/callback", "state", "nonce", "")
|
||||||
|
if strings.Contains(authURL, "audience=") {
|
||||||
|
t.Errorf("Auth URL should NOT contain audience parameter when audience equals client_id, got: %s", authURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuth0Scenario3OpaqueAccessToken tests Auth0 scenario 3:
|
||||||
|
// - No custom audience configured
|
||||||
|
// - No default audience in Auth0
|
||||||
|
// - ID token: aud = client_id
|
||||||
|
// - Access token: opaque (not JWT)
|
||||||
|
// Expected: ID token validates, opaque access token is accepted
|
||||||
|
func TestAuth0Scenario3OpaqueAccessToken(t *testing.T) {
|
||||||
|
ts := NewTestSuite(t)
|
||||||
|
ts.Setup()
|
||||||
|
|
||||||
|
// Enable opaque tokens for this scenario (Option C requirement)
|
||||||
|
ts.tOidc.allowOpaqueTokens = true
|
||||||
|
|
||||||
|
// No custom audience
|
||||||
|
ts.tOidc.audience = ts.tOidc.clientID
|
||||||
|
|
||||||
|
// Create ID token
|
||||||
|
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
"iss": "https://test-issuer.com",
|
||||||
|
"aud": "test-client-id",
|
||||||
|
"nonce": "test-nonce-scenario3", // ID tokens have nonce per OIDC spec
|
||||||
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||||
|
"iat": float64(time.Now().Unix()),
|
||||||
|
"sub": "test-user",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"jti": "id-token-jti-3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create ID token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opaque access token (not a JWT - just a random string)
|
||||||
|
opaqueAccessToken := "opaque_access_token_random_string_12345"
|
||||||
|
|
||||||
|
// Verify ID token validates
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
|
err = ts.tOidc.VerifyToken(idToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ID token validation failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opaque access token should fail JWT validation (expected)
|
||||||
|
err = ts.tOidc.VerifyToken(opaqueAccessToken)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Opaque access token should fail JWT validation")
|
||||||
|
} else {
|
||||||
|
t.Logf("Opaque access token correctly rejected by JWT validator: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that validateStandardTokens handles opaque tokens correctly
|
||||||
|
// by falling back to ID token validation
|
||||||
|
req := httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||||
|
|
||||||
|
session, err := ts.tOidc.sessionManager.GetSession(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get session: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.SetAuthenticated(true)
|
||||||
|
session.SetAccessToken(opaqueAccessToken)
|
||||||
|
session.SetIDToken(idToken)
|
||||||
|
|
||||||
|
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
|
||||||
|
if !authenticated || needsRefresh || expired {
|
||||||
|
t.Errorf("Session with opaque access token and valid ID token should be authenticated. Got: auth=%v, refresh=%v, expired=%v",
|
||||||
|
authenticated, needsRefresh, expired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuth0AudienceArrayValidation tests that audience validation
|
||||||
|
// correctly handles array audiences (common in Auth0)
|
||||||
|
func TestAuth0AudienceArrayValidation(t *testing.T) {
|
||||||
|
ts := NewTestSuite(t)
|
||||||
|
ts.Setup()
|
||||||
|
|
||||||
|
customAudience := "https://my-api.example.com"
|
||||||
|
ts.tOidc.audience = customAudience
|
||||||
|
|
||||||
|
// Access token with audience as array containing our custom audience
|
||||||
|
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
"iss": "https://test-issuer.com",
|
||||||
|
"aud": []interface{}{
|
||||||
|
"https://test-issuer.com/userinfo",
|
||||||
|
customAudience,
|
||||||
|
"https://another-api.example.com",
|
||||||
|
},
|
||||||
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||||
|
"iat": float64(time.Now().Unix()),
|
||||||
|
"sub": "test-user",
|
||||||
|
"scope": "openid profile email read:data write:data",
|
||||||
|
"jti": "array-aud-token-jti",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create access token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should validate successfully - custom audience is in the array
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
|
err = ts.tOidc.VerifyToken(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Access token with audience array should validate when custom audience is present: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuth0MismatchedAudience tests that tokens with wrong audience fail validation
|
||||||
|
func TestAuth0MismatchedAudience(t *testing.T) {
|
||||||
|
ts := NewTestSuite(t)
|
||||||
|
ts.Setup()
|
||||||
|
|
||||||
|
customAudience := "https://my-api.example.com"
|
||||||
|
ts.tOidc.audience = customAudience
|
||||||
|
|
||||||
|
// Access token with WRONG audience
|
||||||
|
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
"iss": "https://test-issuer.com",
|
||||||
|
"aud": []interface{}{
|
||||||
|
"https://test-issuer.com/userinfo",
|
||||||
|
"https://different-api.example.com", // Wrong audience
|
||||||
|
},
|
||||||
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||||
|
"iat": float64(time.Now().Unix()),
|
||||||
|
"sub": "test-user",
|
||||||
|
"scope": "openid profile email",
|
||||||
|
"jti": "wrong-aud-token-jti",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create access token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should fail validation - audience doesn't match
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
|
err = ts.tOidc.VerifyToken(accessToken)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Access token with wrong audience should fail validation")
|
||||||
|
} else if !strings.Contains(err.Error(), "invalid audience") {
|
||||||
|
t.Errorf("Expected 'invalid audience' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuth0Scenario2StrictMode tests strict audience validation mode:
|
||||||
|
// - Scenario 2 (access token with wrong audience) should be REJECTED
|
||||||
|
// - strictAudienceValidation=true prevents fallback to ID token
|
||||||
|
// - This addresses Allan's security concerns about audience bypass
|
||||||
|
func TestAuth0Scenario2StrictMode(t *testing.T) {
|
||||||
|
ts := NewTestSuite(t)
|
||||||
|
ts.Setup()
|
||||||
|
|
||||||
|
// Enable strict mode to prevent Scenario 2 bypass (Option C)
|
||||||
|
ts.tOidc.strictAudienceValidation = true
|
||||||
|
|
||||||
|
// Configure custom audience
|
||||||
|
customAudience := "https://my-api.example.com"
|
||||||
|
ts.tOidc.audience = customAudience
|
||||||
|
|
||||||
|
// Create ID token with aud = client_id (valid)
|
||||||
|
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
"iss": "https://test-issuer.com",
|
||||||
|
"aud": "test-client-id",
|
||||||
|
"nonce": "test-nonce-strict",
|
||||||
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||||
|
"iat": float64(time.Now().Unix()),
|
||||||
|
"sub": "test-user",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"jti": "id-token-strict-jti",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create ID token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create access token with WRONG audience (doesn't include custom audience)
|
||||||
|
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
"iss": "https://test-issuer.com",
|
||||||
|
"aud": []interface{}{
|
||||||
|
"https://test-issuer.com/userinfo",
|
||||||
|
"https://wrong-api.example.com", // Wrong audience - not our custom audience
|
||||||
|
},
|
||||||
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||||
|
"iat": float64(time.Now().Unix()),
|
||||||
|
"sub": "test-user",
|
||||||
|
"scope": "openid profile email",
|
||||||
|
"jti": "access-token-strict-jti",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create access token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test session validation with wrong access token and valid ID token
|
||||||
|
req := httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||||
|
session, err := ts.tOidc.sessionManager.GetSession(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get session: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.SetAuthenticated(true)
|
||||||
|
session.SetAccessToken(accessToken)
|
||||||
|
session.SetIDToken(idToken)
|
||||||
|
session.SetRefreshToken("test-refresh-token") // Add refresh token so it can attempt refresh
|
||||||
|
|
||||||
|
// In strict mode, this should FAIL (no fallback to ID token)
|
||||||
|
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
|
||||||
|
if authenticated {
|
||||||
|
t.Errorf("Strict mode: Session with wrong access token audience should be rejected, but got authenticated=true")
|
||||||
|
}
|
||||||
|
if !needsRefresh {
|
||||||
|
t.Errorf("Strict mode: Should signal refresh needed after rejection, got needsRefresh=%v", needsRefresh)
|
||||||
|
}
|
||||||
|
if expired {
|
||||||
|
t.Errorf("Strict mode: Should not mark as expired (should try refresh first), got expired=%v", expired)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Strict mode correctly rejected Scenario 2 (access token audience mismatch)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIDTokenAlwaysValidatesAgainstClientID verifies that ID tokens
|
||||||
|
// are ALWAYS validated against client_id, regardless of configured audience
|
||||||
|
func TestIDTokenAlwaysValidatesAgainstClientID(t *testing.T) {
|
||||||
|
ts := NewTestSuite(t)
|
||||||
|
ts.Setup()
|
||||||
|
|
||||||
|
// Configure a custom audience different from client_id
|
||||||
|
customAudience := "https://my-api.example.com"
|
||||||
|
ts.tOidc.audience = customAudience
|
||||||
|
|
||||||
|
// Create ID token with aud = client_id (per OIDC spec)
|
||||||
|
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
"iss": "https://test-issuer.com",
|
||||||
|
"aud": "test-client-id", // ID token MUST have client_id
|
||||||
|
"nonce": "test-nonce-123", // ID tokens have nonce for replay protection
|
||||||
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||||
|
"iat": float64(time.Now().Unix()),
|
||||||
|
"sub": "test-user",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"jti": "id-token-client-id-jti",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create ID token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should validate successfully - ID tokens are checked against client_id
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
|
err = ts.tOidc.VerifyToken(idToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ID token should validate against client_id even when custom audience is configured: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ID token with WRONG audience (should fail)
|
||||||
|
wrongIDToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
"iss": "https://test-issuer.com",
|
||||||
|
"aud": customAudience, // WRONG - should be client_id
|
||||||
|
"nonce": "test-nonce-wrong-456", // ID token has nonce, so it will be detected as ID token
|
||||||
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||||
|
"iat": float64(time.Now().Unix()),
|
||||||
|
"sub": "test-user",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"jti": "wrong-id-token-jti",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create wrong ID token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should fail - ID tokens must have client_id as audience
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
|
err = ts.tOidc.VerifyToken(wrongIDToken)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("ID token with custom audience (not client_id) should fail validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,14 @@ func (cm *CacheManager) GetSharedJWKCache() JWKCacheInterface {
|
|||||||
return &JWKCache{cache: cm.manager.GetJWKCache()}
|
return &JWKCache{cache: cm.manager.GetJWKCache()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSharedIntrospectionCache returns the shared token introspection cache
|
||||||
|
// for caching OAuth 2.0 Token Introspection (RFC 7662) results
|
||||||
|
func (cm *CacheManager) GetSharedIntrospectionCache() CacheInterface {
|
||||||
|
cm.mu.RLock()
|
||||||
|
defer cm.mu.RUnlock()
|
||||||
|
return &CacheInterfaceWrapper{cache: cm.manager.GetIntrospectionCache()}
|
||||||
|
}
|
||||||
|
|
||||||
// Close gracefully shuts down all cache components
|
// Close gracefully shuts down all cache components
|
||||||
func (cm *CacheManager) Close() error {
|
func (cm *CacheManager) Close() error {
|
||||||
cm.mu.Lock()
|
cm.mu.Lock()
|
||||||
|
|||||||
@@ -152,20 +152,24 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
|
|||||||
}
|
}
|
||||||
return config.PostLogoutRedirectURI
|
return config.PostLogoutRedirectURI
|
||||||
}(),
|
}(),
|
||||||
tokenBlacklist: cacheManager.GetSharedTokenBlacklist(),
|
tokenBlacklist: cacheManager.GetSharedTokenBlacklist(),
|
||||||
jwkCache: cacheManager.GetSharedJWKCache(),
|
jwkCache: cacheManager.GetSharedJWKCache(),
|
||||||
metadataCache: cacheManager.GetSharedMetadataCache(),
|
metadataCache: cacheManager.GetSharedMetadataCache(),
|
||||||
clientID: config.ClientID,
|
introspectionCache: cacheManager.GetSharedIntrospectionCache(), // Cache for introspection results
|
||||||
clientSecret: config.ClientSecret,
|
clientID: config.ClientID,
|
||||||
|
clientSecret: config.ClientSecret,
|
||||||
audience: func() string {
|
audience: func() string {
|
||||||
if config.Audience != "" {
|
if config.Audience != "" {
|
||||||
return config.Audience
|
return config.Audience
|
||||||
}
|
}
|
||||||
return config.ClientID
|
return config.ClientID
|
||||||
}(),
|
}(),
|
||||||
forceHTTPS: config.ForceHTTPS,
|
forceHTTPS: config.ForceHTTPS,
|
||||||
enablePKCE: config.EnablePKCE,
|
enablePKCE: config.EnablePKCE,
|
||||||
overrideScopes: config.OverrideScopes,
|
overrideScopes: config.OverrideScopes,
|
||||||
|
strictAudienceValidation: config.StrictAudienceValidation,
|
||||||
|
allowOpaqueTokens: config.AllowOpaqueTokens,
|
||||||
|
requireTokenIntrospection: config.RequireTokenIntrospection,
|
||||||
scopes: func() []string {
|
scopes: func() []string {
|
||||||
userProvidedScopes := deduplicateScopes(config.Scopes)
|
userProvidedScopes := deduplicateScopes(config.Scopes)
|
||||||
|
|
||||||
@@ -355,7 +359,7 @@ func (t *TraefikOidc) initializeMetadata(providerURL string) {
|
|||||||
|
|
||||||
// updateMetadataEndpoints updates internal endpoint URLs with discovered metadata.
|
// updateMetadataEndpoints updates internal endpoint URLs with discovered metadata.
|
||||||
// It sets the authorization URL, token URL, JWKS URL, issuer URL, revocation URL,
|
// It sets the authorization URL, token URL, JWKS URL, issuer URL, revocation URL,
|
||||||
// and end session URL based on the provider's metadata.
|
// end session URL, and introspection URL based on the provider's metadata.
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - metadata: A pointer to the ProviderMetadata struct containing the discovered endpoints.
|
// - metadata: A pointer to the ProviderMetadata struct containing the discovered endpoints.
|
||||||
func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
|
func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
|
||||||
@@ -363,12 +367,23 @@ func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
|
|||||||
defer t.metadataMu.Unlock()
|
defer t.metadataMu.Unlock()
|
||||||
|
|
||||||
t.jwksURL = metadata.JWKSURL
|
t.jwksURL = metadata.JWKSURL
|
||||||
t.scopesSupported = metadata.ScopesSupported // NEW - store supported scopes from discovery
|
t.scopesSupported = metadata.ScopesSupported // Store supported scopes from discovery
|
||||||
t.authURL = metadata.AuthURL
|
t.authURL = metadata.AuthURL
|
||||||
t.tokenURL = metadata.TokenURL
|
t.tokenURL = metadata.TokenURL
|
||||||
t.issuerURL = metadata.Issuer
|
t.issuerURL = metadata.Issuer
|
||||||
t.revocationURL = metadata.RevokeURL
|
t.revocationURL = metadata.RevokeURL
|
||||||
t.endSessionURL = metadata.EndSessionURL
|
t.endSessionURL = metadata.EndSessionURL
|
||||||
|
t.introspectionURL = metadata.IntrospectionURL // OAuth 2.0 Token Introspection endpoint (RFC 7662)
|
||||||
|
|
||||||
|
// Log introspection endpoint availability for opaque token support
|
||||||
|
if t.introspectionURL != "" {
|
||||||
|
t.logger.Debugf("Token introspection endpoint discovered: %s", t.introspectionURL)
|
||||||
|
if t.allowOpaqueTokens {
|
||||||
|
t.logger.Infof("Opaque token support enabled with introspection endpoint")
|
||||||
|
}
|
||||||
|
} else if t.allowOpaqueTokens || t.requireTokenIntrospection {
|
||||||
|
t.logger.Infof("⚠️ Opaque tokens enabled but no introspection endpoint available from provider")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startMetadataRefresh starts a background goroutine that periodically refreshes provider metadata.
|
// startMetadataRefresh starts a background goroutine that periodically refreshes provider metadata.
|
||||||
|
|||||||
+36
-17
@@ -39,23 +39,42 @@ type Config struct {
|
|||||||
// For Auth0 API access tokens with custom audiences, set this to your API identifier.
|
// For Auth0 API access tokens with custom audiences, set this to your API identifier.
|
||||||
// For Azure AD with Application ID URI, set to "api://your-app-id".
|
// For Azure AD with Application ID URI, set to "api://your-app-id".
|
||||||
// Security: This value is validated against the JWT aud claim to prevent token confusion attacks.
|
// Security: This value is validated against the JWT aud claim to prevent token confusion attacks.
|
||||||
Audience string `json:"audience,omitempty"`
|
Audience string `json:"audience,omitempty"`
|
||||||
PostLogoutRedirectURI string `json:"postLogoutRedirectURI"`
|
PostLogoutRedirectURI string `json:"postLogoutRedirectURI"`
|
||||||
LogLevel string `json:"logLevel"`
|
LogLevel string `json:"logLevel"`
|
||||||
SessionEncryptionKey string `json:"sessionEncryptionKey"`
|
SessionEncryptionKey string `json:"sessionEncryptionKey"`
|
||||||
ProviderURL string `json:"providerURL"`
|
ProviderURL string `json:"providerURL"`
|
||||||
RevocationURL string `json:"revocationURL"`
|
RevocationURL string `json:"revocationURL"`
|
||||||
ExcludedURLs []string `json:"excludedURLs"`
|
ExcludedURLs []string `json:"excludedURLs"`
|
||||||
AllowedUserDomains []string `json:"allowedUserDomains"`
|
AllowedUserDomains []string `json:"allowedUserDomains"`
|
||||||
AllowedUsers []string `json:"allowedUsers"`
|
AllowedUsers []string `json:"allowedUsers"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
Headers []TemplatedHeader `json:"headers"`
|
Headers []TemplatedHeader `json:"headers"`
|
||||||
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
|
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
|
||||||
RateLimit int `json:"rateLimit"`
|
RateLimit int `json:"rateLimit"`
|
||||||
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
|
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
|
||||||
ForceHTTPS bool `json:"forceHTTPS"`
|
ForceHTTPS bool `json:"forceHTTPS"`
|
||||||
EnablePKCE bool `json:"enablePKCE"`
|
EnablePKCE bool `json:"enablePKCE"`
|
||||||
OverrideScopes bool `json:"overrideScopes"`
|
OverrideScopes bool `json:"overrideScopes"`
|
||||||
|
// StrictAudienceValidation enforces strict audience validation for access tokens.
|
||||||
|
// When enabled, sessions are rejected if access token validation fails (prevents fallback to ID token).
|
||||||
|
// This addresses Auth0 Scenario 2 security concerns where access tokens without proper
|
||||||
|
// audience claims could be accepted based on ID token validation.
|
||||||
|
// Default: false (backward compatible - allows ID token fallback)
|
||||||
|
// Recommended: true for production environments requiring strict OAuth 2.0 compliance
|
||||||
|
StrictAudienceValidation bool `json:"strictAudienceValidation,omitempty"`
|
||||||
|
// AllowOpaqueTokens enables acceptance of non-JWT (opaque) access tokens.
|
||||||
|
// When enabled, opaque tokens are validated via OAuth 2.0 Token Introspection (RFC 7662).
|
||||||
|
// This supports Auth0 Scenario 3 and other providers that issue opaque access tokens.
|
||||||
|
// Default: false (only JWT access tokens accepted)
|
||||||
|
// Note: Requires introspection endpoint to be available from provider metadata
|
||||||
|
AllowOpaqueTokens bool `json:"allowOpaqueTokens,omitempty"`
|
||||||
|
// RequireTokenIntrospection forces token introspection for all opaque access tokens.
|
||||||
|
// When enabled, opaque tokens are rejected if introspection endpoint is unavailable.
|
||||||
|
// When disabled, opaque tokens fall back to ID token validation.
|
||||||
|
// Default: false (allows fallback to ID token)
|
||||||
|
// Recommended: true when AllowOpaqueTokens is enabled for maximum security
|
||||||
|
RequireTokenIntrospection bool `json:"requireTokenIntrospection,omitempty"`
|
||||||
SecurityHeaders *SecurityHeadersConfig `json:"securityHeaders,omitempty"`
|
SecurityHeaders *SecurityHeadersConfig `json:"securityHeaders,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Package traefikoidc provides OIDC authentication middleware for Traefik.
|
||||||
|
// This file implements OAuth 2.0 Token Introspection (RFC 7662) for opaque token validation.
|
||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IntrospectionResponse represents the response from an OAuth 2.0 token introspection endpoint.
|
||||||
|
// Per RFC 7662, this contains information about the token's validity and properties.
|
||||||
|
type IntrospectionResponse struct {
|
||||||
|
Active bool `json:"active"` // REQUIRED - whether the token is currently active
|
||||||
|
Scope string `json:"scope,omitempty"` // Space-separated list of scopes
|
||||||
|
ClientID string `json:"client_id,omitempty"` // Client identifier for the token
|
||||||
|
Username string `json:"username,omitempty"` // Human-readable identifier for the resource owner
|
||||||
|
TokenType string `json:"token_type,omitempty"` // Type of token (e.g., "Bearer")
|
||||||
|
Exp int64 `json:"exp,omitempty"` // Expiration time (seconds since epoch)
|
||||||
|
Iat int64 `json:"iat,omitempty"` // Issued at time (seconds since epoch)
|
||||||
|
Nbf int64 `json:"nbf,omitempty"` // Not before time (seconds since epoch)
|
||||||
|
Sub string `json:"sub,omitempty"` // Subject of the token
|
||||||
|
Aud string `json:"aud,omitempty"` // Intended audience
|
||||||
|
Iss string `json:"iss,omitempty"` // Issuer
|
||||||
|
Jti string `json:"jti,omitempty"` // JWT ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// introspectToken performs OAuth 2.0 Token Introspection (RFC 7662) for an opaque token.
|
||||||
|
// It queries the provider's introspection endpoint to determine token validity and properties.
|
||||||
|
// Results are cached to minimize repeated introspection requests.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - token: The opaque access token to introspect
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *IntrospectionResponse: The introspection result
|
||||||
|
// - error: Any error that occurred during introspection
|
||||||
|
func (t *TraefikOidc) introspectToken(token string) (*IntrospectionResponse, error) {
|
||||||
|
// Check cache first
|
||||||
|
if t.introspectionCache != nil {
|
||||||
|
if cached, found := t.introspectionCache.Get(token); found {
|
||||||
|
if response, ok := cached.(*IntrospectionResponse); ok {
|
||||||
|
t.logger.Debugf("Using cached introspection result for token")
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get introspection URL
|
||||||
|
t.metadataMu.RLock()
|
||||||
|
introspectionURL := t.introspectionURL
|
||||||
|
t.metadataMu.RUnlock()
|
||||||
|
|
||||||
|
if introspectionURL == "" {
|
||||||
|
return nil, fmt.Errorf("introspection endpoint not available from provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare introspection request per RFC 7662 Section 2.1
|
||||||
|
data := url.Values{}
|
||||||
|
data.Set("token", token)
|
||||||
|
data.Set("token_type_hint", "access_token") // Hint that it's an access token
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), "POST", introspectionURL, strings.NewReader(data.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create introspection request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set required headers
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
// Authenticate using client credentials (per RFC 7662 Section 2.1)
|
||||||
|
// The introspection endpoint requires authentication
|
||||||
|
req.SetBasicAuth(t.clientID, t.clientSecret)
|
||||||
|
|
||||||
|
// Send request with circuit breaker if available
|
||||||
|
var resp *http.Response
|
||||||
|
if t.errorRecoveryManager != nil {
|
||||||
|
t.metadataMu.RLock()
|
||||||
|
serviceName := fmt.Sprintf("token-introspection-%s", t.issuerURL)
|
||||||
|
t.metadataMu.RUnlock()
|
||||||
|
|
||||||
|
err = t.errorRecoveryManager.ExecuteWithRecovery(context.Background(), serviceName, func() error {
|
||||||
|
var reqErr error
|
||||||
|
resp, reqErr = t.httpClient.Do(req)
|
||||||
|
return reqErr
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resp, err = t.httpClient.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("introspection request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Check HTTP status
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
limitReader := io.LimitReader(resp.Body, 1024*10)
|
||||||
|
body, _ := io.ReadAll(limitReader)
|
||||||
|
return nil, fmt.Errorf("introspection endpoint returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response per RFC 7662 Section 2.2
|
||||||
|
var introspectionResp IntrospectionResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&introspectionResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode introspection response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
if t.introspectionCache != nil {
|
||||||
|
// Cache for a short duration or until token expiry (whichever is shorter)
|
||||||
|
cacheDuration := 5 * time.Minute
|
||||||
|
if introspectionResp.Exp > 0 {
|
||||||
|
expTime := time.Unix(introspectionResp.Exp, 0)
|
||||||
|
untilExp := time.Until(expTime)
|
||||||
|
if untilExp > 0 && untilExp < cacheDuration {
|
||||||
|
cacheDuration = untilExp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.introspectionCache.Set(token, &introspectionResp, cacheDuration)
|
||||||
|
t.logger.Debugf("Cached introspection result for %v", cacheDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &introspectionResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateOpaqueToken validates an opaque access token using token introspection.
|
||||||
|
// It checks if the token is active, not expired, and has the correct audience if specified.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - token: The opaque access token to validate
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Validation error if token is invalid, nil if valid
|
||||||
|
func (t *TraefikOidc) validateOpaqueToken(token string) error {
|
||||||
|
// Check if opaque tokens are allowed
|
||||||
|
if !t.allowOpaqueTokens {
|
||||||
|
return fmt.Errorf("opaque tokens are not enabled (set allowOpaqueTokens to true)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if introspection is required but not available
|
||||||
|
t.metadataMu.RLock()
|
||||||
|
introspectionURL := t.introspectionURL
|
||||||
|
t.metadataMu.RUnlock()
|
||||||
|
|
||||||
|
if introspectionURL == "" {
|
||||||
|
if t.requireTokenIntrospection {
|
||||||
|
return fmt.Errorf("token introspection required but endpoint not available")
|
||||||
|
}
|
||||||
|
// Allow fallback to ID token validation
|
||||||
|
t.logger.Debugf("Introspection endpoint not available, will rely on ID token validation")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform introspection
|
||||||
|
resp, err := t.introspectToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("token introspection failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is active (per RFC 7662 Section 2.2)
|
||||||
|
if !resp.Active {
|
||||||
|
return fmt.Errorf("token is not active (revoked or expired)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate expiration if present
|
||||||
|
if resp.Exp > 0 {
|
||||||
|
expTime := time.Unix(resp.Exp, 0)
|
||||||
|
if time.Now().After(expTime) {
|
||||||
|
return fmt.Errorf("token has expired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate not-before if present
|
||||||
|
if resp.Nbf > 0 {
|
||||||
|
nbfTime := time.Unix(resp.Nbf, 0)
|
||||||
|
if time.Now().Before(nbfTime) {
|
||||||
|
return fmt.Errorf("token not yet valid (nbf)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate audience if configured
|
||||||
|
// Note: For opaque tokens, audience validation via introspection may be limited
|
||||||
|
// depending on what the introspection endpoint returns
|
||||||
|
if t.audience != "" && t.audience != t.clientID && resp.Aud != "" {
|
||||||
|
if resp.Aud != t.audience {
|
||||||
|
return fmt.Errorf("invalid audience: expected %s, got %s", t.audience, resp.Aud)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.logger.Debugf("Opaque token validation successful via introspection")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+199
-7
@@ -240,13 +240,126 @@ func (t *TraefikOidc) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error
|
|||||||
t.safeLogDebugf("DIAGNOSTIC: Signature verification successful for kid=%s", kid)
|
t.safeLogDebugf("DIAGNOSTIC: Signature verification successful for kid=%s", kid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use configured audience (defaults to clientID if not specified)
|
// Determine expected audience based on token type
|
||||||
|
// Per OIDC spec: ID tokens MUST have aud=client_id
|
||||||
|
// Access tokens can have custom audience values (e.g., Auth0 API identifiers)
|
||||||
|
|
||||||
|
// Token type detection strategy (RFC 9068 + OIDC Core 1.0):
|
||||||
|
// 1. Check 'typ' header claim (RFC 9068) → "at+jwt" = ACCESS_TOKEN, "JWT" = could be either
|
||||||
|
// 2. Check explicit token type claims (token_use, token_type) if present
|
||||||
|
// 3. Check 'scope' claim → ACCESS_TOKEN (use configured audience)
|
||||||
|
// 4. Check 'nonce' claim → ID_TOKEN (use client_id, per OIDC spec)
|
||||||
|
// 5. Check if aud == client_id only → ID_TOKEN (use client_id)
|
||||||
|
// 6. Else → ACCESS_TOKEN with custom audience (use configured audience)
|
||||||
|
|
||||||
|
isIDToken := false
|
||||||
|
isAccessToken := false
|
||||||
|
|
||||||
|
// Step 1: Check typ header for explicit type (RFC 9068)
|
||||||
|
if typ, ok := jwt.Header["typ"].(string); ok {
|
||||||
|
if typ == "at+jwt" {
|
||||||
|
// RFC 9068 compliant access token
|
||||||
|
isAccessToken = true
|
||||||
|
if !t.suppressDiagnosticLogs {
|
||||||
|
t.safeLogDebugf("RFC 9068 access token detected (typ=at+jwt)")
|
||||||
|
}
|
||||||
|
} else if typ == "JWT" {
|
||||||
|
// Generic JWT, need further checks
|
||||||
|
if !t.suppressDiagnosticLogs {
|
||||||
|
t.safeLogDebugf("Generic JWT detected (typ=JWT), checking claims")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Check explicit token type claims (if not already determined)
|
||||||
|
if !isAccessToken && !isIDToken {
|
||||||
|
// Check for token_use claim (used by some providers like AWS Cognito)
|
||||||
|
if tokenUse, ok := jwt.Claims["token_use"].(string); ok {
|
||||||
|
if tokenUse == "access" {
|
||||||
|
isAccessToken = true
|
||||||
|
} else if tokenUse == "id" {
|
||||||
|
isIDToken = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for token_type claim
|
||||||
|
if !isAccessToken && !isIDToken {
|
||||||
|
if tokenType, ok := jwt.Claims["token_type"].(string); ok {
|
||||||
|
if tokenType == "access_token" || tokenType == "Bearer" {
|
||||||
|
isAccessToken = true
|
||||||
|
} else if tokenType == "id_token" {
|
||||||
|
isIDToken = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Check scope claim (access tokens have this)
|
||||||
|
if !isAccessToken && !isIDToken {
|
||||||
|
if scope, ok := jwt.Claims["scope"]; ok {
|
||||||
|
if _, ok := scope.(string); ok {
|
||||||
|
isAccessToken = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Check nonce claim (ID tokens have this per OIDC spec for replay protection)
|
||||||
|
if !isAccessToken && !isIDToken {
|
||||||
|
if nonce, ok := jwt.Claims["nonce"]; ok {
|
||||||
|
if _, ok := nonce.(string); ok {
|
||||||
|
isIDToken = true // Nonce indicates ID token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: If no scope and no nonce, check if aud matches client_id (indicates ID token)
|
||||||
|
if !isAccessToken && !isIDToken {
|
||||||
|
if aud, ok := jwt.Claims["aud"]; ok {
|
||||||
|
// Check string audience
|
||||||
|
if audStr, ok := aud.(string); ok && audStr == t.clientID {
|
||||||
|
isIDToken = true
|
||||||
|
}
|
||||||
|
// Check array audience
|
||||||
|
if audArr, ok := aud.([]interface{}); ok {
|
||||||
|
for _, v := range audArr {
|
||||||
|
if str, ok := v.(string); ok && str == t.clientID {
|
||||||
|
// Only treat as ID token if it's the sole audience
|
||||||
|
// Access tokens can also contain client_id in array
|
||||||
|
if len(audArr) == 1 {
|
||||||
|
isIDToken = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Default to access token if still undetermined
|
||||||
|
if !isIDToken {
|
||||||
|
isAccessToken = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine expected audience
|
||||||
|
expectedAudience := t.audience // Default to configured audience
|
||||||
|
if isIDToken {
|
||||||
|
expectedAudience = t.clientID
|
||||||
|
if !t.suppressDiagnosticLogs {
|
||||||
|
t.safeLogDebugf("ID token detected, validating with client_id: %s", expectedAudience)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Access token or ambiguous - use configured audience
|
||||||
|
if !t.suppressDiagnosticLogs {
|
||||||
|
t.safeLogDebugf("Access token detected, validating with audience: %s", expectedAudience)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Read issuerURL with RLock
|
// Read issuerURL with RLock
|
||||||
t.metadataMu.RLock()
|
t.metadataMu.RLock()
|
||||||
issuerURL := t.issuerURL
|
issuerURL := t.issuerURL
|
||||||
t.metadataMu.RUnlock()
|
t.metadataMu.RUnlock()
|
||||||
|
|
||||||
if err := jwt.Verify(issuerURL, t.audience, true); err != nil {
|
if err := jwt.Verify(issuerURL, expectedAudience, true); err != nil {
|
||||||
return fmt.Errorf("standard claim verification failed: %w", err)
|
return fmt.Errorf("standard claim verification failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,11 +830,42 @@ func (t *TraefikOidc) validateStandardTokens(session *SessionData) (bool, bool,
|
|||||||
dotCount := strings.Count(accessToken, ".")
|
dotCount := strings.Count(accessToken, ".")
|
||||||
isOpaqueToken := dotCount != 2
|
isOpaqueToken := dotCount != 2
|
||||||
|
|
||||||
// For opaque access tokens, rely on ID token for session validation
|
// For opaque access tokens, use introspection if available (RFC 7662 - Option C: Scenario 3)
|
||||||
if isOpaqueToken {
|
if isOpaqueToken {
|
||||||
t.logger.Debugf("Access token appears to be opaque (dots: %d), validating session via ID token", dotCount)
|
t.logger.Debugf("Access token appears to be opaque (dots: %d)", dotCount)
|
||||||
|
|
||||||
// For opaque access tokens, check ID token for authentication status
|
// Try introspection first if opaque tokens are allowed
|
||||||
|
if t.allowOpaqueTokens {
|
||||||
|
if err := t.validateOpaqueToken(accessToken); err != nil {
|
||||||
|
t.logger.Infof("⚠️ Opaque access token validation via introspection failed: %v", err)
|
||||||
|
|
||||||
|
// If introspection required, reject the session
|
||||||
|
if t.requireTokenIntrospection {
|
||||||
|
t.logger.Errorf("❌ SECURITY: Opaque token rejected (introspection required but failed)")
|
||||||
|
if session.GetRefreshToken() != "" {
|
||||||
|
return false, true, false
|
||||||
|
}
|
||||||
|
return false, false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise fall back to ID token validation (Scenario 3 backward compatibility)
|
||||||
|
t.logger.Infof("⚠️ Falling back to ID token validation for opaque access token")
|
||||||
|
} else {
|
||||||
|
// Introspection successful
|
||||||
|
t.logger.Debugf("✓ Opaque access token validated via introspection")
|
||||||
|
// Still need to check ID token for session expiry
|
||||||
|
idToken := session.GetIDToken()
|
||||||
|
if idToken != "" {
|
||||||
|
return t.validateTokenExpiry(session, idToken)
|
||||||
|
}
|
||||||
|
return true, false, false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Opaque tokens not allowed - log warning and reject or fall back
|
||||||
|
t.logger.Infof("⚠️ Opaque access token detected but allowOpaqueTokens=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to ID token validation
|
||||||
idToken := session.GetIDToken()
|
idToken := session.GetIDToken()
|
||||||
if idToken == "" {
|
if idToken == "" {
|
||||||
t.logger.Debug("Opaque access token present but no ID token found")
|
t.logger.Debug("Opaque access token present but no ID token found")
|
||||||
@@ -756,11 +900,53 @@ func (t *TraefikOidc) validateStandardTokens(session *SessionData) (bool, bool,
|
|||||||
return t.validateTokenExpiry(session, idToken)
|
return t.validateTokenExpiry(session, idToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JWT access token present - validate it explicitly to detect Scenario 2
|
||||||
|
// (Option C: Scenario 2 detection and strict mode)
|
||||||
|
accessTokenValid := false
|
||||||
|
accessTokenError := ""
|
||||||
|
|
||||||
|
if err := t.verifyToken(accessToken); err != nil {
|
||||||
|
// Access token validation failed
|
||||||
|
accessTokenError = err.Error()
|
||||||
|
|
||||||
|
// Check if it's an audience validation failure (Scenario 2)
|
||||||
|
if strings.Contains(accessTokenError, "invalid audience") || strings.Contains(accessTokenError, "audience") {
|
||||||
|
// SCENARIO 2 DETECTED: Access token has wrong audience
|
||||||
|
t.logger.Infof("⚠️ SCENARIO 2 DETECTED: Access token validation failed due to audience mismatch: %v", err)
|
||||||
|
|
||||||
|
if t.strictAudienceValidation {
|
||||||
|
// Strict mode: Reject the session (don't fall back to ID token)
|
||||||
|
t.logger.Errorf("❌ SECURITY: Session rejected due to access token audience mismatch (strictAudienceValidation=true)")
|
||||||
|
t.logger.Errorf("❌ This prevents potential cross-API token confusion attacks (Auth0 Scenario 2)")
|
||||||
|
if session.GetRefreshToken() != "" {
|
||||||
|
return false, true, false // try refresh
|
||||||
|
}
|
||||||
|
return false, false, true // must re-authenticate
|
||||||
|
} else {
|
||||||
|
// Backward compatibility mode: Log loud warning but allow fallback to ID token
|
||||||
|
t.logger.Infof("⚠️⚠️⚠️ SECURITY WARNING: Falling back to ID token validation despite access token audience mismatch!")
|
||||||
|
t.logger.Infof("⚠️ This could allow tokens intended for different APIs to grant access")
|
||||||
|
t.logger.Infof("⚠️ Set strictAudienceValidation=true to enforce proper audience validation")
|
||||||
|
t.logger.Infof("⚠️ See: https://github.com/lukaszraczylo/traefikoidc/issues/74")
|
||||||
|
}
|
||||||
|
} else if !strings.Contains(accessTokenError, "token has expired") {
|
||||||
|
// Other validation errors (not expiration, not audience)
|
||||||
|
t.logger.Debugf("Access token validation failed (non-expiration, non-audience): %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Access token is valid
|
||||||
|
accessTokenValid = true
|
||||||
|
}
|
||||||
|
|
||||||
idToken := session.GetIDToken()
|
idToken := session.GetIDToken()
|
||||||
if idToken == "" {
|
if idToken == "" {
|
||||||
t.logger.Debug("Authenticated flag set with access token, but no ID token found in session (possibly opaque token)")
|
if accessTokenValid {
|
||||||
session.SetAuthenticated(true)
|
// Access token is valid, no ID token needed
|
||||||
|
t.logger.Debug("Access token valid, no ID token present")
|
||||||
|
return t.validateTokenExpiry(session, accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.logger.Debug("Authenticated flag set with access token, but no ID token found in session")
|
||||||
if session.GetRefreshToken() != "" {
|
if session.GetRefreshToken() != "" {
|
||||||
t.logger.Debug("ID token missing but refresh token exists. Signaling conditional refresh to obtain ID token.")
|
t.logger.Debug("ID token missing but refresh token exists. Signaling conditional refresh to obtain ID token.")
|
||||||
return true, true, false
|
return true, true, false
|
||||||
@@ -768,6 +954,7 @@ func (t *TraefikOidc) validateStandardTokens(session *SessionData) (bool, bool,
|
|||||||
return true, false, false
|
return true, false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate ID token
|
||||||
if err := t.verifyToken(idToken); err != nil {
|
if err := t.verifyToken(idToken); err != nil {
|
||||||
if strings.Contains(err.Error(), "token has expired") {
|
if strings.Contains(err.Error(), "token has expired") {
|
||||||
t.logger.Debugf("ID token signature/claims valid but token expired, needs refresh")
|
t.logger.Debugf("ID token signature/claims valid but token expired, needs refresh")
|
||||||
@@ -785,6 +972,11 @@ func (t *TraefikOidc) validateStandardTokens(session *SessionData) (bool, bool,
|
|||||||
return false, false, true
|
return false, false, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If access token was valid, use it for expiry; otherwise use ID token
|
||||||
|
if accessTokenValid {
|
||||||
|
return t.validateTokenExpiry(session, accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
return t.validateTokenExpiry(session, idToken)
|
return t.validateTokenExpiry(session, idToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,13 +49,14 @@ type TokenExchanger interface {
|
|||||||
// This data is typically retrieved from the provider's .well-known/openid-configuration endpoint
|
// This data is typically retrieved from the provider's .well-known/openid-configuration endpoint
|
||||||
// and contains essential URLs for authentication, token exchange, and key retrieval.
|
// and contains essential URLs for authentication, token exchange, and key retrieval.
|
||||||
type ProviderMetadata struct {
|
type ProviderMetadata struct {
|
||||||
Issuer string `json:"issuer"`
|
Issuer string `json:"issuer"`
|
||||||
AuthURL string `json:"authorization_endpoint"`
|
AuthURL string `json:"authorization_endpoint"`
|
||||||
TokenURL string `json:"token_endpoint"`
|
TokenURL string `json:"token_endpoint"`
|
||||||
JWKSURL string `json:"jwks_uri"`
|
JWKSURL string `json:"jwks_uri"`
|
||||||
RevokeURL string `json:"revocation_endpoint"`
|
RevokeURL string `json:"revocation_endpoint"`
|
||||||
EndSessionURL string `json:"end_session_endpoint"`
|
EndSessionURL string `json:"end_session_endpoint"`
|
||||||
ScopesSupported []string `json:"scopes_supported,omitempty"` // NEW FIELD
|
IntrospectionURL string `json:"introspection_endpoint,omitempty"` // OAuth 2.0 Token Introspection (RFC 7662)
|
||||||
|
ScopesSupported []string `json:"scopes_supported,omitempty"` // Supported scopes from discovery
|
||||||
}
|
}
|
||||||
|
|
||||||
// TraefikOidc is the main middleware struct that implements OIDC authentication for Traefik.
|
// TraefikOidc is the main middleware struct that implements OIDC authentication for Traefik.
|
||||||
@@ -106,14 +107,19 @@ type TraefikOidc struct {
|
|||||||
jwksURL string
|
jwksURL string
|
||||||
issuerURL string
|
issuerURL string
|
||||||
revocationURL string
|
revocationURL string
|
||||||
|
introspectionURL string // OAuth 2.0 Token Introspection endpoint (RFC 7662)
|
||||||
providerURL string
|
providerURL string
|
||||||
scopes []string
|
scopes []string
|
||||||
refreshGracePeriod time.Duration
|
refreshGracePeriod time.Duration
|
||||||
|
introspectionCache CacheInterface // Cache for token introspection results
|
||||||
shutdownOnce sync.Once
|
shutdownOnce sync.Once
|
||||||
firstRequestMutex sync.Mutex
|
firstRequestMutex sync.Mutex
|
||||||
forceHTTPS bool
|
forceHTTPS bool
|
||||||
enablePKCE bool
|
enablePKCE bool
|
||||||
overrideScopes bool
|
overrideScopes bool
|
||||||
|
strictAudienceValidation bool // Prevents Scenario 2 fallback to ID token
|
||||||
|
allowOpaqueTokens bool // Enables opaque token support via introspection
|
||||||
|
requireTokenIntrospection bool // Forces introspection for opaque tokens
|
||||||
suppressDiagnosticLogs bool
|
suppressDiagnosticLogs bool
|
||||||
firstRequestReceived bool
|
firstRequestReceived bool
|
||||||
metadataRefreshStarted bool
|
metadataRefreshStarted bool
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import (
|
|||||||
|
|
||||||
// UniversalCacheManager manages all cache instances using the universal cache
|
// UniversalCacheManager manages all cache instances using the universal cache
|
||||||
type UniversalCacheManager struct {
|
type UniversalCacheManager struct {
|
||||||
tokenCache *UniversalCache
|
tokenCache *UniversalCache
|
||||||
blacklistCache *UniversalCache
|
blacklistCache *UniversalCache
|
||||||
metadataCache *UniversalCache
|
metadataCache *UniversalCache
|
||||||
jwkCache *UniversalCache
|
jwkCache *UniversalCache
|
||||||
sessionCache *UniversalCache
|
sessionCache *UniversalCache
|
||||||
mu sync.RWMutex
|
introspectionCache *UniversalCache // OAuth 2.0 Token Introspection cache (RFC 7662)
|
||||||
logger *Logger
|
mu sync.RWMutex
|
||||||
|
logger *Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -85,6 +86,14 @@ func GetUniversalCacheManager(logger *Logger) *UniversalCacheManager {
|
|||||||
DefaultTTL: 30 * time.Minute,
|
DefaultTTL: 30 * time.Minute,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Initialize introspection cache for OAuth 2.0 Token Introspection (RFC 7662)
|
||||||
|
universalCacheManager.introspectionCache = NewUniversalCache(UniversalCacheConfig{
|
||||||
|
Type: CacheTypeToken, // Use token cache type for introspection results
|
||||||
|
MaxSize: 1000, // Cache up to 1000 introspection results
|
||||||
|
DefaultTTL: 5 * time.Minute, // Short TTL for security (introspect frequently)
|
||||||
|
Logger: logger,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return universalCacheManager
|
return universalCacheManager
|
||||||
@@ -125,13 +134,20 @@ func (m *UniversalCacheManager) GetSessionCache() *UniversalCache {
|
|||||||
return m.sessionCache
|
return m.sessionCache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetIntrospectionCache returns the token introspection cache
|
||||||
|
func (m *UniversalCacheManager) GetIntrospectionCache() *UniversalCache {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.introspectionCache
|
||||||
|
}
|
||||||
|
|
||||||
// Close shuts down all caches
|
// Close shuts down all caches
|
||||||
func (m *UniversalCacheManager) Close() error {
|
func (m *UniversalCacheManager) Close() error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
for _, cache := range []*UniversalCache{
|
for _, cache := range []*UniversalCache{
|
||||||
m.tokenCache, m.blacklistCache, m.metadataCache, m.jwkCache, m.sessionCache,
|
m.tokenCache, m.blacklistCache, m.metadataCache, m.jwkCache, m.sessionCache, m.introspectionCache,
|
||||||
} {
|
} {
|
||||||
if cache != nil {
|
if cache != nil {
|
||||||
cache.Close()
|
cache.Close()
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge stri
|
|||||||
params.Set("state", state)
|
params.Set("state", state)
|
||||||
params.Set("nonce", nonce)
|
params.Set("nonce", nonce)
|
||||||
|
|
||||||
|
// Add audience parameter for custom API audiences (e.g., Auth0 APIs)
|
||||||
|
// This allows access tokens to have the correct audience claim
|
||||||
|
// Only add if audience is configured and different from client_id
|
||||||
|
// ID tokens will always have aud=client_id per OIDC spec
|
||||||
|
if t.audience != "" && t.audience != t.clientID {
|
||||||
|
params.Set("audience", t.audience)
|
||||||
|
t.logger.Debugf("Adding audience parameter to authorize URL: %s", t.audience)
|
||||||
|
}
|
||||||
|
|
||||||
if t.enablePKCE && codeChallenge != "" {
|
if t.enablePKCE && codeChallenge != "" {
|
||||||
params.Set("code_challenge", codeChallenge)
|
params.Set("code_challenge", codeChallenge)
|
||||||
params.Set("code_challenge_method", "S256")
|
params.Set("code_challenge_method", "S256")
|
||||||
|
|||||||
Reference in New Issue
Block a user