mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-04 07:53:59 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab36f10a70 | ||
|
|
4972b21373 | ||
|
|
0be45f06a5 | ||
|
|
2cc89f0f31 | ||
|
|
b9928f2f0c | ||
|
|
2e1a3a9320 | ||
|
|
9dabd0e5cf | ||
|
|
dedbdf63c3 | ||
|
|
af032c6cd3 | ||
|
|
9938cff053 | ||
|
|
7a404ef76f | ||
|
|
63922f362f | ||
|
|
2de9297ab6 | ||
|
|
971c84f762 | ||
|
|
d2a0d2167e | ||
|
|
c46d958397 | ||
|
|
95cf0034d6 | ||
|
|
380ef96571 | ||
|
|
1886396dc1 | ||
|
|
24ecf00053 | ||
|
|
e338992f84 | ||
|
|
a9a596031b | ||
|
|
23afcad2ba | ||
|
|
d06f9fcf90 |
+18
-294
@@ -4,303 +4,27 @@ type: middleware
|
|||||||
import: github.com/lukaszraczylo/traefikoidc
|
import: github.com/lukaszraczylo/traefikoidc
|
||||||
|
|
||||||
summary: |
|
summary: |
|
||||||
Middleware adding OpenID Connect (OIDC) authentication to Traefik routes.
|
Middleware adding OIDC authentication to traefik routes. Does what it says on the tin.
|
||||||
|
Middleware has been tested with Auth0 and Logto. It should work with any OIDC provider.
|
||||||
This middleware replaces the need for forward-auth and oauth2-proxy when using Traefik as a reverse proxy.
|
|
||||||
It provides a complete OIDC authentication solution with features like domain restrictions,
|
|
||||||
role-based access control, token caching, and more.
|
|
||||||
|
|
||||||
The middleware has been tested with Auth0, Logto, Google, and other standard OIDC providers.
|
|
||||||
It includes special handling for Google's OAuth implementation to ensure compatibility.
|
|
||||||
It supports various authentication scenarios including:
|
|
||||||
|
|
||||||
- Basic authentication with customizable callback and logout URLs
|
|
||||||
- Email domain restrictions to limit access to specific organizations
|
|
||||||
- Role and group-based access control
|
|
||||||
- Public URLs that bypass authentication
|
|
||||||
- Rate limiting to prevent brute force attacks
|
|
||||||
- Custom post-logout redirect behavior
|
|
||||||
- Secure session management with encrypted cookies
|
|
||||||
- Automatic token validation and refresh
|
|
||||||
|
|
||||||
testData:
|
testData:
|
||||||
# Required parameters
|
providerURL: https://accounts.google.com
|
||||||
providerURL: https://accounts.google.com # Base URL of the OIDC provider
|
clientID: 1234567890.apps.googleusercontent.com
|
||||||
clientID: 1234567890.apps.googleusercontent.com # OAuth 2.0 client identifier
|
clientSecret: secret
|
||||||
clientSecret: secret # OAuth 2.0 client secret
|
callbackURL: /oauth2/callback
|
||||||
callbackURL: /oauth2/callback # Path where the OIDC provider will redirect after authentication
|
logoutURL: /oauth2/logout
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long # Key used to encrypt session data (must be at least 32 bytes)
|
scopes: # If not provided, default scopes will be used (openid, email, profile)
|
||||||
|
|
||||||
# Optional parameters with defaults
|
|
||||||
logoutURL: /oauth2/logout # Path for handling logout requests (if not provided, it will be set to callbackURL + "/logout")
|
|
||||||
postLogoutRedirectURI: /oidc/different-logout # URL to redirect to after logout (default: "/")
|
|
||||||
|
|
||||||
scopes: # OAuth 2.0 scopes to request (default: ["openid", "email", "profile"])
|
|
||||||
- openid
|
- openid
|
||||||
- email
|
- email
|
||||||
- profile
|
- profile
|
||||||
- roles # Include this to get role information from the provider
|
allowedUserDomains: # If not provided - will rely entirely on the OIDC yes/no
|
||||||
|
- raczylo.com
|
||||||
allowedUserDomains: # Restricts access to specific email domains (if not provided, relies on OIDC provider)
|
|
||||||
- company.com
|
|
||||||
- subsidiary.com
|
|
||||||
|
|
||||||
allowedUsers: # Restricts access to specific email addresses regardless of domain
|
|
||||||
- [email protected]
|
|
||||||
- [email protected]
|
|
||||||
|
|
||||||
allowedRolesAndGroups: # Restricts access to users with specific roles or groups (if not provided, no role/group restrictions)
|
|
||||||
- guest-endpoints
|
|
||||||
- admin
|
|
||||||
- developer
|
|
||||||
|
|
||||||
forceHTTPS: false # Forces the use of HTTPS for all URLs (default: true for security)
|
|
||||||
logLevel: debug # Sets logging verbosity: debug, info, error (default: info)
|
|
||||||
rateLimit: 100 # Maximum number of requests per second (default: 100, minimum: 10)
|
|
||||||
|
|
||||||
excludedURLs: # Lists paths that bypass authentication
|
|
||||||
- /login # covers /login, /login/me, /login/reminder etc.
|
|
||||||
- /public
|
|
||||||
- /health
|
|
||||||
- /metrics
|
|
||||||
|
|
||||||
headers: # Custom headers to set with templated values from claims and tokens
|
|
||||||
- name: "X-User-Email"
|
|
||||||
value: "{{.Claims.email}}"
|
|
||||||
- name: "X-User-ID"
|
|
||||||
value: "{{.Claims.sub}}"
|
|
||||||
- name: "Authorization"
|
|
||||||
value: "Bearer {{.AccessToken}}"
|
|
||||||
- name: "X-User-Roles"
|
|
||||||
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
|
|
||||||
|
|
||||||
# Advanced parameters (usually discovered automatically from provider metadata)
|
|
||||||
revocationURL: https://accounts.google.com/revoke # Endpoint for revoking tokens
|
|
||||||
oidcEndSessionURL: https://accounts.google.com/logout # Provider's end session endpoint
|
|
||||||
enablePKCE: false # Enables PKCE (Proof Key for Code Exchange) for additional security
|
|
||||||
|
|
||||||
# Configuration documentation
|
|
||||||
configuration:
|
|
||||||
providerURL:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
The base URL of the OIDC provider. This is the issuer URL that will be used to discover
|
|
||||||
OIDC endpoints like authorization, token, and JWKS URIs.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- https://accounts.google.com
|
|
||||||
- https://login.microsoftonline.com/tenant-id/v2.0
|
|
||||||
- https://your-auth0-domain.auth0.com
|
|
||||||
- https://your-logto-instance.com/oidc
|
|
||||||
required: true
|
|
||||||
|
|
||||||
clientID:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
The OAuth 2.0 client identifier obtained from your OIDC provider.
|
|
||||||
This is the public identifier for your application.
|
|
||||||
required: true
|
|
||||||
|
|
||||||
clientSecret:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
The OAuth 2.0 client secret obtained from your OIDC provider.
|
|
||||||
This should be kept confidential and not exposed in client-side code.
|
|
||||||
|
|
||||||
For Kubernetes deployments, you can use the secret reference format:
|
|
||||||
urn:k8s:secret:namespace:secret-name:key
|
|
||||||
required: true
|
|
||||||
|
|
||||||
callbackURL:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
The path where the OIDC provider will redirect after authentication.
|
|
||||||
This must match one of the redirect URIs configured in your OIDC provider.
|
|
||||||
|
|
||||||
The full redirect URI will be constructed as:
|
|
||||||
[scheme]://[host][callbackURL]
|
|
||||||
|
|
||||||
Example: /oauth2/callback
|
|
||||||
required: true
|
|
||||||
|
|
||||||
sessionEncryptionKey:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
Key used to encrypt session data stored in cookies.
|
|
||||||
Must be at least 32 bytes long for security.
|
|
||||||
|
|
||||||
Example: potato-secret-is-at-least-32-bytes-long
|
|
||||||
required: true
|
|
||||||
|
|
||||||
logoutURL:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
The path for handling logout requests.
|
|
||||||
If not provided, it will be set to callbackURL + "/logout".
|
|
||||||
|
|
||||||
Example: /oauth2/logout
|
|
||||||
required: false
|
|
||||||
|
|
||||||
postLogoutRedirectURI:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
The URL to redirect to after logout.
|
|
||||||
Default: "/"
|
|
||||||
|
|
||||||
Example: /logged-out-page
|
|
||||||
required: false
|
|
||||||
|
|
||||||
scopes:
|
|
||||||
type: array
|
|
||||||
description: |
|
|
||||||
The OAuth 2.0 scopes to request from the OIDC provider.
|
|
||||||
Default: ["openid", "profile", "email"]
|
|
||||||
|
|
||||||
Include "roles" or similar scope if you need role/group information.
|
|
||||||
Note: For Google OAuth, the middleware automatically handles the
|
|
||||||
proper authentication parameters and does NOT require the "offline_access"
|
|
||||||
scope (which Google rejects as invalid). See documentation for details.
|
|
||||||
required: false
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
logLevel:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
Sets the logging verbosity.
|
|
||||||
Valid values: "debug", "info", "error"
|
|
||||||
Default: "info"
|
|
||||||
required: false
|
|
||||||
enum:
|
|
||||||
- debug
|
|
||||||
- info
|
|
||||||
- error
|
|
||||||
|
|
||||||
forceHTTPS:
|
|
||||||
type: boolean
|
|
||||||
description: |
|
|
||||||
Forces the use of HTTPS for all URLs.
|
|
||||||
This is recommended for security in production environments.
|
|
||||||
Default: true
|
|
||||||
required: false
|
|
||||||
|
|
||||||
rateLimit:
|
|
||||||
type: integer
|
|
||||||
description: |
|
|
||||||
Sets the maximum number of requests per second.
|
|
||||||
This helps prevent brute force attacks.
|
|
||||||
Default: 100
|
|
||||||
Minimum: 10
|
|
||||||
required: false
|
|
||||||
|
|
||||||
excludedURLs:
|
|
||||||
type: array
|
|
||||||
description: |
|
|
||||||
Lists paths that bypass authentication.
|
|
||||||
These paths will be accessible without OIDC authentication.
|
|
||||||
|
|
||||||
The middleware uses prefix matching, so "/public" will match
|
|
||||||
"/public", "/public/page", "/public-data", etc.
|
|
||||||
|
|
||||||
Examples: ["/health", "/metrics", "/public"]
|
|
||||||
required: false
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
allowedUserDomains:
|
|
||||||
type: array
|
|
||||||
description: |
|
|
||||||
Restricts access to users with email addresses from specific domains.
|
|
||||||
If not provided, the middleware relies entirely on the OIDC provider
|
|
||||||
for authentication decisions.
|
|
||||||
|
|
||||||
Examples: ["company.com", "subsidiary.com"]
|
|
||||||
required: false
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
allowedUsers:
|
|
||||||
type: array
|
|
||||||
description: |
|
|
||||||
Restricts access to specific email addresses.
|
|
||||||
If provided, only users with these exact email addresses will be allowed access,
|
|
||||||
in addition to any domain-level restrictions set by allowedUserDomains.
|
|
||||||
|
|
||||||
This provides fine-grained control over individual access and can be used
|
|
||||||
together with allowedUserDomains for flexible access control strategies.
|
|
||||||
|
|
||||||
Examples: ["[email protected]", "[email protected]"]
|
|
||||||
required: false
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
allowedRolesAndGroups:
|
allowedRolesAndGroups:
|
||||||
type: array
|
- guest-endpoints
|
||||||
description: |
|
sessionEncryptionKey: potato-secret
|
||||||
Restricts access to users with specific roles or groups.
|
forceHTTPS: false
|
||||||
If not provided, no role/group restrictions are applied.
|
logLevel: debug # debug, info, warn, error
|
||||||
|
rateLimit: 100 # Simple rate limiter to prevent brute force attacks
|
||||||
The middleware checks both the "roles" and "groups" claims in the ID token.
|
excludedURLs: # Determines the list of URLs which are NOT a subject to authentication
|
||||||
|
- /login # covers /login, /login/me, /login/reminder etc.
|
||||||
Examples: ["admin", "developer"]
|
- /my-public-data
|
||||||
required: false
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
revocationURL:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
The endpoint for revoking tokens.
|
|
||||||
If not provided, it will be discovered from provider metadata.
|
|
||||||
|
|
||||||
Example: https://accounts.google.com/revoke
|
|
||||||
required: false
|
|
||||||
|
|
||||||
oidcEndSessionURL:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
The provider's end session endpoint.
|
|
||||||
If not provided, it will be discovered from provider metadata.
|
|
||||||
|
|
||||||
Example: https://accounts.google.com/logout
|
|
||||||
required: false
|
|
||||||
|
|
||||||
enablePKCE:
|
|
||||||
type: boolean
|
|
||||||
description: |
|
|
||||||
Enables PKCE (Proof Key for Code Exchange) for the OAuth 2.0 authorization code flow.
|
|
||||||
PKCE adds an extra layer of security to protect against authorization code interception attacks.
|
|
||||||
|
|
||||||
Not all OIDC providers support PKCE, so this should only be enabled if your provider supports it.
|
|
||||||
If enabled, the middleware will generate and use a code verifier/challenge pair during authentication.
|
|
||||||
|
|
||||||
Default: false
|
|
||||||
required: false
|
|
||||||
|
|
||||||
headers:
|
|
||||||
type: array
|
|
||||||
description: |
|
|
||||||
Custom HTTP headers to set with templated values derived from OIDC claims and tokens.
|
|
||||||
Each header has a name and a value template that can access:
|
|
||||||
- {{.Claims.field}} - Access ID token claims (e.g., email, sub, name)
|
|
||||||
- {{.AccessToken}} - The raw access token string
|
|
||||||
- {{.IdToken}} - The raw ID token string
|
|
||||||
- {{.RefreshToken}} - The raw refresh token string
|
|
||||||
|
|
||||||
Templates support Go template syntax including conditionals and iteration.
|
|
||||||
Variable names are case-sensitive - use .Claims not .claims.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- name: "X-User-Email", value: "{{.Claims.email}}"
|
|
||||||
- name: "Authorization", value: "Bearer {{.AccessToken}}"
|
|
||||||
- name: "X-User-Roles", value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
|
|
||||||
required: false
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
description: The HTTP header name to set
|
|
||||||
value:
|
|
||||||
type: string
|
|
||||||
description: Template string for the header value
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 Lukasz Raczylo
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,438 +1,150 @@
|
|||||||
# Traefik OIDC Middleware
|
## Traefik OIDC middleware
|
||||||
|
|
||||||
This middleware replaces the need for forward-auth and oauth2-proxy when using Traefik as a reverse proxy to support OpenID Connect (OIDC) authentication.
|
This middleware is supposed to replace the need for the forward-auth and oauth2-proxy when using traefik as a reverse proxy to support the OIDC authentication.
|
||||||
|
|
||||||
## Overview
|
Middleware has been tested with Auth0 and Logto.
|
||||||
|
|
||||||
The Traefik OIDC middleware provides a complete OIDC authentication solution with features like:
|
### Traefik version compatibility
|
||||||
- Token validation and verification
|
|
||||||
- Session management
|
|
||||||
- Domain restrictions
|
|
||||||
- Role-based access control
|
|
||||||
- Token caching and blacklisting
|
|
||||||
- Rate limiting
|
|
||||||
- Excluded paths (public URLs)
|
|
||||||
|
|
||||||
The middleware has been tested with Auth0, Logto, Google and other standard OIDC providers. It includes special handling for Google's OAuth implementation.
|
Code follows closely the current traefik helm chart versions. If plugin fails to load - it's time to update to the latest version of the traefik helm chart.
|
||||||
|
|
||||||
## Traefik Version Compatibility
|
### Configuration options
|
||||||
|
|
||||||
This middleware follows closely the current Traefik helm chart versions. If the plugin fails to load, it's time to update to the latest version of the Traefik helm chart.
|
Middleware currently supports following scenarios:
|
||||||
|
|
||||||
## Installation
|
* Setting custom callback and logout URLs via `callbackURL` and `logoutURL`
|
||||||
|
* Allowing for access only from the listed domains if `allowedUserDomains` is set, otherwise it relies entirely on the OIDC provider
|
||||||
|
* Using excluded URLs which do **NOT** require the OIDC authentication
|
||||||
|
* Rate limiting requests to prevent the bruteforce attacks
|
||||||
|
|
||||||
### As a Traefik Plugin
|
#### How to configure...
|
||||||
|
|
||||||
1. Enable the plugin in your Traefik static configuration:
|
##### Keeping secrets secret
|
||||||
|
|
||||||
|
This works ONLY in kubernetes environments. Don't forget to create secret traefik-middleware-oidc with fields ISSUER, CLIENT_ID and SECRET keys.
|
||||||
|
|
||||||
```yaml
|
|
||||||
# traefik.yml
|
|
||||||
experimental:
|
|
||||||
plugins:
|
|
||||||
traefikoidc:
|
|
||||||
moduleName: github.com/lukaszraczylo/traefikoidc
|
|
||||||
version: v0.2.1 # Use the latest version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Configure the middleware in your dynamic configuration (see examples below).
|
|
||||||
|
|
||||||
### Local Development with Docker Compose
|
|
||||||
|
|
||||||
For local development or testing, you can use the provided Docker Compose setup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd docker
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
This will start Traefik with the OIDC middleware and two test services.
|
|
||||||
|
|
||||||
## Configuration Options
|
|
||||||
|
|
||||||
The middleware supports the following configuration options:
|
|
||||||
|
|
||||||
### Required Parameters
|
|
||||||
|
|
||||||
| Parameter | Description | Example |
|
|
||||||
|-----------|-------------|---------|
|
|
||||||
| `providerURL` | The base URL of the OIDC provider | `https://accounts.google.com` |
|
|
||||||
| `clientID` | The OAuth 2.0 client identifier | `1234567890.apps.googleusercontent.com` |
|
|
||||||
| `clientSecret` | The OAuth 2.0 client secret | `your-client-secret` |
|
|
||||||
| `sessionEncryptionKey` | Key used to encrypt session data (must be at least 32 bytes long) | `potato-secret-is-at-least-32-bytes-long` |
|
|
||||||
| `callbackURL` | The path where the OIDC provider will redirect after authentication | `/oauth2/callback` |
|
|
||||||
|
|
||||||
### Optional Parameters
|
|
||||||
|
|
||||||
| Parameter | Description | Default | Example |
|
|
||||||
|-----------|-------------|---------|---------|
|
|
||||||
| `logoutURL` | The path for handling logout requests | `callbackURL + "/logout"` | `/oauth2/logout` |
|
|
||||||
| `postLogoutRedirectURI` | The URL to redirect to after logout | `/` | `/logged-out-page` |
|
|
||||||
| `scopes` | The OAuth 2.0 scopes to request | `["openid", "profile", "email"]` | `["openid", "email", "profile", "roles"]` |
|
|
||||||
| `logLevel` | Sets the logging verbosity | `info` | `debug`, `info`, `error` |
|
|
||||||
| `forceHTTPS` | Forces the use of HTTPS for all URLs | `true` | `true`, `false` |
|
|
||||||
| `rateLimit` | Sets the maximum number of requests per second | `100` | `500` |
|
|
||||||
| `excludedURLs` | Lists paths that bypass authentication | none | `["/health", "/metrics", "/public"]` |
|
|
||||||
| `allowedUserDomains` | Restricts access to specific email domains | none | `["company.com", "subsidiary.com"]` |
|
|
||||||
| `allowedUsers` | A list of specific email addresses that are allowed access | none | `["[email protected]", "[email protected]"]` |
|
|
||||||
| `allowedRolesAndGroups` | Restricts access to users with specific roles or groups | none | `["admin", "developer"]` |
|
|
||||||
| `revocationURL` | The endpoint for revoking tokens | auto-discovered | `https://accounts.google.com/revoke` |
|
|
||||||
| `oidcEndSessionURL` | The provider's end session endpoint | auto-discovered | `https://accounts.google.com/logout` |
|
|
||||||
| `enablePKCE` | Enables PKCE (Proof Key for Code Exchange) for authorization code flow | `false` | `true`, `false` |
|
|
||||||
| `refreshGracePeriodSeconds` | Seconds before token expiry to attempt proactive refresh | `60` | `120` |
|
|
||||||
| `headers` | Custom HTTP headers with templates that can access OIDC claims and tokens | none | See "Templated Headers" section |
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Basic Configuration
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: oidc-basic
|
|
||||||
namespace: traefik
|
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
traefikoidc:
|
|
||||||
providerURL: https://accounts.google.com
|
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
|
||||||
clientSecret: your-client-secret
|
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
|
||||||
callbackURL: /oauth2/callback
|
|
||||||
logoutURL: /oauth2/logout
|
|
||||||
scopes:
|
|
||||||
- openid
|
|
||||||
- email
|
|
||||||
- profile
|
|
||||||
```
|
|
||||||
|
|
||||||
### With Excluded URLs (Public Access Paths)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
apiVersion: traefik.io/v1alpha1
|
||||||
kind: Middleware
|
kind: Middleware
|
||||||
metadata:
|
metadata:
|
||||||
name: oidc-with-open-urls
|
name: oidc-with-open-urls
|
||||||
namespace: traefik
|
namespace: traefik
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
traefikoidc:
|
|
||||||
providerURL: https://accounts.google.com
|
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
|
||||||
clientSecret: your-client-secret
|
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
|
||||||
callbackURL: /oauth2/callback
|
|
||||||
logoutURL: /oauth2/logout
|
|
||||||
scopes:
|
|
||||||
- openid
|
|
||||||
- email
|
|
||||||
- profile
|
|
||||||
excludedURLs:
|
|
||||||
- /login # covers /login, /login/me, /login/reminder etc.
|
|
||||||
- /public-data
|
|
||||||
- /health
|
|
||||||
- /metrics
|
|
||||||
```
|
|
||||||
|
|
||||||
### With Email Domain Restrictions
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: oidc-domain-restricted
|
|
||||||
namespace: traefik
|
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
traefikoidc:
|
|
||||||
providerURL: https://accounts.google.com
|
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
|
||||||
clientSecret: your-client-secret
|
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
|
||||||
callbackURL: /oauth2/callback
|
|
||||||
logoutURL: /oauth2/logout
|
|
||||||
scopes:
|
|
||||||
- openid
|
|
||||||
- email
|
|
||||||
- profile
|
|
||||||
allowedUserDomains:
|
|
||||||
- company.com
|
|
||||||
- subsidiary.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### With Specific User Access
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: oidc-specific-users
|
|
||||||
namespace: traefik
|
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
traefikoidc:
|
|
||||||
providerURL: https://accounts.google.com
|
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
|
||||||
clientSecret: your-client-secret
|
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
|
||||||
callbackURL: /oauth2/callback
|
|
||||||
logoutURL: /oauth2/logout
|
|
||||||
scopes:
|
|
||||||
- openid
|
|
||||||
- email
|
|
||||||
- profile
|
|
||||||
allowedUsers:
|
|
||||||
- [email protected]
|
|
||||||
- [email protected]
|
|
||||||
```
|
|
||||||
|
|
||||||
### With Both Domain and Specific User Access
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: oidc-domain-and-users
|
|
||||||
namespace: traefik
|
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
traefikoidc:
|
|
||||||
providerURL: https://accounts.google.com
|
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
|
||||||
clientSecret: your-client-secret
|
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
|
||||||
callbackURL: /oauth2/callback
|
|
||||||
logoutURL: /oauth2/logout
|
|
||||||
scopes:
|
|
||||||
- openid
|
|
||||||
- email
|
|
||||||
- profile
|
|
||||||
allowedUserDomains:
|
|
||||||
- company.com
|
|
||||||
allowedUsers:
|
|
||||||
- [email protected]
|
|
||||||
- [email protected]
|
|
||||||
```
|
|
||||||
|
|
||||||
When configuring access control:
|
|
||||||
- If only `allowedUsers` is set, only the specified email addresses will be granted access
|
|
||||||
- If only `allowedUserDomains` is set, only users with email addresses from those domains will be granted access
|
|
||||||
- If both are set, access is granted if the user's email is in `allowedUsers` OR their email's domain is in `allowedUserDomains`
|
|
||||||
- If neither is set, any authenticated user will be granted access
|
|
||||||
- Email matching is case-insensitive
|
|
||||||
|
|
||||||
### With Role-Based Access Control
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: oidc-rbac
|
|
||||||
namespace: traefik
|
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
traefikoidc:
|
|
||||||
providerURL: https://accounts.google.com
|
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
|
||||||
clientSecret: your-client-secret
|
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
|
||||||
callbackURL: /oauth2/callback
|
|
||||||
logoutURL: /oauth2/logout
|
|
||||||
scopes:
|
|
||||||
- openid
|
|
||||||
- email
|
|
||||||
- profile
|
|
||||||
- roles # Include this to get role information from the provider
|
|
||||||
allowedRolesAndGroups:
|
|
||||||
- admin
|
|
||||||
- developer
|
|
||||||
```
|
|
||||||
|
|
||||||
### With Custom Logging and Rate Limiting
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: oidc-custom-settings
|
|
||||||
namespace: traefik
|
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
traefikoidc:
|
|
||||||
providerURL: https://accounts.google.com
|
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
|
||||||
clientSecret: your-client-secret
|
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
|
||||||
callbackURL: /oauth2/callback
|
|
||||||
logoutURL: /oauth2/logout
|
|
||||||
logLevel: debug # Options: debug, info, error (default: info)
|
|
||||||
rateLimit: 500 # Requests per second (default: 100)
|
|
||||||
forceHTTPS: false # Default is true for security
|
|
||||||
scopes:
|
|
||||||
- openid
|
|
||||||
- email
|
|
||||||
- profile
|
|
||||||
```
|
|
||||||
|
|
||||||
### With Custom Post-Logout Redirect
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: oidc-custom-logout
|
|
||||||
namespace: traefik
|
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
traefikoidc:
|
|
||||||
providerURL: https://accounts.google.com
|
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
|
||||||
clientSecret: your-client-secret
|
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
|
||||||
callbackURL: /oauth2/callback
|
|
||||||
logoutURL: /oauth2/logout
|
|
||||||
postLogoutRedirectURI: /logged-out-page # Where to redirect after logout
|
|
||||||
scopes:
|
|
||||||
- openid
|
|
||||||
- email
|
|
||||||
- profile
|
|
||||||
```
|
|
||||||
|
|
||||||
### With Templated Headers
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: oidc-with-headers
|
|
||||||
namespace: traefik
|
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
traefikoidc:
|
|
||||||
providerURL: https://accounts.google.com
|
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
|
||||||
clientSecret: your-client-secret
|
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
|
||||||
callbackURL: /oauth2/callback
|
|
||||||
logoutURL: /oauth2/logout
|
|
||||||
scopes:
|
|
||||||
- openid
|
|
||||||
- email
|
|
||||||
- profile
|
|
||||||
- roles
|
|
||||||
headers:
|
|
||||||
- name: "X-User-Email"
|
|
||||||
value: "{{.Claims.email}}"
|
|
||||||
- name: "X-User-ID"
|
|
||||||
value: "{{.Claims.sub}}"
|
|
||||||
- name: "Authorization"
|
|
||||||
value: "Bearer {{.AccessToken}}"
|
|
||||||
- name: "X-User-Roles"
|
|
||||||
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
|
|
||||||
- name: "X-Is-Admin"
|
|
||||||
value: "{{if eq .Claims.role \"admin\"}}true{{else}}false{{end}}"
|
|
||||||
```
|
|
||||||
|
|
||||||
### With PKCE Enabled
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: oidc-with-pkce
|
|
||||||
namespace: traefik
|
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
traefikoidc:
|
|
||||||
providerURL: https://accounts.google.com
|
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
|
||||||
clientSecret: your-client-secret
|
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
|
||||||
callbackURL: /oauth2/callback
|
|
||||||
logoutURL: /oauth2/logout
|
|
||||||
enablePKCE: true # Enables PKCE for added security
|
|
||||||
scopes:
|
|
||||||
- openid
|
|
||||||
- email
|
|
||||||
- profile
|
|
||||||
```
|
|
||||||
|
|
||||||
### Google OIDC Configuration Example
|
|
||||||
|
|
||||||
This example shows a configuration specifically tailored for Google OIDC:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: oidc-google
|
|
||||||
namespace: traefik
|
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
traefikoidc:
|
|
||||||
providerURL: https://accounts.google.com
|
|
||||||
clientID: your-google-client-id.apps.googleusercontent.com # Replace with your Client ID
|
|
||||||
clientSecret: your-google-client-secret # Replace with your Client Secret
|
|
||||||
sessionEncryptionKey: your-secure-encryption-key-min-32-chars # Replace with your key
|
|
||||||
callbackURL: /oauth2/callback # Adjust if needed
|
|
||||||
logoutURL: /oauth2/logout # Optional: Adjust if needed
|
|
||||||
scopes:
|
|
||||||
- openid
|
|
||||||
- email
|
|
||||||
- profile
|
|
||||||
# Note: DO NOT manually add offline_access scope for Google
|
|
||||||
# The middleware automatically handles Google-specific requirements
|
|
||||||
refreshGracePeriodSeconds: 300 # Optional: Start refresh 5 min before expiry (default 60)
|
|
||||||
# Other optional parameters like allowedUserDomains, etc. can be added here
|
|
||||||
```
|
|
||||||
|
|
||||||
The middleware automatically detects Google as the provider and applies the necessary adjustments to ensure proper authentication and token refresh. See the [Google OAuth Fix](#google-oauth-compatibility-fix) section for details.
|
|
||||||
|
|
||||||
### Keeping Secrets Secret in Kubernetes
|
|
||||||
|
|
||||||
For Kubernetes environments, you can reference secrets instead of hardcoding sensitive values:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: oidc-with-secrets
|
|
||||||
namespace: traefik
|
|
||||||
spec:
|
spec:
|
||||||
plugin:
|
plugin:
|
||||||
traefikoidc:
|
traefikoidc:
|
||||||
providerURL: urn:k8s:secret:traefik-middleware-oidc:ISSUER
|
providerURL: urn:k8s:secret:traefik-middleware-oidc:ISSUER
|
||||||
clientID: urn:k8s:secret:traefik-middleware-oidc:CLIENT_ID
|
clientID: urn:k8s:secret:traefik-middleware-oidc:CLIENT_ID
|
||||||
clientSecret: urn:k8s:secret:traefik-middleware-oidc:SECRET
|
clientSecret: urn:k8s:secret:traefik-middleware-oidc:SECRET
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
sessionEncryptionKey: vvv
|
||||||
callbackURL: /oauth2/callback
|
callbackURL: /cool-oidc/callback
|
||||||
logoutURL: /oauth2/logout
|
logoutURL: /cool-oidc/logout
|
||||||
scopes:
|
scopes:
|
||||||
- openid
|
- openid
|
||||||
- email
|
- email
|
||||||
- profile
|
- profile
|
||||||
|
excludedURLs: # Determines the list of URLs which are NOT a subject to authentication
|
||||||
|
- /login # covers /login, /login/me, /login/reminder etc.
|
||||||
|
- /my-public-data
|
||||||
```
|
```
|
||||||
|
|
||||||
Don't forget to create the secret:
|
##### Excluded URLs with open access
|
||||||
|
|
||||||
```bash
|
```
|
||||||
kubectl create secret generic traefik-middleware-oidc \
|
apiVersion: traefik.io/v1alpha1
|
||||||
--from-literal=ISSUER=https://accounts.google.com \
|
kind: Middleware
|
||||||
--from-literal=CLIENT_ID=1234567890.apps.googleusercontent.com \
|
metadata:
|
||||||
--from-literal=SECRET=your-client-secret \
|
name: oidc-with-open-urls
|
||||||
-n traefik
|
namespace: traefik
|
||||||
|
spec:
|
||||||
|
plugin:
|
||||||
|
traefikoidc:
|
||||||
|
providerURL: xxx
|
||||||
|
clientID: yyy
|
||||||
|
clientSecret: zzz
|
||||||
|
sessionEncryptionKey: vvv
|
||||||
|
callbackURL: /cool-oidc/callback
|
||||||
|
logoutURL: /cool-oidc/logout
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- email
|
||||||
|
- profile
|
||||||
|
excludedURLs: # Determines the list of URLs which are NOT a subject to authentication
|
||||||
|
- /login # covers /login, /login/me, /login/reminder etc.
|
||||||
|
- /my-public-data
|
||||||
```
|
```
|
||||||
|
|
||||||
## Complete Docker Compose Example
|
|
||||||
|
|
||||||
Here's a complete example of using the middleware with Docker Compose:
|
##### Allowed email domains
|
||||||
|
|
||||||
|
Assuming that your OIDC provider allows anyone to log in, you may want to limit the access to people using emains in specific domain.
|
||||||
|
|
||||||
|
```
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: oidc-only-my-users
|
||||||
|
namespace: traefik
|
||||||
|
spec:
|
||||||
|
plugin:
|
||||||
|
traefikoidc:
|
||||||
|
providerURL: xxx
|
||||||
|
clientID: yyy
|
||||||
|
clientSecret: zzz
|
||||||
|
sessionEncryptionKey: vvv
|
||||||
|
callbackURL: /new-oidc/callback
|
||||||
|
logoutURL: /new-oidc/logout
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- email
|
||||||
|
- profile
|
||||||
|
allowedUserDomains:
|
||||||
|
- raczylo.com
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
##### Allowed groups and roles
|
||||||
|
|
||||||
|
In case of multiple roles / groups and access separation for various endpoints you will need to create multiple traefik middlewares.
|
||||||
|
Following example allows access for users who have additional role `guest-endpoints` assigned.
|
||||||
|
|
||||||
|
```
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: oidc-guest-endpoints
|
||||||
|
namespace: traefik
|
||||||
|
spec:
|
||||||
|
plugin:
|
||||||
|
traefikoidc:
|
||||||
|
providerURL: xxx
|
||||||
|
clientID: yyy
|
||||||
|
clientSecret: zzz
|
||||||
|
sessionEncryptionKey: vvv
|
||||||
|
callbackURL: /my-oidc/callback
|
||||||
|
logoutURL: /my-oidc/logout
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- email
|
||||||
|
- profile
|
||||||
|
- roles # This line queries the OIDC provider for roles
|
||||||
|
forceHTTPS: true
|
||||||
|
allowedRolesAndGroups:
|
||||||
|
- guest-endpoints # This line specifies the roles or groups allowed to access content
|
||||||
|
allowedUserDomains:
|
||||||
|
- raczylo.com
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
#### Docker compose example
|
||||||
|
|
||||||
|
`docker-compose.yaml`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: "3.7"
|
version: "3.7"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.2.1
|
image: traefik:v3.0.1
|
||||||
command:
|
command:
|
||||||
- "--experimental.plugins.traefikoidc.modulename=github.com/lukaszraczylo/traefikoidc"
|
- "--experimental.plugins.traefikoidc.modulename=github.com/lukaszraczylo/traefikoidc"
|
||||||
- "--experimental.plugins.traefikoidc.version=v0.2.1"
|
- "--experimental.plugins.traefikoidc.version=v0.2.1"
|
||||||
@@ -443,6 +155,7 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- "traefik.http.routers.dash.rule=Host(`dash.localhost`)"
|
- "traefik.http.routers.dash.rule=Host(`dash.localhost`)"
|
||||||
- "traefik.http.routers.dash.service=api@internal"
|
- "traefik.http.routers.dash.service=api@internal"
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
|
|
||||||
@@ -465,7 +178,8 @@ services:
|
|||||||
- traefik.http.routers.whoami.middlewares=my-plugin@file
|
- traefik.http.routers.whoami.middlewares=my-plugin@file
|
||||||
```
|
```
|
||||||
|
|
||||||
`traefik-config/traefik.yml`:
|
`traefik-config/traefik.yaml`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
log:
|
log:
|
||||||
level: INFO
|
level: INFO
|
||||||
@@ -494,7 +208,7 @@ providers:
|
|||||||
filename: /etc/traefik/dynamic-configuration.yml
|
filename: /etc/traefik/dynamic-configuration.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
`traefik-config/dynamic-configuration.yml`:
|
`traefik-config/dynamic-configuration.yaml`
|
||||||
```yaml
|
```yaml
|
||||||
http:
|
http:
|
||||||
middlewares:
|
middlewares:
|
||||||
@@ -503,183 +217,20 @@ http:
|
|||||||
traefikoidc:
|
traefikoidc:
|
||||||
providerURL: https://accounts.google.com
|
providerURL: https://accounts.google.com
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
clientID: 1234567890.apps.googleusercontent.com
|
||||||
clientSecret: your-client-secret
|
clientSecret: secret
|
||||||
callbackURL: /oauth2/callback
|
callbackURL: /oauth2/callback
|
||||||
logoutURL: /oauth2/logout
|
logoutURL: /oauth2/logout
|
||||||
postLogoutRedirectURI: /logged-out-page
|
scopes: # If not provided, default scopes will be used (openid, email, profile)
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
|
||||||
scopes:
|
|
||||||
- openid
|
- openid
|
||||||
- email
|
- email
|
||||||
- profile
|
- profile
|
||||||
allowedUserDomains:
|
allowedUserDomains: # If not provided - will rely entirely on the OIDC yes/no
|
||||||
- company.com
|
- raczylo.com
|
||||||
allowedUsers:
|
sessionEncryptionKey: potato-secret
|
||||||
- [email protected]
|
|
||||||
- [email protected]
|
|
||||||
allowedRolesAndGroups:
|
|
||||||
- admin
|
|
||||||
- developer
|
|
||||||
forceHTTPS: false
|
forceHTTPS: false
|
||||||
logLevel: debug
|
logLevel: debug # debug, info, warn, error
|
||||||
rateLimit: 100
|
rateLimit: 100 # Simple rate limiter to prevent brute force attacks
|
||||||
excludedURLs:
|
excludedURLs: # Determines the list of URLs which are NOT a subject to authentication
|
||||||
- /login
|
- /login # covers /login, /login/me, /login/reminder etc.
|
||||||
- /public
|
- /my-public-data
|
||||||
- /health
|
|
||||||
- /metrics
|
|
||||||
headers:
|
|
||||||
- name: "X-User-Email"
|
|
||||||
value: "{{.Claims.email}}"
|
|
||||||
- name: "X-User-ID"
|
|
||||||
value: "{{.Claims.sub}}"
|
|
||||||
- name: "Authorization"
|
|
||||||
value: "Bearer {{.AccessToken}}"
|
|
||||||
- name: "X-User-Roles"
|
|
||||||
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Advanced Configuration
|
|
||||||
|
|
||||||
### Session Management
|
|
||||||
|
|
||||||
The middleware uses encrypted cookies to manage user sessions. The `sessionEncryptionKey` must be at least 32 bytes long and should be kept secret.
|
|
||||||
|
|
||||||
### PKCE Support
|
|
||||||
|
|
||||||
The middleware supports PKCE (Proof Key for Code Exchange), which is an extension to the authorization code flow to prevent authorization code interception attacks. When enabled via the `enablePKCE` option, the middleware will generate a code verifier for each authentication request and derive a code challenge from it. The code verifier is stored in the user's session and sent during the token exchange process.
|
|
||||||
|
|
||||||
PKCE is recommended when:
|
|
||||||
- Your OIDC provider supports it (most modern providers do)
|
|
||||||
- You need an additional layer of security for the authorization code flow
|
|
||||||
- You're concerned about potential authorization code interception attacks
|
|
||||||
|
|
||||||
Note that not all OIDC providers support PKCE, so check your provider's documentation before enabling this feature.
|
|
||||||
|
|
||||||
### Session Duration and Token Refresh
|
|
||||||
|
|
||||||
This middleware aims to provide long-lived user sessions, typically up to 24 hours, by utilizing OIDC refresh tokens.
|
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
- When a user authenticates, the middleware requests an access token and, if available, a refresh token from the OIDC provider.
|
|
||||||
- The access token usually has a short lifespan (e.g., 1 hour).
|
|
||||||
- Before the access token expires (controlled by `refreshGracePeriodSeconds`), the middleware uses the refresh token to obtain a new access token from the provider without requiring the user to log in again.
|
|
||||||
- This process repeats, allowing the session to remain valid for as long as the refresh token is valid (often 24 hours or more, depending on the provider).
|
|
||||||
|
|
||||||
**Provider-Specific Considerations (e.g., Google):**
|
|
||||||
- Some providers, like Google, issue short-lived access tokens (e.g., 1 hour) and require specific configurations for long-term sessions.
|
|
||||||
- To enable session extension beyond the initial token expiry with Google and similar providers, the middleware automatically includes the `offline_access` scope in the authentication request. This scope is necessary to obtain a refresh token.
|
|
||||||
- For Google specifically, the middleware also adds the `prompt=consent` parameter to the initial authorization request. This ensures Google issues a refresh token, which is crucial for extending the session.
|
|
||||||
- If a refresh attempt fails (e.g., the refresh token is revoked or expired), the user will be required to re-authenticate. The middleware includes enhanced error handling and logging for these scenarios.
|
|
||||||
- Ensure your OIDC provider is configured to issue refresh tokens and allows their use for extending sessions. Check your provider's documentation for details on refresh token validity periods.
|
|
||||||
|
|
||||||
### Google OAuth Compatibility Fix
|
|
||||||
|
|
||||||
The middleware includes a specific fix for Google's OAuth implementation, which differs from the standard OIDC specification in how it handles refresh tokens:
|
|
||||||
|
|
||||||
- **Issue**: Google does not support the standard `offline_access` scope for requesting refresh tokens and instead requires special parameters.
|
|
||||||
|
|
||||||
- **Automatic Solution**: The middleware detects Google as the provider based on the issuer URL and:
|
|
||||||
- Uses `access_type=offline` query parameter instead of the `offline_access` scope
|
|
||||||
- Adds `prompt=consent` to ensure refresh tokens are consistently issued
|
|
||||||
- Properly handles token refresh with Google's implementation
|
|
||||||
|
|
||||||
You do not need any special configuration to use Google OAuth - just set `providerURL` to `https://accounts.google.com` and the middleware will automatically apply the proper parameters.
|
|
||||||
|
|
||||||
For detailed information on the Google OAuth fix, see the [dedicated documentation](docs/google-oauth-fix.md).
|
|
||||||
|
|
||||||
### Token Caching and Blacklisting
|
|
||||||
|
|
||||||
The middleware automatically caches validated tokens to improve performance and maintains a blacklist of revoked tokens.
|
|
||||||
### Templated Headers
|
|
||||||
|
|
||||||
The middleware supports setting custom HTTP headers with values templated from OIDC claims and tokens. This allows you to pass authentication information to downstream services in a flexible, customized format.
|
|
||||||
|
|
||||||
Templates can access the following variables:
|
|
||||||
- `{{.Claims.field}}` - Access individual claims from the ID token (e.g., `{{.Claims.email}}`, `{{.Claims.sub}}`)
|
|
||||||
- `{{.AccessToken}}` - The raw access token string
|
|
||||||
- `{{.IdToken}}` - The raw ID token string (same as AccessToken in most configurations)
|
|
||||||
- `{{.RefreshToken}}` - The raw refresh token string
|
|
||||||
|
|
||||||
**Example configuration:**
|
|
||||||
```yaml
|
|
||||||
headers:
|
|
||||||
- name: "X-User-Email"
|
|
||||||
value: "{{.Claims.email}}"
|
|
||||||
- name: "X-User-ID"
|
|
||||||
value: "{{.Claims.sub}}"
|
|
||||||
- name: "Authorization"
|
|
||||||
value: "Bearer {{.AccessToken}}"
|
|
||||||
- name: "X-User-Name"
|
|
||||||
value: "{{.Claims.given_name}} {{.Claims.family_name}}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Advanced template examples:**
|
|
||||||
|
|
||||||
Conditional logic:
|
|
||||||
```yaml
|
|
||||||
headers:
|
|
||||||
- name: "X-Is-Admin"
|
|
||||||
value: "{{if eq .Claims.role \"admin\"}}true{{else}}false{{end}}"
|
|
||||||
```
|
|
||||||
|
|
||||||
Array handling:
|
|
||||||
```yaml
|
|
||||||
headers:
|
|
||||||
- name: "X-User-Roles"
|
|
||||||
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes:**
|
|
||||||
- Variable names are case-sensitive (use `.Claims`, not `.claims`)
|
|
||||||
- Missing claims will result in `<no value>` in the header value
|
|
||||||
- The middleware validates templates during startup and logs errors for invalid templates
|
|
||||||
|
|
||||||
### Default Headers Set for Downstream Services
|
|
||||||
|
|
||||||
|
|
||||||
When a user is authenticated, the middleware sets the following headers for downstream services:
|
|
||||||
|
|
||||||
- `X-Forwarded-User`: The user's email address
|
|
||||||
- `X-User-Groups`: Comma-separated list of user groups (if available)
|
|
||||||
- `X-User-Roles`: Comma-separated list of user roles (if available)
|
|
||||||
- `X-Auth-Request-Redirect`: The original request URI
|
|
||||||
- `X-Auth-Request-User`: The user's email address
|
|
||||||
- `X-Auth-Request-Token`: The user's access token
|
|
||||||
|
|
||||||
### Security Headers
|
|
||||||
|
|
||||||
The middleware also sets the following security headers:
|
|
||||||
|
|
||||||
- `X-Frame-Options: DENY`
|
|
||||||
- `X-Content-Type-Options: nosniff`
|
|
||||||
- `X-XSS-Protection: 1; mode=block`
|
|
||||||
- `Referrer-Policy: strict-origin-when-cross-origin`
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
|
|
||||||
Set the `logLevel` to `debug` to get more detailed logs:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
logLevel: debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **Token verification failed**: Check that your `providerURL` is correct and accessible.
|
|
||||||
2. **Session encryption key too short**: Ensure your `sessionEncryptionKey` is at least 32 bytes long.
|
|
||||||
3. **No matching public key found**: The JWKS endpoint might be unavailable or the token's key ID (kid) doesn't match any key in the JWKS.
|
|
||||||
4. **Access denied: Your email domain is not allowed**: The user's email domain is not in the `allowedUserDomains` list.
|
|
||||||
5. **Access denied: You do not have any of the allowed roles or groups**: The user doesn't have any of the roles or groups specified in `allowedRolesAndGroups`.
|
|
||||||
6. **Google sessions expire after ~1 hour**: If using Google as the OIDC provider and sessions expire prematurely (around 1 hour instead of longer), ensure:
|
|
||||||
- Do NOT manually add the `offline_access` scope. Google rejects this scope as invalid.
|
|
||||||
- The middleware automatically applies the required Google parameters (`access_type=offline` and `prompt=consent`).
|
|
||||||
- Your Google Cloud OAuth consent screen is set to "External" and "Production" mode. "Testing" mode often limits refresh token validity.
|
|
||||||
- Verify you're using a version of the middleware that includes the Google OAuth compatibility fix.
|
|
||||||
- For more details, see the [Google OAuth Compatibility Fix](#google-oauth-compatibility-fix) section or the [detailed documentation](docs/google-oauth-fix.md).
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
### TODO / wishlist
|
|
||||||
|
|
||||||
- [] Improve test coverage
|
|
||||||
- [x] Improve caching mechanism
|
|
||||||
- [x] Add automatic release and semver generation
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// autoCleanupRoutine periodically calls the provided cleanup function.
|
|
||||||
// It starts a ticker with the given interval and executes the cleanup function
|
|
||||||
// on each tick. The routine stops gracefully when a signal is received on the
|
|
||||||
// stop channel. This is typically used for background cleanup tasks like
|
|
||||||
// expiring cache entries.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - interval: The time duration between cleanup calls.
|
|
||||||
// - stop: A channel used to signal the routine to stop. Receiving any value will terminate the loop.
|
|
||||||
// - cleanup: The function to call periodically for cleanup tasks.
|
|
||||||
func autoCleanupRoutine(interval time.Duration, stop <-chan struct{}, cleanup func()) {
|
|
||||||
ticker := time.NewTicker(interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
cleanup()
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAutoCleanupRoutine(t *testing.T) {
|
|
||||||
var counter int32
|
|
||||||
cleanupFunc := func() {
|
|
||||||
atomic.AddInt32(&counter, 1)
|
|
||||||
}
|
|
||||||
stop := make(chan struct{})
|
|
||||||
go autoCleanupRoutine(50*time.Millisecond, stop, cleanupFunc)
|
|
||||||
time.Sleep(250 * time.Millisecond)
|
|
||||||
close(stop)
|
|
||||||
|
|
||||||
if atomic.LoadInt32(&counter) < 3 {
|
|
||||||
t.Errorf("Expected cleanup to be called at least 3 times, got %d", counter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,228 +1,73 @@
|
|||||||
package traefikoidc
|
package traefikoidc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"container/list"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CacheItem represents an item stored in the cache with its associated metadata.
|
// CacheItem represents an item in the cache
|
||||||
type CacheItem struct {
|
type CacheItem struct {
|
||||||
// Value is the cached data of any type.
|
Value interface{}
|
||||||
Value interface{}
|
ExpiresAt int64 // Changed to int64 for faster comparisons
|
||||||
|
|
||||||
// ExpiresAt is the timestamp when this item should be considered expired.
|
|
||||||
ExpiresAt time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// lruEntry represents an entry in the LRU list.
|
// Cache is a simple in-memory cache
|
||||||
type lruEntry struct {
|
|
||||||
key string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache provides a thread-safe in-memory caching mechanism with expiration support.
|
|
||||||
// It implements an LRU (Least Recently Used) eviction policy using a doubly-linked list for efficiency.
|
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
// items stores the cached data with string keys.
|
|
||||||
items map[string]CacheItem
|
items map[string]CacheItem
|
||||||
|
|
||||||
// order maintains the usage order; most recently used items are at the back.
|
|
||||||
order *list.List
|
|
||||||
|
|
||||||
// elems maps keys to their corresponding list elements for O(1) access.
|
|
||||||
elems map[string]*list.Element
|
|
||||||
|
|
||||||
// mutex protects concurrent access to the cache.
|
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
|
|
||||||
// maxSize is the maximum number of items allowed in the cache.
|
|
||||||
maxSize int
|
|
||||||
// autoCleanupInterval defines how often Cleanup is called automatically.
|
|
||||||
autoCleanupInterval time.Duration
|
|
||||||
// stopCleanup channel to terminate the auto cleanup goroutine.
|
|
||||||
stopCleanup chan struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultMaxSize is the default maximum number of items in the cache.
|
// NewCache creates a new Cache
|
||||||
const DefaultMaxSize = 500
|
|
||||||
|
|
||||||
// NewCache creates a new empty cache instance with default settings.
|
|
||||||
// It initializes the internal maps and list, sets the default maximum size,
|
|
||||||
// and starts the automatic cleanup goroutine.
|
|
||||||
func NewCache() *Cache {
|
func NewCache() *Cache {
|
||||||
c := &Cache{
|
return &Cache{
|
||||||
items: make(map[string]CacheItem, DefaultMaxSize),
|
items: make(map[string]CacheItem),
|
||||||
order: list.New(),
|
|
||||||
elems: make(map[string]*list.Element, DefaultMaxSize),
|
|
||||||
maxSize: DefaultMaxSize,
|
|
||||||
autoCleanupInterval: 5 * time.Minute,
|
|
||||||
stopCleanup: make(chan struct{}),
|
|
||||||
}
|
}
|
||||||
go c.startAutoCleanup()
|
|
||||||
return c
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set adds or updates an item in the cache with the specified key, value, and expiration duration.
|
// Set adds an item to the cache
|
||||||
// If the key already exists, its value and expiration time are updated, and it's moved
|
|
||||||
// to the most recently used position in the LRU list.
|
|
||||||
// If the key does not exist and the cache is full, the least recently used item is evicted
|
|
||||||
// before adding the new item.
|
|
||||||
// The expiration duration is relative to the time Set is called.
|
|
||||||
func (c *Cache) Set(key string, value interface{}, expiration time.Duration) {
|
func (c *Cache) Set(key string, value interface{}, expiration time.Duration) {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
// Removed defer for slightly better performance
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
expTime := now.Add(expiration)
|
|
||||||
|
|
||||||
// Update existing item.
|
|
||||||
if _, exists := c.items[key]; exists {
|
|
||||||
c.items[key] = CacheItem{
|
|
||||||
Value: value,
|
|
||||||
ExpiresAt: expTime,
|
|
||||||
}
|
|
||||||
if elem, ok := c.elems[key]; ok {
|
|
||||||
c.order.MoveToBack(elem)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evict oldest item if cache is full.
|
|
||||||
if len(c.items) >= c.maxSize {
|
|
||||||
c.evictOldest()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new item.
|
|
||||||
c.items[key] = CacheItem{
|
c.items[key] = CacheItem{
|
||||||
Value: value,
|
Value: value,
|
||||||
ExpiresAt: expTime,
|
ExpiresAt: time.Now().Add(expiration).UnixNano(), // Store as UnixNano for faster comparisons
|
||||||
}
|
}
|
||||||
elem := c.order.PushBack(lruEntry{key: key})
|
c.mutex.Unlock()
|
||||||
c.elems[key] = elem
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves an item from the cache by its key.
|
// Get retrieves an item from the cache
|
||||||
// If the item exists and has not expired, its value and true are returned.
|
|
||||||
// Accessing an item moves it to the most recently used position in the LRU list.
|
|
||||||
// If the item does not exist or has expired, nil and false are returned, and the
|
|
||||||
// expired item is removed from the cache.
|
|
||||||
func (c *Cache) Get(key string) (interface{}, bool) {
|
func (c *Cache) Get(key string) (interface{}, bool) {
|
||||||
c.mutex.Lock()
|
c.mutex.RLock()
|
||||||
defer c.mutex.Unlock()
|
item, found := c.items[key]
|
||||||
|
if !found {
|
||||||
item, exists := c.items[key]
|
c.mutex.RUnlock()
|
||||||
if !exists {
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
if time.Now().UnixNano() > item.ExpiresAt {
|
||||||
// Check for expiration.
|
c.mutex.RUnlock()
|
||||||
if time.Now().After(item.ExpiresAt) {
|
// Use a separate goroutine to delete expired items to avoid blocking
|
||||||
c.removeItem(key)
|
go c.Delete(key)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
c.mutex.RUnlock()
|
||||||
// Move item to the back (most recently used).
|
|
||||||
if elem, ok := c.elems[key]; ok {
|
|
||||||
c.order.MoveToBack(elem)
|
|
||||||
}
|
|
||||||
|
|
||||||
return item.Value, true
|
return item.Value, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes an item from the cache by its key.
|
// Delete removes an item from the cache
|
||||||
// If the key exists, the corresponding item is removed from the cache storage
|
|
||||||
// and the LRU list.
|
|
||||||
func (c *Cache) Delete(key string) {
|
func (c *Cache) Delete(key string) {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
delete(c.items, key)
|
||||||
|
c.mutex.Unlock()
|
||||||
c.removeItem(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup iterates through the cache and removes all items that have expired.
|
// Cleanup removes expired items from the cache
|
||||||
// An item is considered expired if the current time is after its ExpiresAt timestamp.
|
|
||||||
// This method is called automatically by the auto-cleanup goroutine, but can also
|
|
||||||
// be called manually.
|
|
||||||
func (c *Cache) Cleanup() {
|
func (c *Cache) Cleanup() {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
now := time.Now().UnixNano()
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
for key, item := range c.items {
|
for key, item := range c.items {
|
||||||
// Remove items that are expired
|
if now > item.ExpiresAt {
|
||||||
if now.After(item.ExpiresAt) {
|
delete(c.items, key)
|
||||||
c.removeItem(key)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
c.mutex.Unlock()
|
||||||
|
|
||||||
// evictOldest removes the least recently used (oldest) item from the cache.
|
|
||||||
// It first attempts to find and remove an expired item from the front of the LRU list.
|
|
||||||
// If no expired items are found at the front, it removes the absolute oldest item (front of the list).
|
|
||||||
// This method is called internally by Set when the cache reaches its maximum size.
|
|
||||||
// Note: This function assumes the write lock is already held.
|
|
||||||
func (c *Cache) evictOldest() {
|
|
||||||
now := time.Now()
|
|
||||||
elem := c.order.Front()
|
|
||||||
|
|
||||||
// First try to find an expired item from the front
|
|
||||||
for elem != nil {
|
|
||||||
entry := elem.Value.(lruEntry)
|
|
||||||
if item, exists := c.items[entry.key]; exists {
|
|
||||||
if now.After(item.ExpiresAt) {
|
|
||||||
c.removeItem(entry.key)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elem = elem.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no expired items found, remove the oldest item
|
|
||||||
if elem = c.order.Front(); elem != nil {
|
|
||||||
entry := elem.Value.(lruEntry)
|
|
||||||
c.removeItem(entry.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMaxSize changes the maximum number of items the cache can hold.
|
|
||||||
// If the new size is smaller than the current number of items in the cache,
|
|
||||||
// oldest items will be evicted until the cache size is within the new limit.
|
|
||||||
func (c *Cache) SetMaxSize(size int) {
|
|
||||||
if size <= 0 {
|
|
||||||
return // Invalid size, ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mutex.Lock()
|
|
||||||
defer c.mutex.Unlock()
|
|
||||||
|
|
||||||
c.maxSize = size
|
|
||||||
|
|
||||||
// If cache exceeds the new max size, evict oldest items
|
|
||||||
for len(c.items) > c.maxSize {
|
|
||||||
c.evictOldest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeItem removes an item specified by the key from the cache's internal storage (items map)
|
|
||||||
// and its corresponding entry from the LRU list (order list and elems map).
|
|
||||||
// Note: This function assumes the write lock is already held.
|
|
||||||
func (c *Cache) removeItem(key string) {
|
|
||||||
delete(c.items, key)
|
|
||||||
if elem, ok := c.elems[key]; ok {
|
|
||||||
c.order.Remove(elem)
|
|
||||||
delete(c.elems, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startAutoCleanup starts the background goroutine that automatically calls the Cleanup method
|
|
||||||
// at the interval specified by c.autoCleanupInterval.
|
|
||||||
// It uses the autoCleanupRoutine helper function.
|
|
||||||
func (c *Cache) startAutoCleanup() {
|
|
||||||
autoCleanupRoutine(c.autoCleanupInterval, c.stopCleanup, c.Cleanup)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close stops the automatic cleanup goroutine associated with this cache instance.
|
|
||||||
// It should be called when the cache is no longer needed to prevent resource leaks.
|
|
||||||
func (c *Cache) Close() {
|
|
||||||
close(c.stopCleanup)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCache_Cleanup(t *testing.T) {
|
|
||||||
c := NewCache()
|
|
||||||
|
|
||||||
// Add some items with different expiration times
|
|
||||||
now := time.Now()
|
|
||||||
pastTime := now.Add(-1 * time.Hour) // Already expired
|
|
||||||
futureTime := now.Add(1 * time.Hour) // Not expired
|
|
||||||
|
|
||||||
// Create test items
|
|
||||||
c.items["expired"] = CacheItem{
|
|
||||||
Value: "expired-value",
|
|
||||||
ExpiresAt: pastTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.items["valid"] = CacheItem{
|
|
||||||
Value: "valid-value",
|
|
||||||
ExpiresAt: futureTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store original elements in the order list to match items
|
|
||||||
c.elems["expired"] = c.order.PushBack(lruEntry{key: "expired"})
|
|
||||||
c.elems["valid"] = c.order.PushBack(lruEntry{key: "valid"})
|
|
||||||
|
|
||||||
// Call cleanup, which should only remove expired items
|
|
||||||
c.Cleanup()
|
|
||||||
|
|
||||||
// Check that only the expired item was removed
|
|
||||||
if _, exists := c.items["expired"]; exists {
|
|
||||||
t.Error("Expired item was not removed by Cleanup()")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, exists := c.items["valid"]; !exists {
|
|
||||||
t.Error("Valid item was incorrectly removed by Cleanup()")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCache_SetMaxSize(t *testing.T) {
|
|
||||||
c := NewCache()
|
|
||||||
|
|
||||||
// Set a lower max size
|
|
||||||
originalMaxSize := c.maxSize
|
|
||||||
newMaxSize := 3
|
|
||||||
|
|
||||||
// Add more items than the new max size
|
|
||||||
for i := 0; i < originalMaxSize; i++ {
|
|
||||||
key := "key" + string(rune('A'+i))
|
|
||||||
c.Set(key, i, 1*time.Hour)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify items were added
|
|
||||||
if len(c.items) != originalMaxSize {
|
|
||||||
t.Errorf("Expected %d items before SetMaxSize, got %d", originalMaxSize, len(c.items))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change the max size to a smaller value
|
|
||||||
c.SetMaxSize(newMaxSize)
|
|
||||||
|
|
||||||
// Check that the cache was reduced to the new max size
|
|
||||||
if len(c.items) > newMaxSize {
|
|
||||||
t.Errorf("Cache size %d exceeds new max size %d after SetMaxSize", len(c.items), newMaxSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.maxSize != newMaxSize {
|
|
||||||
t.Errorf("Cache maxSize not updated, expected %d, got %d", newMaxSize, c.maxSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that the oldest items were evicted (should keep "keyC", "keyD", "keyE", etc.)
|
|
||||||
if _, exists := c.items["keyA"]; exists {
|
|
||||||
t.Error("Expected oldest item 'keyA' to be evicted, but it still exists")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJWKCache_WithInternalCache(t *testing.T) {
|
|
||||||
cache := NewJWKCache()
|
|
||||||
|
|
||||||
// Check that the internal cache is properly initialized
|
|
||||||
if cache.internalCache == nil {
|
|
||||||
t.Error("internalCache field was not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test max size configuration
|
|
||||||
testSize := 50
|
|
||||||
cache.SetMaxSize(testSize)
|
|
||||||
|
|
||||||
if cache.maxSize != testSize {
|
|
||||||
t.Errorf("JWKCache maxSize not updated, expected %d, got %d", testSize, cache.maxSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cache.internalCache.maxSize != testSize {
|
|
||||||
t.Errorf("internalCache maxSize not updated, expected %d, got %d", testSize, cache.internalCache.maxSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
# Google OAuth Integration Fix
|
|
||||||
|
|
||||||
## Problem Overview
|
|
||||||
|
|
||||||
The Traefik OIDC plugin encountered an authentication issue when using Google as an OAuth provider. Authentication would fail with the following error:
|
|
||||||
|
|
||||||
```
|
|
||||||
Some requested scopes were invalid. {valid=[openid, https://www.googleapis.com/auth/userinfo.email, https://www.googleapis.com/auth/userinfo.profile], invalid=[offline_access]}
|
|
||||||
```
|
|
||||||
|
|
||||||
This occurred because Google's OAuth implementation differs from the standard OIDC specification in how it handles refresh tokens and offline access.
|
|
||||||
|
|
||||||
## Technical Details of the Issue
|
|
||||||
|
|
||||||
### Standard OIDC Provider Behavior
|
|
||||||
|
|
||||||
Most OpenID Connect (OIDC) providers follow the standard specification, where:
|
|
||||||
- To obtain a refresh token, clients include the `offline_access` scope in their authorization request
|
|
||||||
- This allows authenticated sessions to persist beyond the initial access token expiration
|
|
||||||
|
|
||||||
### Google's Non-Standard Approach
|
|
||||||
|
|
||||||
Google's OAuth implementation deviates from the standard by:
|
|
||||||
1. Not supporting the `offline_access` scope, instead rejecting it as an invalid scope
|
|
||||||
2. Requiring the `access_type=offline` query parameter for requesting refresh tokens
|
|
||||||
3. Needing the `prompt=consent` parameter to consistently issue refresh tokens (especially for repeat authentications)
|
|
||||||
|
|
||||||
This difference caused the plugin to fail when configured for Google OAuth, as it was using a standard approach that didn't work with Google's implementation.
|
|
||||||
|
|
||||||
## Solution Implementation
|
|
||||||
|
|
||||||
The fix involved modifying the authentication flow to specifically handle Google providers:
|
|
||||||
|
|
||||||
1. **Google Provider Detection**: Added code to detect if the OIDC provider is Google based on the issuer URL:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Check if we're dealing with a Google OIDC provider
|
|
||||||
isGoogleProvider := strings.Contains(t.issuerURL, "google") ||
|
|
||||||
strings.Contains(t.issuerURL, "accounts.google.com")
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Provider-Specific Auth URL Building**: Modified the `buildAuthURL` function to handle Google and non-Google providers differently:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Handle offline access differently for Google vs other providers
|
|
||||||
if isGoogleProvider {
|
|
||||||
// For Google, use access_type=offline parameter instead of offline_access scope
|
|
||||||
params.Set("access_type", "offline")
|
|
||||||
t.logger.Debug("Google OIDC provider detected, added access_type=offline for refresh tokens")
|
|
||||||
|
|
||||||
// Add prompt=consent for Google to ensure refresh token is issued
|
|
||||||
params.Set("prompt", "consent")
|
|
||||||
t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
|
|
||||||
} else {
|
|
||||||
// For non-Google providers, use the offline_access scope
|
|
||||||
hasOfflineAccess := false
|
|
||||||
for _, scope := range scopes {
|
|
||||||
if scope == "offline_access" {
|
|
||||||
hasOfflineAccess = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasOfflineAccess {
|
|
||||||
scopes = append(scopes, "offline_access")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Token Refresh Enhancement**: Improved the token refresh logic to better handle Google's behavior, particularly when refresh tokens aren't returned in refresh responses (as Google often uses the same refresh token for multiple requests).
|
|
||||||
|
|
||||||
## Why This Approach Works
|
|
||||||
|
|
||||||
This solution aligns with Google's OAuth 2.0 documentation which specifies:
|
|
||||||
|
|
||||||
1. **Access Type Parameter**: Google's [OAuth 2.0 documentation](https://developers.google.com/identity/protocols/oauth2/web-server#offline) states that to request a refresh token, applications must include `access_type=offline` in the authorization request.
|
|
||||||
|
|
||||||
2. **Prompt Parameter**: The [`prompt=consent`](https://developers.google.com/identity/protocols/oauth2/web-server#forceapprovalprompt) parameter forces the consent screen to appear, ensuring a refresh token is issued even if the user has previously granted access.
|
|
||||||
|
|
||||||
3. **Scope Validation**: Google strictly validates scopes and rejects non-standard ones like `offline_access`, instead relying on the `access_type` parameter to indicate whether a refresh token should be issued.
|
|
||||||
|
|
||||||
By adapting to these Google-specific requirements, the OIDC plugin can now seamlessly work with both standard OIDC providers and Google's OAuth implementation.
|
|
||||||
|
|
||||||
## Testing and Verification
|
|
||||||
|
|
||||||
Comprehensive tests were implemented to verify the solution:
|
|
||||||
|
|
||||||
1. **Provider Detection Test**: Ensures the code correctly identifies Google providers and applies the appropriate parameters.
|
|
||||||
|
|
||||||
2. **Auth URL Parameter Tests**: Verifies that:
|
|
||||||
- For Google providers: `access_type=offline` and `prompt=consent` are included; `offline_access` scope is NOT included
|
|
||||||
- For non-Google providers: `offline_access` scope IS included; `access_type` parameter is NOT added
|
|
||||||
|
|
||||||
3. **Token Refresh Tests**: Validates that Google's token refresh process works correctly, including the preservation of refresh tokens when Google doesn't return a new one.
|
|
||||||
|
|
||||||
4. **Integration Test**: Tests the complete authentication flow with a mocked Google provider to ensure all components work together seamlessly.
|
|
||||||
|
|
||||||
Sample test case (simplified):
|
|
||||||
|
|
||||||
```go
|
|
||||||
t.Run("Google provider detection adds required parameters", func(t *testing.T) {
|
|
||||||
// Test buildAuthURL to ensure it adds access_type=offline and prompt=consent for Google
|
|
||||||
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
|
|
||||||
|
|
||||||
// Check that access_type=offline was added (not offline_access scope for Google)
|
|
||||||
if !strings.Contains(authURL, "access_type=offline") {
|
|
||||||
t.Errorf("access_type=offline not added to Google auth URL: %s", authURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify offline_access scope is NOT included for Google providers
|
|
||||||
if strings.Contains(authURL, "offline_access") {
|
|
||||||
t.Errorf("offline_access scope incorrectly added to Google auth URL: %s", authURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that prompt=consent was added
|
|
||||||
if !strings.Contains(authURL, "prompt=consent") {
|
|
||||||
t.Errorf("prompt=consent not added to Google auth URL: %s", authURL)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Guidance for Developers
|
|
||||||
|
|
||||||
When configuring the Traefik OIDC middleware for Google:
|
|
||||||
|
|
||||||
1. **Provider URL**: Use `https://accounts.google.com` as the `providerURL` value
|
|
||||||
|
|
||||||
2. **Client Configuration**: Create OAuth 2.0 credentials in the Google Cloud Console:
|
|
||||||
- Configure the authorized redirect URI to match your `callbackURL` setting
|
|
||||||
- Ensure your OAuth consent screen is properly configured (especially if you want long-lived refresh tokens)
|
|
||||||
|
|
||||||
3. **Configuration Example**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: oidc-google
|
|
||||||
namespace: traefik
|
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
traefikoidc:
|
|
||||||
providerURL: https://accounts.google.com
|
|
||||||
clientID: your-google-client-id.apps.googleusercontent.com
|
|
||||||
clientSecret: your-google-client-secret
|
|
||||||
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
|
|
||||||
callbackURL: /oauth2/callback
|
|
||||||
scopes:
|
|
||||||
- openid
|
|
||||||
- email
|
|
||||||
- profile
|
|
||||||
# Note: DO NOT manually add offline_access scope for Google
|
|
||||||
# The middleware handles this automatically and correctly
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Troubleshooting**: If sessions still expire prematurely with Google (typically after 1 hour):
|
|
||||||
- Ensure your Google Cloud OAuth consent screen is set to "External" and "Production" mode (not "Testing" mode, which limits refresh token validity)
|
|
||||||
- Review your application logs with `logLevel: debug` to check for refresh token errors
|
|
||||||
- Verify you're using a version of the middleware that includes this fix
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
This fix ensures that the Traefik OIDC plugin works seamlessly with Google's OAuth implementation without requiring users to make provider-specific configuration changes. The middleware now intelligently adapts to the provider's requirements, making it more robust and user-friendly while maintaining compatibility with the standard OIDC specification for other providers.
|
|
||||||
@@ -1,615 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"math/rand/v2"
|
|
||||||
"net"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CircuitBreakerState represents the current state of a circuit breaker
|
|
||||||
type CircuitBreakerState int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// CircuitBreakerClosed - normal operation, requests are allowed
|
|
||||||
CircuitBreakerClosed CircuitBreakerState = iota
|
|
||||||
// CircuitBreakerOpen - circuit is open, requests are rejected
|
|
||||||
CircuitBreakerOpen
|
|
||||||
// CircuitBreakerHalfOpen - testing if service has recovered
|
|
||||||
CircuitBreakerHalfOpen
|
|
||||||
)
|
|
||||||
|
|
||||||
// CircuitBreaker implements the circuit breaker pattern for external service calls
|
|
||||||
type CircuitBreaker struct {
|
|
||||||
// Configuration
|
|
||||||
maxFailures int // Maximum failures before opening
|
|
||||||
timeout time.Duration // How long to wait before trying again
|
|
||||||
resetTimeout time.Duration // How long to wait in half-open state
|
|
||||||
|
|
||||||
// State
|
|
||||||
state CircuitBreakerState
|
|
||||||
failures int64
|
|
||||||
lastFailureTime time.Time
|
|
||||||
lastSuccessTime time.Time
|
|
||||||
mutex sync.RWMutex
|
|
||||||
|
|
||||||
// Metrics
|
|
||||||
totalRequests int64
|
|
||||||
totalFailures int64
|
|
||||||
totalSuccesses int64
|
|
||||||
|
|
||||||
// Logger
|
|
||||||
logger *Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// CircuitBreakerConfig holds configuration for circuit breakers
|
|
||||||
type CircuitBreakerConfig struct {
|
|
||||||
MaxFailures int `json:"max_failures"`
|
|
||||||
Timeout time.Duration `json:"timeout"`
|
|
||||||
ResetTimeout time.Duration `json:"reset_timeout"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultCircuitBreakerConfig returns default circuit breaker configuration
|
|
||||||
func DefaultCircuitBreakerConfig() CircuitBreakerConfig {
|
|
||||||
return CircuitBreakerConfig{
|
|
||||||
MaxFailures: 5,
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
ResetTimeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCircuitBreaker creates a new circuit breaker with the given configuration
|
|
||||||
func NewCircuitBreaker(config CircuitBreakerConfig, logger *Logger) *CircuitBreaker {
|
|
||||||
return &CircuitBreaker{
|
|
||||||
maxFailures: config.MaxFailures,
|
|
||||||
timeout: config.Timeout,
|
|
||||||
resetTimeout: config.ResetTimeout,
|
|
||||||
state: CircuitBreakerClosed,
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute runs the given function with circuit breaker protection
|
|
||||||
func (cb *CircuitBreaker) Execute(fn func() error) error {
|
|
||||||
atomic.AddInt64(&cb.totalRequests, 1)
|
|
||||||
|
|
||||||
// Check if circuit breaker allows the request
|
|
||||||
if !cb.allowRequest() {
|
|
||||||
return fmt.Errorf("circuit breaker is open")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the function
|
|
||||||
err := fn()
|
|
||||||
// Record the result
|
|
||||||
if err != nil {
|
|
||||||
cb.recordFailure()
|
|
||||||
atomic.AddInt64(&cb.totalFailures, 1)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cb.recordSuccess()
|
|
||||||
atomic.AddInt64(&cb.totalSuccesses, 1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// allowRequest checks if the circuit breaker allows the request
|
|
||||||
func (cb *CircuitBreaker) allowRequest() bool {
|
|
||||||
cb.mutex.Lock()
|
|
||||||
defer cb.mutex.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
switch cb.state {
|
|
||||||
case CircuitBreakerClosed:
|
|
||||||
return true
|
|
||||||
|
|
||||||
case CircuitBreakerOpen:
|
|
||||||
// Check if timeout has passed
|
|
||||||
if now.Sub(cb.lastFailureTime) > cb.timeout {
|
|
||||||
cb.state = CircuitBreakerHalfOpen
|
|
||||||
cb.logger.Infof("Circuit breaker transitioning to half-open state")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
|
|
||||||
case CircuitBreakerHalfOpen:
|
|
||||||
// Allow limited requests in half-open state
|
|
||||||
return true
|
|
||||||
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordFailure records a failure and potentially opens the circuit
|
|
||||||
func (cb *CircuitBreaker) recordFailure() {
|
|
||||||
cb.mutex.Lock()
|
|
||||||
defer cb.mutex.Unlock()
|
|
||||||
|
|
||||||
cb.failures++
|
|
||||||
cb.lastFailureTime = time.Now()
|
|
||||||
|
|
||||||
switch cb.state {
|
|
||||||
case CircuitBreakerClosed:
|
|
||||||
if cb.failures >= int64(cb.maxFailures) {
|
|
||||||
cb.state = CircuitBreakerOpen
|
|
||||||
cb.logger.Errorf("Circuit breaker opened after %d failures", cb.failures)
|
|
||||||
}
|
|
||||||
|
|
||||||
case CircuitBreakerHalfOpen:
|
|
||||||
// Go back to open state on any failure in half-open
|
|
||||||
cb.state = CircuitBreakerOpen
|
|
||||||
cb.logger.Errorf("Circuit breaker returned to open state after failure in half-open")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordSuccess records a success and potentially closes the circuit
|
|
||||||
func (cb *CircuitBreaker) recordSuccess() {
|
|
||||||
cb.mutex.Lock()
|
|
||||||
defer cb.mutex.Unlock()
|
|
||||||
|
|
||||||
cb.lastSuccessTime = time.Now()
|
|
||||||
|
|
||||||
switch cb.state {
|
|
||||||
case CircuitBreakerHalfOpen:
|
|
||||||
// Reset failures and close circuit on success in half-open
|
|
||||||
cb.failures = 0
|
|
||||||
cb.state = CircuitBreakerClosed
|
|
||||||
cb.logger.Infof("Circuit breaker closed after successful request in half-open state")
|
|
||||||
|
|
||||||
case CircuitBreakerClosed:
|
|
||||||
// Reset failure count on success
|
|
||||||
cb.failures = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetState returns the current state of the circuit breaker
|
|
||||||
func (cb *CircuitBreaker) GetState() CircuitBreakerState {
|
|
||||||
cb.mutex.RLock()
|
|
||||||
defer cb.mutex.RUnlock()
|
|
||||||
return cb.state
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMetrics returns circuit breaker metrics
|
|
||||||
func (cb *CircuitBreaker) GetMetrics() map[string]interface{} {
|
|
||||||
cb.mutex.RLock()
|
|
||||||
defer cb.mutex.RUnlock()
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
|
||||||
"state": cb.state,
|
|
||||||
"failures": cb.failures,
|
|
||||||
"total_requests": atomic.LoadInt64(&cb.totalRequests),
|
|
||||||
"total_failures": atomic.LoadInt64(&cb.totalFailures),
|
|
||||||
"total_successes": atomic.LoadInt64(&cb.totalSuccesses),
|
|
||||||
"last_failure": cb.lastFailureTime,
|
|
||||||
"last_success": cb.lastSuccessTime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RetryConfig holds configuration for retry mechanisms
|
|
||||||
type RetryConfig struct {
|
|
||||||
MaxAttempts int `json:"max_attempts"`
|
|
||||||
InitialDelay time.Duration `json:"initial_delay"`
|
|
||||||
MaxDelay time.Duration `json:"max_delay"`
|
|
||||||
BackoffFactor float64 `json:"backoff_factor"`
|
|
||||||
EnableJitter bool `json:"enable_jitter"`
|
|
||||||
RetryableErrors []string `json:"retryable_errors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultRetryConfig returns default retry configuration
|
|
||||||
func DefaultRetryConfig() RetryConfig {
|
|
||||||
return RetryConfig{
|
|
||||||
MaxAttempts: 3,
|
|
||||||
InitialDelay: 100 * time.Millisecond,
|
|
||||||
MaxDelay: 5 * time.Second,
|
|
||||||
BackoffFactor: 2.0,
|
|
||||||
EnableJitter: true,
|
|
||||||
RetryableErrors: []string{
|
|
||||||
"connection refused",
|
|
||||||
"timeout",
|
|
||||||
"temporary failure",
|
|
||||||
"network unreachable",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RetryExecutor implements retry logic with exponential backoff
|
|
||||||
type RetryExecutor struct {
|
|
||||||
config RetryConfig
|
|
||||||
logger *Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRetryExecutor creates a new retry executor
|
|
||||||
func NewRetryExecutor(config RetryConfig, logger *Logger) *RetryExecutor {
|
|
||||||
return &RetryExecutor{
|
|
||||||
config: config,
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute runs the given function with retry logic
|
|
||||||
func (re *RetryExecutor) Execute(ctx context.Context, fn func() error) error {
|
|
||||||
var lastErr error
|
|
||||||
|
|
||||||
for attempt := 1; attempt <= re.config.MaxAttempts; attempt++ {
|
|
||||||
// Execute the function
|
|
||||||
err := fn()
|
|
||||||
if err == nil {
|
|
||||||
if attempt > 1 {
|
|
||||||
re.logger.Infof("Operation succeeded on attempt %d", attempt)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lastErr = err
|
|
||||||
|
|
||||||
// Check if error is retryable
|
|
||||||
if !re.isRetryableError(err) {
|
|
||||||
re.logger.Debugf("Non-retryable error on attempt %d: %v", attempt, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't wait after the last attempt
|
|
||||||
if attempt == re.config.MaxAttempts {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate delay with exponential backoff
|
|
||||||
delay := re.calculateDelay(attempt)
|
|
||||||
re.logger.Debugf("Retrying operation after %v (attempt %d/%d): %v",
|
|
||||||
delay, attempt, re.config.MaxAttempts, err)
|
|
||||||
|
|
||||||
// Wait with context cancellation support
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(delay):
|
|
||||||
// Continue to next attempt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("operation failed after %d attempts: %w", re.config.MaxAttempts, lastErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isRetryableError checks if an error should trigger a retry
|
|
||||||
func (re *RetryExecutor) isRetryableError(err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
errStr := err.Error()
|
|
||||||
|
|
||||||
// Check against configured retryable errors
|
|
||||||
for _, retryableErr := range re.config.RetryableErrors {
|
|
||||||
if contains(errStr, retryableErr) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for common network errors using modern Go error handling
|
|
||||||
if netErr, ok := err.(net.Error); ok {
|
|
||||||
// Use Timeout() method which is still valid
|
|
||||||
if netErr.Timeout() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Check for specific temporary error patterns instead of deprecated Temporary()
|
|
||||||
errStr := netErr.Error()
|
|
||||||
temporaryPatterns := []string{
|
|
||||||
"connection refused",
|
|
||||||
"connection reset",
|
|
||||||
"network is unreachable",
|
|
||||||
"no route to host",
|
|
||||||
"temporary failure",
|
|
||||||
"try again",
|
|
||||||
"resource temporarily unavailable",
|
|
||||||
}
|
|
||||||
for _, pattern := range temporaryPatterns {
|
|
||||||
if contains(errStr, pattern) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for HTTP status codes that are retryable
|
|
||||||
if httpErr, ok := err.(*HTTPError); ok {
|
|
||||||
return httpErr.StatusCode >= 500 || httpErr.StatusCode == 429
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateDelay calculates the delay for the next retry attempt
|
|
||||||
func (re *RetryExecutor) calculateDelay(attempt int) time.Duration {
|
|
||||||
// Calculate exponential backoff
|
|
||||||
delay := float64(re.config.InitialDelay) * math.Pow(re.config.BackoffFactor, float64(attempt-1))
|
|
||||||
|
|
||||||
// Apply maximum delay limit
|
|
||||||
if delay > float64(re.config.MaxDelay) {
|
|
||||||
delay = float64(re.config.MaxDelay)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add jitter to prevent thundering herd
|
|
||||||
if re.config.EnableJitter {
|
|
||||||
jitter := delay * 0.1 * (2.0*rand.Float64() - 1.0) // ±10% jitter
|
|
||||||
delay += jitter
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Duration(delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTPError represents an HTTP error with status code
|
|
||||||
type HTTPError struct {
|
|
||||||
StatusCode int
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error implements the error interface
|
|
||||||
func (e *HTTPError) Error() string {
|
|
||||||
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GracefulDegradation implements graceful degradation patterns
|
|
||||||
type GracefulDegradation struct {
|
|
||||||
// Fallback functions for different operations
|
|
||||||
fallbacks map[string]func() (interface{}, error)
|
|
||||||
|
|
||||||
// Health checks for dependencies
|
|
||||||
healthChecks map[string]func() bool
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
config GracefulDegradationConfig
|
|
||||||
|
|
||||||
// State tracking
|
|
||||||
degradedServices map[string]time.Time
|
|
||||||
mutex sync.RWMutex
|
|
||||||
|
|
||||||
logger *Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// GracefulDegradationConfig holds configuration for graceful degradation
|
|
||||||
type GracefulDegradationConfig struct {
|
|
||||||
HealthCheckInterval time.Duration `json:"health_check_interval"`
|
|
||||||
RecoveryTimeout time.Duration `json:"recovery_timeout"`
|
|
||||||
EnableFallbacks bool `json:"enable_fallbacks"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultGracefulDegradationConfig returns default configuration
|
|
||||||
func DefaultGracefulDegradationConfig() GracefulDegradationConfig {
|
|
||||||
return GracefulDegradationConfig{
|
|
||||||
HealthCheckInterval: 30 * time.Second,
|
|
||||||
RecoveryTimeout: 5 * time.Minute,
|
|
||||||
EnableFallbacks: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewGracefulDegradation creates a new graceful degradation manager
|
|
||||||
func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *GracefulDegradation {
|
|
||||||
gd := &GracefulDegradation{
|
|
||||||
fallbacks: make(map[string]func() (interface{}, error)),
|
|
||||||
healthChecks: make(map[string]func() bool),
|
|
||||||
degradedServices: make(map[string]time.Time),
|
|
||||||
config: config,
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start health check routine
|
|
||||||
go gd.startHealthCheckRoutine()
|
|
||||||
|
|
||||||
return gd
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterFallback registers a fallback function for a service
|
|
||||||
func (gd *GracefulDegradation) RegisterFallback(serviceName string, fallback func() (interface{}, error)) {
|
|
||||||
gd.mutex.Lock()
|
|
||||||
defer gd.mutex.Unlock()
|
|
||||||
gd.fallbacks[serviceName] = fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterHealthCheck registers a health check function for a service
|
|
||||||
func (gd *GracefulDegradation) RegisterHealthCheck(serviceName string, healthCheck func() bool) {
|
|
||||||
gd.mutex.Lock()
|
|
||||||
defer gd.mutex.Unlock()
|
|
||||||
gd.healthChecks[serviceName] = healthCheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteWithFallback executes a function with fallback support
|
|
||||||
func (gd *GracefulDegradation) ExecuteWithFallback(serviceName string, primary func() (interface{}, error)) (interface{}, error) {
|
|
||||||
// Check if service is degraded
|
|
||||||
if gd.isServiceDegraded(serviceName) {
|
|
||||||
return gd.executeFallback(serviceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try primary function
|
|
||||||
result, err := primary()
|
|
||||||
if err != nil {
|
|
||||||
// Mark service as degraded
|
|
||||||
gd.markServiceDegraded(serviceName)
|
|
||||||
|
|
||||||
// Try fallback if available
|
|
||||||
if gd.config.EnableFallbacks {
|
|
||||||
return gd.executeFallback(serviceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isServiceDegraded checks if a service is currently degraded
|
|
||||||
func (gd *GracefulDegradation) isServiceDegraded(serviceName string) bool {
|
|
||||||
gd.mutex.RLock()
|
|
||||||
defer gd.mutex.RUnlock()
|
|
||||||
|
|
||||||
degradedTime, exists := gd.degradedServices[serviceName]
|
|
||||||
if !exists {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if recovery timeout has passed
|
|
||||||
if time.Since(degradedTime) > gd.config.RecoveryTimeout {
|
|
||||||
delete(gd.degradedServices, serviceName)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// markServiceDegraded marks a service as degraded
|
|
||||||
func (gd *GracefulDegradation) markServiceDegraded(serviceName string) {
|
|
||||||
gd.mutex.Lock()
|
|
||||||
defer gd.mutex.Unlock()
|
|
||||||
|
|
||||||
if _, exists := gd.degradedServices[serviceName]; !exists {
|
|
||||||
gd.logger.Errorf("Service %s marked as degraded", serviceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
gd.degradedServices[serviceName] = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeFallback executes the fallback function for a service
|
|
||||||
func (gd *GracefulDegradation) executeFallback(serviceName string) (interface{}, error) {
|
|
||||||
gd.mutex.RLock()
|
|
||||||
fallback, exists := gd.fallbacks[serviceName]
|
|
||||||
gd.mutex.RUnlock()
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return nil, fmt.Errorf("no fallback available for service %s", serviceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
gd.logger.Infof("Executing fallback for degraded service %s", serviceName)
|
|
||||||
return fallback()
|
|
||||||
}
|
|
||||||
|
|
||||||
// startHealthCheckRoutine starts the background health check routine
|
|
||||||
func (gd *GracefulDegradation) startHealthCheckRoutine() {
|
|
||||||
ticker := time.NewTicker(gd.config.HealthCheckInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for range ticker.C {
|
|
||||||
gd.performHealthChecks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// performHealthChecks runs health checks for all registered services
|
|
||||||
func (gd *GracefulDegradation) performHealthChecks() {
|
|
||||||
gd.mutex.RLock()
|
|
||||||
healthChecks := make(map[string]func() bool)
|
|
||||||
for name, check := range gd.healthChecks {
|
|
||||||
healthChecks[name] = check
|
|
||||||
}
|
|
||||||
gd.mutex.RUnlock()
|
|
||||||
|
|
||||||
for serviceName, healthCheck := range healthChecks {
|
|
||||||
if healthCheck() {
|
|
||||||
// Service is healthy, remove from degraded list
|
|
||||||
gd.mutex.Lock()
|
|
||||||
if _, wasDegraded := gd.degradedServices[serviceName]; wasDegraded {
|
|
||||||
delete(gd.degradedServices, serviceName)
|
|
||||||
gd.logger.Infof("Service %s recovered from degraded state", serviceName)
|
|
||||||
}
|
|
||||||
gd.mutex.Unlock()
|
|
||||||
} else {
|
|
||||||
// Service is unhealthy, mark as degraded
|
|
||||||
gd.markServiceDegraded(serviceName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDegradedServices returns a list of currently degraded services
|
|
||||||
func (gd *GracefulDegradation) GetDegradedServices() []string {
|
|
||||||
gd.mutex.RLock()
|
|
||||||
defer gd.mutex.RUnlock()
|
|
||||||
|
|
||||||
var degraded []string
|
|
||||||
for serviceName := range gd.degradedServices {
|
|
||||||
degraded = append(degraded, serviceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return degraded
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrorRecoveryManager coordinates all error recovery mechanisms
|
|
||||||
type ErrorRecoveryManager struct {
|
|
||||||
circuitBreakers map[string]*CircuitBreaker
|
|
||||||
retryExecutor *RetryExecutor
|
|
||||||
gracefulDegradation *GracefulDegradation
|
|
||||||
mutex sync.RWMutex
|
|
||||||
logger *Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewErrorRecoveryManager creates a new error recovery manager
|
|
||||||
func NewErrorRecoveryManager(logger *Logger) *ErrorRecoveryManager {
|
|
||||||
return &ErrorRecoveryManager{
|
|
||||||
circuitBreakers: make(map[string]*CircuitBreaker),
|
|
||||||
retryExecutor: NewRetryExecutor(DefaultRetryConfig(), logger),
|
|
||||||
gracefulDegradation: NewGracefulDegradation(DefaultGracefulDegradationConfig(), logger),
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCircuitBreaker gets or creates a circuit breaker for a service
|
|
||||||
func (erm *ErrorRecoveryManager) GetCircuitBreaker(serviceName string) *CircuitBreaker {
|
|
||||||
erm.mutex.Lock()
|
|
||||||
defer erm.mutex.Unlock()
|
|
||||||
|
|
||||||
if cb, exists := erm.circuitBreakers[serviceName]; exists {
|
|
||||||
return cb
|
|
||||||
}
|
|
||||||
|
|
||||||
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), erm.logger)
|
|
||||||
erm.circuitBreakers[serviceName] = cb
|
|
||||||
return cb
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteWithRecovery executes a function with full error recovery support
|
|
||||||
func (erm *ErrorRecoveryManager) ExecuteWithRecovery(ctx context.Context, serviceName string, fn func() error) error {
|
|
||||||
cb := erm.GetCircuitBreaker(serviceName)
|
|
||||||
|
|
||||||
return erm.retryExecutor.Execute(ctx, func() error {
|
|
||||||
return cb.Execute(fn)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRecoveryMetrics returns metrics for all recovery mechanisms
|
|
||||||
func (erm *ErrorRecoveryManager) GetRecoveryMetrics() map[string]interface{} {
|
|
||||||
erm.mutex.RLock()
|
|
||||||
defer erm.mutex.RUnlock()
|
|
||||||
|
|
||||||
metrics := make(map[string]interface{})
|
|
||||||
|
|
||||||
// Circuit breaker metrics
|
|
||||||
cbMetrics := make(map[string]interface{})
|
|
||||||
for name, cb := range erm.circuitBreakers {
|
|
||||||
cbMetrics[name] = cb.GetMetrics()
|
|
||||||
}
|
|
||||||
metrics["circuit_breakers"] = cbMetrics
|
|
||||||
|
|
||||||
// Degraded services
|
|
||||||
metrics["degraded_services"] = erm.gracefulDegradation.GetDegradedServices()
|
|
||||||
|
|
||||||
return metrics
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to check if a string contains a substring (case-insensitive)
|
|
||||||
func contains(s, substr string) bool {
|
|
||||||
return len(s) >= len(substr) &&
|
|
||||||
(s == substr ||
|
|
||||||
(len(s) > len(substr) &&
|
|
||||||
(s[:len(substr)] == substr ||
|
|
||||||
s[len(s)-len(substr):] == substr ||
|
|
||||||
containsSubstring(s, substr))))
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsSubstring(s, substr string) bool {
|
|
||||||
for i := 0; i <= len(s)-len(substr); i++ {
|
|
||||||
if s[i:i+len(substr)] == substr {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCircuitBreaker(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
config := DefaultCircuitBreakerConfig()
|
|
||||||
config.MaxFailures = 2
|
|
||||||
config.Timeout = 100 * time.Millisecond
|
|
||||||
|
|
||||||
cb := NewCircuitBreaker(config, logger)
|
|
||||||
|
|
||||||
t.Run("Initial state is closed", func(t *testing.T) {
|
|
||||||
if cb.GetState() != CircuitBreakerClosed {
|
|
||||||
t.Errorf("Expected initial state to be closed, got %v", cb.GetState())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Successful execution", func(t *testing.T) {
|
|
||||||
err := cb.Execute(func() error {
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Circuit opens after max failures", func(t *testing.T) {
|
|
||||||
// Trigger failures to open circuit
|
|
||||||
for i := 0; i < config.MaxFailures; i++ {
|
|
||||||
cb.Execute(func() error {
|
|
||||||
return errors.New("test error")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if cb.GetState() != CircuitBreakerOpen {
|
|
||||||
t.Errorf("Expected circuit to be open, got %v", cb.GetState())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should reject requests when open
|
|
||||||
err := cb.Execute(func() error {
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err == nil || err.Error() != "circuit breaker is open" {
|
|
||||||
t.Errorf("Expected circuit breaker open error, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Circuit transitions to half-open after timeout", func(t *testing.T) {
|
|
||||||
// Wait for timeout
|
|
||||||
time.Sleep(config.Timeout + 10*time.Millisecond)
|
|
||||||
|
|
||||||
// Next request should transition to half-open
|
|
||||||
cb.Execute(func() error {
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if cb.GetState() != CircuitBreakerClosed {
|
|
||||||
t.Errorf("Expected circuit to be closed after successful request, got %v", cb.GetState())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Get metrics", func(t *testing.T) {
|
|
||||||
metrics := cb.GetMetrics()
|
|
||||||
if metrics["state"] == nil {
|
|
||||||
t.Error("Expected metrics to contain state")
|
|
||||||
}
|
|
||||||
if metrics["total_requests"] == nil {
|
|
||||||
t.Error("Expected metrics to contain total_requests")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRetryExecutor(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
config := DefaultRetryConfig()
|
|
||||||
config.MaxAttempts = 3
|
|
||||||
config.InitialDelay = 10 * time.Millisecond
|
|
||||||
|
|
||||||
re := NewRetryExecutor(config, logger)
|
|
||||||
|
|
||||||
t.Run("Successful execution on first attempt", func(t *testing.T) {
|
|
||||||
attempts := 0
|
|
||||||
err := re.Execute(context.Background(), func() error {
|
|
||||||
attempts++
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if attempts != 1 {
|
|
||||||
t.Errorf("Expected 1 attempt, got %d", attempts)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Retry on retryable error", func(t *testing.T) {
|
|
||||||
attempts := 0
|
|
||||||
err := re.Execute(context.Background(), func() error {
|
|
||||||
attempts++
|
|
||||||
if attempts < 2 {
|
|
||||||
return errors.New("connection refused")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Expected no error after retry, got %v", err)
|
|
||||||
}
|
|
||||||
if attempts != 2 {
|
|
||||||
t.Errorf("Expected 2 attempts, got %d", attempts)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("No retry on non-retryable error", func(t *testing.T) {
|
|
||||||
attempts := 0
|
|
||||||
err := re.Execute(context.Background(), func() error {
|
|
||||||
attempts++
|
|
||||||
return errors.New("non-retryable error")
|
|
||||||
})
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error to be returned")
|
|
||||||
}
|
|
||||||
if attempts != 1 {
|
|
||||||
t.Errorf("Expected 1 attempt, got %d", attempts)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Max attempts reached", func(t *testing.T) {
|
|
||||||
attempts := 0
|
|
||||||
err := re.Execute(context.Background(), func() error {
|
|
||||||
attempts++
|
|
||||||
return errors.New("timeout")
|
|
||||||
})
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error after max attempts")
|
|
||||||
}
|
|
||||||
if attempts != config.MaxAttempts {
|
|
||||||
t.Errorf("Expected %d attempts, got %d", config.MaxAttempts, attempts)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Context cancellation", func(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel() // Cancel immediately
|
|
||||||
|
|
||||||
err := re.Execute(ctx, func() error {
|
|
||||||
return errors.New("timeout")
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != context.Canceled {
|
|
||||||
t.Errorf("Expected context canceled error, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Network error handling", func(t *testing.T) {
|
|
||||||
// Test timeout error
|
|
||||||
timeoutErr := &net.OpError{Op: "dial", Err: errors.New("timeout")}
|
|
||||||
if !re.isRetryableError(timeoutErr) {
|
|
||||||
t.Error("Expected timeout error to be retryable")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test connection refused
|
|
||||||
connErr := errors.New("connection refused")
|
|
||||||
if !re.isRetryableError(connErr) {
|
|
||||||
t.Error("Expected connection refused to be retryable")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("HTTP error handling", func(t *testing.T) {
|
|
||||||
// Test 500 error (retryable)
|
|
||||||
httpErr500 := &HTTPError{StatusCode: 500, Message: "Internal Server Error"}
|
|
||||||
if !re.isRetryableError(httpErr500) {
|
|
||||||
t.Error("Expected 500 error to be retryable")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 429 error (retryable)
|
|
||||||
httpErr429 := &HTTPError{StatusCode: 429, Message: "Too Many Requests"}
|
|
||||||
if !re.isRetryableError(httpErr429) {
|
|
||||||
t.Error("Expected 429 error to be retryable")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 400 error (not retryable)
|
|
||||||
httpErr400 := &HTTPError{StatusCode: 400, Message: "Bad Request"}
|
|
||||||
if re.isRetryableError(httpErr400) {
|
|
||||||
t.Error("Expected 400 error to not be retryable")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGracefulDegradation(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
config := DefaultGracefulDegradationConfig()
|
|
||||||
config.HealthCheckInterval = 50 * time.Millisecond
|
|
||||||
config.RecoveryTimeout = 100 * time.Millisecond
|
|
||||||
|
|
||||||
gd := NewGracefulDegradation(config, logger)
|
|
||||||
defer func() {
|
|
||||||
// Clean up goroutine
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
}()
|
|
||||||
|
|
||||||
t.Run("Register fallback and health check", func(t *testing.T) {
|
|
||||||
gd.RegisterFallback("test-service", func() (interface{}, error) {
|
|
||||||
return "fallback-result", nil
|
|
||||||
})
|
|
||||||
|
|
||||||
gd.RegisterHealthCheck("test-service", func() bool {
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Should not be degraded initially
|
|
||||||
if gd.isServiceDegraded("test-service") {
|
|
||||||
t.Error("Service should not be degraded initially")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Execute with fallback on failure", func(t *testing.T) {
|
|
||||||
gd.RegisterFallback("failing-service", func() (interface{}, error) {
|
|
||||||
return "fallback-result", nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// First call should fail and mark service as degraded
|
|
||||||
result, err := gd.ExecuteWithFallback("failing-service", func() (interface{}, error) {
|
|
||||||
return nil, errors.New("service failure")
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Expected fallback to succeed, got error: %v", err)
|
|
||||||
}
|
|
||||||
if result != "fallback-result" {
|
|
||||||
t.Errorf("Expected fallback result, got %v", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service should now be degraded
|
|
||||||
if !gd.isServiceDegraded("failing-service") {
|
|
||||||
t.Error("Service should be marked as degraded")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("No fallback available", func(t *testing.T) {
|
|
||||||
_, err := gd.ExecuteWithFallback("no-fallback-service", func() (interface{}, error) {
|
|
||||||
return nil, errors.New("service failure")
|
|
||||||
})
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error when no fallback available")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Get degraded services", func(t *testing.T) {
|
|
||||||
degraded := gd.GetDegradedServices()
|
|
||||||
found := false
|
|
||||||
for _, service := range degraded {
|
|
||||||
if service == "failing-service" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Error("Expected failing-service to be in degraded list")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Service recovery after timeout", func(t *testing.T) {
|
|
||||||
// Wait for recovery timeout
|
|
||||||
time.Sleep(config.RecoveryTimeout + 20*time.Millisecond)
|
|
||||||
|
|
||||||
// Service should no longer be degraded
|
|
||||||
if gd.isServiceDegraded("failing-service") {
|
|
||||||
t.Error("Service should have recovered after timeout")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorRecoveryManager(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
erm := NewErrorRecoveryManager(logger)
|
|
||||||
|
|
||||||
t.Run("Get circuit breaker", func(t *testing.T) {
|
|
||||||
cb1 := erm.GetCircuitBreaker("service1")
|
|
||||||
cb2 := erm.GetCircuitBreaker("service1")
|
|
||||||
|
|
||||||
// Should return the same instance
|
|
||||||
if cb1 != cb2 {
|
|
||||||
t.Error("Expected same circuit breaker instance for same service")
|
|
||||||
}
|
|
||||||
|
|
||||||
cb3 := erm.GetCircuitBreaker("service2")
|
|
||||||
if cb1 == cb3 {
|
|
||||||
t.Error("Expected different circuit breaker instances for different services")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Execute with recovery", func(t *testing.T) {
|
|
||||||
attempts := 0
|
|
||||||
err := erm.ExecuteWithRecovery(context.Background(), "test-service", func() error {
|
|
||||||
attempts++
|
|
||||||
if attempts < 2 {
|
|
||||||
return errors.New("temporary failure")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Expected recovery to succeed, got %v", err)
|
|
||||||
}
|
|
||||||
if attempts < 2 {
|
|
||||||
t.Errorf("Expected at least 2 attempts, got %d", attempts)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Get recovery metrics", func(t *testing.T) {
|
|
||||||
metrics := erm.GetRecoveryMetrics()
|
|
||||||
|
|
||||||
if metrics["circuit_breakers"] == nil {
|
|
||||||
t.Error("Expected circuit_breakers in metrics")
|
|
||||||
}
|
|
||||||
if metrics["degraded_services"] == nil {
|
|
||||||
t.Error("Expected degraded_services in metrics")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHTTPError(t *testing.T) {
|
|
||||||
err := &HTTPError{StatusCode: 500, Message: "Internal Server Error"}
|
|
||||||
expected := "HTTP 500: Internal Server Error"
|
|
||||||
if err.Error() != expected {
|
|
||||||
t.Errorf("Expected %q, got %q", expected, err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHelperFunctions(t *testing.T) {
|
|
||||||
t.Run("contains function", func(t *testing.T) {
|
|
||||||
if !contains("hello world", "hello") {
|
|
||||||
t.Error("Expected contains to find substring at start")
|
|
||||||
}
|
|
||||||
if !contains("hello world", "world") {
|
|
||||||
t.Error("Expected contains to find substring at end")
|
|
||||||
}
|
|
||||||
if !contains("hello world", "lo wo") {
|
|
||||||
t.Error("Expected contains to find substring in middle")
|
|
||||||
}
|
|
||||||
if contains("hello world", "xyz") {
|
|
||||||
t.Error("Expected contains to not find non-existent substring")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("containsSubstring function", func(t *testing.T) {
|
|
||||||
if !containsSubstring("hello world", "lo wo") {
|
|
||||||
t.Error("Expected containsSubstring to find substring")
|
|
||||||
}
|
|
||||||
if containsSubstring("hello", "hello world") {
|
|
||||||
t.Error("Expected containsSubstring to not find longer substring")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultConfigs(t *testing.T) {
|
|
||||||
t.Run("DefaultCircuitBreakerConfig", func(t *testing.T) {
|
|
||||||
config := DefaultCircuitBreakerConfig()
|
|
||||||
if config.MaxFailures <= 0 {
|
|
||||||
t.Error("Expected positive MaxFailures")
|
|
||||||
}
|
|
||||||
if config.Timeout <= 0 {
|
|
||||||
t.Error("Expected positive Timeout")
|
|
||||||
}
|
|
||||||
if config.ResetTimeout <= 0 {
|
|
||||||
t.Error("Expected positive ResetTimeout")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("DefaultRetryConfig", func(t *testing.T) {
|
|
||||||
config := DefaultRetryConfig()
|
|
||||||
if config.MaxAttempts <= 0 {
|
|
||||||
t.Error("Expected positive MaxAttempts")
|
|
||||||
}
|
|
||||||
if config.InitialDelay <= 0 {
|
|
||||||
t.Error("Expected positive InitialDelay")
|
|
||||||
}
|
|
||||||
if config.BackoffFactor <= 1 {
|
|
||||||
t.Error("Expected BackoffFactor > 1")
|
|
||||||
}
|
|
||||||
if len(config.RetryableErrors) == 0 {
|
|
||||||
t.Error("Expected some retryable errors")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("DefaultGracefulDegradationConfig", func(t *testing.T) {
|
|
||||||
config := DefaultGracefulDegradationConfig()
|
|
||||||
if config.HealthCheckInterval <= 0 {
|
|
||||||
t.Error("Expected positive HealthCheckInterval")
|
|
||||||
}
|
|
||||||
if config.RecoveryTimeout <= 0 {
|
|
||||||
t.Error("Expected positive RecoveryTimeout")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock network error for testing
|
|
||||||
type mockNetError struct {
|
|
||||||
timeout bool
|
|
||||||
temp bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *mockNetError) Error() string { return "mock network error" }
|
|
||||||
func (e *mockNetError) Timeout() bool { return e.timeout }
|
|
||||||
func (e *mockNetError) Temporary() bool { return e.temp }
|
|
||||||
|
|
||||||
func TestNetworkErrorHandling(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
config := DefaultRetryConfig()
|
|
||||||
re := NewRetryExecutor(config, logger)
|
|
||||||
|
|
||||||
t.Run("Timeout error is retryable", func(t *testing.T) {
|
|
||||||
err := &mockNetError{timeout: true}
|
|
||||||
if !re.isRetryableError(err) {
|
|
||||||
t.Error("Expected timeout error to be retryable")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Non-timeout network error with retryable pattern", func(t *testing.T) {
|
|
||||||
err := &mockNetError{timeout: false}
|
|
||||||
// This should not be retryable since it doesn't match patterns and isn't timeout
|
|
||||||
if re.isRetryableError(err) {
|
|
||||||
t.Error("Expected non-timeout network error without pattern to not be retryable")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ toolchain go1.23.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/sessions v1.3.0
|
github.com/gorilla/sessions v1.4.0
|
||||||
golang.org/x/time v0.7.0
|
golang.org/x/time v0.7.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,9 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
|
|||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
|
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
|
||||||
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
||||||
|
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||||
|
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||||
|
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||||
|
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
|
|||||||
@@ -1,592 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockJWTVerifier implements the JWTVerifier interface for testing
|
|
||||||
type MockJWTVerifier struct {
|
|
||||||
VerifyJWTFunc func(jwt *JWT, token string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockJWTVerifier) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error {
|
|
||||||
if m.VerifyJWTFunc != nil {
|
|
||||||
return m.VerifyJWTFunc(jwt, token)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
|
|
||||||
// Create a mocked TraefikOidc instance that simulates Google provider behavior
|
|
||||||
mockLogger := NewLogger("debug")
|
|
||||||
|
|
||||||
// Create a test instance with a Google-like issuer URL
|
|
||||||
tOidc := &TraefikOidc{
|
|
||||||
issuerURL: "https://accounts.google.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
clientSecret: "test-client-secret",
|
|
||||||
logger: mockLogger,
|
|
||||||
scopes: []string{"openid", "profile", "email"},
|
|
||||||
refreshGracePeriod: 60,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a session manager
|
|
||||||
sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, mockLogger)
|
|
||||||
tOidc.sessionManager = sessionManager
|
|
||||||
|
|
||||||
t.Run("Google provider detection adds required parameters", func(t *testing.T) {
|
|
||||||
// Test buildAuthURL to ensure it adds access_type=offline and prompt=consent for Google
|
|
||||||
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
|
|
||||||
|
|
||||||
// Check that access_type=offline was added (not offline_access scope for Google)
|
|
||||||
if !strings.Contains(authURL, "access_type=offline") {
|
|
||||||
t.Errorf("access_type=offline not added to Google auth URL: %s", authURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify offline_access scope is NOT included for Google providers
|
|
||||||
if strings.Contains(authURL, "offline_access") {
|
|
||||||
t.Errorf("offline_access scope incorrectly added to Google auth URL: %s", authURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that prompt=consent was added
|
|
||||||
if !strings.Contains(authURL, "prompt=consent") {
|
|
||||||
t.Errorf("prompt=consent not added to Google auth URL: %s", authURL)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Non-Google provider doesn't add Google-specific params", func(t *testing.T) {
|
|
||||||
// Create a test instance with a non-Google issuer URL
|
|
||||||
nonGoogleOidc := &TraefikOidc{
|
|
||||||
issuerURL: "https://auth.example.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
clientSecret: "test-client-secret",
|
|
||||||
logger: mockLogger,
|
|
||||||
scopes: []string{"openid", "profile", "email"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test buildAuthURL without Google-specific parameters
|
|
||||||
authURL := nonGoogleOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
|
|
||||||
|
|
||||||
// Check that prompt=consent is not automatically added
|
|
||||||
if strings.Contains(authURL, "prompt=consent") {
|
|
||||||
t.Errorf("prompt=consent added to non-Google auth URL: %s", authURL)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Session refresh with Google provider", func(t *testing.T) {
|
|
||||||
// Create a request and response recorder
|
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Create a session and set a refresh token
|
|
||||||
session, _ := sessionManager.GetSession(req)
|
|
||||||
session.SetAuthenticated(true)
|
|
||||||
session.SetEmail("[email protected]")
|
|
||||||
session.SetAccessToken("old-access-token")
|
|
||||||
session.SetRefreshToken("valid-refresh-token")
|
|
||||||
|
|
||||||
// Create a mock token exchanger that simulates Google's behavior
|
|
||||||
mockTokenExchanger := &MockTokenExchanger{
|
|
||||||
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
|
|
||||||
// Check that the refresh token is passed correctly
|
|
||||||
if refreshToken != "valid-refresh-token" {
|
|
||||||
t.Errorf("Incorrect refresh token passed: %s", refreshToken)
|
|
||||||
return nil, fmt.Errorf("invalid token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return a simulated Google token response with a new access token
|
|
||||||
// but without a new refresh token (Google doesn't always return a new refresh token)
|
|
||||||
return &TokenResponse{
|
|
||||||
IDToken: "new-id-token-from-google",
|
|
||||||
AccessToken: "new-access-token-from-google",
|
|
||||||
RefreshToken: "", // Google often doesn't return a new refresh token
|
|
||||||
ExpiresIn: 3600,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the mock token exchanger
|
|
||||||
tOidc.tokenExchanger = mockTokenExchanger
|
|
||||||
|
|
||||||
// Create a struct that implements the TokenVerifier interface
|
|
||||||
tOidc.tokenVerifier = &MockTokenVerifier{
|
|
||||||
VerifyFunc: func(token string) error {
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
tOidc.extractClaimsFunc = func(token string) (map[string]interface{}, error) {
|
|
||||||
// Return mock claims
|
|
||||||
return map[string]interface{}{
|
|
||||||
"email": "[email protected]",
|
|
||||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to refresh the token
|
|
||||||
refreshed := tOidc.refreshToken(rw, req, session)
|
|
||||||
|
|
||||||
// Verify the refresh was successful
|
|
||||||
if !refreshed {
|
|
||||||
t.Error("Token refresh failed for Google provider")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that we kept the original refresh token since Google didn't provide a new one
|
|
||||||
if session.GetRefreshToken() != "valid-refresh-token" {
|
|
||||||
t.Errorf("Original refresh token not preserved: got %s, expected 'valid-refresh-token'",
|
|
||||||
session.GetRefreshToken())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that the tokens were updated correctly
|
|
||||||
if session.GetIDToken() != "new-id-token-from-google" {
|
|
||||||
t.Errorf("ID token not updated: got %s, expected 'new-id-token-from-google'",
|
|
||||||
session.GetIDToken())
|
|
||||||
}
|
|
||||||
|
|
||||||
if session.GetAccessToken() != "new-access-token-from-google" {
|
|
||||||
t.Errorf("Access token not updated: got %s, expected 'new-access-token-from-google'",
|
|
||||||
session.GetAccessToken())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// Test that our fix specifically addresses the reported Google error
|
|
||||||
t.Run("Google provider handles offline access correctly", func(t *testing.T) {
|
|
||||||
// Build the auth URL with Google provider detection
|
|
||||||
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
|
|
||||||
|
|
||||||
// Parse the URL to examine its parameters
|
|
||||||
parsedURL, err := url.Parse(authURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse auth URL: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
params := parsedURL.Query()
|
|
||||||
|
|
||||||
// Verify that access_type=offline is set (Google's way of requesting refresh tokens)
|
|
||||||
if params.Get("access_type") != "offline" {
|
|
||||||
t.Errorf("access_type=offline not set in Google auth URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that the scope parameter doesn't contain offline_access
|
|
||||||
// (which Google reports as invalid: {invalid=[offline_access]})
|
|
||||||
scope := params.Get("scope")
|
|
||||||
if strings.Contains(scope, "offline_access") {
|
|
||||||
t.Errorf("offline_access incorrectly included in scope for Google provider: %s", scope)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that the necessary scopes are still included
|
|
||||||
for _, requiredScope := range []string{"openid", "profile", "email"} {
|
|
||||||
if !strings.Contains(scope, requiredScope) {
|
|
||||||
t.Errorf("Required scope '%s' missing from auth URL", requiredScope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Enhanced test for verifying non-Google provider includes offline_access scope
|
|
||||||
t.Run("Non-Google provider includes offline_access scope", func(t *testing.T) {
|
|
||||||
// Create a test instance with a non-Google issuer URL
|
|
||||||
nonGoogleOidc := &TraefikOidc{
|
|
||||||
issuerURL: "https://auth.example.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
clientSecret: "test-client-secret",
|
|
||||||
logger: mockLogger,
|
|
||||||
scopes: []string{"openid", "profile", "email"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test buildAuthURL for a non-Google provider
|
|
||||||
authURL := nonGoogleOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
|
|
||||||
|
|
||||||
// Parse the URL to examine its parameters
|
|
||||||
parsedURL, err := url.Parse(authURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse auth URL: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
params := parsedURL.Query()
|
|
||||||
|
|
||||||
// Verify that access_type=offline is NOT set for non-Google providers
|
|
||||||
if params.Get("access_type") == "offline" {
|
|
||||||
t.Errorf("access_type=offline incorrectly added to non-Google auth URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that offline_access scope IS included for non-Google providers
|
|
||||||
scope := params.Get("scope")
|
|
||||||
if !strings.Contains(scope, "offline_access") {
|
|
||||||
t.Errorf("offline_access scope missing from non-Google auth URL scope: %s", scope)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that the necessary scopes are still included
|
|
||||||
for _, requiredScope := range []string{"openid", "profile", "email"} {
|
|
||||||
if !strings.Contains(scope, requiredScope) {
|
|
||||||
t.Errorf("Required scope '%s' missing from non-Google auth URL", requiredScope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Additional test for complete URL construction for Google provider
|
|
||||||
t.Run("Complete Google auth URL construction", func(t *testing.T) {
|
|
||||||
// Build the auth URL with additional parameters
|
|
||||||
redirectURL := "https://example.com/callback"
|
|
||||||
state := "state123"
|
|
||||||
nonce := "nonce123"
|
|
||||||
codeChallenge := "code_challenge_value" // For PKCE
|
|
||||||
|
|
||||||
// Enable PKCE for this test
|
|
||||||
tOidc.enablePKCE = true
|
|
||||||
|
|
||||||
// Build auth URL
|
|
||||||
authURL := tOidc.buildAuthURL(redirectURL, state, nonce, codeChallenge)
|
|
||||||
|
|
||||||
// Parse the URL to examine its structure and parameters
|
|
||||||
parsedURL, err := url.Parse(authURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse auth URL: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the base URL
|
|
||||||
expectedBaseURL := "https://accounts.google.com/o/oauth2/v2/auth"
|
|
||||||
if !strings.HasPrefix(authURL, expectedBaseURL) && !strings.Contains(authURL, "accounts.google.com") {
|
|
||||||
t.Errorf("Auth URL doesn't start with expected Google OAuth endpoint: %s", authURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check all required parameters
|
|
||||||
params := parsedURL.Query()
|
|
||||||
expectedParams := map[string]string{
|
|
||||||
"client_id": "test-client-id",
|
|
||||||
"response_type": "code",
|
|
||||||
"redirect_uri": redirectURL,
|
|
||||||
"state": state,
|
|
||||||
"nonce": nonce,
|
|
||||||
"access_type": "offline",
|
|
||||||
"prompt": "consent",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check PKCE parameters if enabled
|
|
||||||
if tOidc.enablePKCE {
|
|
||||||
expectedParams["code_challenge"] = codeChallenge
|
|
||||||
expectedParams["code_challenge_method"] = "S256"
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, expectedValue := range expectedParams {
|
|
||||||
if value := params.Get(key); value != expectedValue {
|
|
||||||
t.Errorf("Parameter %s has incorrect value. Expected: %s, Got: %s",
|
|
||||||
key, expectedValue, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify scope parameter separately due to it being space-separated values
|
|
||||||
scope := params.Get("scope")
|
|
||||||
if scope == "" {
|
|
||||||
t.Error("Scope parameter missing from Google auth URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that all required scopes are present
|
|
||||||
scopeList := strings.Split(scope, " ")
|
|
||||||
expectedScopes := []string{"openid", "profile", "email"}
|
|
||||||
for _, expectedScope := range expectedScopes {
|
|
||||||
found := false
|
|
||||||
for _, actualScope := range scopeList {
|
|
||||||
if actualScope == expectedScope {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Errorf("Expected scope '%s' not found in scope parameter: %s", expectedScope, scope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify offline_access is NOT in the scope list
|
|
||||||
for _, actualScope := range scopeList {
|
|
||||||
if actualScope == "offline_access" {
|
|
||||||
t.Errorf("offline_access scope incorrectly included in Google auth URL: %s", scope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Integration test with mocked Google provider
|
|
||||||
t.Run("Integration test with mocked Google provider", func(t *testing.T) {
|
|
||||||
// Generate an RSA key for signing the test JWTs
|
|
||||||
rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to generate RSA key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create JWK for the RSA public key
|
|
||||||
jwk := JWK{
|
|
||||||
Kty: "RSA",
|
|
||||||
Kid: "test-key-id",
|
|
||||||
Alg: "RS256",
|
|
||||||
N: base64.RawURLEncoding.EncodeToString(rsaPrivateKey.PublicKey.N.Bytes()),
|
|
||||||
E: base64.RawURLEncoding.EncodeToString(bigIntToBytes(big.NewInt(int64(rsaPrivateKey.PublicKey.E)))),
|
|
||||||
}
|
|
||||||
jwks := &JWKSet{
|
|
||||||
Keys: []JWK{jwk},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a mock JWK cache
|
|
||||||
mockJWKCache := &MockJWKCache{
|
|
||||||
JWKS: jwks,
|
|
||||||
Err: nil,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a complete test instance with all required fields
|
|
||||||
mockLogger := NewLogger("debug")
|
|
||||||
googleTOidc := &TraefikOidc{
|
|
||||||
issuerURL: "https://accounts.google.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
clientSecret: "test-client-secret",
|
|
||||||
logger: mockLogger,
|
|
||||||
scopes: []string{"openid", "profile", "email"},
|
|
||||||
refreshGracePeriod: 60,
|
|
||||||
tokenCache: NewTokenCache(), // Initialize tokenCache
|
|
||||||
tokenBlacklist: NewCache(), // Initialize tokenBlacklist
|
|
||||||
enablePKCE: false,
|
|
||||||
limiter: rate.NewLimiter(rate.Inf, 0), // No rate limiting for tests
|
|
||||||
jwkCache: mockJWKCache,
|
|
||||||
jwksURL: "https://accounts.google.com/jwks",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a session manager
|
|
||||||
sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, mockLogger)
|
|
||||||
googleTOidc.sessionManager = sessionManager
|
|
||||||
|
|
||||||
// Create a mock token verifier
|
|
||||||
mockTokenVerifier := &MockTokenVerifier{
|
|
||||||
VerifyFunc: func(token string) error {
|
|
||||||
return nil // Always verify successfully for this test
|
|
||||||
},
|
|
||||||
}
|
|
||||||
googleTOidc.tokenVerifier = mockTokenVerifier
|
|
||||||
|
|
||||||
// Create JWT tokens for the test
|
|
||||||
now := time.Now()
|
|
||||||
exp := now.Add(1 * time.Hour).Unix()
|
|
||||||
iat := now.Unix()
|
|
||||||
nbf := now.Unix()
|
|
||||||
|
|
||||||
// Create initial ID token
|
|
||||||
initialIDToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
||||||
"iss": "https://accounts.google.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": exp,
|
|
||||||
"iat": iat,
|
|
||||||
"nbf": nbf,
|
|
||||||
"sub": "test-subject",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"nonce": "nonce123", // For initial authentication verification
|
|
||||||
"jti": generateRandomString(16),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create test ID token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create refresh ID token
|
|
||||||
refreshedIDToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
||||||
"iss": "https://accounts.google.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": exp,
|
|
||||||
"iat": iat,
|
|
||||||
"nbf": nbf,
|
|
||||||
"sub": "test-subject",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"jti": generateRandomString(16),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create refreshed test ID token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up token verifier with mock
|
|
||||||
googleTOidc.tokenVerifier = &MockTokenVerifier{
|
|
||||||
VerifyFunc: func(token string) error {
|
|
||||||
return nil // Always verify successfully for this test
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up JWT verifier with mock
|
|
||||||
googleTOidc.jwtVerifier = &MockJWTVerifier{
|
|
||||||
VerifyJWTFunc: func(jwt *JWT, token string) error {
|
|
||||||
return nil // Always verify successfully for this test
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a mock token exchanger that simulates Google's OAuth behavior
|
|
||||||
mockTokenExchanger := &MockTokenExchanger{
|
|
||||||
ExchangeCodeFunc: func(ctx context.Context, grantType, codeOrToken, redirectURL, codeVerifier string) (*TokenResponse, error) {
|
|
||||||
// Verify the correct parameters are passed
|
|
||||||
if grantType != "authorization_code" {
|
|
||||||
t.Errorf("Expected grant_type=authorization_code, got %s", grantType)
|
|
||||||
}
|
|
||||||
if codeOrToken != "test_auth_code" {
|
|
||||||
t.Errorf("Expected code=test_auth_code, got %s", codeOrToken)
|
|
||||||
}
|
|
||||||
if redirectURL != "https://example.com/callback" {
|
|
||||||
t.Errorf("Expected redirect_uri=https://example.com/callback, got %s", redirectURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return a successful token response with a proper JWT
|
|
||||||
return &TokenResponse{
|
|
||||||
IDToken: initialIDToken,
|
|
||||||
AccessToken: initialIDToken, // Use a valid JWT as the access token too
|
|
||||||
RefreshToken: "google_refresh_token",
|
|
||||||
ExpiresIn: 3600,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
|
|
||||||
// Verify the correct refresh token is passed
|
|
||||||
if refreshToken != "google_refresh_token" {
|
|
||||||
t.Errorf("Expected refresh_token=google_refresh_token, got %s", refreshToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return a successful refresh response with a proper JWT
|
|
||||||
return &TokenResponse{
|
|
||||||
IDToken: refreshedIDToken,
|
|
||||||
AccessToken: refreshedIDToken, // Use a valid JWT as the access token
|
|
||||||
RefreshToken: "", // Google doesn't always return a new refresh token
|
|
||||||
ExpiresIn: 3600,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
googleTOidc.tokenExchanger = mockTokenExchanger
|
|
||||||
|
|
||||||
// Use the real extractClaimsFunc to parse the proper JWT tokens
|
|
||||||
googleTOidc.extractClaimsFunc = extractClaims
|
|
||||||
|
|
||||||
// 1. Test building the authorization URL
|
|
||||||
authURL := googleTOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
|
|
||||||
|
|
||||||
// Verify Google-specific parameters
|
|
||||||
if !strings.Contains(authURL, "access_type=offline") {
|
|
||||||
t.Errorf("Google auth URL missing access_type=offline: %s", authURL)
|
|
||||||
}
|
|
||||||
if !strings.Contains(authURL, "prompt=consent") {
|
|
||||||
t.Errorf("Google auth URL missing prompt=consent: %s", authURL)
|
|
||||||
}
|
|
||||||
if strings.Contains(authURL, "offline_access") {
|
|
||||||
t.Errorf("Google auth URL incorrectly includes offline_access scope: %s", authURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Test handling the callback and token exchange
|
|
||||||
// Create a request and response recorder for the callback
|
|
||||||
req := httptest.NewRequest("GET", "/callback?code=test_auth_code&state=state123", nil)
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Create a session and set the necessary values
|
|
||||||
session, _ := googleTOidc.sessionManager.GetSession(req)
|
|
||||||
session.SetCSRF("state123") // Must match the state parameter
|
|
||||||
session.SetNonce("nonce123")
|
|
||||||
|
|
||||||
// Save the session to the request
|
|
||||||
if err := session.Save(req, rw); err != nil {
|
|
||||||
t.Fatalf("Failed to save session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get cookies from the response and add them to a new request
|
|
||||||
cookies := rw.Result().Cookies()
|
|
||||||
callbackReq := httptest.NewRequest("GET", "/callback?code=test_auth_code&state=state123", nil)
|
|
||||||
for _, cookie := range cookies {
|
|
||||||
callbackReq.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
callbackRw := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Handle the callback
|
|
||||||
googleTOidc.handleCallback(callbackRw, callbackReq, "https://example.com/callback")
|
|
||||||
|
|
||||||
// Verify the response is a redirect (302 Found)
|
|
||||||
if callbackRw.Code != 302 {
|
|
||||||
t.Errorf("Expected 302 redirect, got %d", callbackRw.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new request to get the updated session
|
|
||||||
newReq := httptest.NewRequest("GET", "/", nil)
|
|
||||||
for _, cookie := range callbackRw.Result().Cookies() {
|
|
||||||
newReq.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the updated session
|
|
||||||
newSession, err := googleTOidc.sessionManager.GetSession(newReq)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get session after callback: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the session contains the expected values
|
|
||||||
if !newSession.GetAuthenticated() {
|
|
||||||
t.Error("Session not marked as authenticated after callback")
|
|
||||||
}
|
|
||||||
if newSession.GetEmail() != "[email protected]" {
|
|
||||||
t.Errorf("Session email incorrect: got %s, expected [email protected]",
|
|
||||||
newSession.GetEmail())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for non-empty access token that can be parsed as JWT
|
|
||||||
accessToken := newSession.GetAccessToken()
|
|
||||||
if accessToken == "" {
|
|
||||||
t.Error("Session access token is empty")
|
|
||||||
} else {
|
|
||||||
claims, err := extractClaims(accessToken)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Failed to parse access token as JWT: %v", err)
|
|
||||||
} else if email, ok := claims["email"].(string); !ok || email != "[email protected]" {
|
|
||||||
t.Errorf("Access token JWT doesn't contain expected email claim")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check refresh token
|
|
||||||
if newSession.GetRefreshToken() != "google_refresh_token" {
|
|
||||||
t.Errorf("Session refresh token incorrect: got %s, expected google_refresh_token",
|
|
||||||
newSession.GetRefreshToken())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Test token refresh
|
|
||||||
refreshReq := httptest.NewRequest("GET", "/", nil)
|
|
||||||
for _, cookie := range callbackRw.Result().Cookies() {
|
|
||||||
refreshReq.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
refreshRw := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Get the session for refresh
|
|
||||||
refreshSession, _ := googleTOidc.sessionManager.GetSession(refreshReq)
|
|
||||||
|
|
||||||
// Refresh the token
|
|
||||||
refreshed := googleTOidc.refreshToken(refreshRw, refreshReq, refreshSession)
|
|
||||||
|
|
||||||
// Verify refresh was successful
|
|
||||||
if !refreshed {
|
|
||||||
t.Error("Token refresh failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the session data after refresh
|
|
||||||
// Check for non-empty refreshed access token that can be parsed as JWT
|
|
||||||
refreshedAccessToken := refreshSession.GetAccessToken()
|
|
||||||
if refreshedAccessToken == "" {
|
|
||||||
t.Error("Session access token is empty after refresh")
|
|
||||||
} else {
|
|
||||||
claims, err := extractClaims(refreshedAccessToken)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Failed to parse refreshed access token as JWT: %v", err)
|
|
||||||
} else if email, ok := claims["email"].(string); !ok || email != "[email protected]" {
|
|
||||||
t.Errorf("Refreshed access token JWT doesn't contain expected email claim")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since Google didn't return a new refresh token, the original should be preserved
|
|
||||||
if refreshSession.GetRefreshToken() != "google_refresh_token" {
|
|
||||||
t.Errorf("Original refresh token not preserved: got %s, expected google_refresh_token",
|
|
||||||
refreshSession.GetRefreshToken())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// No need to redefine MockTokenExchanger - it's already defined in main_test.go
|
|
||||||
+238
-285
@@ -3,153 +3,60 @@ package traefikoidc
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
// generateNonce creates a cryptographically secure random string suitable for use as an OIDC nonce.
|
// generateNonce generates a random nonce
|
||||||
// The nonce is used during the authentication flow to mitigate replay attacks by associating
|
|
||||||
// the ID token with the specific authentication request.
|
|
||||||
// It generates 32 random bytes and encodes them using base64 URL encoding.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A base64 URL encoded random string (nonce).
|
|
||||||
// - An error if the random byte generation fails.
|
|
||||||
func generateNonce() (string, error) {
|
func generateNonce() (string, error) {
|
||||||
nonceBytes := make([]byte, 32)
|
nonceBytes := make([]byte, 32)
|
||||||
_, err := rand.Read(nonceBytes)
|
if _, err := rand.Read(nonceBytes); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("could not generate nonce: %w", err)
|
return "", fmt.Errorf("could not generate nonce: %w", err)
|
||||||
}
|
}
|
||||||
return base64.URLEncoding.EncodeToString(nonceBytes), nil
|
return base64.URLEncoding.EncodeToString(nonceBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateCodeVerifier creates a cryptographically secure random string suitable for use as a PKCE code verifier.
|
// buildFullURL constructs a full URL from scheme, host, and path
|
||||||
// According to RFC 7636, the verifier should be a high-entropy string between 43 and 128 characters long.
|
func buildFullURL(scheme, host, path string) string {
|
||||||
// This function generates 32 random bytes, resulting in a 43-character base64 URL encoded string.
|
if scheme == "" {
|
||||||
//
|
scheme = "http"
|
||||||
// Returns:
|
|
||||||
// - A base64 URL encoded random string (code verifier).
|
|
||||||
// - An error if the random byte generation fails.
|
|
||||||
func generateCodeVerifier() (string, error) {
|
|
||||||
// Using 32 bytes (256 bits) will produce a 43 character base64url string
|
|
||||||
verifierBytes := make([]byte, 32)
|
|
||||||
_, err := rand.Read(verifierBytes)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("could not generate code verifier: %w", err)
|
|
||||||
}
|
}
|
||||||
return base64.RawURLEncoding.EncodeToString(verifierBytes), nil
|
return fmt.Sprintf("%s://%s%s", scheme, host, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deriveCodeChallenge computes the PKCE code challenge from a given code verifier.
|
// exchangeTokens exchanges a code or refresh token for tokens
|
||||||
// It uses the S256 challenge method (SHA-256 hash followed by base64 URL encoding)
|
func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType, codeOrToken, redirectURL string) (*TokenResponse, error) {
|
||||||
// as defined in RFC 7636.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - codeVerifier: The high-entropy string generated by generateCodeVerifier.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - The base64 URL encoded SHA-256 hash of the code verifier (code challenge).
|
|
||||||
func deriveCodeChallenge(codeVerifier string) string {
|
|
||||||
// Calculate SHA-256 hash of the code verifier
|
|
||||||
hasher := sha256.New()
|
|
||||||
hasher.Write([]byte(codeVerifier))
|
|
||||||
hash := hasher.Sum(nil)
|
|
||||||
|
|
||||||
// Base64url encode the hash to get the code challenge
|
|
||||||
return base64.RawURLEncoding.EncodeToString(hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenResponse represents the response from the OIDC token endpoint.
|
|
||||||
// It contains the various tokens and metadata returned after successful
|
|
||||||
// code exchange or token refresh operations.
|
|
||||||
type TokenResponse struct {
|
|
||||||
// IDToken is the OIDC ID token containing user claims
|
|
||||||
IDToken string `json:"id_token"`
|
|
||||||
|
|
||||||
// AccessToken is the OAuth 2.0 access token for API access
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
|
|
||||||
// RefreshToken is the OAuth 2.0 refresh token for obtaining new tokens
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
|
|
||||||
// ExpiresIn is the lifetime in seconds of the access token
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
|
|
||||||
// TokenType is the type of token, typically "Bearer"
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// exchangeTokens performs the OAuth 2.0 token exchange with the OIDC provider's token endpoint.
|
|
||||||
// It handles both the "authorization_code" grant type (exchanging an authorization code for tokens)
|
|
||||||
// and the "refresh_token" grant type (using a refresh token to obtain new tokens).
|
|
||||||
// It includes necessary parameters like client credentials and handles PKCE verification if applicable.
|
|
||||||
// The function follows redirects and handles potential errors during the exchange.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - ctx: The context for the outgoing HTTP request.
|
|
||||||
// - grantType: The OAuth 2.0 grant type ("authorization_code" or "refresh_token").
|
|
||||||
// - codeOrToken: The authorization code (for "authorization_code" grant) or the refresh token (for "refresh_token" grant).
|
|
||||||
// - redirectURL: The redirect URI that was used in the initial authorization request (required for "authorization_code" grant).
|
|
||||||
// - codeVerifier: The PKCE code verifier (required for "authorization_code" grant if PKCE was used).
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A TokenResponse containing the obtained tokens (ID, access, refresh).
|
|
||||||
// - An error if the token exchange fails (e.g., network error, provider error, invalid grant).
|
|
||||||
func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
|
||||||
data := url.Values{
|
data := url.Values{
|
||||||
"grant_type": {grantType},
|
"grant_type": {grantType},
|
||||||
"client_id": {t.clientID},
|
"client_id": {t.clientID},
|
||||||
"client_secret": {t.clientSecret},
|
"client_secret": {t.clientSecret},
|
||||||
}
|
}
|
||||||
|
|
||||||
if grantType == "authorization_code" {
|
switch grantType {
|
||||||
|
case "authorization_code":
|
||||||
data.Set("code", codeOrToken)
|
data.Set("code", codeOrToken)
|
||||||
data.Set("redirect_uri", redirectURL)
|
data.Set("redirect_uri", redirectURL)
|
||||||
|
case "refresh_token":
|
||||||
// Add code_verifier if PKCE is being used
|
|
||||||
if codeVerifier != "" {
|
|
||||||
data.Set("code_verifier", codeVerifier)
|
|
||||||
}
|
|
||||||
} else if grantType == "refresh_token" {
|
|
||||||
data.Set("refresh_token", codeOrToken)
|
data.Set("refresh_token", codeOrToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the reusable token HTTP client, fallback to creating one if not initialized
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.tokenURL, strings.NewReader(data.Encode()))
|
||||||
client := t.tokenHTTPClient
|
|
||||||
if client == nil {
|
|
||||||
// Fallback for tests or incomplete initialization - create a temporary client
|
|
||||||
// with the same behavior as the original implementation
|
|
||||||
jar, _ := cookiejar.New(nil)
|
|
||||||
client = &http.Client{
|
|
||||||
Transport: t.httpClient.Transport,
|
|
||||||
Timeout: t.httpClient.Timeout,
|
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
||||||
// Always follow redirects for OIDC endpoints
|
|
||||||
if len(via) >= 50 {
|
|
||||||
return fmt.Errorf("stopped after 50 redirects")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
Jar: jar,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", t.tokenURL, strings.NewReader(data.Encode()))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := t.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to exchange tokens: %w", err)
|
return nil, fmt.Errorf("failed to exchange tokens: %w", err)
|
||||||
}
|
}
|
||||||
@@ -168,38 +75,192 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
|
|||||||
return &tokenResponse, nil
|
return &tokenResponse, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getNewTokenWithRefreshToken uses a refresh token to obtain a new set of tokens (ID, access, refresh)
|
// TokenResponse represents the response from the token endpoint
|
||||||
// from the OIDC provider's token endpoint. It wraps the exchangeTokens function with the
|
type TokenResponse struct {
|
||||||
// "refresh_token" grant type.
|
IDToken string `json:"id_token"`
|
||||||
//
|
AccessToken string `json:"access_token"`
|
||||||
// Parameters:
|
RefreshToken string `json:"refresh_token"`
|
||||||
// - refreshToken: The refresh token previously obtained during authentication or a prior refresh.
|
ExpiresIn int `json:"expires_in"`
|
||||||
//
|
TokenType string `json:"token_type"`
|
||||||
// Returns:
|
}
|
||||||
// - A TokenResponse containing the newly obtained tokens.
|
|
||||||
// - An error if the refresh operation fails.
|
// getNewTokenWithRefreshToken refreshes the token using the refresh token
|
||||||
func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) {
|
func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
tokenResponse, err := t.exchangeTokens(ctx, "refresh_token", refreshToken, "", "")
|
tokenResponse, err := t.exchangeTokens(ctx, "refresh_token", refreshToken, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to refresh token: %w", err)
|
return nil, fmt.Errorf("failed to refresh token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.logger.Debugf("Token response: %+v", tokenResponse)
|
t.logger.Debugf("Token response: %+v", tokenResponse)
|
||||||
|
|
||||||
return tokenResponse, nil
|
return tokenResponse, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractClaims decodes the payload (claims set) part of a JWT string.
|
// handleLogout handles the user logout
|
||||||
// It splits the JWT into its three parts, base64 URL decodes the second part (payload),
|
func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
|
||||||
// and unmarshals the resulting JSON into a map.
|
session, err := t.store.Get(req, cookieName)
|
||||||
// Note: This function does *not* validate the token's signature or claims.
|
t.logger.Debugf("Logging out user")
|
||||||
//
|
if err != nil {
|
||||||
// Parameters:
|
handleError(rw, "Session error", http.StatusInternalServerError, t.logger)
|
||||||
// - tokenString: The raw JWT string.
|
return
|
||||||
//
|
}
|
||||||
// Returns:
|
|
||||||
// - A map representing the JSON claims extracted from the token payload.
|
// Revoke tokens if available
|
||||||
// - An error if the token format is invalid, decoding fails, or JSON unmarshaling fails.
|
for _, tokenType := range []string{"refresh_token", "access_token"} {
|
||||||
|
if token, ok := session.Values[tokenType].(string); ok && token != "" {
|
||||||
|
if err := t.RevokeTokenWithProvider(token, tokenType); err != nil {
|
||||||
|
t.logger.Errorf("Failed to revoke %s: %v", tokenType, err)
|
||||||
|
}
|
||||||
|
t.RevokeToken(token)
|
||||||
|
}
|
||||||
|
delete(session.Values, tokenType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove other session values
|
||||||
|
delete(session.Values, "id_token")
|
||||||
|
delete(session.Values, "authenticated")
|
||||||
|
|
||||||
|
// Set session options to delete the session
|
||||||
|
session.Options = &sessions.Options{MaxAge: -1, Path: "/", HttpOnly: true, Secure: true}
|
||||||
|
|
||||||
|
if err := session.Save(req, rw); err != nil {
|
||||||
|
handleError(rw, "Failed to save session", http.StatusInternalServerError, t.logger)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
rw.Write([]byte("Logged out successfully"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExpiredToken handles the case when a token has expired
|
||||||
|
func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Request, session *sessions.Session) {
|
||||||
|
if session == nil {
|
||||||
|
t.logger.Error("Session is nil in handleExpiredToken")
|
||||||
|
http.Error(rw, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Clear the existing session
|
||||||
|
for k := range session.Values {
|
||||||
|
delete(session.Values, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new values
|
||||||
|
session.Values["csrf"] = uuid.New().String()
|
||||||
|
session.Values["incoming_path"] = req.URL.Path
|
||||||
|
session.Values["nonce"], _ = generateNonce()
|
||||||
|
session.Options = &sessions.Options{MaxAge: 3600, Path: "/", HttpOnly: true, Secure: true}
|
||||||
|
|
||||||
|
if err := session.Save(req, rw); err != nil {
|
||||||
|
t.logger.Errorf("Failed to save session: %v", err)
|
||||||
|
http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.initiateAuthenticationFunc(rw, req, session, t.redirectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCallback handles the callback from the OIDC provider
|
||||||
|
func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
session, err := t.store.Get(req, cookieName)
|
||||||
|
if err != nil {
|
||||||
|
t.logger.Errorf("Session error: %v", err)
|
||||||
|
http.Error(rw, "Session error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.logger.Debugf("Handling callback, URL: %s", req.URL.String())
|
||||||
|
|
||||||
|
if errParam := req.URL.Query().Get("error"); errParam != "" {
|
||||||
|
errorDescription := req.URL.Query().Get("error_description")
|
||||||
|
t.logger.Errorf("Authentication error: %s - %s", errParam, errorDescription)
|
||||||
|
http.Error(rw, fmt.Sprintf("Authentication error: %s", errorDescription), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := req.URL.Query().Get("state")
|
||||||
|
csrfToken, ok := session.Values["csrf"].(string)
|
||||||
|
if !ok || state == "" || csrfToken == "" || state != csrfToken {
|
||||||
|
t.logger.Error("Invalid state parameter or CSRF token")
|
||||||
|
http.Error(rw, "Invalid state parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := req.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
t.logger.Error("No code in callback")
|
||||||
|
http.Error(rw, "No code in callback", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenResponse, err := t.exchangeCodeForTokenFunc(code)
|
||||||
|
if err != nil {
|
||||||
|
t.logger.Errorf("Failed to exchange code for token: %v", err)
|
||||||
|
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken := tokenResponse.IDToken
|
||||||
|
if idToken == "" {
|
||||||
|
t.logger.Error("No id_token in token response")
|
||||||
|
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.verifyToken(idToken); err != nil {
|
||||||
|
t.logger.Errorf("Failed to verify id_token: %v", err)
|
||||||
|
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := t.extractClaimsFunc(idToken)
|
||||||
|
if err != nil {
|
||||||
|
t.logger.Errorf("Failed to extract claims: %v", err)
|
||||||
|
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceClaim, ok := claims["nonce"].(string)
|
||||||
|
sessionNonce, ok2 := session.Values["nonce"].(string)
|
||||||
|
if !ok || !ok2 || nonceClaim == "" || sessionNonce == "" || nonceClaim != sessionNonce {
|
||||||
|
t.logger.Error("Invalid nonce")
|
||||||
|
http.Error(rw, "Authentication failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email, _ := claims["email"].(string)
|
||||||
|
if email == "" || !t.isAllowedDomain(email) {
|
||||||
|
t.logger.Errorf("Invalid or disallowed email: %s", email)
|
||||||
|
http.Error(rw, "Authentication failed: Invalid or disallowed email", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Values["authenticated"] = true
|
||||||
|
session.Values["email"] = email
|
||||||
|
session.Values["id_token"] = idToken
|
||||||
|
session.Values["refresh_token"] = tokenResponse.RefreshToken
|
||||||
|
session.Options = &sessions.Options{MaxAge: 3600, Path: "/", HttpOnly: true, Secure: true}
|
||||||
|
|
||||||
|
delete(session.Values, "csrf")
|
||||||
|
delete(session.Values, "nonce")
|
||||||
|
|
||||||
|
if err := session.Save(req, rw); err != nil {
|
||||||
|
t.logger.Errorf("Failed to save session: %v", err)
|
||||||
|
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.logger.Debugf("Authentication successful. User email: %s", email)
|
||||||
|
|
||||||
|
redirectPath := "/"
|
||||||
|
if path, ok := session.Values["incoming_path"].(string); ok && path != t.redirURLPath {
|
||||||
|
t.logger.Debugf("Redirecting to incoming path from original request: %s", path)
|
||||||
|
redirectPath = path
|
||||||
|
}
|
||||||
|
http.Redirect(rw, req, redirectPath, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractClaims extracts claims from a JWT token
|
||||||
func extractClaims(tokenString string) (map[string]interface{}, error) {
|
func extractClaims(tokenString string) (map[string]interface{}, error) {
|
||||||
parts := strings.Split(tokenString, ".")
|
parts := strings.Split(tokenString, ".")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
@@ -219,47 +280,56 @@ func extractClaims(tokenString string) (map[string]interface{}, error) {
|
|||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenCache provides a caching mechanism for validated tokens.
|
// TokenBlacklist maintains a blacklist of tokens
|
||||||
// It stores token claims to avoid repeated validation of the
|
type TokenBlacklist struct {
|
||||||
// same token, improving performance for frequently used tokens.
|
blacklist sync.Map
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTokenBlacklist creates a new TokenBlacklist
|
||||||
|
func NewTokenBlacklist() *TokenBlacklist {
|
||||||
|
return &TokenBlacklist{}
|
||||||
|
}
|
||||||
|
func (tb *TokenBlacklist) Add(token string, expiration time.Time) {
|
||||||
|
tb.blacklist.Store(token, expiration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *TokenBlacklist) IsBlacklisted(token string) bool {
|
||||||
|
if exp, ok := tb.blacklist.Load(token); ok {
|
||||||
|
return time.Now().Before(exp.(time.Time))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *TokenBlacklist) Cleanup() {
|
||||||
|
now := time.Now()
|
||||||
|
tb.blacklist.Range(func(key, value interface{}) bool {
|
||||||
|
if now.After(value.(time.Time)) {
|
||||||
|
tb.blacklist.Delete(key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenCache caches tokens
|
||||||
type TokenCache struct {
|
type TokenCache struct {
|
||||||
// cache is the underlying cache implementation
|
|
||||||
cache *Cache
|
cache *Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTokenCache creates and initializes a new TokenCache.
|
// NewTokenCache creates a new TokenCache
|
||||||
// It internally creates a new generic Cache instance for storage.
|
|
||||||
func NewTokenCache() *TokenCache {
|
func NewTokenCache() *TokenCache {
|
||||||
return &TokenCache{
|
return &TokenCache{
|
||||||
cache: NewCache(),
|
cache: NewCache(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set stores the claims associated with a specific token string in the cache.
|
// Set sets a token in the cache
|
||||||
// It prefixes the token string to avoid potential collisions with other cache types
|
|
||||||
// and sets the provided expiration duration.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - token: The raw token string (used as the key).
|
|
||||||
// - claims: The map of claims associated with the token.
|
|
||||||
// - expiration: The duration for which the cache entry should be valid.
|
|
||||||
func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
|
func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
|
||||||
token = "t-" + token
|
tc.cache.Set("t-"+token, claims, expiration)
|
||||||
tc.cache.Set(token, claims, expiration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves the cached claims for a given token string.
|
// Get retrieves a token from the cache
|
||||||
// It prefixes the token string before querying the underlying cache.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - token: The raw token string to look up.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - The cached claims map if found and valid.
|
|
||||||
// - A boolean indicating whether the token was found in the cache (true if found, false otherwise).
|
|
||||||
func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
|
func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
|
||||||
token = "t-" + token
|
value, found := tc.cache.Get("t-" + token)
|
||||||
value, found := tc.cache.Get(token)
|
|
||||||
if !found {
|
if !found {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -267,148 +337,31 @@ func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
|
|||||||
return claims, ok
|
return claims, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes the cached entry for a specific token string.
|
// Delete removes a token from the cache
|
||||||
// It prefixes the token string before calling the underlying cache's Delete method.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - token: The raw token string to remove from the cache.
|
|
||||||
func (tc *TokenCache) Delete(token string) {
|
func (tc *TokenCache) Delete(token string) {
|
||||||
token = "t-" + token
|
tc.cache.Delete("t-" + token)
|
||||||
tc.cache.Delete(token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup triggers the cleanup process for the underlying generic cache,
|
// Cleanup cleans up expired tokens from the cache
|
||||||
// removing expired token entries.
|
|
||||||
func (tc *TokenCache) Cleanup() {
|
func (tc *TokenCache) Cleanup() {
|
||||||
tc.cache.Cleanup()
|
tc.cache.Cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close stops the cleanup goroutine in the underlying cache.
|
// exchangeCodeForToken exchanges the authorization code for tokens
|
||||||
func (tc *TokenCache) Close() {
|
func (t *TraefikOidc) exchangeCodeForToken(code string) (*TokenResponse, error) {
|
||||||
tc.cache.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// exchangeCodeForToken is a convenience function that wraps exchangeTokens specifically
|
|
||||||
// for the "authorization_code" grant type. It handles the conditional inclusion of the
|
|
||||||
// PKCE code verifier based on the middleware's configuration (t.enablePKCE).
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - code: The authorization code received from the OIDC provider.
|
|
||||||
// - redirectURL: The redirect URI used in the initial authorization request.
|
|
||||||
// - codeVerifier: The PKCE code verifier stored in the session (if PKCE is enabled).
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A TokenResponse containing the obtained tokens.
|
|
||||||
// - An error if the code exchange fails.
|
|
||||||
func (t *TraefikOidc) exchangeCodeForToken(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
tokenResponse, err := t.exchangeTokens(ctx, "authorization_code", code, t.redirectURL)
|
||||||
// Only include code verifier if PKCE is enabled
|
|
||||||
effectiveCodeVerifier := ""
|
|
||||||
if t.enablePKCE && codeVerifier != "" {
|
|
||||||
effectiveCodeVerifier = codeVerifier
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenResponse, err := t.exchangeTokens(ctx, "authorization_code", code, redirectURL, effectiveCodeVerifier)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
|
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
|
||||||
}
|
}
|
||||||
return tokenResponse, nil
|
return tokenResponse, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createStringMap converts a slice of strings into a map[string]struct{} (a set).
|
// createStringMap creates a map from a slice of strings
|
||||||
// This is useful for creating efficient lookups (O(1) average time complexity)
|
|
||||||
// for checking the presence of items like allowed domains, roles, or groups.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - keys: A slice of strings to be added to the set.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A map where the keys are the strings from the input slice and the values are empty structs.
|
|
||||||
func createStringMap(keys []string) map[string]struct{} {
|
func createStringMap(keys []string) map[string]struct{} {
|
||||||
result := make(map[string]struct{})
|
result := make(map[string]struct{}, len(keys))
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
result[key] = struct{}{}
|
result[key] = struct{}{}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleLogout processes requests to the configured logout path.
|
|
||||||
// It performs the following steps:
|
|
||||||
// 1. Retrieves the current user session.
|
|
||||||
// 2. Gets the access token (ID token hint) from the session.
|
|
||||||
// 3. Clears all authentication-related data from the session cookies.
|
|
||||||
// 4. Determines the final post-logout redirect URI.
|
|
||||||
// 5. If an OIDC end_session_endpoint is configured and an ID token hint is available,
|
|
||||||
// it builds the OIDC logout URL and redirects the user agent to the provider for logout.
|
|
||||||
// 6. Otherwise, it redirects the user agent directly to the post-logout redirect URI.
|
|
||||||
//
|
|
||||||
// It handles potential errors during session retrieval or clearing.
|
|
||||||
func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
|
|
||||||
session, err := t.sessionManager.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.logger.Errorf("Error getting session: %v", err)
|
|
||||||
http.Error(rw, "Session error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken := session.GetAccessToken()
|
|
||||||
|
|
||||||
if err := session.Clear(req, rw); err != nil {
|
|
||||||
t.logger.Errorf("Error clearing session: %v", err)
|
|
||||||
http.Error(rw, "Session error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
host := t.determineHost(req)
|
|
||||||
scheme := t.determineScheme(req)
|
|
||||||
baseURL := fmt.Sprintf("%s://%s", scheme, host)
|
|
||||||
|
|
||||||
postLogoutRedirectURI := t.postLogoutRedirectURI
|
|
||||||
if postLogoutRedirectURI == "" {
|
|
||||||
postLogoutRedirectURI = fmt.Sprintf("%s/", baseURL)
|
|
||||||
} else if !strings.HasPrefix(postLogoutRedirectURI, "http") {
|
|
||||||
postLogoutRedirectURI = fmt.Sprintf("%s%s", baseURL, postLogoutRedirectURI)
|
|
||||||
}
|
|
||||||
|
|
||||||
if t.endSessionURL != "" && accessToken != "" {
|
|
||||||
logoutURL, err := BuildLogoutURL(t.endSessionURL, accessToken, postLogoutRedirectURI)
|
|
||||||
if err != nil {
|
|
||||||
t.logger.Errorf("Failed to build logout URL: %v", err)
|
|
||||||
http.Error(rw, "Logout error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(rw, req, logoutURL, http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(rw, req, postLogoutRedirectURI, http.StatusFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildLogoutURL constructs the URL for redirecting the user agent to the OIDC provider's
|
|
||||||
// end_session_endpoint, including the required id_token_hint and optional
|
|
||||||
// post_logout_redirect_uri parameters as query arguments.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - endSessionURL: The URL of the OIDC provider's end session endpoint.
|
|
||||||
// - idToken: The ID token previously issued to the user (used as id_token_hint).
|
|
||||||
// - postLogoutRedirectURI: The optional URI where the provider should redirect the user agent after logout.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - The fully constructed logout URL string.
|
|
||||||
// - An error if the provided endSessionURL is invalid.
|
|
||||||
func BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI string) (string, error) {
|
|
||||||
u, err := url.Parse(endSessionURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse end session URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
q := u.Query()
|
|
||||||
q.Set("id_token_hint", idToken)
|
|
||||||
if postLogoutRedirectURI != "" {
|
|
||||||
q.Set("post_logout_redirect_uri", postLogoutRedirectURI)
|
|
||||||
}
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
|
|
||||||
return u.String(), nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
)
|
|
||||||
|
|
||||||
// generateRandomString generates a random string of the specified length
|
|
||||||
// This is used in tests to create unique identifiers
|
|
||||||
func generateRandomString(length int) string {
|
|
||||||
bytes := make([]byte, length/2)
|
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
|
||||||
// In tests, fallback to a predictable string if random fails
|
|
||||||
return "random-string-fallback"
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(bytes)
|
|
||||||
}
|
|
||||||
@@ -1,657 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
|
||||||
|
|
||||||
// InputValidator provides comprehensive input validation and sanitization
|
|
||||||
type InputValidator struct {
|
|
||||||
// Configuration
|
|
||||||
maxTokenLength int
|
|
||||||
maxURLLength int
|
|
||||||
maxHeaderLength int
|
|
||||||
maxClaimLength int
|
|
||||||
maxEmailLength int
|
|
||||||
maxUsernameLength int
|
|
||||||
|
|
||||||
// Compiled regex patterns
|
|
||||||
emailRegex *regexp.Regexp
|
|
||||||
urlRegex *regexp.Regexp
|
|
||||||
tokenRegex *regexp.Regexp
|
|
||||||
usernameRegex *regexp.Regexp
|
|
||||||
|
|
||||||
// Security patterns to detect
|
|
||||||
sqlInjectionPatterns []string
|
|
||||||
xssPatterns []string
|
|
||||||
pathTraversalPatterns []string
|
|
||||||
|
|
||||||
logger *Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidationResult represents the result of input validation
|
|
||||||
type ValidationResult struct {
|
|
||||||
IsValid bool `json:"is_valid"`
|
|
||||||
Errors []string `json:"errors,omitempty"`
|
|
||||||
Warnings []string `json:"warnings,omitempty"`
|
|
||||||
SanitizedValue string `json:"sanitized_value,omitempty"`
|
|
||||||
SecurityRisk string `json:"security_risk,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// InputValidationConfig holds configuration for input validation
|
|
||||||
type InputValidationConfig struct {
|
|
||||||
MaxTokenLength int `json:"max_token_length"`
|
|
||||||
MaxURLLength int `json:"max_url_length"`
|
|
||||||
MaxHeaderLength int `json:"max_header_length"`
|
|
||||||
MaxClaimLength int `json:"max_claim_length"`
|
|
||||||
MaxEmailLength int `json:"max_email_length"`
|
|
||||||
MaxUsernameLength int `json:"max_username_length"`
|
|
||||||
StrictMode bool `json:"strict_mode"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultInputValidationConfig returns default validation configuration
|
|
||||||
func DefaultInputValidationConfig() InputValidationConfig {
|
|
||||||
return InputValidationConfig{
|
|
||||||
MaxTokenLength: 50000, // 50KB for tokens
|
|
||||||
MaxURLLength: 2048, // Standard URL length limit
|
|
||||||
MaxHeaderLength: 8192, // 8KB for headers
|
|
||||||
MaxClaimLength: 1024, // 1KB for individual claims
|
|
||||||
MaxEmailLength: 254, // RFC 5321 limit
|
|
||||||
MaxUsernameLength: 64, // Reasonable username limit
|
|
||||||
StrictMode: true, // Enable strict validation by default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewInputValidator creates a new input validator with the given configuration
|
|
||||||
func NewInputValidator(config InputValidationConfig, logger *Logger) (*InputValidator, error) {
|
|
||||||
// Compile regex patterns
|
|
||||||
emailRegex, err := regexp.Compile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to compile email regex: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
urlRegex, err := regexp.Compile(`^https?://[a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,})?(?::[0-9]+)?(?:/[^\s]*)?$`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to compile URL regex: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenRegex, err := regexp.Compile(`^[A-Za-z0-9._-]+$`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to compile token regex: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
usernameRegex, err := regexp.Compile(`^[a-zA-Z0-9._-]+$`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to compile username regex: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &InputValidator{
|
|
||||||
maxTokenLength: config.MaxTokenLength,
|
|
||||||
maxURLLength: config.MaxURLLength,
|
|
||||||
maxHeaderLength: config.MaxHeaderLength,
|
|
||||||
maxClaimLength: config.MaxClaimLength,
|
|
||||||
maxEmailLength: config.MaxEmailLength,
|
|
||||||
maxUsernameLength: config.MaxUsernameLength,
|
|
||||||
emailRegex: emailRegex,
|
|
||||||
urlRegex: urlRegex,
|
|
||||||
tokenRegex: tokenRegex,
|
|
||||||
usernameRegex: usernameRegex,
|
|
||||||
sqlInjectionPatterns: []string{
|
|
||||||
"'", "\"", ";", "--", "/*", "*/", "xp_", "sp_",
|
|
||||||
"union", "select", "insert", "update", "delete", "drop",
|
|
||||||
"create", "alter", "exec", "execute", "script",
|
|
||||||
},
|
|
||||||
xssPatterns: []string{
|
|
||||||
"<script", "</script>", "javascript:", "vbscript:",
|
|
||||||
"onload=", "onerror=", "onclick=", "onmouseover=",
|
|
||||||
"<iframe", "<object", "<embed", "<link", "<meta",
|
|
||||||
},
|
|
||||||
pathTraversalPatterns: []string{
|
|
||||||
"../", "..\\", "%2e%2e%2f", "%2e%2e%5c",
|
|
||||||
"..%2f", "..%5c", "%252e%252e%252f",
|
|
||||||
},
|
|
||||||
logger: logger,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateToken validates JWT tokens and similar token strings
|
|
||||||
func (iv *InputValidator) ValidateToken(token string) ValidationResult {
|
|
||||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
|
||||||
|
|
||||||
// Check for empty token
|
|
||||||
if token == "" {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "token cannot be empty")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check length limits
|
|
||||||
if len(token) > iv.maxTokenLength {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("token length %d exceeds maximum %d", len(token), iv.maxTokenLength))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for minimum reasonable length
|
|
||||||
if len(token) < 10 {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "token is too short to be valid")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for valid JWT structure (3 parts separated by dots)
|
|
||||||
parts := strings.Split(token, ".")
|
|
||||||
if len(parts) != 3 {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "token does not have valid JWT structure (expected 3 parts)")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate each part is base64url encoded
|
|
||||||
for i, part := range parts {
|
|
||||||
if !iv.isValidBase64URL(part) {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("token part %d is not valid base64url", i+1))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for suspicious patterns
|
|
||||||
if risk := iv.detectSecurityRisk(token); risk != "" {
|
|
||||||
result.SecurityRisk = risk
|
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for null bytes and control characters
|
|
||||||
if iv.containsNullBytes(token) {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "token contains null bytes")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if iv.containsControlCharacters(token) {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "token contains control characters")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate UTF-8 encoding
|
|
||||||
if !utf8.ValidString(token) {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "token contains invalid UTF-8 sequences")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
result.SanitizedValue = token
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateEmail validates email addresses
|
|
||||||
func (iv *InputValidator) ValidateEmail(email string) ValidationResult {
|
|
||||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
|
||||||
|
|
||||||
// Check for empty email
|
|
||||||
if email == "" {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "email cannot be empty")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check length limits
|
|
||||||
if len(email) > iv.maxEmailLength {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("email length %d exceeds maximum %d", len(email), iv.maxEmailLength))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize email (trim whitespace, convert to lowercase)
|
|
||||||
sanitized := strings.TrimSpace(strings.ToLower(email))
|
|
||||||
|
|
||||||
// Check regex pattern
|
|
||||||
if !iv.emailRegex.MatchString(sanitized) {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "email format is invalid")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for suspicious patterns
|
|
||||||
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
|
|
||||||
result.SecurityRisk = risk
|
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional email-specific validations
|
|
||||||
parts := strings.Split(sanitized, "@")
|
|
||||||
if len(parts) != 2 {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "email must contain exactly one @ symbol")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
localPart, domain := parts[0], parts[1]
|
|
||||||
|
|
||||||
// Validate local part
|
|
||||||
if len(localPart) == 0 || len(localPart) > 64 {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "email local part length is invalid")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate domain
|
|
||||||
if len(domain) == 0 || len(domain) > 253 {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "email domain length is invalid")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for consecutive dots
|
|
||||||
if strings.Contains(sanitized, "..") {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "email contains consecutive dots")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
result.SanitizedValue = sanitized
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateURL validates URLs
|
|
||||||
func (iv *InputValidator) ValidateURL(urlStr string) ValidationResult {
|
|
||||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
|
||||||
|
|
||||||
// Check for empty URL
|
|
||||||
if urlStr == "" {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "URL cannot be empty")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check length limits
|
|
||||||
if len(urlStr) > iv.maxURLLength {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("URL length %d exceeds maximum %d", len(urlStr), iv.maxURLLength))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize URL (trim whitespace)
|
|
||||||
sanitized := strings.TrimSpace(urlStr)
|
|
||||||
|
|
||||||
// Parse URL
|
|
||||||
parsedURL, err := url.Parse(sanitized)
|
|
||||||
if err != nil {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("URL parsing failed: %v", err))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check scheme
|
|
||||||
if parsedURL.Scheme != "https" && parsedURL.Scheme != "http" {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "URL scheme must be http or https")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer HTTPS
|
|
||||||
if parsedURL.Scheme == "http" {
|
|
||||||
result.Warnings = append(result.Warnings, "HTTP URLs are less secure than HTTPS")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check host
|
|
||||||
if parsedURL.Host == "" {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "URL must have a valid host")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for suspicious patterns
|
|
||||||
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
|
|
||||||
result.SecurityRisk = risk
|
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for path traversal attempts
|
|
||||||
if iv.containsPathTraversal(sanitized) {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "URL contains path traversal patterns")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
result.SanitizedValue = sanitized
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateUsername validates usernames
|
|
||||||
func (iv *InputValidator) ValidateUsername(username string) ValidationResult {
|
|
||||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
|
||||||
|
|
||||||
// Check for empty username
|
|
||||||
if username == "" {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "username cannot be empty")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check length limits
|
|
||||||
if len(username) > iv.maxUsernameLength {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("username length %d exceeds maximum %d", len(username), iv.maxUsernameLength))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check minimum length
|
|
||||||
if len(username) < 2 {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "username must be at least 2 characters long")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize username (trim whitespace)
|
|
||||||
sanitized := strings.TrimSpace(username)
|
|
||||||
|
|
||||||
// Check regex pattern
|
|
||||||
if !iv.usernameRegex.MatchString(sanitized) {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "username contains invalid characters (only letters, numbers, dots, underscores, and hyphens allowed)")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for suspicious patterns
|
|
||||||
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
|
|
||||||
result.SecurityRisk = risk
|
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
|
|
||||||
}
|
|
||||||
|
|
||||||
result.SanitizedValue = sanitized
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateClaim validates individual JWT claims
|
|
||||||
func (iv *InputValidator) ValidateClaim(claimName, claimValue string) ValidationResult {
|
|
||||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
|
||||||
|
|
||||||
// Check claim name
|
|
||||||
if claimName == "" {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "claim name cannot be empty")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check claim value length
|
|
||||||
if len(claimValue) > iv.maxClaimLength {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("claim value length %d exceeds maximum %d", len(claimValue), iv.maxClaimLength))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for null bytes and control characters
|
|
||||||
if iv.containsNullBytes(claimValue) {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "claim value contains null bytes")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if iv.containsControlCharacters(claimValue) {
|
|
||||||
result.Warnings = append(result.Warnings, "claim value contains control characters")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate UTF-8 encoding
|
|
||||||
if !utf8.ValidString(claimValue) {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "claim value contains invalid UTF-8 sequences")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for suspicious patterns
|
|
||||||
if risk := iv.detectSecurityRisk(claimValue); risk != "" {
|
|
||||||
result.SecurityRisk = risk
|
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Specific validations based on claim name
|
|
||||||
switch claimName {
|
|
||||||
case "email":
|
|
||||||
emailResult := iv.ValidateEmail(claimValue)
|
|
||||||
if !emailResult.IsValid {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, emailResult.Errors...)
|
|
||||||
}
|
|
||||||
result.Warnings = append(result.Warnings, emailResult.Warnings...)
|
|
||||||
result.SanitizedValue = emailResult.SanitizedValue
|
|
||||||
|
|
||||||
case "iss", "aud":
|
|
||||||
urlResult := iv.ValidateURL(claimValue)
|
|
||||||
if !urlResult.IsValid {
|
|
||||||
// For issuer/audience, we're more lenient - just warn
|
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("%s claim is not a valid URL: %v", claimName, urlResult.Errors))
|
|
||||||
}
|
|
||||||
result.SanitizedValue = claimValue
|
|
||||||
|
|
||||||
case "preferred_username", "username":
|
|
||||||
usernameResult := iv.ValidateUsername(claimValue)
|
|
||||||
if !usernameResult.IsValid {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, usernameResult.Errors...)
|
|
||||||
}
|
|
||||||
result.Warnings = append(result.Warnings, usernameResult.Warnings...)
|
|
||||||
result.SanitizedValue = usernameResult.SanitizedValue
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Generic string validation
|
|
||||||
result.SanitizedValue = strings.TrimSpace(claimValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateHeader validates HTTP header values
|
|
||||||
func (iv *InputValidator) ValidateHeader(headerName, headerValue string) ValidationResult {
|
|
||||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
|
||||||
|
|
||||||
// Check header name
|
|
||||||
if headerName == "" {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "header name cannot be empty")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for control characters in header name (including CRLF)
|
|
||||||
if iv.containsControlCharacters(headerName) {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "header name contains control characters")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for CRLF injection in header name
|
|
||||||
if strings.Contains(headerName, "\r") || strings.Contains(headerName, "\n") {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "header name contains CRLF characters (potential header injection)")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check header value length
|
|
||||||
if len(headerValue) > iv.maxHeaderLength {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("header value length %d exceeds maximum %d", len(headerValue), iv.maxHeaderLength))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for null bytes and control characters (except allowed ones)
|
|
||||||
if iv.containsNullBytes(headerValue) {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "header value contains null bytes")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for CRLF injection
|
|
||||||
if strings.Contains(headerValue, "\r") || strings.Contains(headerValue, "\n") {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "header value contains CRLF characters (potential header injection)")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate UTF-8 encoding
|
|
||||||
if !utf8.ValidString(headerValue) {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "header value contains invalid UTF-8 sequences")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for suspicious patterns
|
|
||||||
if risk := iv.detectSecurityRisk(headerValue); risk != "" {
|
|
||||||
result.SecurityRisk = risk
|
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
|
|
||||||
}
|
|
||||||
|
|
||||||
result.SanitizedValue = strings.TrimSpace(headerValue)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// isValidBase64URL checks if a string is valid base64url encoding
|
|
||||||
func (iv *InputValidator) isValidBase64URL(s string) bool {
|
|
||||||
// Base64url uses A-Z, a-z, 0-9, -, _ and no padding
|
|
||||||
for _, r := range s {
|
|
||||||
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') ||
|
|
||||||
(r >= '0' && r <= '9') || r == '-' || r == '_') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// containsNullBytes checks if a string contains null bytes
|
|
||||||
func (iv *InputValidator) containsNullBytes(s string) bool {
|
|
||||||
return strings.Contains(s, "\x00")
|
|
||||||
}
|
|
||||||
|
|
||||||
// containsControlCharacters checks if a string contains control characters
|
|
||||||
func (iv *InputValidator) containsControlCharacters(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r' {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// containsPathTraversal checks for path traversal patterns
|
|
||||||
func (iv *InputValidator) containsPathTraversal(s string) bool {
|
|
||||||
lowerS := strings.ToLower(s)
|
|
||||||
for _, pattern := range iv.pathTraversalPatterns {
|
|
||||||
if strings.Contains(lowerS, pattern) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectSecurityRisk detects potential security risks in input
|
|
||||||
func (iv *InputValidator) detectSecurityRisk(input string) string {
|
|
||||||
lowerInput := strings.ToLower(input)
|
|
||||||
|
|
||||||
// Check for SQL injection patterns
|
|
||||||
for _, pattern := range iv.sqlInjectionPatterns {
|
|
||||||
if strings.Contains(lowerInput, pattern) {
|
|
||||||
return "sql_injection"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for XSS patterns
|
|
||||||
for _, pattern := range iv.xssPatterns {
|
|
||||||
if strings.Contains(lowerInput, pattern) {
|
|
||||||
return "xss"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for path traversal
|
|
||||||
if iv.containsPathTraversal(input) {
|
|
||||||
return "path_traversal"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for excessive length (potential DoS)
|
|
||||||
if len(input) > 10000 {
|
|
||||||
return "excessive_length"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for suspicious character patterns
|
|
||||||
if iv.containsNullBytes(input) {
|
|
||||||
return "null_bytes"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for binary data patterns
|
|
||||||
nonPrintableCount := 0
|
|
||||||
for _, r := range input {
|
|
||||||
if !unicode.IsPrint(r) && !unicode.IsSpace(r) {
|
|
||||||
nonPrintableCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if nonPrintableCount > len(input)/10 { // More than 10% non-printable
|
|
||||||
return "binary_data"
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// SanitizeInput provides general input sanitization
|
|
||||||
func (iv *InputValidator) SanitizeInput(input string, maxLength int) string {
|
|
||||||
// Trim whitespace
|
|
||||||
sanitized := strings.TrimSpace(input)
|
|
||||||
|
|
||||||
// Truncate if too long
|
|
||||||
if len(sanitized) > maxLength {
|
|
||||||
sanitized = sanitized[:maxLength]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove null bytes
|
|
||||||
sanitized = strings.ReplaceAll(sanitized, "\x00", "")
|
|
||||||
|
|
||||||
// Remove other control characters except tab, newline, carriage return
|
|
||||||
var result strings.Builder
|
|
||||||
for _, r := range sanitized {
|
|
||||||
if !unicode.IsControl(r) || r == '\t' || r == '\n' || r == '\r' {
|
|
||||||
result.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateBoundaryValues validates numeric boundary values
|
|
||||||
func (iv *InputValidator) ValidateBoundaryValues(value interface{}, min, max int64) ValidationResult {
|
|
||||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
|
||||||
|
|
||||||
var numValue int64
|
|
||||||
|
|
||||||
switch v := value.(type) {
|
|
||||||
case int:
|
|
||||||
numValue = int64(v)
|
|
||||||
case int32:
|
|
||||||
numValue = int64(v)
|
|
||||||
case int64:
|
|
||||||
numValue = v
|
|
||||||
case float64:
|
|
||||||
numValue = int64(v)
|
|
||||||
if float64(numValue) != v {
|
|
||||||
result.Warnings = append(result.Warnings, "floating point value truncated to integer")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, "value is not a numeric type")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if numValue < min {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("value %d is below minimum %d", numValue, min))
|
|
||||||
}
|
|
||||||
|
|
||||||
if numValue > max {
|
|
||||||
result.IsValid = false
|
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("value %d exceeds maximum %d", numValue, max))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestInputValidator(t *testing.T) {
|
|
||||||
config := DefaultInputValidationConfig()
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
validator, err := NewInputValidator(config, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create validator: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("Valid token validation", func(t *testing.T) {
|
|
||||||
validToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8jO19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHs3UjpMC6M6FNqI2J-I2NxrragtnDxGxdJUvDERDQVHzeNlVQiuqWDEeO_O-0KptafbfyuGqfQxH_6dp2_MeFpAc"
|
|
||||||
|
|
||||||
result := validator.ValidateToken(validToken)
|
|
||||||
if !result.IsValid {
|
|
||||||
t.Errorf("Expected valid token to pass validation, got errors: %v", result.Errors)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Invalid token validation", func(t *testing.T) {
|
|
||||||
invalidTokens := []string{
|
|
||||||
"", // Empty token
|
|
||||||
"invalid.token", // Invalid format
|
|
||||||
"a.b", // Too few parts
|
|
||||||
"a.b.c.d", // Too many parts
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, token := range invalidTokens {
|
|
||||||
result := validator.ValidateToken(token)
|
|
||||||
if result.IsValid {
|
|
||||||
t.Errorf("Expected invalid token '%s' to fail validation", token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Valid email validation", func(t *testing.T) {
|
|
||||||
validEmails := []string{
|
|
||||||
"[email protected]",
|
|
||||||
"[email protected]",
|
|
||||||
"[email protected]",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, email := range validEmails {
|
|
||||||
result := validator.ValidateEmail(email)
|
|
||||||
if !result.IsValid {
|
|
||||||
t.Errorf("Expected valid email '%s' to pass validation, got errors: %v", email, result.Errors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Invalid email validation", func(t *testing.T) {
|
|
||||||
invalidEmails := []string{
|
|
||||||
"", // Empty
|
|
||||||
"invalid", // No @ symbol
|
|
||||||
"@domain.com", // No local part
|
|
||||||
"user@", // No domain
|
|
||||||
"user@domain", // No TLD
|
|
||||||
"[email protected]", // Double dots
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, email := range invalidEmails {
|
|
||||||
result := validator.ValidateEmail(email)
|
|
||||||
if result.IsValid {
|
|
||||||
t.Errorf("Expected invalid email '%s' to fail validation", email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Valid URL validation", func(t *testing.T) {
|
|
||||||
validURLs := []string{
|
|
||||||
"https://example.com",
|
|
||||||
"https://sub.domain.com/path",
|
|
||||||
"https://localhost:8080/callback",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, url := range validURLs {
|
|
||||||
result := validator.ValidateURL(url)
|
|
||||||
if !result.IsValid {
|
|
||||||
t.Errorf("Expected valid URL '%s' to pass validation, got errors: %v", url, result.Errors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Invalid URL validation", func(t *testing.T) {
|
|
||||||
invalidURLs := []string{
|
|
||||||
"", // Empty
|
|
||||||
"not-a-url", // Invalid format
|
|
||||||
"ftp://example.com", // Wrong scheme
|
|
||||||
"https://", // No host
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, url := range invalidURLs {
|
|
||||||
result := validator.ValidateURL(url)
|
|
||||||
if result.IsValid {
|
|
||||||
t.Errorf("Expected invalid URL '%s' to fail validation", url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Valid username validation", func(t *testing.T) {
|
|
||||||
validUsernames := []string{
|
|
||||||
"user123",
|
|
||||||
"test_user",
|
|
||||||
"user-name",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, username := range validUsernames {
|
|
||||||
result := validator.ValidateUsername(username)
|
|
||||||
if !result.IsValid {
|
|
||||||
t.Errorf("Expected valid username '%s' to pass validation, got errors: %v", username, result.Errors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Invalid username validation", func(t *testing.T) {
|
|
||||||
invalidUsernames := []string{
|
|
||||||
"", // Empty
|
|
||||||
"a", // Too short
|
|
||||||
strings.Repeat("a", 100), // Too long
|
|
||||||
"user name", // Spaces
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, username := range invalidUsernames {
|
|
||||||
result := validator.ValidateUsername(username)
|
|
||||||
if result.IsValid {
|
|
||||||
t.Errorf("Expected invalid username '%s' to fail validation", username)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Valid claim validation", func(t *testing.T) {
|
|
||||||
validClaims := map[string]string{
|
|
||||||
"sub": "user123",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"name": "John Doe",
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, value := range validClaims {
|
|
||||||
result := validator.ValidateClaim(key, value)
|
|
||||||
if !result.IsValid {
|
|
||||||
t.Errorf("Expected valid claim '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Invalid claim validation", func(t *testing.T) {
|
|
||||||
invalidClaims := map[string]string{
|
|
||||||
"": "value", // Empty key
|
|
||||||
"long_key": strings.Repeat("a", 10000), // Too long value
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, value := range invalidClaims {
|
|
||||||
result := validator.ValidateClaim(key, value)
|
|
||||||
if result.IsValid {
|
|
||||||
t.Errorf("Expected invalid claim '%s'='%s' to fail validation", key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Valid header validation", func(t *testing.T) {
|
|
||||||
validHeaders := map[string]string{
|
|
||||||
"Authorization": "Bearer token123",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Custom": "custom-value",
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, value := range validHeaders {
|
|
||||||
result := validator.ValidateHeader(key, value)
|
|
||||||
if !result.IsValid {
|
|
||||||
t.Errorf("Expected valid header '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Invalid header validation", func(t *testing.T) {
|
|
||||||
invalidHeaders := map[string]string{
|
|
||||||
"": "value", // Empty key
|
|
||||||
"Invalid\nKey": "value", // Control characters in key
|
|
||||||
"key": "value\r\n", // Control characters in value
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, value := range invalidHeaders {
|
|
||||||
result := validator.ValidateHeader(key, value)
|
|
||||||
if result.IsValid {
|
|
||||||
t.Errorf("Expected invalid header '%s'='%s' to fail validation", key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizeInput(t *testing.T) {
|
|
||||||
config := DefaultInputValidationConfig()
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
validator, err := NewInputValidator(config, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create validator: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
maxLen int
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Normal text",
|
|
||||||
input: "Hello World",
|
|
||||||
maxLen: 100,
|
|
||||||
expected: "Hello World",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Control characters",
|
|
||||||
input: "text\x00with\x01control\x02chars",
|
|
||||||
maxLen: 100,
|
|
||||||
expected: "textwithcontrolchars",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Truncation",
|
|
||||||
input: "very long text",
|
|
||||||
maxLen: 5,
|
|
||||||
expected: "very ",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := validator.SanitizeInput(tt.input, tt.maxLen)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("Expected sanitized input '%s', got '%s'", tt.expected, result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateBoundaryValues(t *testing.T) {
|
|
||||||
config := DefaultInputValidationConfig()
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
validator, err := NewInputValidator(config, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create validator: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("Valid boundary values", func(t *testing.T) {
|
|
||||||
validValues := []interface{}{
|
|
||||||
int(50),
|
|
||||||
int64(100),
|
|
||||||
float64(75.5),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, value := range validValues {
|
|
||||||
result := validator.ValidateBoundaryValues(value, 1, 1000)
|
|
||||||
if !result.IsValid {
|
|
||||||
t.Errorf("Expected valid boundary value %v to pass validation, got errors: %v", value, result.Errors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Invalid boundary values", func(t *testing.T) {
|
|
||||||
invalidValues := []interface{}{
|
|
||||||
int(-1),
|
|
||||||
int64(2000),
|
|
||||||
"not a number",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, value := range invalidValues {
|
|
||||||
result := validator.ValidateBoundaryValues(value, 1, 1000)
|
|
||||||
if result.IsValid {
|
|
||||||
t.Errorf("Expected invalid boundary value %v to fail validation", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultInputValidationConfig(t *testing.T) {
|
|
||||||
config := DefaultInputValidationConfig()
|
|
||||||
|
|
||||||
if config.MaxTokenLength <= 0 {
|
|
||||||
t.Error("Expected positive MaxTokenLength")
|
|
||||||
}
|
|
||||||
if config.MaxEmailLength <= 0 {
|
|
||||||
t.Error("Expected positive MaxEmailLength")
|
|
||||||
}
|
|
||||||
if config.MaxUsernameLength <= 0 {
|
|
||||||
t.Error("Expected positive MaxUsernameLength")
|
|
||||||
}
|
|
||||||
if config.MaxClaimLength <= 0 {
|
|
||||||
t.Error("Expected positive MaxClaimLength")
|
|
||||||
}
|
|
||||||
if config.MaxHeaderLength <= 0 {
|
|
||||||
t.Error("Expected positive MaxHeaderLength")
|
|
||||||
}
|
|
||||||
if !config.StrictMode {
|
|
||||||
t.Error("Expected StrictMode to be true by default")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInputValidationHelpers(t *testing.T) {
|
|
||||||
config := DefaultInputValidationConfig()
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
validator, err := NewInputValidator(config, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create validator: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("isValidBase64URL", func(t *testing.T) {
|
|
||||||
validBase64URL := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
||||||
if !validator.isValidBase64URL(validBase64URL) {
|
|
||||||
t.Error("Expected valid base64url to be recognized")
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidBase64URL := "invalid+base64/with+padding="
|
|
||||||
if validator.isValidBase64URL(invalidBase64URL) {
|
|
||||||
t.Error("Expected invalid base64url to be rejected")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("containsNullBytes", func(t *testing.T) {
|
|
||||||
withNull := "text\x00with\x00null"
|
|
||||||
if !validator.containsNullBytes(withNull) {
|
|
||||||
t.Error("Expected string with null bytes to be detected")
|
|
||||||
}
|
|
||||||
|
|
||||||
withoutNull := "normal text"
|
|
||||||
if validator.containsNullBytes(withoutNull) {
|
|
||||||
t.Error("Expected string without null bytes to pass")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("containsControlCharacters", func(t *testing.T) {
|
|
||||||
withControl := "text\x01with\x02control"
|
|
||||||
if !validator.containsControlCharacters(withControl) {
|
|
||||||
t.Error("Expected string with control characters to be detected")
|
|
||||||
}
|
|
||||||
|
|
||||||
withoutControl := "normal text"
|
|
||||||
if validator.containsControlCharacters(withoutControl) {
|
|
||||||
t.Error("Expected string without control characters to pass")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("containsPathTraversal", func(t *testing.T) {
|
|
||||||
withTraversal := "../../../etc/passwd"
|
|
||||||
if !validator.containsPathTraversal(withTraversal) {
|
|
||||||
t.Error("Expected path traversal to be detected")
|
|
||||||
}
|
|
||||||
|
|
||||||
normalPath := "/normal/path"
|
|
||||||
if validator.containsPathTraversal(normalPath) {
|
|
||||||
t.Error("Expected normal path to pass")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("detectSecurityRisk", func(t *testing.T) {
|
|
||||||
riskyInputs := []string{
|
|
||||||
"<script>alert('xss')</script>",
|
|
||||||
"'; DROP TABLE users; --",
|
|
||||||
"javascript:alert('xss')",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, input := range riskyInputs {
|
|
||||||
if validator.detectSecurityRisk(input) == "" {
|
|
||||||
t.Errorf("Expected security risk to be detected in: %s", input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
safeInput := "normal safe text"
|
|
||||||
if validator.detectSecurityRisk(safeInput) != "" {
|
|
||||||
t.Error("Expected safe input to pass security check")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInputValidationEdgeCases(t *testing.T) {
|
|
||||||
config := DefaultInputValidationConfig()
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
validator, err := NewInputValidator(config, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create validator: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("Empty inputs", func(t *testing.T) {
|
|
||||||
// Most validations should reject empty inputs
|
|
||||||
if result := validator.ValidateToken(""); result.IsValid {
|
|
||||||
t.Error("Expected empty token to be rejected")
|
|
||||||
}
|
|
||||||
if result := validator.ValidateEmail(""); result.IsValid {
|
|
||||||
t.Error("Expected empty email to be rejected")
|
|
||||||
}
|
|
||||||
if result := validator.ValidateURL(""); result.IsValid {
|
|
||||||
t.Error("Expected empty URL to be rejected")
|
|
||||||
}
|
|
||||||
if result := validator.ValidateUsername(""); result.IsValid {
|
|
||||||
t.Error("Expected empty username to be rejected")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Very long inputs", func(t *testing.T) {
|
|
||||||
longString := strings.Repeat("a", 10000)
|
|
||||||
|
|
||||||
if result := validator.ValidateEmail(longString + "@domain.com"); result.IsValid {
|
|
||||||
t.Error("Expected very long email to be rejected")
|
|
||||||
}
|
|
||||||
if result := validator.ValidateUsername(longString); result.IsValid {
|
|
||||||
t.Error("Expected very long username to be rejected")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Unicode handling", func(t *testing.T) {
|
|
||||||
unicodeEmail := "用户@example.com"
|
|
||||||
// Should handle unicode gracefully
|
|
||||||
validator.ValidateEmail(unicodeEmail) // Don't fail on unicode
|
|
||||||
|
|
||||||
unicodeUsername := "用户名"
|
|
||||||
validator.ValidateUsername(unicodeUsername) // Don't fail on unicode
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package traefikoidc
|
package traefikoidc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
@@ -16,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// JWK represents a JSON Web Key
|
||||||
type JWK struct {
|
type JWK struct {
|
||||||
Kty string `json:"kty"`
|
Kty string `json:"kty"`
|
||||||
Kid string `json:"kid"`
|
Kid string `json:"kid"`
|
||||||
@@ -28,151 +28,54 @@ type JWK struct {
|
|||||||
Y string `json:"y"`
|
Y string `json:"y"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JWKSet represents a set of JWKs
|
||||||
type JWKSet struct {
|
type JWKSet struct {
|
||||||
Keys []JWK `json:"keys"`
|
Keys []JWK `json:"keys"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JWKCache caches the JWKs
|
||||||
type JWKCache struct {
|
type JWKCache struct {
|
||||||
jwks *JWKSet
|
jwks *JWKSet
|
||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
// CacheLifetime is configurable to determine how long the JWKS is cached.
|
|
||||||
CacheLifetime time.Duration
|
|
||||||
internalCache *Cache // To hold the closable Cache instance from cache.go
|
|
||||||
maxSize int // Maximum number of items in the cache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JWKCacheInterface defines the interface for the JWK cache
|
||||||
type JWKCacheInterface interface {
|
type JWKCacheInterface interface {
|
||||||
GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error)
|
GetJWKS(jwksURL string, httpClient *http.Client) (*JWKSet, error)
|
||||||
Cleanup()
|
|
||||||
Close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetJWKS retrieves the JSON Web Key Set (JWKS) from the cache or fetches it from the provider.
|
// GetJWKS gets the JWKS, either from cache or by fetching it
|
||||||
// It first checks if a valid, non-expired JWKS is present in the cache. If so, it returns the cached version.
|
func (c *JWKCache) GetJWKS(jwksURL string, httpClient *http.Client) (*JWKSet, error) {
|
||||||
// Otherwise, it attempts to fetch the JWKS from the specified jwksURL using the provided httpClient.
|
|
||||||
// If the fetch is successful, the JWKS is stored in the cache with an expiration time based on CacheLifetime
|
|
||||||
// (defaulting to 1 hour if not set) and returned.
|
|
||||||
// This method uses double-checked locking to minimize contention when the cache needs refreshing.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - ctx: Context for the HTTP request if fetching is required.
|
|
||||||
// - jwksURL: The URL of the OIDC provider's JWKS endpoint.
|
|
||||||
// - httpClient: The HTTP client to use for fetching the JWKS.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A pointer to the JWKSet containing the keys.
|
|
||||||
// - An error if fetching fails or the response cannot be decoded.
|
|
||||||
func NewJWKCache() *JWKCache {
|
|
||||||
cache := &JWKCache{
|
|
||||||
CacheLifetime: 1 * time.Hour,
|
|
||||||
maxSize: 100, // Default maximum size
|
|
||||||
internalCache: NewCache(),
|
|
||||||
}
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *JWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
|
|
||||||
// First check if we already have cached JWKS for this URL
|
|
||||||
if c.internalCache != nil {
|
|
||||||
if cachedJwks, found := c.internalCache.Get(jwksURL); found {
|
|
||||||
return cachedJwks.(*JWKSet), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// STABILITY FIX: Fix race condition in double-checked locking
|
|
||||||
// First read check with read lock
|
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
if c.jwks != nil && time.Now().Before(c.expiresAt) {
|
if c.jwks != nil && time.Now().Before(c.expiresAt) {
|
||||||
jwks := c.jwks // Copy reference while holding read lock
|
defer c.mutex.RUnlock()
|
||||||
c.mutex.RUnlock()
|
return c.jwks, nil
|
||||||
return jwks, nil
|
|
||||||
}
|
}
|
||||||
c.mutex.RUnlock()
|
c.mutex.RUnlock()
|
||||||
|
|
||||||
// Acquire write lock for potential update
|
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
// Second check after acquiring write lock (double-checked locking)
|
// Double-check locking pattern
|
||||||
if c.jwks != nil && time.Now().Before(c.expiresAt) {
|
if c.jwks != nil && time.Now().Before(c.expiresAt) {
|
||||||
return c.jwks, nil
|
return c.jwks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch new JWKS
|
jwks, err := fetchJWKS(jwksURL, httpClient)
|
||||||
jwks, err := fetchJWKS(ctx, jwksURL, httpClient)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// STABILITY FIX: Validate JWKS contains keys before caching
|
|
||||||
if len(jwks.Keys) == 0 {
|
|
||||||
return nil, fmt.Errorf("JWKS response contains no keys")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cache atomically
|
|
||||||
c.jwks = jwks
|
c.jwks = jwks
|
||||||
lifetime := c.CacheLifetime
|
c.expiresAt = time.Now().Add(1 * time.Hour)
|
||||||
if lifetime == 0 {
|
|
||||||
lifetime = 1 * time.Hour
|
|
||||||
}
|
|
||||||
c.expiresAt = time.Now().Add(lifetime)
|
|
||||||
|
|
||||||
// Also store in the internalCache
|
|
||||||
if c.internalCache != nil {
|
|
||||||
c.internalCache.Set(jwksURL, jwks, lifetime)
|
|
||||||
}
|
|
||||||
|
|
||||||
return jwks, nil
|
return jwks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup removes the cached JWKS if it has expired.
|
// fetchJWKS fetches the JWKS from the provider
|
||||||
// This is intended to be called periodically to ensure stale JWKS data is cleared.
|
func fetchJWKS(jwksURL string, httpClient *http.Client) (*JWKSet, error) {
|
||||||
func (c *JWKCache) Cleanup() {
|
resp, err := httpClient.Get(jwksURL)
|
||||||
c.mutex.Lock()
|
|
||||||
defer c.mutex.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
if c.jwks != nil && now.After(c.expiresAt) {
|
|
||||||
c.jwks = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close shuts down the cache's auto-cleanup routine.
|
|
||||||
func (c *JWKCache) Close() {
|
|
||||||
// Close shuts down the internal cache's auto-cleanup routine, if the cache exists.
|
|
||||||
if c.internalCache != nil {
|
|
||||||
c.internalCache.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMaxSize sets the maximum number of items in the cache
|
|
||||||
func (c *JWKCache) SetMaxSize(size int) {
|
|
||||||
c.maxSize = size
|
|
||||||
if c.internalCache != nil {
|
|
||||||
c.internalCache.maxSize = size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchJWKS retrieves the JSON Web Key Set (JWKS) from the specified URL.
|
|
||||||
// It uses the provided context and HTTP client to make the request.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - ctx: Context for the HTTP request.
|
|
||||||
// - jwksURL: The URL of the OIDC provider's JWKS endpoint.
|
|
||||||
// - httpClient: The HTTP client to use for the request.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A pointer to the fetched JWKSet.
|
|
||||||
// - An error if the request fails, the status code is not OK, or the response body cannot be decoded.
|
|
||||||
func fetchJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
|
|
||||||
// Create a request with context to enforce timeout
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", jwksURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create JWKS request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
|
return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
|
||||||
}
|
}
|
||||||
@@ -190,16 +93,7 @@ func fetchJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*J
|
|||||||
return &jwks, nil
|
return &jwks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// jwkToPEM converts a JWK (JSON Web Key) object into PEM (Privacy-Enhanced Mail) format.
|
// jwkToPEM converts a JWK to PEM format
|
||||||
// It selects the appropriate conversion function based on the JWK's key type ("kty").
|
|
||||||
// Currently supports "RSA" and "EC" key types.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - jwk: A pointer to the JWK object to convert.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A byte slice containing the public key in PEM format.
|
|
||||||
// - An error if the key type is unsupported or conversion fails.
|
|
||||||
func jwkToPEM(jwk *JWK) ([]byte, error) {
|
func jwkToPEM(jwk *JWK) ([]byte, error) {
|
||||||
converter, ok := jwkConverters[jwk.Kty]
|
converter, ok := jwkConverters[jwk.Kty]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -215,17 +109,7 @@ var jwkConverters = map[string]jwkToPEMConverter{
|
|||||||
"EC": ecJWKToPEM,
|
"EC": ecJWKToPEM,
|
||||||
}
|
}
|
||||||
|
|
||||||
// rsaJWKToPEM converts an RSA JWK into PEM format.
|
// rsaJWKToPEM converts an RSA JWK to PEM
|
||||||
// It decodes the modulus (n) and exponent (e) from base64 URL encoding,
|
|
||||||
// constructs an rsa.PublicKey, marshals it into PKIX format, and then
|
|
||||||
// encodes it as a PEM block.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - jwk: A pointer to the RSA JWK object (must have "kty": "RSA").
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A byte slice containing the RSA public key in PEM format.
|
|
||||||
// - An error if decoding parameters fails or key marshaling fails.
|
|
||||||
func rsaJWKToPEM(jwk *JWK) ([]byte, error) {
|
func rsaJWKToPEM(jwk *JWK) ([]byte, error) {
|
||||||
nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N)
|
nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -236,39 +120,15 @@ func rsaJWKToPEM(jwk *JWK) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to decode JWK 'e' parameter: %w", err)
|
return nil, fmt.Errorf("failed to decode JWK 'e' parameter: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
n := new(big.Int).SetBytes(nBytes)
|
|
||||||
e := new(big.Int).SetBytes(eBytes)
|
|
||||||
|
|
||||||
pubKey := &rsa.PublicKey{
|
pubKey := &rsa.PublicKey{
|
||||||
N: n,
|
N: new(big.Int).SetBytes(nBytes),
|
||||||
E: int(e.Int64()),
|
E: int(new(big.Int).SetBytes(eBytes).Int64()),
|
||||||
}
|
}
|
||||||
|
|
||||||
pubKeyBytes, err := x509.MarshalPKIXPublicKey(pubKey)
|
return marshalPublicKey(pubKey)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to marshal RSA public key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pubKeyPEM := pem.EncodeToMemory(&pem.Block{
|
|
||||||
Type: "PUBLIC KEY",
|
|
||||||
Bytes: pubKeyBytes,
|
|
||||||
})
|
|
||||||
|
|
||||||
return pubKeyPEM, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ecJWKToPEM converts an EC (Elliptic Curve) JWK into PEM format.
|
// ecJWKToPEM converts an EC JWK to PEM
|
||||||
// It decodes the X and Y coordinates from base64 URL encoding, determines the
|
|
||||||
// elliptic curve based on the "crv" parameter (P-256, P-384, P-521),
|
|
||||||
// constructs an ecdsa.PublicKey, marshals it into PKIX format, and then
|
|
||||||
// encodes it as a PEM block.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - jwk: A pointer to the EC JWK object (must have "kty": "EC").
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A byte slice containing the EC public key in PEM format.
|
|
||||||
// - An error if decoding parameters fails, the curve is unsupported, or key marshaling fails.
|
|
||||||
func ecJWKToPEM(jwk *JWK) ([]byte, error) {
|
func ecJWKToPEM(jwk *JWK) ([]byte, error) {
|
||||||
xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
|
xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -279,16 +139,9 @@ func ecJWKToPEM(jwk *JWK) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to decode JWK 'y' parameter: %w", err)
|
return nil, fmt.Errorf("failed to decode JWK 'y' parameter: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var curve elliptic.Curve
|
curve, err := getCurve(jwk.Crv)
|
||||||
switch jwk.Crv {
|
if err != nil {
|
||||||
case "P-256":
|
return nil, err
|
||||||
curve = elliptic.P256()
|
|
||||||
case "P-384":
|
|
||||||
curve = elliptic.P384()
|
|
||||||
case "P-521":
|
|
||||||
curve = elliptic.P521()
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported elliptic curve: %s", jwk.Crv)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pubKey := &ecdsa.PublicKey{
|
pubKey := &ecdsa.PublicKey{
|
||||||
@@ -297,15 +150,32 @@ func ecJWKToPEM(jwk *JWK) ([]byte, error) {
|
|||||||
Y: new(big.Int).SetBytes(yBytes),
|
Y: new(big.Int).SetBytes(yBytes),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return marshalPublicKey(pubKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCurve returns the elliptic curve based on the JWK curve parameter
|
||||||
|
func getCurve(crv string) (elliptic.Curve, error) {
|
||||||
|
switch crv {
|
||||||
|
case "P-256":
|
||||||
|
return elliptic.P256(), nil
|
||||||
|
case "P-384":
|
||||||
|
return elliptic.P384(), nil
|
||||||
|
case "P-521":
|
||||||
|
return elliptic.P521(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported elliptic curve: %s", crv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalPublicKey marshals a public key to PEM format
|
||||||
|
func marshalPublicKey(pubKey interface{}) ([]byte, error) {
|
||||||
pubKeyBytes, err := x509.MarshalPKIXPublicKey(pubKey)
|
pubKeyBytes, err := x509.MarshalPKIXPublicKey(pubKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to marshal EC public key: %w", err)
|
return nil, fmt.Errorf("failed to marshal public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pubKeyPEM := pem.EncodeToMemory(&pem.Block{
|
return pem.EncodeToMemory(&pem.Block{
|
||||||
Type: "PUBLIC KEY",
|
Type: "PUBLIC KEY",
|
||||||
Bytes: pubKeyBytes,
|
Bytes: pubKeyBytes,
|
||||||
})
|
}), nil
|
||||||
|
|
||||||
return pubKeyPEM, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,40 +8,26 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
replayCacheMu sync.Mutex
|
ErrInvalidJWTFormat = errors.New("invalid JWT format")
|
||||||
replayCache *Cache // Replace unbounded map with bounded Cache
|
ErrInvalidAudience = errors.New("invalid audience")
|
||||||
|
ErrInvalidIssuer = errors.New("invalid issuer")
|
||||||
|
ErrTokenExpired = errors.New("token has expired")
|
||||||
|
ErrTokenUsedBeforeIssued = errors.New("token used before issued")
|
||||||
|
ErrMissingClaim = errors.New("missing claim")
|
||||||
|
ErrInvalidClaimType = errors.New("invalid claim type")
|
||||||
|
ErrUnsupportedAlgorithm = errors.New("unsupported algorithm")
|
||||||
|
ErrInvalidSignature = errors.New("invalid signature")
|
||||||
)
|
)
|
||||||
|
|
||||||
// initReplayCache initializes the global replay cache with size limit
|
// JWT represents a JSON Web Token
|
||||||
func initReplayCache() {
|
|
||||||
if replayCache == nil {
|
|
||||||
replayCache = NewCache()
|
|
||||||
replayCache.SetMaxSize(10000) // Set size limit to 10,000 entries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// STABILITY FIX: Standardize clock skew tolerance usage
|
|
||||||
// ClockSkewToleranceFuture defines the tolerance for future-based claims like 'exp'.
|
|
||||||
// Allows for more leniency with expiration checks.
|
|
||||||
var ClockSkewToleranceFuture = 2 * time.Minute
|
|
||||||
|
|
||||||
// ClockSkewTolerancePast defines the tolerance for past-based claims like 'iat' and 'nbf'.
|
|
||||||
// A smaller tolerance is typically used here to prevent accepting tokens issued too far in the future.
|
|
||||||
var ClockSkewTolerancePast = 10 * time.Second
|
|
||||||
|
|
||||||
// ClockSkewTolerance is deprecated - use ClockSkewToleranceFuture or ClockSkewTolerancePast
|
|
||||||
// STABILITY FIX: Remove inconsistent usage
|
|
||||||
var ClockSkewTolerance = ClockSkewToleranceFuture
|
|
||||||
|
|
||||||
// JWT represents a JSON Web Token as defined in RFC 7519.
|
|
||||||
type JWT struct {
|
type JWT struct {
|
||||||
Header map[string]interface{}
|
Header map[string]interface{}
|
||||||
Claims map[string]interface{}
|
Claims map[string]interface{}
|
||||||
@@ -49,366 +35,191 @@ type JWT struct {
|
|||||||
Token string
|
Token string
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseJWT decodes a raw JWT string into its constituent parts: header, claims, and signature.
|
// parseJWT parses a JWT token string into a JWT struct
|
||||||
// It splits the token string by '.', decodes each part using base64 URL decoding,
|
|
||||||
// and unmarshals the header and claims JSON into maps. The raw signature bytes are stored.
|
|
||||||
// It performs basic format validation (expecting 3 parts).
|
|
||||||
// Note: This function does *not* validate the signature or the claims.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - tokenString: The raw JWT string.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A pointer to a JWT struct containing the decoded parts.
|
|
||||||
// - An error if the token format is invalid or decoding/unmarshaling fails.
|
|
||||||
func parseJWT(tokenString string) (*JWT, error) {
|
func parseJWT(tokenString string) (*JWT, error) {
|
||||||
parts := strings.Split(tokenString, ".")
|
parts := strings.Split(tokenString, ".")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
|
return nil, fmt.Errorf("%w: expected 3 parts, got %d", ErrInvalidJWTFormat, len(parts))
|
||||||
}
|
}
|
||||||
|
|
||||||
jwt := &JWT{
|
jwt := &JWT{Token: tokenString}
|
||||||
Token: tokenString,
|
|
||||||
|
if err := decodeJSONPart(parts[0], &jwt.Header); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
if err := decodeJSONPart(parts[1], &jwt.Claims); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode claims: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
jwt.Signature, err = base64.RawURLEncoding.DecodeString(parts[2])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid JWT format: failed to decode header: %v", err)
|
return nil, fmt.Errorf("failed to decode signature: %w", err)
|
||||||
}
|
}
|
||||||
// STABILITY FIX: Add comprehensive JSON error handling with panic protection
|
|
||||||
if err := json.Unmarshal(headerBytes, &jwt.Header); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal header: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate header structure
|
|
||||||
if jwt.Header == nil {
|
|
||||||
return nil, fmt.Errorf("invalid JWT format: header is nil after unmarshaling")
|
|
||||||
}
|
|
||||||
|
|
||||||
claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid JWT format: failed to decode claims: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// STABILITY FIX: Add comprehensive JSON error handling with panic protection
|
|
||||||
if err := json.Unmarshal(claimsBytes, &jwt.Claims); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal claims: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate claims structure
|
|
||||||
if jwt.Claims == nil {
|
|
||||||
return nil, fmt.Errorf("invalid JWT format: claims is nil after unmarshaling")
|
|
||||||
}
|
|
||||||
|
|
||||||
signatureBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid JWT format: failed to decode signature: %v", err)
|
|
||||||
}
|
|
||||||
jwt.Signature = signatureBytes
|
|
||||||
|
|
||||||
return jwt, nil
|
return jwt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify performs standard claim validation on the JWT according to RFC 7519.
|
func decodeJSONPart(part string, target interface{}) error {
|
||||||
// It checks the following:
|
bytes, err := base64.RawURLEncoding.DecodeString(part)
|
||||||
// - Algorithm ('alg') is supported.
|
if err != nil {
|
||||||
// - Issuer ('iss') matches the expected issuerURL.
|
return err
|
||||||
// - Audience ('aud') contains the expected clientID.
|
|
||||||
// - Expiration time ('exp') is in the future (within tolerance).
|
|
||||||
// - Issued at time ('iat') is in the past (within tolerance).
|
|
||||||
// - Not before time ('nbf'), if present, is in the past (within tolerance).
|
|
||||||
// - Subject ('sub') claim exists and is not empty.
|
|
||||||
// - JWT ID ('jti'), if present, is checked against a replay cache to prevent token reuse.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - issuerURL: The expected issuer URL (e.g., "https://accounts.google.com").
|
|
||||||
// - clientID: The expected audience value (the client ID of this application).
|
|
||||||
// - skipReplayCheck: If true, skips JTI replay detection (used for revalidation of cached tokens).
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - nil if all standard claims are valid.
|
|
||||||
// - An error describing the first validation failure encountered.
|
|
||||||
func (j *JWT) Verify(issuerURL, clientID string, skipReplayCheck ...bool) error {
|
|
||||||
// Validate algorithm to prevent algorithm switching attacks
|
|
||||||
alg, ok := j.Header["alg"].(string)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("missing 'alg' header")
|
|
||||||
}
|
|
||||||
supportedAlgs := map[string]bool{
|
|
||||||
"RS256": true, "RS384": true, "RS512": true,
|
|
||||||
"PS256": true, "PS384": true, "PS512": true,
|
|
||||||
"ES256": true, "ES384": true, "ES512": true,
|
|
||||||
}
|
|
||||||
if !supportedAlgs[alg] {
|
|
||||||
return fmt.Errorf("unsupported algorithm: %s", alg)
|
|
||||||
}
|
}
|
||||||
|
return json.Unmarshal(bytes, target)
|
||||||
|
}
|
||||||
|
|
||||||
claims := j.Claims
|
// Verify verifies the standard claims in the JWT
|
||||||
|
func (j *JWT) Verify(issuerURL, clientID string) error {
|
||||||
iss, ok := claims["iss"].(string)
|
if err := verifyIssuer(j.Claims["iss"], issuerURL); err != nil {
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("missing 'iss' claim")
|
|
||||||
}
|
|
||||||
if err := verifyIssuer(iss, issuerURL); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
aud, ok := claims["aud"]
|
if err := verifyAudience(j.Claims["aud"], clientID); err != nil {
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("missing 'aud' claim")
|
|
||||||
}
|
|
||||||
if err := verifyAudience(aud, clientID); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
exp, ok := claims["exp"].(float64)
|
if err := verifyExpiration(j.Claims["exp"]); err != nil {
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("missing or invalid 'exp' claim")
|
|
||||||
}
|
|
||||||
if err := verifyExpiration(exp); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
iat, ok := claims["iat"].(float64)
|
if err := verifyIssuedAt(j.Claims["iat"]); err != nil {
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("missing or invalid 'iat' claim")
|
|
||||||
}
|
|
||||||
if err := verifyIssuedAt(iat); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if nbf, ok := claims["nbf"].(float64); ok {
|
if sub, ok := j.Claims["sub"].(string); !ok || sub == "" {
|
||||||
if err := verifyNotBefore(nbf); err != nil {
|
return fmt.Errorf("%w: sub", ErrMissingClaim)
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Implement replay protection by checking the jti (JWT ID)
|
|
||||||
// Skip replay check if explicitly requested (for revalidation scenarios)
|
|
||||||
shouldSkipReplay := len(skipReplayCheck) > 0 && skipReplayCheck[0]
|
|
||||||
|
|
||||||
if jti, ok := claims["jti"].(string); ok && !shouldSkipReplay {
|
|
||||||
// Skip replay detection for tokens that are being verified from the cache
|
|
||||||
if j.Token == "" {
|
|
||||||
// This is a parsed JWT without the original token string,
|
|
||||||
// which means it's likely from a cached token verification
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY FIX: Use bounded Cache with thread-safe operations
|
|
||||||
replayCacheMu.Lock()
|
|
||||||
defer replayCacheMu.Unlock()
|
|
||||||
|
|
||||||
// Initialize cache if not already done
|
|
||||||
initReplayCache()
|
|
||||||
|
|
||||||
// SECURITY FIX: Check for replay attack using Cache API
|
|
||||||
if _, exists := replayCache.Get(jti); exists {
|
|
||||||
return fmt.Errorf("token replay detected")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate expiration time
|
|
||||||
expFloat, ok := claims["exp"].(float64)
|
|
||||||
var expTime time.Time
|
|
||||||
if ok {
|
|
||||||
expTime = time.Unix(int64(expFloat), 0)
|
|
||||||
} else {
|
|
||||||
expTime = time.Now().Add(10 * time.Minute)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY FIX: Add to replay cache with expiration using Cache API
|
|
||||||
duration := time.Until(expTime)
|
|
||||||
if duration > 0 {
|
|
||||||
replayCache.Set(jti, true, duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sub, ok := claims["sub"].(string)
|
|
||||||
if !ok || sub == "" {
|
|
||||||
return fmt.Errorf("missing or empty 'sub' claim")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyAudience checks if the expected audience is present in the token's 'aud' claim.
|
|
||||||
// The 'aud' claim can be a single string or an array of strings.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - tokenAudience: The 'aud' claim value extracted from the token (can be string or []interface{}).
|
|
||||||
// - expectedAudience: The audience value expected for this application (client ID).
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - nil if the expected audience is found.
|
|
||||||
// - An error if the claim type is invalid or the expected audience is not present.
|
|
||||||
func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
|
func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
|
||||||
switch aud := tokenAudience.(type) {
|
switch aud := tokenAudience.(type) {
|
||||||
case string:
|
case string:
|
||||||
if aud != expectedAudience {
|
if aud != expectedAudience {
|
||||||
return fmt.Errorf("invalid audience")
|
return ErrInvalidAudience
|
||||||
}
|
}
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
found := false
|
|
||||||
for _, v := range aud {
|
for _, v := range aud {
|
||||||
if str, ok := v.(string); ok && str == expectedAudience {
|
if str, ok := v.(string); ok && str == expectedAudience {
|
||||||
found = true
|
return nil
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
return ErrInvalidAudience
|
||||||
return fmt.Errorf("invalid audience")
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid 'aud' claim type")
|
return fmt.Errorf("%w: aud", ErrInvalidClaimType)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyIssuer checks if the token's 'iss' claim matches the expected issuer URL.
|
func verifyIssuer(tokenIssuer interface{}, expectedIssuer string) error {
|
||||||
//
|
iss, ok := tokenIssuer.(string)
|
||||||
// Parameters:
|
if !ok {
|
||||||
// - tokenIssuer: The 'iss' claim value from the token.
|
return fmt.Errorf("%w: iss", ErrMissingClaim)
|
||||||
// - expectedIssuer: The expected issuer URL configured for the OIDC provider.
|
}
|
||||||
//
|
if iss != expectedIssuer {
|
||||||
// Returns:
|
return ErrInvalidIssuer
|
||||||
// - nil if the issuers match.
|
|
||||||
// - An error if the issuers do not match.
|
|
||||||
func verifyIssuer(tokenIssuer, expectedIssuer string) error {
|
|
||||||
if tokenIssuer != expectedIssuer {
|
|
||||||
return fmt.Errorf("invalid issuer (token: %s, expected: %s)", tokenIssuer, expectedIssuer)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyTimeConstraint checks time-based claims ('exp', 'iat', 'nbf') against the current time,
|
func verifyExpiration(expiration interface{}) error {
|
||||||
// allowing for configurable clock skew. It uses different tolerances for past and future checks.
|
exp, ok := expiration.(float64)
|
||||||
//
|
if !ok {
|
||||||
// Parameters:
|
return fmt.Errorf("%w: exp", ErrInvalidClaimType)
|
||||||
// - unixTime: The timestamp value from the claim (as a float64 Unix time).
|
|
||||||
// - claimName: The name of the claim being verified ("exp", "iat", "nbf").
|
|
||||||
// - future: A boolean indicating the direction of the check (true for 'exp', false for 'iat'/'nbf').
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - nil if the time constraint is met within the allowed tolerance.
|
|
||||||
// - An error describing the failure (e.g., "token has expired", "token used before issued").
|
|
||||||
func verifyTimeConstraint(unixTime float64, claimName string, future bool) error {
|
|
||||||
claimTime := time.Unix(int64(unixTime), 0)
|
|
||||||
now := time.Now() // Use current time without truncation
|
|
||||||
|
|
||||||
var err error
|
|
||||||
if future { // 'exp' check
|
|
||||||
// Token is expired if Now is after (ClaimTime + FutureTolerance)
|
|
||||||
allowedExpiry := claimTime.Add(ClockSkewToleranceFuture)
|
|
||||||
if now.After(allowedExpiry) {
|
|
||||||
err = fmt.Errorf("token has expired (exp: %v, now: %v, allowed_until: %v)", claimTime.UTC(), now.UTC(), allowedExpiry.UTC())
|
|
||||||
}
|
|
||||||
} else { // 'iat' or 'nbf' check
|
|
||||||
// Token is invalid if Now is before (ClaimTime - PastTolerance)
|
|
||||||
allowedStart := claimTime.Add(-ClockSkewTolerancePast)
|
|
||||||
if now.Before(allowedStart) {
|
|
||||||
reason := "not yet valid"
|
|
||||||
if claimName == "iat" {
|
|
||||||
reason = "used before issued"
|
|
||||||
}
|
|
||||||
err = fmt.Errorf("token %s (%s: %v, now: %v, allowed_from: %v)", reason, claimName, claimTime.UTC(), now.UTC(), allowedStart.UTC())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if time.Now().After(time.Unix(int64(exp), 0)) {
|
||||||
return err
|
return ErrTokenExpired
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyExpiration checks the 'exp' (Expiration Time) claim.
|
func verifyIssuedAt(issuedAt interface{}) error {
|
||||||
// It calls verifyTimeConstraint with future=true.
|
iat, ok := issuedAt.(float64)
|
||||||
func verifyExpiration(expiration float64) error {
|
if !ok {
|
||||||
return verifyTimeConstraint(expiration, "exp", true)
|
return fmt.Errorf("%w: iat", ErrInvalidClaimType)
|
||||||
|
}
|
||||||
|
if time.Now().Before(time.Unix(int64(iat), 0)) {
|
||||||
|
return ErrTokenUsedBeforeIssued
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyIssuedAt checks the 'iat' (Issued At) claim.
|
|
||||||
// It calls verifyTimeConstraint with future=false.
|
|
||||||
func verifyIssuedAt(issuedAt float64) error {
|
|
||||||
return verifyTimeConstraint(issuedAt, "iat", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// verifyNotBefore checks the 'nbf' (Not Before) claim.
|
|
||||||
// It calls verifyTimeConstraint with future=false.
|
|
||||||
func verifyNotBefore(notBefore float64) error {
|
|
||||||
return verifyTimeConstraint(notBefore, "nbf", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// verifySignature validates the JWT's signature using the provided public key.
|
|
||||||
// It parses the public key from PEM format, selects the appropriate hashing algorithm
|
|
||||||
// based on the 'alg' parameter (SHA256/384/512), hashes the token's signing input
|
|
||||||
// (header + "." + payload), and then verifies the signature against the hash using
|
|
||||||
// the corresponding RSA (PKCS1v15 or PSS) or ECDSA verification method.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - tokenString: The raw, complete JWT string.
|
|
||||||
// - publicKeyPEM: The public key corresponding to the private key used for signing, in PEM format.
|
|
||||||
// - alg: The algorithm specified in the JWT header (e.g., "RS256", "ES384").
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - nil if the signature is valid.
|
|
||||||
// - An error if the token format is invalid, decoding fails, key parsing fails,
|
|
||||||
// the algorithm is unsupported, or the signature verification fails.
|
|
||||||
func verifySignature(tokenString string, publicKeyPEM []byte, alg string) error {
|
func verifySignature(tokenString string, publicKeyPEM []byte, alg string) error {
|
||||||
parts := strings.Split(tokenString, ".")
|
parts := strings.Split(tokenString, ".")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return fmt.Errorf("invalid token format")
|
return ErrInvalidJWTFormat
|
||||||
}
|
}
|
||||||
signedContent := parts[0] + "." + parts[1]
|
signedContent := parts[0] + "." + parts[1]
|
||||||
|
|
||||||
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
|
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to decode signature: %w", err)
|
return fmt.Errorf("failed to decode signature: %w", err)
|
||||||
}
|
}
|
||||||
block, _ := pem.Decode(publicKeyPEM)
|
|
||||||
if block == nil {
|
pubKey, err := parsePublicKey(publicKeyPEM)
|
||||||
return fmt.Errorf("failed to parse PEM block containing the public key")
|
|
||||||
}
|
|
||||||
pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse public key: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
var hashFunc crypto.Hash
|
|
||||||
switch alg {
|
hashFunc, err := getHashFunc(alg)
|
||||||
case "RS256", "PS256", "ES256":
|
if err != nil {
|
||||||
hashFunc = crypto.SHA256
|
return err
|
||||||
case "RS384", "PS384", "ES384":
|
|
||||||
hashFunc = crypto.SHA384
|
|
||||||
case "RS512", "PS512", "ES512":
|
|
||||||
hashFunc = crypto.SHA512
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported algorithm: %s", alg)
|
|
||||||
}
|
}
|
||||||
h := hashFunc.New()
|
|
||||||
h.Write([]byte(signedContent))
|
hashed := hashFunc.New().Sum([]byte(signedContent))
|
||||||
hashed := h.Sum(nil)
|
|
||||||
switch pubKey := pubKey.(type) {
|
switch pubKey := pubKey.(type) {
|
||||||
case *rsa.PublicKey:
|
case *rsa.PublicKey:
|
||||||
if strings.HasPrefix(alg, "RS") {
|
return verifyRSASignature(pubKey, hashFunc, hashed, signature, alg)
|
||||||
return rsa.VerifyPKCS1v15(pubKey, hashFunc, hashed, signature)
|
|
||||||
} else if strings.HasPrefix(alg, "PS") {
|
|
||||||
return rsa.VerifyPSS(pubKey, hashFunc, hashed, signature, nil)
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("unexpected key type for algorithm %s", alg)
|
|
||||||
}
|
|
||||||
case *ecdsa.PublicKey:
|
case *ecdsa.PublicKey:
|
||||||
if strings.HasPrefix(alg, "ES") {
|
return verifyECDSASignature(pubKey, hashed, signature)
|
||||||
var r, s big.Int
|
|
||||||
sigLen := len(signature)
|
|
||||||
if sigLen%2 != 0 {
|
|
||||||
return fmt.Errorf("invalid ECDSA signature length")
|
|
||||||
}
|
|
||||||
r.SetBytes(signature[:sigLen/2])
|
|
||||||
s.SetBytes(signature[sigLen/2:])
|
|
||||||
if ecdsa.Verify(pubKey, hashed, &r, &s) {
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("invalid ECDSA signature")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("unexpected key type for algorithm %s", alg)
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported public key type: %T", pubKey)
|
return fmt.Errorf("unsupported public key type: %T", pubKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePublicKey(publicKeyPEM []byte) (interface{}, error) {
|
||||||
|
block, _ := pem.Decode(publicKeyPEM)
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("failed to parse PEM block containing the public key")
|
||||||
|
}
|
||||||
|
return x509.ParsePKIXPublicKey(block.Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHashFunc(alg string) (crypto.Hash, error) {
|
||||||
|
switch alg {
|
||||||
|
case "RS256", "PS256", "ES256":
|
||||||
|
return crypto.SHA256, nil
|
||||||
|
case "RS384", "PS384", "ES384":
|
||||||
|
return crypto.SHA384, nil
|
||||||
|
case "RS512", "PS512", "ES512":
|
||||||
|
return crypto.SHA512, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("%w: %s", ErrUnsupportedAlgorithm, alg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyRSASignature(pubKey *rsa.PublicKey, hashFunc crypto.Hash, hashed, signature []byte, alg string) error {
|
||||||
|
if strings.HasPrefix(alg, "RS") {
|
||||||
|
return rsa.VerifyPKCS1v15(pubKey, hashFunc, hashed, signature)
|
||||||
|
} else if strings.HasPrefix(alg, "PS") {
|
||||||
|
return rsa.VerifyPSS(pubKey, hashFunc, hashed, signature, nil)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %s", ErrUnsupportedAlgorithm, alg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyECDSASignature(pubKey *ecdsa.PublicKey, hashed, signature []byte) error {
|
||||||
|
sigLen := len(signature)
|
||||||
|
if sigLen%2 != 0 {
|
||||||
|
return errors.New("invalid ECDSA signature length")
|
||||||
|
}
|
||||||
|
r, s := new(big.Int), new(big.Int)
|
||||||
|
r.SetBytes(signature[:sigLen/2])
|
||||||
|
s.SetBytes(signature[sigLen/2:])
|
||||||
|
if ecdsa.Verify(pubKey, hashed, r, s) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ErrInvalidSignature
|
||||||
|
}
|
||||||
|
|||||||
+158
-2890
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MetadataCache struct {
|
|
||||||
metadata *ProviderMetadata
|
|
||||||
expiresAt time.Time
|
|
||||||
mutex sync.RWMutex
|
|
||||||
autoCleanupInterval time.Duration
|
|
||||||
stopCleanup chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMetadataCache creates a new MetadataCache instance.
|
|
||||||
// It initializes the cache structure and starts the background cleanup goroutine.
|
|
||||||
func NewMetadataCache() *MetadataCache {
|
|
||||||
c := &MetadataCache{
|
|
||||||
autoCleanupInterval: 5 * time.Minute,
|
|
||||||
stopCleanup: make(chan struct{}),
|
|
||||||
}
|
|
||||||
go c.startAutoCleanup()
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup removes the cached provider metadata if it has expired.
|
|
||||||
// This is called periodically by the auto-cleanup goroutine.
|
|
||||||
func (c *MetadataCache) Cleanup() {
|
|
||||||
c.mutex.Lock()
|
|
||||||
defer c.mutex.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
if c.metadata != nil && now.After(c.expiresAt) {
|
|
||||||
c.metadata = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isCacheValid checks if the cached metadata is present and has not expired.
|
|
||||||
// Note: This function assumes the read lock is held or it's called from a context
|
|
||||||
// where the lock is already held (like within GetMetadata after locking).
|
|
||||||
func (c *MetadataCache) isCacheValid() bool {
|
|
||||||
return c.metadata != nil && time.Now().Before(c.expiresAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMetadata retrieves the OIDC provider metadata.
|
|
||||||
// It first checks the cache for valid, non-expired metadata. If found, it's returned immediately.
|
|
||||||
// If the cache is empty or expired, it attempts to fetch the metadata from the provider's
|
|
||||||
// well-known endpoint using discoverProviderMetadata.
|
|
||||||
// If fetching is successful, the new metadata is cached for 1 hour.
|
|
||||||
// If fetching fails but valid metadata exists in the cache (even if expired), the cache expiry
|
|
||||||
// is extended by 5 minutes, and the cached data is returned to prevent thundering herd issues.
|
|
||||||
// If fetching fails and there's no cached data, an error is returned.
|
|
||||||
// It employs double-checked locking for thread safety and performance.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - providerURL: The base URL of the OIDC provider.
|
|
||||||
// - httpClient: The HTTP client to use for fetching metadata.
|
|
||||||
// - logger: The logger instance for recording errors or warnings.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A pointer to the ProviderMetadata struct.
|
|
||||||
// - An error if metadata cannot be retrieved from cache or fetched from the provider.
|
|
||||||
func (c *MetadataCache) GetMetadata(providerURL string, httpClient *http.Client, logger *Logger) (*ProviderMetadata, error) {
|
|
||||||
c.mutex.RLock()
|
|
||||||
if c.isCacheValid() {
|
|
||||||
defer c.mutex.RUnlock()
|
|
||||||
return c.metadata, nil
|
|
||||||
}
|
|
||||||
c.mutex.RUnlock()
|
|
||||||
|
|
||||||
c.mutex.Lock()
|
|
||||||
defer c.mutex.Unlock()
|
|
||||||
|
|
||||||
// Double-check after acquiring write lock
|
|
||||||
if c.isCacheValid() {
|
|
||||||
return c.metadata, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata, err := discoverProviderMetadata(providerURL, httpClient, logger)
|
|
||||||
if err != nil {
|
|
||||||
if c.metadata != nil {
|
|
||||||
// On error, extend current cache by 5 minutes to prevent thundering herd
|
|
||||||
c.expiresAt = time.Now().Add(5 * time.Minute)
|
|
||||||
logger.Errorf("Failed to refresh metadata, using cached version for 5 more minutes: %v", err)
|
|
||||||
return c.metadata, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to fetch provider metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.metadata = metadata
|
|
||||||
// Set a fixed cache lifetime (e.g., 1 hour)
|
|
||||||
// TODO: Consider making this configurable or respecting HTTP cache headers
|
|
||||||
c.expiresAt = time.Now().Add(1 * time.Hour)
|
|
||||||
|
|
||||||
// End of GetMetadata
|
|
||||||
return metadata, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// startAutoCleanup starts the background goroutine that periodically calls Cleanup
|
|
||||||
// to remove expired metadata from the cache.
|
|
||||||
func (c *MetadataCache) startAutoCleanup() {
|
|
||||||
autoCleanupRoutine(c.autoCleanupInterval, c.stopCleanup, c.Cleanup)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close stops the automatic cleanup goroutine associated with this metadata cache.
|
|
||||||
func (c *MetadataCache) Close() {
|
|
||||||
close(c.stopCleanup)
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIsCacheValid(t *testing.T) {
|
|
||||||
// Setup with a dummy ProviderMetadata.
|
|
||||||
pm := &ProviderMetadata{}
|
|
||||||
mc := &MetadataCache{
|
|
||||||
metadata: pm,
|
|
||||||
expiresAt: time.Now().Add(1 * time.Hour),
|
|
||||||
}
|
|
||||||
if !mc.isCacheValid() {
|
|
||||||
t.Errorf("Expected cache to be valid")
|
|
||||||
}
|
|
||||||
mc.expiresAt = time.Now().Add(-1 * time.Hour)
|
|
||||||
if mc.isCacheValid() {
|
|
||||||
t.Errorf("Expected cache to be invalid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCleanup(t *testing.T) {
|
|
||||||
pm := &ProviderMetadata{}
|
|
||||||
mc := &MetadataCache{
|
|
||||||
metadata: pm,
|
|
||||||
expiresAt: time.Now().Add(-1 * time.Hour),
|
|
||||||
}
|
|
||||||
mc.Cleanup()
|
|
||||||
if mc.metadata != nil {
|
|
||||||
t.Errorf("Expected metadata to be nil after cleanup")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetMetadata_Cached(t *testing.T) {
|
|
||||||
dummyData := &ProviderMetadata{}
|
|
||||||
// Construct MetadataCache manually to avoid interference from auto cleanup.
|
|
||||||
mc := &MetadataCache{
|
|
||||||
metadata: dummyData,
|
|
||||||
expiresAt: time.Now().Add(1 * time.Hour),
|
|
||||||
stopCleanup: make(chan struct{}),
|
|
||||||
autoCleanupInterval: 5 * time.Minute,
|
|
||||||
}
|
|
||||||
// Use NewLogger to create a logger that writes errors only.
|
|
||||||
logger := NewLogger("error")
|
|
||||||
result, err := mc.GetMetadata("http://example.com", http.DefaultClient, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if result != dummyData {
|
|
||||||
t.Errorf("Expected cached metadata to be returned")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMetadataCacheAutoCleanup(t *testing.T) {
|
|
||||||
mc := &MetadataCache{
|
|
||||||
autoCleanupInterval: 50 * time.Millisecond,
|
|
||||||
stopCleanup: make(chan struct{}),
|
|
||||||
}
|
|
||||||
// Start auto cleanup.
|
|
||||||
go mc.startAutoCleanup()
|
|
||||||
mc.mutex.Lock()
|
|
||||||
mc.metadata = &ProviderMetadata{}
|
|
||||||
mc.expiresAt = time.Now().Add(-50 * time.Millisecond)
|
|
||||||
mc.mutex.Unlock()
|
|
||||||
|
|
||||||
// Wait enough time for the auto cleanup to run.
|
|
||||||
time.Sleep(200 * time.Millisecond)
|
|
||||||
mc.Close()
|
|
||||||
mc.mutex.RLock()
|
|
||||||
defer mc.mutex.RUnlock()
|
|
||||||
if mc.metadata != nil {
|
|
||||||
t.Errorf("Expected metadata to be cleared by auto cleanup")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type errorRoundTripper struct {
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e errorRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
||||||
return nil, e.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetMetadata_FetchError(t *testing.T) {
|
|
||||||
// Create an HTTP client that always returns an error.
|
|
||||||
errorClient := &http.Client{
|
|
||||||
Transport: errorRoundTripper{err: fmt.Errorf("fake fetch error")},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 1: Cache is empty.
|
|
||||||
mc := &MetadataCache{
|
|
||||||
stopCleanup: make(chan struct{}),
|
|
||||||
}
|
|
||||||
logger := NewLogger("error")
|
|
||||||
metadata, err := mc.GetMetadata("http://example.com", errorClient, logger)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("Expected error, got nil")
|
|
||||||
}
|
|
||||||
if metadata != nil {
|
|
||||||
t.Errorf("Expected nil metadata, got %v", metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: Cache has old metadata.
|
|
||||||
dummy := &ProviderMetadata{}
|
|
||||||
mc.metadata = dummy
|
|
||||||
mc.expiresAt = time.Now().Add(-1 * time.Minute)
|
|
||||||
logger2 := NewLogger("error")
|
|
||||||
metadata, err = mc.GetMetadata("http://example.com", errorClient, logger2)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Expected no error when cached metadata exists, got %v", err)
|
|
||||||
}
|
|
||||||
if metadata != dummy {
|
|
||||||
t.Errorf("Expected cached metadata to be returned")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,709 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"runtime"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PerformanceMetrics tracks various performance-related metrics
|
|
||||||
type PerformanceMetrics struct {
|
|
||||||
// Cache metrics
|
|
||||||
cacheHits int64
|
|
||||||
cacheMisses int64
|
|
||||||
cacheEvictions int64
|
|
||||||
cacheSize int64
|
|
||||||
|
|
||||||
// Token operation metrics
|
|
||||||
tokenVerifications int64
|
|
||||||
tokenValidations int64
|
|
||||||
tokenRefreshes int64
|
|
||||||
|
|
||||||
// Success/failure tracking
|
|
||||||
successfulVerifications int64
|
|
||||||
successfulValidations int64
|
|
||||||
successfulRefreshes int64
|
|
||||||
failedVerifications int64
|
|
||||||
failedValidations int64
|
|
||||||
failedRefreshes int64
|
|
||||||
|
|
||||||
// Timing metrics
|
|
||||||
avgVerificationTime time.Duration
|
|
||||||
avgValidationTime time.Duration
|
|
||||||
avgRefreshTime time.Duration
|
|
||||||
|
|
||||||
// Resource metrics
|
|
||||||
memoryUsage int64
|
|
||||||
goroutineCount int64
|
|
||||||
memoryPressure int64 // Memory pressure level (0-100)
|
|
||||||
gcPauseTime int64 // Last GC pause time in nanoseconds
|
|
||||||
heapSize int64 // Current heap size
|
|
||||||
heapInUse int64 // Heap memory in use
|
|
||||||
|
|
||||||
// Error metrics (kept for backward compatibility)
|
|
||||||
verificationErrors int64
|
|
||||||
validationErrors int64
|
|
||||||
refreshErrors int64
|
|
||||||
|
|
||||||
// Rate limiting metrics
|
|
||||||
rateLimitedRequests int64
|
|
||||||
|
|
||||||
// Session metrics
|
|
||||||
activeSessions int64
|
|
||||||
sessionCreations int64
|
|
||||||
sessionDeletions int64
|
|
||||||
|
|
||||||
// Timing tracking
|
|
||||||
timingMutex sync.RWMutex
|
|
||||||
verificationTimes []time.Duration
|
|
||||||
validationTimes []time.Duration
|
|
||||||
refreshTimes []time.Duration
|
|
||||||
|
|
||||||
// Start time for uptime calculation
|
|
||||||
startTime time.Time
|
|
||||||
|
|
||||||
logger *Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPerformanceMetrics creates a new performance metrics tracker
|
|
||||||
func NewPerformanceMetrics(logger *Logger) *PerformanceMetrics {
|
|
||||||
pm := &PerformanceMetrics{
|
|
||||||
startTime: time.Now(),
|
|
||||||
verificationTimes: make([]time.Duration, 0, 1000), // Keep last 1000 measurements
|
|
||||||
validationTimes: make([]time.Duration, 0, 1000),
|
|
||||||
refreshTimes: make([]time.Duration, 0, 1000),
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start background metrics collection
|
|
||||||
go pm.startMetricsCollection()
|
|
||||||
|
|
||||||
return pm
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordCacheHit records a cache hit
|
|
||||||
func (pm *PerformanceMetrics) RecordCacheHit() {
|
|
||||||
atomic.AddInt64(&pm.cacheHits, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordCacheMiss records a cache miss
|
|
||||||
func (pm *PerformanceMetrics) RecordCacheMiss() {
|
|
||||||
atomic.AddInt64(&pm.cacheMisses, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordCacheEviction records a cache eviction
|
|
||||||
func (pm *PerformanceMetrics) RecordCacheEviction() {
|
|
||||||
atomic.AddInt64(&pm.cacheEvictions, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateCacheSize updates the current cache size
|
|
||||||
func (pm *PerformanceMetrics) UpdateCacheSize(size int64) {
|
|
||||||
atomic.StoreInt64(&pm.cacheSize, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordTokenVerification records a token verification operation
|
|
||||||
func (pm *PerformanceMetrics) RecordTokenVerification(duration time.Duration, success bool) {
|
|
||||||
atomic.AddInt64(&pm.tokenVerifications, 1)
|
|
||||||
|
|
||||||
if success {
|
|
||||||
atomic.AddInt64(&pm.successfulVerifications, 1)
|
|
||||||
pm.addVerificationTime(duration)
|
|
||||||
} else {
|
|
||||||
atomic.AddInt64(&pm.failedVerifications, 1)
|
|
||||||
atomic.AddInt64(&pm.verificationErrors, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordTokenValidation records a token validation operation
|
|
||||||
func (pm *PerformanceMetrics) RecordTokenValidation(duration time.Duration, success bool) {
|
|
||||||
atomic.AddInt64(&pm.tokenValidations, 1)
|
|
||||||
|
|
||||||
if success {
|
|
||||||
atomic.AddInt64(&pm.successfulValidations, 1)
|
|
||||||
pm.addValidationTime(duration)
|
|
||||||
} else {
|
|
||||||
atomic.AddInt64(&pm.failedValidations, 1)
|
|
||||||
atomic.AddInt64(&pm.validationErrors, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordTokenRefresh records a token refresh operation
|
|
||||||
func (pm *PerformanceMetrics) RecordTokenRefresh(duration time.Duration, success bool) {
|
|
||||||
atomic.AddInt64(&pm.tokenRefreshes, 1)
|
|
||||||
|
|
||||||
if success {
|
|
||||||
atomic.AddInt64(&pm.successfulRefreshes, 1)
|
|
||||||
pm.addRefreshTime(duration)
|
|
||||||
} else {
|
|
||||||
atomic.AddInt64(&pm.failedRefreshes, 1)
|
|
||||||
atomic.AddInt64(&pm.refreshErrors, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordRateLimitedRequest records a rate-limited request
|
|
||||||
func (pm *PerformanceMetrics) RecordRateLimitedRequest() {
|
|
||||||
atomic.AddInt64(&pm.rateLimitedRequests, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordSessionCreation records a session creation
|
|
||||||
func (pm *PerformanceMetrics) RecordSessionCreation() {
|
|
||||||
atomic.AddInt64(&pm.sessionCreations, 1)
|
|
||||||
atomic.AddInt64(&pm.activeSessions, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordSessionDeletion records a session deletion
|
|
||||||
func (pm *PerformanceMetrics) RecordSessionDeletion() {
|
|
||||||
atomic.AddInt64(&pm.sessionDeletions, 1)
|
|
||||||
atomic.AddInt64(&pm.activeSessions, -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addVerificationTime adds a verification time measurement
|
|
||||||
func (pm *PerformanceMetrics) addVerificationTime(duration time.Duration) {
|
|
||||||
pm.timingMutex.Lock()
|
|
||||||
defer pm.timingMutex.Unlock()
|
|
||||||
|
|
||||||
pm.verificationTimes = append(pm.verificationTimes, duration)
|
|
||||||
if len(pm.verificationTimes) > 1000 {
|
|
||||||
pm.verificationTimes = pm.verificationTimes[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.updateAverageVerificationTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
// addValidationTime adds a validation time measurement
|
|
||||||
func (pm *PerformanceMetrics) addValidationTime(duration time.Duration) {
|
|
||||||
pm.timingMutex.Lock()
|
|
||||||
defer pm.timingMutex.Unlock()
|
|
||||||
|
|
||||||
pm.validationTimes = append(pm.validationTimes, duration)
|
|
||||||
if len(pm.validationTimes) > 1000 {
|
|
||||||
pm.validationTimes = pm.validationTimes[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.updateAverageValidationTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
// addRefreshTime adds a refresh time measurement
|
|
||||||
func (pm *PerformanceMetrics) addRefreshTime(duration time.Duration) {
|
|
||||||
pm.timingMutex.Lock()
|
|
||||||
defer pm.timingMutex.Unlock()
|
|
||||||
|
|
||||||
pm.refreshTimes = append(pm.refreshTimes, duration)
|
|
||||||
if len(pm.refreshTimes) > 1000 {
|
|
||||||
pm.refreshTimes = pm.refreshTimes[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.updateAverageRefreshTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateAverageVerificationTime calculates the average verification time
|
|
||||||
func (pm *PerformanceMetrics) updateAverageVerificationTime() {
|
|
||||||
if len(pm.verificationTimes) == 0 {
|
|
||||||
pm.avgVerificationTime = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var total time.Duration
|
|
||||||
for _, t := range pm.verificationTimes {
|
|
||||||
total += t
|
|
||||||
}
|
|
||||||
pm.avgVerificationTime = total / time.Duration(len(pm.verificationTimes))
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateAverageValidationTime calculates the average validation time
|
|
||||||
func (pm *PerformanceMetrics) updateAverageValidationTime() {
|
|
||||||
if len(pm.validationTimes) == 0 {
|
|
||||||
pm.avgValidationTime = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var total time.Duration
|
|
||||||
for _, t := range pm.validationTimes {
|
|
||||||
total += t
|
|
||||||
}
|
|
||||||
pm.avgValidationTime = total / time.Duration(len(pm.validationTimes))
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateAverageRefreshTime calculates the average refresh time
|
|
||||||
func (pm *PerformanceMetrics) updateAverageRefreshTime() {
|
|
||||||
if len(pm.refreshTimes) == 0 {
|
|
||||||
pm.avgRefreshTime = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var total time.Duration
|
|
||||||
for _, t := range pm.refreshTimes {
|
|
||||||
total += t
|
|
||||||
}
|
|
||||||
pm.avgRefreshTime = total / time.Duration(len(pm.refreshTimes))
|
|
||||||
}
|
|
||||||
|
|
||||||
// startMetricsCollection starts background collection of system metrics
|
|
||||||
func (pm *PerformanceMetrics) startMetricsCollection() {
|
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for range ticker.C {
|
|
||||||
pm.collectSystemMetrics()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectSystemMetrics collects system-level metrics
|
|
||||||
func (pm *PerformanceMetrics) collectSystemMetrics() {
|
|
||||||
// Memory statistics
|
|
||||||
var m runtime.MemStats
|
|
||||||
runtime.ReadMemStats(&m)
|
|
||||||
atomic.StoreInt64(&pm.memoryUsage, int64(m.Alloc))
|
|
||||||
atomic.StoreInt64(&pm.heapSize, int64(m.HeapSys))
|
|
||||||
atomic.StoreInt64(&pm.heapInUse, int64(m.HeapInuse))
|
|
||||||
atomic.StoreInt64(&pm.gcPauseTime, int64(m.PauseNs[(m.NumGC+255)%256]))
|
|
||||||
|
|
||||||
// Calculate memory pressure (0-100 scale)
|
|
||||||
// Based on heap utilization and GC frequency
|
|
||||||
heapUtilization := float64(m.HeapInuse) / float64(m.HeapSys)
|
|
||||||
gcFrequency := float64(m.NumGC) / time.Since(pm.startTime).Minutes()
|
|
||||||
|
|
||||||
// Memory pressure calculation
|
|
||||||
pressure := int64(heapUtilization * 50) // 0-50 based on heap utilization
|
|
||||||
if gcFrequency > 10 { // High GC frequency indicates pressure
|
|
||||||
pressure += int64((gcFrequency - 10) * 2) // Add up to 50 more
|
|
||||||
}
|
|
||||||
if pressure > 100 {
|
|
||||||
pressure = 100
|
|
||||||
}
|
|
||||||
atomic.StoreInt64(&pm.memoryPressure, pressure)
|
|
||||||
|
|
||||||
// Goroutine count
|
|
||||||
atomic.StoreInt64(&pm.goroutineCount, int64(runtime.NumGoroutine()))
|
|
||||||
|
|
||||||
// Log memory pressure warnings
|
|
||||||
if pressure > 80 {
|
|
||||||
pm.logger.Errorf("High memory pressure detected: %d%% (heap utilization: %.1f%%, GC frequency: %.1f/min)",
|
|
||||||
pressure, heapUtilization*100, gcFrequency)
|
|
||||||
} else if pressure > 60 {
|
|
||||||
pm.logger.Infof("Moderate memory pressure: %d%% (heap utilization: %.1f%%, GC frequency: %.1f/min)",
|
|
||||||
pressure, heapUtilization*100, gcFrequency)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMetrics returns all current performance metrics
|
|
||||||
func (pm *PerformanceMetrics) GetMetrics() map[string]interface{} {
|
|
||||||
pm.timingMutex.RLock()
|
|
||||||
defer pm.timingMutex.RUnlock()
|
|
||||||
|
|
||||||
// Calculate cache hit ratio
|
|
||||||
hits := atomic.LoadInt64(&pm.cacheHits)
|
|
||||||
misses := atomic.LoadInt64(&pm.cacheMisses)
|
|
||||||
var hitRatio float64
|
|
||||||
if hits+misses > 0 {
|
|
||||||
hitRatio = float64(hits) / float64(hits+misses)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate error rates
|
|
||||||
verifications := atomic.LoadInt64(&pm.tokenVerifications)
|
|
||||||
validations := atomic.LoadInt64(&pm.tokenValidations)
|
|
||||||
refreshes := atomic.LoadInt64(&pm.tokenRefreshes)
|
|
||||||
|
|
||||||
var verificationErrorRate, validationErrorRate, refreshErrorRate float64
|
|
||||||
|
|
||||||
if verifications > 0 {
|
|
||||||
verificationErrorRate = float64(atomic.LoadInt64(&pm.verificationErrors)) / float64(verifications)
|
|
||||||
}
|
|
||||||
if validations > 0 {
|
|
||||||
validationErrorRate = float64(atomic.LoadInt64(&pm.validationErrors)) / float64(validations)
|
|
||||||
}
|
|
||||||
if refreshes > 0 {
|
|
||||||
refreshErrorRate = float64(atomic.LoadInt64(&pm.refreshErrors)) / float64(refreshes)
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
|
||||||
// Cache metrics
|
|
||||||
"cache_hits": hits,
|
|
||||||
"cache_misses": misses,
|
|
||||||
"cache_hit_ratio": hitRatio,
|
|
||||||
"cache_evictions": atomic.LoadInt64(&pm.cacheEvictions),
|
|
||||||
"cache_size": atomic.LoadInt64(&pm.cacheSize),
|
|
||||||
|
|
||||||
// Token operation metrics
|
|
||||||
"token_verifications": verifications,
|
|
||||||
"token_validations": validations,
|
|
||||||
"token_refreshes": refreshes,
|
|
||||||
"verification_error_rate": verificationErrorRate,
|
|
||||||
"validation_error_rate": validationErrorRate,
|
|
||||||
"refresh_error_rate": refreshErrorRate,
|
|
||||||
|
|
||||||
// Success/failure metrics
|
|
||||||
"successful_verifications": atomic.LoadInt64(&pm.successfulVerifications),
|
|
||||||
"successful_validations": atomic.LoadInt64(&pm.successfulValidations),
|
|
||||||
"successful_refreshes": atomic.LoadInt64(&pm.successfulRefreshes),
|
|
||||||
"failed_verifications": atomic.LoadInt64(&pm.failedVerifications),
|
|
||||||
"failed_validations": atomic.LoadInt64(&pm.failedValidations),
|
|
||||||
"failed_refreshes": atomic.LoadInt64(&pm.failedRefreshes),
|
|
||||||
|
|
||||||
// Timing metrics
|
|
||||||
"avg_verification_time_ms": pm.avgVerificationTime.Milliseconds(),
|
|
||||||
"avg_validation_time_ms": pm.avgValidationTime.Milliseconds(),
|
|
||||||
"avg_refresh_time_ms": pm.avgRefreshTime.Milliseconds(),
|
|
||||||
|
|
||||||
// Resource metrics
|
|
||||||
"memory_usage_bytes": atomic.LoadInt64(&pm.memoryUsage),
|
|
||||||
"memory_pressure": atomic.LoadInt64(&pm.memoryPressure),
|
|
||||||
"heap_size_bytes": atomic.LoadInt64(&pm.heapSize),
|
|
||||||
"heap_inuse_bytes": atomic.LoadInt64(&pm.heapInUse),
|
|
||||||
"gc_pause_time_ns": atomic.LoadInt64(&pm.gcPauseTime),
|
|
||||||
"goroutine_count": atomic.LoadInt64(&pm.goroutineCount),
|
|
||||||
|
|
||||||
// Rate limiting metrics
|
|
||||||
"rate_limited_requests": atomic.LoadInt64(&pm.rateLimitedRequests),
|
|
||||||
|
|
||||||
// Session metrics
|
|
||||||
"active_sessions": atomic.LoadInt64(&pm.activeSessions),
|
|
||||||
"sessions_created": atomic.LoadInt64(&pm.sessionCreations),
|
|
||||||
"sessions_deleted": atomic.LoadInt64(&pm.sessionDeletions),
|
|
||||||
"session_creations": atomic.LoadInt64(&pm.sessionCreations),
|
|
||||||
"session_deletions": atomic.LoadInt64(&pm.sessionDeletions),
|
|
||||||
|
|
||||||
// Uptime
|
|
||||||
"uptime_seconds": time.Since(pm.startTime).Seconds(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDetailedTimingMetrics returns detailed timing statistics
|
|
||||||
func (pm *PerformanceMetrics) GetDetailedTimingMetrics() map[string]interface{} {
|
|
||||||
pm.timingMutex.RLock()
|
|
||||||
defer pm.timingMutex.RUnlock()
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
|
||||||
"verification_stats": pm.calculateTimingStats(pm.verificationTimes),
|
|
||||||
"verification_timing": pm.calculateTimingStats(pm.verificationTimes),
|
|
||||||
"validation_stats": pm.calculateTimingStats(pm.validationTimes),
|
|
||||||
"validation_timing": pm.calculateTimingStats(pm.validationTimes),
|
|
||||||
"refresh_stats": pm.calculateTimingStats(pm.refreshTimes),
|
|
||||||
"refresh_timing": pm.calculateTimingStats(pm.refreshTimes),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateTimingStats calculates statistical metrics for timing data
|
|
||||||
func (pm *PerformanceMetrics) calculateTimingStats(times []time.Duration) map[string]interface{} {
|
|
||||||
if len(times) == 0 {
|
|
||||||
return map[string]interface{}{
|
|
||||||
"count": 0,
|
|
||||||
"min_ms": float64(0),
|
|
||||||
"max_ms": float64(0),
|
|
||||||
"avg_ms": float64(0),
|
|
||||||
"average_ms": float64(0),
|
|
||||||
"median_ms": float64(0),
|
|
||||||
"p95_ms": float64(0),
|
|
||||||
"p99_ms": float64(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort times for percentile calculations
|
|
||||||
sortedTimes := make([]time.Duration, len(times))
|
|
||||||
copy(sortedTimes, times)
|
|
||||||
|
|
||||||
// Simple bubble sort for small arrays
|
|
||||||
for i := 0; i < len(sortedTimes); i++ {
|
|
||||||
for j := i + 1; j < len(sortedTimes); j++ {
|
|
||||||
if sortedTimes[i] > sortedTimes[j] {
|
|
||||||
sortedTimes[i], sortedTimes[j] = sortedTimes[j], sortedTimes[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate statistics
|
|
||||||
min := sortedTimes[0]
|
|
||||||
max := sortedTimes[len(sortedTimes)-1]
|
|
||||||
|
|
||||||
var total time.Duration
|
|
||||||
for _, t := range sortedTimes {
|
|
||||||
total += t
|
|
||||||
}
|
|
||||||
avg := total / time.Duration(len(sortedTimes))
|
|
||||||
|
|
||||||
median := sortedTimes[len(sortedTimes)/2]
|
|
||||||
p95 := sortedTimes[int(float64(len(sortedTimes))*0.95)]
|
|
||||||
p99 := sortedTimes[int(float64(len(sortedTimes))*0.99)]
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
|
||||||
"count": len(sortedTimes),
|
|
||||||
"min_ms": float64(min.Nanoseconds()) / 1e6,
|
|
||||||
"max_ms": float64(max.Nanoseconds()) / 1e6,
|
|
||||||
"avg_ms": float64(avg.Nanoseconds()) / 1e6,
|
|
||||||
"average_ms": float64(avg.Nanoseconds()) / 1e6,
|
|
||||||
"median_ms": float64(median.Nanoseconds()) / 1e6,
|
|
||||||
"p95_ms": float64(p95.Nanoseconds()) / 1e6,
|
|
||||||
"p99_ms": float64(p99.Nanoseconds()) / 1e6,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResourceMonitor tracks resource usage and limits
|
|
||||||
type ResourceMonitor struct {
|
|
||||||
// Memory limits
|
|
||||||
maxMemoryBytes int64
|
|
||||||
|
|
||||||
// Cache limits
|
|
||||||
maxCacheSize int64
|
|
||||||
|
|
||||||
// Session limits
|
|
||||||
maxSessions int64
|
|
||||||
|
|
||||||
// Cache size tracking
|
|
||||||
cacheSizes map[string]int64
|
|
||||||
cacheMutex sync.RWMutex
|
|
||||||
|
|
||||||
// Monitoring state
|
|
||||||
alertThresholds map[string]float64
|
|
||||||
alerts []ResourceAlert
|
|
||||||
alertsMutex sync.RWMutex
|
|
||||||
|
|
||||||
// Performance metrics reference
|
|
||||||
perfMetrics *PerformanceMetrics
|
|
||||||
|
|
||||||
logger *Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResourceAlert represents a resource usage alert
|
|
||||||
type ResourceAlert struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Threshold float64 `json:"threshold"`
|
|
||||||
CurrentValue float64 `json:"current_value"`
|
|
||||||
Timestamp time.Time `json:"timestamp"`
|
|
||||||
Severity string `json:"severity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewResourceMonitor creates a new resource monitor
|
|
||||||
func NewResourceMonitor(perfMetrics *PerformanceMetrics, logger *Logger) *ResourceMonitor {
|
|
||||||
rm := &ResourceMonitor{
|
|
||||||
maxMemoryBytes: 100 * 1024 * 1024, // 100MB default
|
|
||||||
maxCacheSize: 10000, // 10k items default
|
|
||||||
maxSessions: 1000, // 1k sessions default
|
|
||||||
cacheSizes: make(map[string]int64),
|
|
||||||
alertThresholds: map[string]float64{
|
|
||||||
"memory_usage": 0.8, // 80%
|
|
||||||
"memory_pressure": 0.7, // 70%
|
|
||||||
"cache_usage": 0.9, // 90%
|
|
||||||
"session_usage": 0.85, // 85%
|
|
||||||
"error_rate": 0.1, // 10%
|
|
||||||
},
|
|
||||||
alerts: make([]ResourceAlert, 0),
|
|
||||||
perfMetrics: perfMetrics,
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start monitoring routine
|
|
||||||
go rm.startMonitoring()
|
|
||||||
|
|
||||||
return rm
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMemoryLimit sets the maximum memory usage limit
|
|
||||||
func (rm *ResourceMonitor) SetMemoryLimit(bytes int64) {
|
|
||||||
rm.maxMemoryBytes = bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCacheLimit sets the maximum cache size limit
|
|
||||||
func (rm *ResourceMonitor) SetCacheLimit(size int64) {
|
|
||||||
rm.maxCacheSize = size
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSessionLimit sets the maximum session count limit
|
|
||||||
func (rm *ResourceMonitor) SetSessionLimit(count int64) {
|
|
||||||
rm.maxSessions = count
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateCacheSize updates the size of a specific cache
|
|
||||||
func (rm *ResourceMonitor) UpdateCacheSize(cacheName string, size int64) {
|
|
||||||
rm.cacheMutex.Lock()
|
|
||||||
defer rm.cacheMutex.Unlock()
|
|
||||||
rm.cacheSizes[cacheName] = size
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCacheSizes returns current cache sizes
|
|
||||||
func (rm *ResourceMonitor) GetCacheSizes() map[string]int64 {
|
|
||||||
rm.cacheMutex.RLock()
|
|
||||||
defer rm.cacheMutex.RUnlock()
|
|
||||||
|
|
||||||
sizes := make(map[string]int64)
|
|
||||||
for name, size := range rm.cacheSizes {
|
|
||||||
sizes[name] = size
|
|
||||||
}
|
|
||||||
return sizes
|
|
||||||
}
|
|
||||||
|
|
||||||
// startMonitoring starts the background monitoring routine
|
|
||||||
func (rm *ResourceMonitor) startMonitoring() {
|
|
||||||
ticker := time.NewTicker(10 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for range ticker.C {
|
|
||||||
rm.checkResourceUsage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkResourceUsage checks current resource usage against limits
|
|
||||||
func (rm *ResourceMonitor) checkResourceUsage() {
|
|
||||||
metrics := rm.perfMetrics.GetMetrics()
|
|
||||||
|
|
||||||
// Check memory usage
|
|
||||||
if memUsage, ok := metrics["memory_usage_bytes"].(int64); ok {
|
|
||||||
memUsageRatio := float64(memUsage) / float64(rm.maxMemoryBytes)
|
|
||||||
if memUsageRatio > rm.alertThresholds["memory_usage"] {
|
|
||||||
rm.addAlert(ResourceAlert{
|
|
||||||
Type: "memory_usage",
|
|
||||||
Message: "Memory usage exceeds threshold",
|
|
||||||
Threshold: rm.alertThresholds["memory_usage"],
|
|
||||||
CurrentValue: memUsageRatio,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Severity: rm.getSeverity(memUsageRatio, rm.alertThresholds["memory_usage"]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check memory pressure
|
|
||||||
if memPressure, ok := metrics["memory_pressure"].(int64); ok {
|
|
||||||
pressureRatio := float64(memPressure) / 100.0 // Convert to 0-1 scale
|
|
||||||
if pressureRatio > rm.alertThresholds["memory_pressure"] {
|
|
||||||
rm.addAlert(ResourceAlert{
|
|
||||||
Type: "memory_pressure",
|
|
||||||
Message: "Memory pressure exceeds threshold",
|
|
||||||
Threshold: rm.alertThresholds["memory_pressure"],
|
|
||||||
CurrentValue: pressureRatio,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Severity: rm.getSeverity(pressureRatio, rm.alertThresholds["memory_pressure"]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cache usage
|
|
||||||
if cacheSize, ok := metrics["cache_size"].(int64); ok {
|
|
||||||
cacheUsageRatio := float64(cacheSize) / float64(rm.maxCacheSize)
|
|
||||||
if cacheUsageRatio > rm.alertThresholds["cache_usage"] {
|
|
||||||
rm.addAlert(ResourceAlert{
|
|
||||||
Type: "cache_usage",
|
|
||||||
Message: "Cache usage exceeds threshold",
|
|
||||||
Threshold: rm.alertThresholds["cache_usage"],
|
|
||||||
CurrentValue: cacheUsageRatio,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Severity: rm.getSeverity(cacheUsageRatio, rm.alertThresholds["cache_usage"]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check session usage
|
|
||||||
if activeSessions, ok := metrics["active_sessions"].(int64); ok {
|
|
||||||
sessionUsageRatio := float64(activeSessions) / float64(rm.maxSessions)
|
|
||||||
if sessionUsageRatio > rm.alertThresholds["session_usage"] {
|
|
||||||
rm.addAlert(ResourceAlert{
|
|
||||||
Type: "session_usage",
|
|
||||||
Message: "Active session count exceeds threshold",
|
|
||||||
Threshold: rm.alertThresholds["session_usage"],
|
|
||||||
CurrentValue: sessionUsageRatio,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Severity: rm.getSeverity(sessionUsageRatio, rm.alertThresholds["session_usage"]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check error rates
|
|
||||||
if errorRate, ok := metrics["verification_error_rate"].(float64); ok {
|
|
||||||
if errorRate > rm.alertThresholds["error_rate"] {
|
|
||||||
rm.addAlert(ResourceAlert{
|
|
||||||
Type: "verification_error_rate",
|
|
||||||
Message: "Token verification error rate exceeds threshold",
|
|
||||||
Threshold: rm.alertThresholds["error_rate"],
|
|
||||||
CurrentValue: errorRate,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Severity: rm.getSeverity(errorRate, rm.alertThresholds["error_rate"]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSeverity determines the severity level based on how much the threshold is exceeded
|
|
||||||
func (rm *ResourceMonitor) getSeverity(currentValue, threshold float64) string {
|
|
||||||
ratio := currentValue / threshold
|
|
||||||
if ratio >= 1.5 {
|
|
||||||
return "critical"
|
|
||||||
} else if ratio >= 1.2 {
|
|
||||||
return "high"
|
|
||||||
} else if ratio >= 1.0 {
|
|
||||||
return "medium"
|
|
||||||
}
|
|
||||||
return "low"
|
|
||||||
}
|
|
||||||
|
|
||||||
// addAlert adds a new resource alert
|
|
||||||
func (rm *ResourceMonitor) addAlert(alert ResourceAlert) {
|
|
||||||
rm.alertsMutex.Lock()
|
|
||||||
defer rm.alertsMutex.Unlock()
|
|
||||||
|
|
||||||
// Add alert
|
|
||||||
rm.alerts = append(rm.alerts, alert)
|
|
||||||
|
|
||||||
// Keep only last 100 alerts
|
|
||||||
if len(rm.alerts) > 100 {
|
|
||||||
rm.alerts = rm.alerts[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the alert
|
|
||||||
rm.logger.Errorf("Resource Alert [%s/%s]: %s (%.2f%% > %.2f%%)",
|
|
||||||
alert.Type, alert.Severity, alert.Message,
|
|
||||||
alert.CurrentValue*100, alert.Threshold*100)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAlerts returns current resource alerts
|
|
||||||
func (rm *ResourceMonitor) GetAlerts() []ResourceAlert {
|
|
||||||
rm.alertsMutex.RLock()
|
|
||||||
defer rm.alertsMutex.RUnlock()
|
|
||||||
|
|
||||||
alerts := make([]ResourceAlert, len(rm.alerts))
|
|
||||||
copy(alerts, rm.alerts)
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetResourceStatus returns current resource status
|
|
||||||
func (rm *ResourceMonitor) GetResourceStatus() map[string]interface{} {
|
|
||||||
metrics := rm.perfMetrics.GetMetrics()
|
|
||||||
cacheSizes := rm.GetCacheSizes()
|
|
||||||
|
|
||||||
status := map[string]interface{}{
|
|
||||||
"limits": map[string]interface{}{
|
|
||||||
"max_memory_bytes": rm.maxMemoryBytes,
|
|
||||||
"max_cache_size": rm.maxCacheSize,
|
|
||||||
"max_sessions": rm.maxSessions,
|
|
||||||
},
|
|
||||||
"thresholds": rm.alertThresholds,
|
|
||||||
"current": metrics,
|
|
||||||
"cache_sizes": cacheSizes,
|
|
||||||
// Add expected keys for tests
|
|
||||||
"memory_limit": uint64(rm.maxMemoryBytes),
|
|
||||||
"cache_limit": int(rm.maxCacheSize),
|
|
||||||
"session_limit": int(rm.maxSessions),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate usage ratios
|
|
||||||
if memUsage, ok := metrics["memory_usage_bytes"].(int64); ok {
|
|
||||||
status["memory_usage_ratio"] = float64(memUsage) / float64(rm.maxMemoryBytes)
|
|
||||||
}
|
|
||||||
if memPressure, ok := metrics["memory_pressure"].(int64); ok {
|
|
||||||
status["memory_pressure_ratio"] = float64(memPressure) / 100.0
|
|
||||||
}
|
|
||||||
if cacheSize, ok := metrics["cache_size"].(int64); ok {
|
|
||||||
status["cache_usage_ratio"] = float64(cacheSize) / float64(rm.maxCacheSize)
|
|
||||||
}
|
|
||||||
if activeSessions, ok := metrics["active_sessions"].(int64); ok {
|
|
||||||
status["session_usage_ratio"] = float64(activeSessions) / float64(rm.maxSessions)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate total cache size across all caches
|
|
||||||
var totalCacheSize int64
|
|
||||||
for _, size := range cacheSizes {
|
|
||||||
totalCacheSize += size
|
|
||||||
}
|
|
||||||
status["total_cache_size"] = totalCacheSize
|
|
||||||
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPerformanceMetrics(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
metrics := NewPerformanceMetrics(logger)
|
|
||||||
|
|
||||||
t.Run("Record cache operations", func(t *testing.T) {
|
|
||||||
metrics.RecordCacheHit()
|
|
||||||
metrics.RecordCacheMiss()
|
|
||||||
metrics.RecordCacheEviction()
|
|
||||||
metrics.UpdateCacheSize(100)
|
|
||||||
|
|
||||||
result := metrics.GetMetrics()
|
|
||||||
|
|
||||||
if result["cache_hits"].(int64) != 1 {
|
|
||||||
t.Errorf("Expected 1 cache hit, got %v", result["cache_hits"])
|
|
||||||
}
|
|
||||||
if result["cache_misses"].(int64) != 1 {
|
|
||||||
t.Errorf("Expected 1 cache miss, got %v", result["cache_misses"])
|
|
||||||
}
|
|
||||||
if result["cache_evictions"].(int64) != 1 {
|
|
||||||
t.Errorf("Expected 1 cache eviction, got %v", result["cache_evictions"])
|
|
||||||
}
|
|
||||||
if result["cache_size"].(int64) != 100 {
|
|
||||||
t.Errorf("Expected cache size 100, got %v", result["cache_size"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Record token operations", func(t *testing.T) {
|
|
||||||
start := time.Now()
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
metrics.RecordTokenVerification(time.Since(start), true)
|
|
||||||
|
|
||||||
start = time.Now()
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
metrics.RecordTokenValidation(time.Since(start), false)
|
|
||||||
|
|
||||||
start = time.Now()
|
|
||||||
time.Sleep(15 * time.Millisecond)
|
|
||||||
metrics.RecordTokenRefresh(time.Since(start), true)
|
|
||||||
|
|
||||||
result := metrics.GetMetrics()
|
|
||||||
|
|
||||||
if result["token_verifications"].(int64) != 1 {
|
|
||||||
t.Errorf("Expected 1 token verification, got %v", result["token_verifications"])
|
|
||||||
}
|
|
||||||
if result["token_validations"].(int64) != 1 {
|
|
||||||
t.Errorf("Expected 1 token validation, got %v", result["token_validations"])
|
|
||||||
}
|
|
||||||
if result["token_refreshes"].(int64) != 1 {
|
|
||||||
t.Errorf("Expected 1 token refresh, got %v", result["token_refreshes"])
|
|
||||||
}
|
|
||||||
if result["successful_verifications"].(int64) != 1 {
|
|
||||||
t.Errorf("Expected 1 successful verification, got %v", result["successful_verifications"])
|
|
||||||
}
|
|
||||||
if result["failed_validations"].(int64) != 1 {
|
|
||||||
t.Errorf("Expected 1 failed validation, got %v", result["failed_validations"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Record rate limiting and sessions", func(t *testing.T) {
|
|
||||||
metrics.RecordRateLimitedRequest()
|
|
||||||
metrics.RecordSessionCreation()
|
|
||||||
metrics.RecordSessionDeletion()
|
|
||||||
|
|
||||||
result := metrics.GetMetrics()
|
|
||||||
|
|
||||||
if result["rate_limited_requests"].(int64) != 1 {
|
|
||||||
t.Errorf("Expected 1 rate limited request, got %v", result["rate_limited_requests"])
|
|
||||||
}
|
|
||||||
if result["sessions_created"].(int64) != 1 {
|
|
||||||
t.Errorf("Expected 1 session created, got %v", result["sessions_created"])
|
|
||||||
}
|
|
||||||
if result["sessions_deleted"].(int64) != 1 {
|
|
||||||
t.Errorf("Expected 1 session deleted, got %v", result["sessions_deleted"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Get detailed timing metrics", func(t *testing.T) {
|
|
||||||
// Add more timing data
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
metrics.RecordTokenVerification(time.Duration(i+1)*time.Millisecond, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
detailed := metrics.GetDetailedTimingMetrics()
|
|
||||||
|
|
||||||
if detailed["verification_stats"] == nil {
|
|
||||||
t.Error("Expected verification stats to be present")
|
|
||||||
}
|
|
||||||
|
|
||||||
verificationStats := detailed["verification_stats"].(map[string]interface{})
|
|
||||||
if verificationStats["count"].(int) != 6 { // 1 from previous test + 5 new
|
|
||||||
t.Errorf("Expected 6 verifications, got %v", verificationStats["count"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResourceMonitor(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
metrics := NewPerformanceMetrics(logger)
|
|
||||||
monitor := NewResourceMonitor(metrics, logger)
|
|
||||||
|
|
||||||
t.Run("Set limits", func(t *testing.T) {
|
|
||||||
monitor.SetMemoryLimit(100 * 1024 * 1024) // 100MB
|
|
||||||
monitor.SetCacheLimit(1000)
|
|
||||||
monitor.SetSessionLimit(500)
|
|
||||||
|
|
||||||
// Should not panic
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Get resource status", func(t *testing.T) {
|
|
||||||
status := monitor.GetResourceStatus()
|
|
||||||
|
|
||||||
if status["memory_limit"] == nil {
|
|
||||||
t.Error("Expected memory limit to be set")
|
|
||||||
}
|
|
||||||
if status["cache_limit"] == nil {
|
|
||||||
t.Error("Expected cache limit to be set")
|
|
||||||
}
|
|
||||||
if status["session_limit"] == nil {
|
|
||||||
t.Error("Expected session limit to be set")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Get alerts", func(t *testing.T) {
|
|
||||||
alerts := monitor.GetAlerts()
|
|
||||||
|
|
||||||
// Should return empty slice initially
|
|
||||||
if alerts == nil {
|
|
||||||
t.Error("Expected alerts slice to be initialized")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPerformanceMetricsCalculations(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
metrics := NewPerformanceMetrics(logger)
|
|
||||||
|
|
||||||
t.Run("Average calculation", func(t *testing.T) {
|
|
||||||
// Record multiple operations with known durations
|
|
||||||
durations := []time.Duration{
|
|
||||||
10 * time.Millisecond,
|
|
||||||
20 * time.Millisecond,
|
|
||||||
30 * time.Millisecond,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, d := range durations {
|
|
||||||
metrics.RecordTokenVerification(d, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
detailed := metrics.GetDetailedTimingMetrics()
|
|
||||||
verificationStats := detailed["verification_stats"].(map[string]interface{})
|
|
||||||
|
|
||||||
// Average should be 20ms
|
|
||||||
avgMs := verificationStats["average_ms"].(float64)
|
|
||||||
if avgMs < 19 || avgMs > 21 { // Allow small variance
|
|
||||||
t.Errorf("Expected average around 20ms, got %f", avgMs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Min/Max calculation", func(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
metrics := NewPerformanceMetrics(logger) // Fresh instance
|
|
||||||
|
|
||||||
durations := []time.Duration{
|
|
||||||
5 * time.Millisecond,
|
|
||||||
50 * time.Millisecond,
|
|
||||||
25 * time.Millisecond,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, d := range durations {
|
|
||||||
metrics.RecordTokenVerification(d, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
detailed := metrics.GetDetailedTimingMetrics()
|
|
||||||
verificationStats := detailed["verification_stats"].(map[string]interface{})
|
|
||||||
|
|
||||||
minMs := verificationStats["min_ms"].(float64)
|
|
||||||
maxMs := verificationStats["max_ms"].(float64)
|
|
||||||
|
|
||||||
if minMs < 4 || minMs > 6 {
|
|
||||||
t.Errorf("Expected min around 5ms, got %f", minMs)
|
|
||||||
}
|
|
||||||
if maxMs < 49 || maxMs > 51 {
|
|
||||||
t.Errorf("Expected max around 50ms, got %f", maxMs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPerformanceMetricsReset(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
metrics := NewPerformanceMetrics(logger)
|
|
||||||
|
|
||||||
// Record some data
|
|
||||||
metrics.RecordCacheHit()
|
|
||||||
metrics.RecordTokenVerification(10*time.Millisecond, true)
|
|
||||||
|
|
||||||
// Verify data is there
|
|
||||||
result := metrics.GetMetrics()
|
|
||||||
if result["cache_hits"].(int64) != 1 {
|
|
||||||
t.Error("Expected cache hit to be recorded")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: The current implementation doesn't have a reset method,
|
|
||||||
// but we can test that metrics accumulate correctly
|
|
||||||
metrics.RecordCacheHit()
|
|
||||||
result = metrics.GetMetrics()
|
|
||||||
if result["cache_hits"].(int64) != 2 {
|
|
||||||
t.Error("Expected cache hits to accumulate")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPerformanceMetricsConcurrency(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
metrics := NewPerformanceMetrics(logger)
|
|
||||||
|
|
||||||
// Test concurrent access
|
|
||||||
done := make(chan bool, 10)
|
|
||||||
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
go func() {
|
|
||||||
defer func() { done <- true }()
|
|
||||||
|
|
||||||
for j := 0; j < 100; j++ {
|
|
||||||
metrics.RecordCacheHit()
|
|
||||||
metrics.RecordTokenVerification(time.Millisecond, true)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for all goroutines to complete
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
<-done
|
|
||||||
}
|
|
||||||
|
|
||||||
result := metrics.GetMetrics()
|
|
||||||
|
|
||||||
// Should have 1000 cache hits (10 goroutines * 100 operations)
|
|
||||||
if result["cache_hits"].(int64) != 1000 {
|
|
||||||
t.Errorf("Expected 1000 cache hits, got %v", result["cache_hits"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have 1000 token verifications
|
|
||||||
if result["token_verifications"].(int64) != 1000 {
|
|
||||||
t.Errorf("Expected 1000 token verifications, got %v", result["token_verifications"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResourceMonitorLimits(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
metrics := NewPerformanceMetrics(logger)
|
|
||||||
monitor := NewResourceMonitor(metrics, logger)
|
|
||||||
|
|
||||||
t.Run("Memory limit validation", func(t *testing.T) {
|
|
||||||
// Set a reasonable memory limit
|
|
||||||
monitor.SetMemoryLimit(50 * 1024 * 1024) // 50MB
|
|
||||||
|
|
||||||
status := monitor.GetResourceStatus()
|
|
||||||
if status["memory_limit"].(uint64) != 50*1024*1024 {
|
|
||||||
t.Error("Memory limit not set correctly")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Cache limit validation", func(t *testing.T) {
|
|
||||||
monitor.SetCacheLimit(2000)
|
|
||||||
|
|
||||||
status := monitor.GetResourceStatus()
|
|
||||||
if status["cache_limit"].(int) != 2000 {
|
|
||||||
t.Error("Cache limit not set correctly")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Session limit validation", func(t *testing.T) {
|
|
||||||
monitor.SetSessionLimit(1000)
|
|
||||||
|
|
||||||
status := monitor.GetResourceStatus()
|
|
||||||
if status["session_limit"].(int) != 1000 {
|
|
||||||
t.Error("Session limit not set correctly")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPerformanceMetricsEdgeCases(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
metrics := NewPerformanceMetrics(logger)
|
|
||||||
|
|
||||||
t.Run("Zero duration handling", func(t *testing.T) {
|
|
||||||
metrics.RecordTokenVerification(0, true)
|
|
||||||
|
|
||||||
result := metrics.GetMetrics()
|
|
||||||
if result["token_verifications"].(int64) != 1 {
|
|
||||||
t.Error("Should record verification even with zero duration")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Very large duration handling", func(t *testing.T) {
|
|
||||||
largeDuration := time.Hour
|
|
||||||
metrics.RecordTokenVerification(largeDuration, true)
|
|
||||||
|
|
||||||
detailed := metrics.GetDetailedTimingMetrics()
|
|
||||||
verificationStats := detailed["verification_stats"].(map[string]interface{})
|
|
||||||
|
|
||||||
// Should handle large durations without overflow
|
|
||||||
if verificationStats["max_ms"].(float64) <= 0 {
|
|
||||||
t.Error("Should handle large durations correctly")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Negative cache size handling", func(t *testing.T) {
|
|
||||||
// This shouldn't happen in practice, but test robustness
|
|
||||||
metrics.UpdateCacheSize(-1)
|
|
||||||
|
|
||||||
result := metrics.GetMetrics()
|
|
||||||
// Implementation should handle this gracefully
|
|
||||||
if result["cache_size"] == nil {
|
|
||||||
t.Error("Cache size should be present even if negative")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,781 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestConcurrentTokenVerification tests race conditions in token verification
|
|
||||||
func TestConcurrentTokenVerification(t *testing.T) {
|
|
||||||
ts := &TestSuite{t: t}
|
|
||||||
ts.Setup()
|
|
||||||
|
|
||||||
// Create multiple valid tokens to avoid replay detection
|
|
||||||
tokens := make([]string, 10)
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
||||||
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"sub": "test-subject",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"jti": generateRandomString(16),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create test token %d: %v", i, err)
|
|
||||||
}
|
|
||||||
tokens[i] = token
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a fresh instance for this test
|
|
||||||
tOidc := &TraefikOidc{
|
|
||||||
issuerURL: "https://test-issuer.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
jwkCache: ts.mockJWKCache,
|
|
||||||
tokenBlacklist: NewCache(),
|
|
||||||
tokenCache: NewTokenCache(),
|
|
||||||
limiter: rate.NewLimiter(rate.Every(time.Microsecond), 10000), // Very high rate limit
|
|
||||||
logger: NewLogger("debug"),
|
|
||||||
allowedUserDomains: map[string]struct{}{"example.com": {}},
|
|
||||||
httpClient: &http.Client{},
|
|
||||||
extractClaimsFunc: extractClaims,
|
|
||||||
}
|
|
||||||
tOidc.tokenVerifier = tOidc
|
|
||||||
tOidc.jwtVerifier = tOidc
|
|
||||||
|
|
||||||
// Ensure cleanup when test finishes
|
|
||||||
defer func() {
|
|
||||||
if err := tOidc.Close(); err != nil {
|
|
||||||
t.Logf("Error closing TraefikOidc instance: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Test concurrent verification
|
|
||||||
const numGoroutines = 50
|
|
||||||
const verificationsPerGoroutine = 10
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
var successCount int64
|
|
||||||
var errorCount int64
|
|
||||||
errors := make(chan error, numGoroutines*verificationsPerGoroutine)
|
|
||||||
|
|
||||||
for i := 0; i < numGoroutines; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(goroutineID int) {
|
|
||||||
defer wg.Done()
|
|
||||||
for j := 0; j < verificationsPerGoroutine; j++ {
|
|
||||||
tokenIndex := (goroutineID*verificationsPerGoroutine + j) % len(tokens)
|
|
||||||
err := tOidc.VerifyToken(tokens[tokenIndex])
|
|
||||||
if err != nil {
|
|
||||||
atomic.AddInt64(&errorCount, 1)
|
|
||||||
select {
|
|
||||||
case errors <- fmt.Errorf("goroutine %d, verification %d: %w", goroutineID, j, err):
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
atomic.AddInt64(&successCount, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
close(errors)
|
|
||||||
|
|
||||||
// Check results
|
|
||||||
totalOperations := int64(numGoroutines * verificationsPerGoroutine)
|
|
||||||
t.Logf("Concurrent verification results: %d successes, %d errors out of %d total operations",
|
|
||||||
successCount, errorCount, totalOperations)
|
|
||||||
|
|
||||||
// Collect and log errors
|
|
||||||
var errorList []error
|
|
||||||
for err := range errors {
|
|
||||||
errorList = append(errorList, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(errorList) > 0 {
|
|
||||||
t.Logf("Errors encountered during concurrent verification:")
|
|
||||||
for i, err := range errorList {
|
|
||||||
if i < 10 { // Log first 10 errors
|
|
||||||
t.Logf(" %d: %v", i+1, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(errorList) > 10 {
|
|
||||||
t.Logf(" ... and %d more errors", len(errorList)-10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We expect most operations to succeed
|
|
||||||
if successCount < totalOperations/2 {
|
|
||||||
t.Errorf("Too many failures in concurrent verification: %d successes out of %d operations", successCount, totalOperations)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for data races by verifying cache consistency
|
|
||||||
cacheSize := len(tOidc.tokenCache.cache.items)
|
|
||||||
blacklistSize := len(tOidc.tokenBlacklist.items)
|
|
||||||
t.Logf("Final cache sizes: token cache=%d, blacklist=%d", cacheSize, blacklistSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCacheMemoryExhaustion tests cache behavior under memory pressure
|
|
||||||
func TestCacheMemoryExhaustion(t *testing.T) {
|
|
||||||
ts := &TestSuite{t: t}
|
|
||||||
ts.Setup()
|
|
||||||
|
|
||||||
// Create a cache with limited size
|
|
||||||
cache := NewTokenCache()
|
|
||||||
cache.cache.SetMaxSize(100) // Small cache size
|
|
||||||
|
|
||||||
// Ensure cleanup when test finishes
|
|
||||||
defer cache.Close()
|
|
||||||
|
|
||||||
// Create many tokens to exceed cache capacity
|
|
||||||
const numTokens = 500
|
|
||||||
tokens := make([]string, numTokens)
|
|
||||||
|
|
||||||
for i := 0; i < numTokens; i++ {
|
|
||||||
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
||||||
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"sub": "test-subject",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"jti": fmt.Sprintf("jti-%d", i),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create token %d: %v", i, err)
|
|
||||||
}
|
|
||||||
tokens[i] = token
|
|
||||||
|
|
||||||
// Add to cache
|
|
||||||
claims := map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
||||||
"sub": "test-subject",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"jti": fmt.Sprintf("jti-%d", i),
|
|
||||||
}
|
|
||||||
cache.Set(token, claims, time.Hour)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify cache size is within limits
|
|
||||||
cacheSize := len(cache.cache.items)
|
|
||||||
if cacheSize > 100 {
|
|
||||||
t.Errorf("Cache size exceeded limit: got %d, expected <= 100", cacheSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify LRU eviction works
|
|
||||||
// The first tokens should have been evicted
|
|
||||||
firstToken := tokens[0]
|
|
||||||
if _, exists := cache.Get(firstToken); exists {
|
|
||||||
t.Errorf("First token should have been evicted from cache")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The last tokens should still be in cache
|
|
||||||
lastToken := tokens[numTokens-1]
|
|
||||||
if _, exists := cache.Get(lastToken); !exists {
|
|
||||||
t.Errorf("Last token should still be in cache")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Cache memory exhaustion test passed: cache size=%d", cacheSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSessionConcurrencyProtection tests session safety under concurrent access
|
|
||||||
func TestSessionConcurrencyProtection(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
sessionManager, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create session manager: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test concurrent session access with separate requests
|
|
||||||
const numGoroutines = 20
|
|
||||||
const operationsPerGoroutine = 10 // Reduced to avoid overwhelming
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
var successCount int64
|
|
||||||
var errorCount int64
|
|
||||||
|
|
||||||
for i := 0; i < numGoroutines; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(goroutineID int) {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
// Each goroutine gets its own request and session
|
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
|
|
||||||
for j := 0; j < operationsPerGoroutine; j++ {
|
|
||||||
// Get a fresh session for each operation
|
|
||||||
s, err := sessionManager.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
atomic.AddInt64(&errorCount, 1)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform operations on session
|
|
||||||
s.SetEmail(fmt.Sprintf("user%d-%[email protected]", goroutineID, j))
|
|
||||||
s.SetAuthenticated(true)
|
|
||||||
s.SetAccessToken(fmt.Sprintf("token-%d-%d", goroutineID, j))
|
|
||||||
|
|
||||||
// Save session
|
|
||||||
testRR := httptest.NewRecorder()
|
|
||||||
if err := s.Save(req, testRR); err != nil {
|
|
||||||
atomic.AddInt64(&errorCount, 1)
|
|
||||||
} else {
|
|
||||||
atomic.AddInt64(&successCount, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy cookies back to request for next iteration
|
|
||||||
for _, cookie := range testRR.Result().Cookies() {
|
|
||||||
req.Header.Set("Cookie", cookie.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
totalOperations := int64(numGoroutines * operationsPerGoroutine)
|
|
||||||
t.Logf("Session concurrency test results: %d successes, %d errors out of %d operations",
|
|
||||||
successCount, errorCount, totalOperations)
|
|
||||||
|
|
||||||
// Most operations should succeed
|
|
||||||
if successCount < totalOperations/2 {
|
|
||||||
t.Errorf("Too many session operation failures: %d successes out of %d operations", successCount, totalOperations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestParallelCacheOperations tests cache thread safety
|
|
||||||
func TestParallelCacheOperations(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
cache.SetMaxSize(1000)
|
|
||||||
|
|
||||||
// Ensure cleanup when test finishes
|
|
||||||
defer cache.Close()
|
|
||||||
|
|
||||||
const numGoroutines = 10
|
|
||||||
const operationsPerGoroutine = 100
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
var setCount int64
|
|
||||||
var getCount int64
|
|
||||||
var deleteCount int64
|
|
||||||
|
|
||||||
// Start multiple goroutines performing cache operations
|
|
||||||
for i := 0; i < numGoroutines; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(goroutineID int) {
|
|
||||||
defer wg.Done()
|
|
||||||
for j := 0; j < operationsPerGoroutine; j++ {
|
|
||||||
key := fmt.Sprintf("key-%d-%d", goroutineID, j)
|
|
||||||
value := fmt.Sprintf("value-%d-%d", goroutineID, j)
|
|
||||||
|
|
||||||
// Set operation
|
|
||||||
cache.Set(key, value, time.Minute)
|
|
||||||
atomic.AddInt64(&setCount, 1)
|
|
||||||
|
|
||||||
// Get operation
|
|
||||||
if _, exists := cache.Get(key); exists {
|
|
||||||
atomic.AddInt64(&getCount, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete some items
|
|
||||||
if j%10 == 0 {
|
|
||||||
cache.Delete(key)
|
|
||||||
atomic.AddInt64(&deleteCount, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
t.Logf("Parallel cache operations completed: %d sets, %d gets, %d deletes",
|
|
||||||
setCount, getCount, deleteCount)
|
|
||||||
|
|
||||||
// Verify cache is still functional
|
|
||||||
cache.Set("test-key", "test-value", time.Minute)
|
|
||||||
if value, exists := cache.Get("test-key"); !exists || value != "test-value" {
|
|
||||||
t.Errorf("Cache corrupted after parallel operations")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cache size is reasonable
|
|
||||||
cacheSize := len(cache.items)
|
|
||||||
expectedSize := int(setCount - deleteCount)
|
|
||||||
if cacheSize > expectedSize {
|
|
||||||
t.Logf("Cache size after operations: %d (expected around %d)", cacheSize, expectedSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProviderFailureRecovery tests network failure scenarios
|
|
||||||
func TestProviderFailureRecovery(t *testing.T) {
|
|
||||||
// Create a server that fails initially then recovers
|
|
||||||
var requestCount int64
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
count := atomic.AddInt64(&requestCount, 1)
|
|
||||||
if count <= 3 {
|
|
||||||
// Fail first 3 requests
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Succeed after 3 failures
|
|
||||||
metadata := ProviderMetadata{
|
|
||||||
Issuer: "https://test-issuer.com",
|
|
||||||
AuthURL: "https://test-issuer.com/auth",
|
|
||||||
TokenURL: "https://test-issuer.com/token",
|
|
||||||
JWKSURL: "https://test-issuer.com/jwks",
|
|
||||||
RevokeURL: "https://test-issuer.com/revoke",
|
|
||||||
EndSessionURL: "https://test-issuer.com/end-session",
|
|
||||||
}
|
|
||||||
json.NewEncoder(w).Encode(metadata)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Test metadata discovery with retries
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
httpClient := createDefaultHTTPClient()
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
metadata, err := discoverProviderMetadata(server.URL, httpClient, logger)
|
|
||||||
duration := time.Since(start)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Provider metadata discovery failed after retries: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata == nil {
|
|
||||||
t.Errorf("Expected metadata to be returned after recovery")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have taken some time due to retries (at least the sum of delays: 10ms + 20ms + 40ms = 70ms)
|
|
||||||
expectedMinDuration := 70 * time.Millisecond
|
|
||||||
if duration < expectedMinDuration {
|
|
||||||
t.Errorf("Expected discovery to take at least %v due to retries, but took %v", expectedMinDuration, duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Provider failure recovery test passed: %d requests, duration: %v", requestCount, duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOversizedTokenHandling tests boundary value handling
|
|
||||||
func TestOversizedTokenHandling(t *testing.T) {
|
|
||||||
ts := &TestSuite{t: t}
|
|
||||||
ts.Setup()
|
|
||||||
|
|
||||||
// Create an oversized token with large claims
|
|
||||||
largeClaim := strings.Repeat("x", 10000) // 10KB claim
|
|
||||||
oversizedClaims := map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
||||||
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"sub": "test-subject",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"jti": generateRandomString(16),
|
|
||||||
"large_data": largeClaim,
|
|
||||||
}
|
|
||||||
|
|
||||||
oversizedToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", oversizedClaims)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create oversized token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Created oversized token of length: %d bytes", len(oversizedToken))
|
|
||||||
|
|
||||||
// Test verification of oversized token
|
|
||||||
err = ts.tOidc.VerifyToken(oversizedToken)
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("Oversized token verification failed as expected: %v", err)
|
|
||||||
// This is acceptable - oversized tokens should be rejected
|
|
||||||
} else {
|
|
||||||
t.Logf("Oversized token verification succeeded")
|
|
||||||
// Verify it was cached properly
|
|
||||||
if _, exists := ts.tOidc.tokenCache.Get(oversizedToken); !exists {
|
|
||||||
t.Errorf("Oversized token was not cached after successful verification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test extremely long token (beyond reasonable limits)
|
|
||||||
extremelyLongClaim := strings.Repeat("y", 100000) // 100KB claim
|
|
||||||
extremeClaims := map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
||||||
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"sub": "test-subject",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"jti": generateRandomString(16),
|
|
||||||
"extreme_data": extremelyLongClaim,
|
|
||||||
}
|
|
||||||
|
|
||||||
extremeToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", extremeClaims)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create extreme token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Created extreme token of length: %d bytes", len(extremeToken))
|
|
||||||
|
|
||||||
// This should likely fail due to size limits
|
|
||||||
err = ts.tOidc.VerifyToken(extremeToken)
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("Extreme token verification failed as expected: %v", err)
|
|
||||||
} else {
|
|
||||||
t.Logf("Warning: Extreme token verification succeeded - consider adding size limits")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMaliciousInputValidation tests security input validation
|
|
||||||
func TestMaliciousInputValidation(t *testing.T) {
|
|
||||||
ts := &TestSuite{t: t}
|
|
||||||
ts.Setup()
|
|
||||||
|
|
||||||
maliciousInputs := []struct {
|
|
||||||
name string
|
|
||||||
token string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Empty token",
|
|
||||||
token: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Single dot",
|
|
||||||
token: ".",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Two dots only",
|
|
||||||
token: "..",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SQL injection attempt",
|
|
||||||
token: "'; DROP TABLE users; --",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Script injection attempt",
|
|
||||||
token: "<script>alert('xss')</script>",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Path traversal attempt",
|
|
||||||
token: "../../../etc/passwd",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Null bytes",
|
|
||||||
token: "token\x00with\x00nulls",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Unicode control characters",
|
|
||||||
token: "token\u0000\u0001\u0002",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Extremely long string",
|
|
||||||
token: strings.Repeat("a", 1000000), // 1MB string
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid base64 characters",
|
|
||||||
token: "header.payload!@#$%^&*().signature",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Binary data",
|
|
||||||
token: string([]byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range maliciousInputs {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
// Create a fresh instance for each test to avoid rate limiting issues
|
|
||||||
freshOidc := &TraefikOidc{
|
|
||||||
issuerURL: "https://test-issuer.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
jwkCache: ts.mockJWKCache,
|
|
||||||
tokenBlacklist: NewCache(),
|
|
||||||
tokenCache: NewTokenCache(),
|
|
||||||
limiter: rate.NewLimiter(rate.Every(time.Microsecond), 10000), // Very high rate limit
|
|
||||||
logger: NewLogger("debug"),
|
|
||||||
allowedUserDomains: map[string]struct{}{"example.com": {}},
|
|
||||||
httpClient: &http.Client{},
|
|
||||||
extractClaimsFunc: extractClaims,
|
|
||||||
}
|
|
||||||
freshOidc.tokenVerifier = freshOidc
|
|
||||||
freshOidc.jwtVerifier = freshOidc
|
|
||||||
|
|
||||||
// Ensure cleanup when test finishes
|
|
||||||
defer func() {
|
|
||||||
if err := freshOidc.Close(); err != nil {
|
|
||||||
t.Logf("Error closing TraefikOidc instance: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// All malicious inputs should be safely rejected
|
|
||||||
err := freshOidc.VerifyToken(test.token)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("Malicious input '%s' was not rejected", test.name)
|
|
||||||
} else {
|
|
||||||
t.Logf("Malicious input '%s' correctly rejected: %v", test.name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the system is still functional after malicious input
|
|
||||||
validToken, createErr := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
||||||
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"sub": "test-subject",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"jti": generateRandomString(16),
|
|
||||||
})
|
|
||||||
if createErr != nil {
|
|
||||||
t.Fatalf("Failed to create valid token for recovery test: %v", createErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// System should still work with valid tokens
|
|
||||||
if verifyErr := freshOidc.VerifyToken(validToken); verifyErr != nil {
|
|
||||||
t.Errorf("System failed to process valid token after malicious input: %v", verifyErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNetworkErrorCleanup tests resource cleanup on network errors
|
|
||||||
func TestNetworkErrorCleanup(t *testing.T) {
|
|
||||||
// Create a server that times out
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Simulate network timeout by sleeping
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create HTTP client with short timeout
|
|
||||||
httpClient := &http.Client{
|
|
||||||
Timeout: 100 * time.Millisecond, // Very short timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
|
|
||||||
// Track goroutines before test
|
|
||||||
initialGoroutines := runtime.NumGoroutine()
|
|
||||||
|
|
||||||
// Attempt metadata discovery that should timeout
|
|
||||||
start := time.Now()
|
|
||||||
_, err := discoverProviderMetadata(server.URL, httpClient, logger)
|
|
||||||
duration := time.Since(start)
|
|
||||||
|
|
||||||
// Should fail due to timeout
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("Expected timeout error, but request succeeded")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should fail quickly due to timeout
|
|
||||||
if duration > time.Second {
|
|
||||||
t.Errorf("Request took too long despite timeout: %v", duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give time for cleanup
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
// Check for goroutine leaks
|
|
||||||
finalGoroutines := runtime.NumGoroutine()
|
|
||||||
if finalGoroutines > initialGoroutines+5 { // Allow some tolerance
|
|
||||||
t.Errorf("Potential goroutine leak: started with %d, ended with %d goroutines",
|
|
||||||
initialGoroutines, finalGoroutines)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Network error cleanup test passed: duration=%v, goroutines=%d->%d",
|
|
||||||
duration, initialGoroutines, finalGoroutines)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestResourceLimits tests system behavior under resource constraints
|
|
||||||
func TestResourceLimits(t *testing.T) {
|
|
||||||
// Test memory allocation limits
|
|
||||||
cache := NewCache()
|
|
||||||
cache.SetMaxSize(10) // Very small cache
|
|
||||||
|
|
||||||
// Ensure cleanup when test finishes
|
|
||||||
defer cache.Close()
|
|
||||||
|
|
||||||
// Try to overwhelm the cache
|
|
||||||
for i := 0; i < 1000; i++ {
|
|
||||||
key := fmt.Sprintf("key-%d", i)
|
|
||||||
value := fmt.Sprintf("value-%d", i)
|
|
||||||
cache.Set(key, value, time.Minute)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache should not exceed its limit
|
|
||||||
if len(cache.items) > 10 {
|
|
||||||
t.Errorf("Cache exceeded size limit: got %d items, expected <= 10", len(cache.items))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test rate limiting under load
|
|
||||||
limiter := rate.NewLimiter(rate.Every(time.Second), 5) // 5 requests per second
|
|
||||||
|
|
||||||
allowed := 0
|
|
||||||
denied := 0
|
|
||||||
|
|
||||||
// Make many requests quickly
|
|
||||||
for i := 0; i < 100; i++ {
|
|
||||||
if limiter.Allow() {
|
|
||||||
allowed++
|
|
||||||
} else {
|
|
||||||
denied++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Most should be denied due to rate limiting
|
|
||||||
if denied < 90 {
|
|
||||||
t.Errorf("Rate limiting not effective: allowed=%d, denied=%d", allowed, denied)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Resource limits test passed: cache size=%d, rate limiting: allowed=%d, denied=%d",
|
|
||||||
len(cache.items), allowed, denied)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestErrorRecoveryPatterns tests various error recovery scenarios
|
|
||||||
func TestErrorRecoveryPatterns(t *testing.T) {
|
|
||||||
ts := &TestSuite{t: t}
|
|
||||||
ts.Setup()
|
|
||||||
|
|
||||||
// Test recovery from cache corruption
|
|
||||||
t.Run("CacheCorruption", func(t *testing.T) {
|
|
||||||
// Corrupt the cache by setting invalid data
|
|
||||||
ts.tOidc.tokenCache.cache.items["corrupted"] = CacheItem{
|
|
||||||
Value: "invalid-data",
|
|
||||||
ExpiresAt: time.Now().Add(time.Hour),
|
|
||||||
}
|
|
||||||
|
|
||||||
// System should handle corrupted cache gracefully
|
|
||||||
validToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
||||||
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"sub": "test-subject",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"jti": generateRandomString(16),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create valid token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should still work despite cache corruption
|
|
||||||
if err := ts.tOidc.VerifyToken(validToken); err != nil {
|
|
||||||
t.Errorf("Token verification failed despite cache corruption: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test recovery from blacklist corruption
|
|
||||||
t.Run("BlacklistCorruption", func(t *testing.T) {
|
|
||||||
// Add invalid data to blacklist
|
|
||||||
ts.tOidc.tokenBlacklist.Set("corrupted-entry", "invalid-data", time.Hour)
|
|
||||||
|
|
||||||
// System should still function
|
|
||||||
validToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
||||||
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"sub": "test-subject",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"jti": generateRandomString(16),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create valid token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ts.tOidc.VerifyToken(validToken); err != nil {
|
|
||||||
t.Errorf("Token verification failed despite blacklist corruption: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPerformanceUnderLoad tests system performance under high load
|
|
||||||
func TestPerformanceUnderLoad(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("Skipping performance test in short mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
ts := &TestSuite{t: t}
|
|
||||||
ts.Setup()
|
|
||||||
|
|
||||||
// Create multiple valid tokens
|
|
||||||
const numTokens = 100
|
|
||||||
tokens := make([]string, numTokens)
|
|
||||||
for i := 0; i < numTokens; i++ {
|
|
||||||
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
||||||
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"nbf": float64(time.Now().Add(-2 * time.Minute).Unix()),
|
|
||||||
"sub": "test-subject",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"jti": fmt.Sprintf("jti-%d", i),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create token %d: %v", i, err)
|
|
||||||
}
|
|
||||||
tokens[i] = token
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create fresh instance with high rate limit
|
|
||||||
tOidc := &TraefikOidc{
|
|
||||||
issuerURL: "https://test-issuer.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
jwkCache: ts.mockJWKCache,
|
|
||||||
tokenBlacklist: NewCache(),
|
|
||||||
tokenCache: NewTokenCache(),
|
|
||||||
limiter: rate.NewLimiter(rate.Every(time.Microsecond), 10000), // Very high limit
|
|
||||||
logger: NewLogger("info"), // Reduce logging for performance
|
|
||||||
allowedUserDomains: map[string]struct{}{"example.com": {}},
|
|
||||||
httpClient: &http.Client{},
|
|
||||||
extractClaimsFunc: extractClaims,
|
|
||||||
}
|
|
||||||
tOidc.tokenVerifier = tOidc
|
|
||||||
tOidc.jwtVerifier = tOidc
|
|
||||||
|
|
||||||
// Ensure cleanup when test finishes
|
|
||||||
defer func() {
|
|
||||||
if err := tOidc.Close(); err != nil {
|
|
||||||
t.Logf("Error closing TraefikOidc instance: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Performance test
|
|
||||||
const iterations = 1000
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
for i := 0; i < iterations; i++ {
|
|
||||||
tokenIndex := i % numTokens
|
|
||||||
err := tOidc.VerifyToken(tokens[tokenIndex])
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Token verification failed at iteration %d: %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
duration := time.Since(start)
|
|
||||||
opsPerSecond := float64(iterations) / duration.Seconds()
|
|
||||||
|
|
||||||
t.Logf("Performance test completed: %d operations in %v (%.2f ops/sec)",
|
|
||||||
iterations, duration, opsPerSecond)
|
|
||||||
|
|
||||||
// Should achieve reasonable performance
|
|
||||||
if opsPerSecond < 100 {
|
|
||||||
t.Errorf("Performance too low: %.2f ops/sec (expected > 100)", opsPerSecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,572 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SecurityEvent represents a security-related event that should be logged and monitored
|
|
||||||
type SecurityEvent struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Severity string `json:"severity"`
|
|
||||||
Timestamp time.Time `json:"timestamp"`
|
|
||||||
ClientIP string `json:"client_ip"`
|
|
||||||
UserAgent string `json:"user_agent"`
|
|
||||||
RequestPath string `json:"request_path"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Details map[string]interface{} `json:"details,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecurityMonitor tracks security events and suspicious activity patterns
|
|
||||||
type SecurityMonitor struct {
|
|
||||||
// Event counters
|
|
||||||
authFailures int64
|
|
||||||
tokenValidationFails int64
|
|
||||||
rateLimitHits int64
|
|
||||||
suspiciousRequests int64
|
|
||||||
|
|
||||||
// IP-based tracking
|
|
||||||
ipFailures map[string]*IPFailureTracker
|
|
||||||
ipMutex sync.RWMutex
|
|
||||||
|
|
||||||
// Pattern detection
|
|
||||||
patternDetector *SuspiciousPatternDetector
|
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
eventHandlers []SecurityEventHandler
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
config SecurityMonitorConfig
|
|
||||||
|
|
||||||
// Logger
|
|
||||||
logger *Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPFailureTracker tracks failures for a specific IP address
|
|
||||||
type IPFailureTracker struct {
|
|
||||||
FailureCount int64
|
|
||||||
LastFailure time.Time
|
|
||||||
FirstFailure time.Time
|
|
||||||
FailureTypes map[string]int64
|
|
||||||
IsBlocked bool
|
|
||||||
BlockedUntil time.Time
|
|
||||||
mutex sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// SuspiciousPatternDetector identifies patterns that may indicate attacks
|
|
||||||
type SuspiciousPatternDetector struct {
|
|
||||||
// Time-based windows for pattern detection
|
|
||||||
shortWindow time.Duration // 1 minute
|
|
||||||
mediumWindow time.Duration // 5 minutes
|
|
||||||
longWindow time.Duration // 15 minutes
|
|
||||||
|
|
||||||
// Pattern thresholds
|
|
||||||
rapidFailureThreshold int // failures in short window
|
|
||||||
distributedAttackThreshold int // failures across IPs in medium window
|
|
||||||
persistentAttackThreshold int // failures in long window
|
|
||||||
|
|
||||||
// Pattern tracking
|
|
||||||
recentEvents []SecurityEvent
|
|
||||||
eventsMutex sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecurityEventHandler defines the interface for handling security events
|
|
||||||
type SecurityEventHandler interface {
|
|
||||||
HandleSecurityEvent(event SecurityEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecurityMonitorConfig contains configuration for the security monitor
|
|
||||||
type SecurityMonitorConfig struct {
|
|
||||||
// Failure thresholds
|
|
||||||
MaxFailuresPerIP int `json:"max_failures_per_ip"`
|
|
||||||
FailureWindowMinutes int `json:"failure_window_minutes"`
|
|
||||||
BlockDurationMinutes int `json:"block_duration_minutes"`
|
|
||||||
|
|
||||||
// Pattern detection settings
|
|
||||||
EnablePatternDetection bool `json:"enable_pattern_detection"`
|
|
||||||
RapidFailureThreshold int `json:"rapid_failure_threshold"`
|
|
||||||
|
|
||||||
// Monitoring settings
|
|
||||||
EnableDetailedLogging bool `json:"enable_detailed_logging"`
|
|
||||||
LogSuspiciousOnly bool `json:"log_suspicious_only"`
|
|
||||||
|
|
||||||
// Cleanup settings
|
|
||||||
CleanupIntervalMinutes int `json:"cleanup_interval_minutes"`
|
|
||||||
RetentionHours int `json:"retention_hours"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultSecurityMonitorConfig returns a default configuration
|
|
||||||
func DefaultSecurityMonitorConfig() SecurityMonitorConfig {
|
|
||||||
return SecurityMonitorConfig{
|
|
||||||
MaxFailuresPerIP: 10,
|
|
||||||
FailureWindowMinutes: 15,
|
|
||||||
BlockDurationMinutes: 60,
|
|
||||||
EnablePatternDetection: true,
|
|
||||||
RapidFailureThreshold: 5,
|
|
||||||
EnableDetailedLogging: true,
|
|
||||||
LogSuspiciousOnly: false,
|
|
||||||
CleanupIntervalMinutes: 30,
|
|
||||||
RetentionHours: 24,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSecurityMonitor creates a new security monitor instance
|
|
||||||
func NewSecurityMonitor(config SecurityMonitorConfig, logger *Logger) *SecurityMonitor {
|
|
||||||
sm := &SecurityMonitor{
|
|
||||||
ipFailures: make(map[string]*IPFailureTracker),
|
|
||||||
eventHandlers: make([]SecurityEventHandler, 0),
|
|
||||||
config: config,
|
|
||||||
logger: logger,
|
|
||||||
patternDetector: NewSuspiciousPatternDetector(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start cleanup routine
|
|
||||||
go sm.startCleanupRoutine()
|
|
||||||
|
|
||||||
return sm
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSuspiciousPatternDetector creates a new pattern detector
|
|
||||||
func NewSuspiciousPatternDetector() *SuspiciousPatternDetector {
|
|
||||||
return &SuspiciousPatternDetector{
|
|
||||||
shortWindow: 1 * time.Minute,
|
|
||||||
mediumWindow: 5 * time.Minute,
|
|
||||||
longWindow: 15 * time.Minute,
|
|
||||||
rapidFailureThreshold: 5,
|
|
||||||
distributedAttackThreshold: 20,
|
|
||||||
persistentAttackThreshold: 50,
|
|
||||||
recentEvents: make([]SecurityEvent, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordAuthenticationFailure records an authentication failure event
|
|
||||||
func (sm *SecurityMonitor) RecordAuthenticationFailure(clientIP, userAgent, requestPath, reason string, details map[string]interface{}) {
|
|
||||||
atomic.AddInt64(&sm.authFailures, 1)
|
|
||||||
|
|
||||||
event := SecurityEvent{
|
|
||||||
Type: "authentication_failure",
|
|
||||||
Severity: "medium",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
ClientIP: clientIP,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
RequestPath: requestPath,
|
|
||||||
Message: fmt.Sprintf("Authentication failed: %s", reason),
|
|
||||||
Details: details,
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.recordIPFailure(clientIP, "auth_failure")
|
|
||||||
sm.processSecurityEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordTokenValidationFailure records a token validation failure
|
|
||||||
func (sm *SecurityMonitor) RecordTokenValidationFailure(clientIP, userAgent, requestPath, reason string, tokenPrefix string) {
|
|
||||||
atomic.AddInt64(&sm.tokenValidationFails, 1)
|
|
||||||
|
|
||||||
details := map[string]interface{}{
|
|
||||||
"reason": reason,
|
|
||||||
}
|
|
||||||
if tokenPrefix != "" {
|
|
||||||
details["token_prefix"] = tokenPrefix
|
|
||||||
}
|
|
||||||
|
|
||||||
event := SecurityEvent{
|
|
||||||
Type: "token_validation_failure",
|
|
||||||
Severity: "medium",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
ClientIP: clientIP,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
RequestPath: requestPath,
|
|
||||||
Message: fmt.Sprintf("Token validation failed: %s", reason),
|
|
||||||
Details: details,
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.recordIPFailure(clientIP, "token_failure")
|
|
||||||
sm.processSecurityEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordRateLimitHit records when rate limiting is triggered
|
|
||||||
func (sm *SecurityMonitor) RecordRateLimitHit(clientIP, userAgent, requestPath string) {
|
|
||||||
atomic.AddInt64(&sm.rateLimitHits, 1)
|
|
||||||
|
|
||||||
event := SecurityEvent{
|
|
||||||
Type: "rate_limit_hit",
|
|
||||||
Severity: "low",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
ClientIP: clientIP,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
RequestPath: requestPath,
|
|
||||||
Message: "Rate limit exceeded",
|
|
||||||
Details: map[string]interface{}{
|
|
||||||
"limit_type": "token_verification",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.recordIPFailure(clientIP, "rate_limit")
|
|
||||||
sm.processSecurityEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordSuspiciousActivity records suspicious activity that doesn't fit other categories
|
|
||||||
func (sm *SecurityMonitor) RecordSuspiciousActivity(clientIP, userAgent, requestPath, activityType, description string, details map[string]interface{}) {
|
|
||||||
atomic.AddInt64(&sm.suspiciousRequests, 1)
|
|
||||||
|
|
||||||
event := SecurityEvent{
|
|
||||||
Type: "suspicious_activity",
|
|
||||||
Severity: "high",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
ClientIP: clientIP,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
RequestPath: requestPath,
|
|
||||||
Message: fmt.Sprintf("Suspicious activity detected: %s - %s", activityType, description),
|
|
||||||
Details: details,
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.recordIPFailure(clientIP, "suspicious")
|
|
||||||
sm.processSecurityEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordIPFailure tracks failures for a specific IP address
|
|
||||||
func (sm *SecurityMonitor) recordIPFailure(clientIP, failureType string) {
|
|
||||||
sm.ipMutex.Lock()
|
|
||||||
defer sm.ipMutex.Unlock()
|
|
||||||
|
|
||||||
tracker, exists := sm.ipFailures[clientIP]
|
|
||||||
if !exists {
|
|
||||||
tracker = &IPFailureTracker{
|
|
||||||
FailureTypes: make(map[string]int64),
|
|
||||||
FirstFailure: time.Now(),
|
|
||||||
}
|
|
||||||
sm.ipFailures[clientIP] = tracker
|
|
||||||
}
|
|
||||||
|
|
||||||
tracker.mutex.Lock()
|
|
||||||
defer tracker.mutex.Unlock()
|
|
||||||
|
|
||||||
tracker.FailureCount++
|
|
||||||
tracker.LastFailure = time.Now()
|
|
||||||
tracker.FailureTypes[failureType]++
|
|
||||||
|
|
||||||
// Check if IP should be blocked
|
|
||||||
windowStart := time.Now().Add(-time.Duration(sm.config.FailureWindowMinutes) * time.Minute)
|
|
||||||
if tracker.FirstFailure.After(windowStart) && tracker.FailureCount >= int64(sm.config.MaxFailuresPerIP) {
|
|
||||||
if !tracker.IsBlocked {
|
|
||||||
tracker.IsBlocked = true
|
|
||||||
tracker.BlockedUntil = time.Now().Add(time.Duration(sm.config.BlockDurationMinutes) * time.Minute)
|
|
||||||
|
|
||||||
sm.logger.Errorf("IP %s blocked due to %d failures (types: %v)", clientIP, tracker.FailureCount, tracker.FailureTypes)
|
|
||||||
|
|
||||||
// Record blocking event
|
|
||||||
blockEvent := SecurityEvent{
|
|
||||||
Type: "ip_blocked",
|
|
||||||
Severity: "high",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
ClientIP: clientIP,
|
|
||||||
Message: fmt.Sprintf("IP blocked due to %d failures in %d minutes", tracker.FailureCount, sm.config.FailureWindowMinutes),
|
|
||||||
Details: map[string]interface{}{
|
|
||||||
"failure_count": tracker.FailureCount,
|
|
||||||
"failure_types": tracker.FailureTypes,
|
|
||||||
"blocked_until": tracker.BlockedUntil,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
sm.processSecurityEvent(blockEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsIPBlocked checks if an IP address is currently blocked
|
|
||||||
func (sm *SecurityMonitor) IsIPBlocked(clientIP string) bool {
|
|
||||||
sm.ipMutex.RLock()
|
|
||||||
defer sm.ipMutex.RUnlock()
|
|
||||||
|
|
||||||
tracker, exists := sm.ipFailures[clientIP]
|
|
||||||
if !exists {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
tracker.mutex.RLock()
|
|
||||||
defer tracker.mutex.RUnlock()
|
|
||||||
|
|
||||||
if tracker.IsBlocked && time.Now().Before(tracker.BlockedUntil) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unblock if time has passed
|
|
||||||
if tracker.IsBlocked && time.Now().After(tracker.BlockedUntil) {
|
|
||||||
tracker.IsBlocked = false
|
|
||||||
sm.logger.Infof("IP %s automatically unblocked", clientIP)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// processSecurityEvent processes a security event through all handlers and pattern detection
|
|
||||||
func (sm *SecurityMonitor) processSecurityEvent(event SecurityEvent) {
|
|
||||||
// Add to pattern detector
|
|
||||||
if sm.config.EnablePatternDetection {
|
|
||||||
sm.patternDetector.AddEvent(event)
|
|
||||||
|
|
||||||
// Check for suspicious patterns
|
|
||||||
if patterns := sm.patternDetector.DetectSuspiciousPatterns(); len(patterns) > 0 {
|
|
||||||
for _, pattern := range patterns {
|
|
||||||
sm.logger.Errorf("Suspicious pattern detected: %s", pattern)
|
|
||||||
|
|
||||||
patternEvent := SecurityEvent{
|
|
||||||
Type: "suspicious_pattern",
|
|
||||||
Severity: "high",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Message: fmt.Sprintf("Suspicious pattern detected: %s", pattern),
|
|
||||||
Details: map[string]interface{}{
|
|
||||||
"pattern_type": pattern,
|
|
||||||
"trigger_event": event,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
sm.handleSecurityEvent(patternEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.handleSecurityEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSecurityEvent sends the event to all registered handlers
|
|
||||||
func (sm *SecurityMonitor) handleSecurityEvent(event SecurityEvent) {
|
|
||||||
// Log the event
|
|
||||||
if sm.config.EnableDetailedLogging && (!sm.config.LogSuspiciousOnly || event.Severity == "high") {
|
|
||||||
sm.logger.Infof("Security Event [%s/%s]: %s (IP: %s, Path: %s)",
|
|
||||||
event.Type, event.Severity, event.Message, event.ClientIP, event.RequestPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send to all handlers
|
|
||||||
for _, handler := range sm.eventHandlers {
|
|
||||||
go handler.HandleSecurityEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddEventHandler adds a security event handler
|
|
||||||
func (sm *SecurityMonitor) AddEventHandler(handler SecurityEventHandler) {
|
|
||||||
sm.eventHandlers = append(sm.eventHandlers, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSecurityMetrics returns current security metrics
|
|
||||||
func (sm *SecurityMonitor) GetSecurityMetrics() map[string]interface{} {
|
|
||||||
sm.ipMutex.RLock()
|
|
||||||
defer sm.ipMutex.RUnlock()
|
|
||||||
|
|
||||||
blockedIPs := 0
|
|
||||||
totalTrackedIPs := len(sm.ipFailures)
|
|
||||||
|
|
||||||
for _, tracker := range sm.ipFailures {
|
|
||||||
tracker.mutex.RLock()
|
|
||||||
if tracker.IsBlocked && time.Now().Before(tracker.BlockedUntil) {
|
|
||||||
blockedIPs++
|
|
||||||
}
|
|
||||||
tracker.mutex.RUnlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
|
||||||
"auth_failures": atomic.LoadInt64(&sm.authFailures),
|
|
||||||
"token_validation_fails": atomic.LoadInt64(&sm.tokenValidationFails),
|
|
||||||
"rate_limit_hits": atomic.LoadInt64(&sm.rateLimitHits),
|
|
||||||
"suspicious_requests": atomic.LoadInt64(&sm.suspiciousRequests),
|
|
||||||
"blocked_ips": blockedIPs,
|
|
||||||
"tracked_ips": totalTrackedIPs,
|
|
||||||
"uptime_hours": time.Since(time.Now().Add(-24 * time.Hour)).Hours(), // Placeholder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddEvent adds an event to the pattern detector
|
|
||||||
func (spd *SuspiciousPatternDetector) AddEvent(event SecurityEvent) {
|
|
||||||
spd.eventsMutex.Lock()
|
|
||||||
defer spd.eventsMutex.Unlock()
|
|
||||||
|
|
||||||
spd.recentEvents = append(spd.recentEvents, event)
|
|
||||||
|
|
||||||
// Clean old events
|
|
||||||
cutoff := time.Now().Add(-spd.longWindow)
|
|
||||||
var filteredEvents []SecurityEvent
|
|
||||||
for _, e := range spd.recentEvents {
|
|
||||||
if e.Timestamp.After(cutoff) {
|
|
||||||
filteredEvents = append(filteredEvents, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
spd.recentEvents = filteredEvents
|
|
||||||
}
|
|
||||||
|
|
||||||
// DetectSuspiciousPatterns analyzes recent events for suspicious patterns
|
|
||||||
func (spd *SuspiciousPatternDetector) DetectSuspiciousPatterns() []string {
|
|
||||||
spd.eventsMutex.RLock()
|
|
||||||
defer spd.eventsMutex.RUnlock()
|
|
||||||
|
|
||||||
var patterns []string
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// Check for rapid failures from single IP
|
|
||||||
ipCounts := make(map[string]int)
|
|
||||||
shortWindowStart := now.Add(-spd.shortWindow)
|
|
||||||
|
|
||||||
for _, event := range spd.recentEvents {
|
|
||||||
if event.Timestamp.After(shortWindowStart) &&
|
|
||||||
(event.Type == "authentication_failure" || event.Type == "token_validation_failure") {
|
|
||||||
ipCounts[event.ClientIP]++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for ip, count := range ipCounts {
|
|
||||||
if count >= spd.rapidFailureThreshold {
|
|
||||||
patterns = append(patterns, fmt.Sprintf("rapid_failures_from_ip_%s", ip))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for distributed attack (many IPs failing)
|
|
||||||
mediumWindowStart := now.Add(-spd.mediumWindow)
|
|
||||||
uniqueFailingIPs := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, event := range spd.recentEvents {
|
|
||||||
if event.Timestamp.After(mediumWindowStart) &&
|
|
||||||
(event.Type == "authentication_failure" || event.Type == "token_validation_failure") {
|
|
||||||
uniqueFailingIPs[event.ClientIP] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(uniqueFailingIPs) >= spd.distributedAttackThreshold {
|
|
||||||
patterns = append(patterns, "distributed_attack_pattern")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for persistent attack
|
|
||||||
longWindowStart := now.Add(-spd.longWindow)
|
|
||||||
persistentFailures := 0
|
|
||||||
|
|
||||||
for _, event := range spd.recentEvents {
|
|
||||||
if event.Timestamp.After(longWindowStart) &&
|
|
||||||
(event.Type == "authentication_failure" || event.Type == "token_validation_failure") {
|
|
||||||
persistentFailures++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if persistentFailures >= spd.persistentAttackThreshold {
|
|
||||||
patterns = append(patterns, "persistent_attack_pattern")
|
|
||||||
}
|
|
||||||
|
|
||||||
return patterns
|
|
||||||
}
|
|
||||||
|
|
||||||
// startCleanupRoutine starts the background cleanup routine
|
|
||||||
func (sm *SecurityMonitor) startCleanupRoutine() {
|
|
||||||
ticker := time.NewTicker(time.Duration(sm.config.CleanupIntervalMinutes) * time.Minute)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for range ticker.C {
|
|
||||||
sm.cleanup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanup removes old tracking data
|
|
||||||
func (sm *SecurityMonitor) cleanup() {
|
|
||||||
sm.ipMutex.Lock()
|
|
||||||
defer sm.ipMutex.Unlock()
|
|
||||||
|
|
||||||
cutoff := time.Now().Add(-time.Duration(sm.config.RetentionHours) * time.Hour)
|
|
||||||
|
|
||||||
for ip, tracker := range sm.ipFailures {
|
|
||||||
tracker.mutex.RLock()
|
|
||||||
shouldRemove := tracker.LastFailure.Before(cutoff) && !tracker.IsBlocked
|
|
||||||
tracker.mutex.RUnlock()
|
|
||||||
|
|
||||||
if shouldRemove {
|
|
||||||
delete(sm.ipFailures, ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.logger.Debugf("Security monitor cleanup completed, tracking %d IPs", len(sm.ipFailures))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractClientIP extracts the client IP from the request, considering proxy headers
|
|
||||||
func ExtractClientIP(r *http.Request) string {
|
|
||||||
// Check X-Real-IP header first (highest priority)
|
|
||||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
|
||||||
if net.ParseIP(xri) != nil {
|
|
||||||
return xri
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check X-Forwarded-For header second
|
|
||||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
||||||
// Take the first IP in the chain
|
|
||||||
ips := strings.Split(xff, ",")
|
|
||||||
if len(ips) > 0 {
|
|
||||||
ip := strings.TrimSpace(ips[0])
|
|
||||||
if net.ParseIP(ip) != nil {
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to RemoteAddr
|
|
||||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
return r.RemoteAddr
|
|
||||||
}
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoggingSecurityEventHandler logs security events to the standard logger
|
|
||||||
type LoggingSecurityEventHandler struct {
|
|
||||||
logger *Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLoggingSecurityEventHandler creates a new logging event handler
|
|
||||||
func NewLoggingSecurityEventHandler(logger *Logger) *LoggingSecurityEventHandler {
|
|
||||||
return &LoggingSecurityEventHandler{logger: logger}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleSecurityEvent implements SecurityEventHandler
|
|
||||||
func (h *LoggingSecurityEventHandler) HandleSecurityEvent(event SecurityEvent) {
|
|
||||||
switch event.Severity {
|
|
||||||
case "high":
|
|
||||||
h.logger.Errorf("SECURITY [%s]: %s (IP: %s)", event.Type, event.Message, event.ClientIP)
|
|
||||||
case "medium":
|
|
||||||
h.logger.Errorf("SECURITY [%s]: %s (IP: %s)", event.Type, event.Message, event.ClientIP)
|
|
||||||
case "low":
|
|
||||||
h.logger.Infof("SECURITY [%s]: %s (IP: %s)", event.Type, event.Message, event.ClientIP)
|
|
||||||
default:
|
|
||||||
h.logger.Debugf("SECURITY [%s]: %s (IP: %s)", event.Type, event.Message, event.ClientIP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MetricsSecurityEventHandler tracks security metrics
|
|
||||||
type MetricsSecurityEventHandler struct {
|
|
||||||
eventCounts map[string]int64
|
|
||||||
mutex sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMetricsSecurityEventHandler creates a new metrics event handler
|
|
||||||
func NewMetricsSecurityEventHandler() *MetricsSecurityEventHandler {
|
|
||||||
return &MetricsSecurityEventHandler{
|
|
||||||
eventCounts: make(map[string]int64),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleSecurityEvent implements SecurityEventHandler
|
|
||||||
func (h *MetricsSecurityEventHandler) HandleSecurityEvent(event SecurityEvent) {
|
|
||||||
h.mutex.Lock()
|
|
||||||
defer h.mutex.Unlock()
|
|
||||||
|
|
||||||
h.eventCounts[event.Type]++
|
|
||||||
h.eventCounts[fmt.Sprintf("%s_%s", event.Type, event.Severity)]++
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMetrics returns the current metrics
|
|
||||||
func (h *MetricsSecurityEventHandler) GetMetrics() map[string]int64 {
|
|
||||||
h.mutex.RLock()
|
|
||||||
defer h.mutex.RUnlock()
|
|
||||||
|
|
||||||
metrics := make(map[string]int64)
|
|
||||||
for k, v := range h.eventCounts {
|
|
||||||
metrics[k] = v
|
|
||||||
}
|
|
||||||
return metrics
|
|
||||||
}
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSecurityMonitor(t *testing.T) {
|
|
||||||
config := DefaultSecurityMonitorConfig()
|
|
||||||
config.MaxFailuresPerIP = 3
|
|
||||||
config.BlockDurationMinutes = 1 // 1 minute for testing
|
|
||||||
config.CleanupIntervalMinutes = 1
|
|
||||||
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
monitor := NewSecurityMonitor(config, logger)
|
|
||||||
defer func() {
|
|
||||||
// Allow cleanup goroutine to finish
|
|
||||||
time.Sleep(150 * time.Millisecond)
|
|
||||||
}()
|
|
||||||
|
|
||||||
t.Run("Record authentication failure", func(t *testing.T) {
|
|
||||||
monitor.RecordAuthenticationFailure("192.168.1.1", "test-agent", "/login", "invalid credentials", nil)
|
|
||||||
|
|
||||||
// Should not be blocked after first failure
|
|
||||||
if monitor.IsIPBlocked("192.168.1.1") {
|
|
||||||
t.Error("IP should not be blocked after first failure")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("IP blocked after max failures", func(t *testing.T) {
|
|
||||||
// Record multiple failures
|
|
||||||
for i := 0; i < config.MaxFailuresPerIP; i++ {
|
|
||||||
monitor.RecordAuthenticationFailure("192.168.1.2", "test-agent", "/login", "invalid credentials", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should be blocked now
|
|
||||||
if !monitor.IsIPBlocked("192.168.1.2") {
|
|
||||||
t.Error("IP should be blocked after max failures")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Token validation failure", func(t *testing.T) {
|
|
||||||
monitor.RecordTokenValidationFailure("192.168.1.3", "test-agent", "/api", "invalid token", "abc123")
|
|
||||||
|
|
||||||
metrics := monitor.GetSecurityMetrics()
|
|
||||||
if metrics["token_validation_fails"].(int64) == 0 {
|
|
||||||
t.Error("Expected token validation failures to be recorded")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Rate limit hit", func(t *testing.T) {
|
|
||||||
monitor.RecordRateLimitHit("192.168.1.4", "test-agent", "/api")
|
|
||||||
|
|
||||||
metrics := monitor.GetSecurityMetrics()
|
|
||||||
if metrics["rate_limit_hits"].(int64) == 0 {
|
|
||||||
t.Error("Expected rate limit hits to be recorded")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Suspicious activity", func(t *testing.T) {
|
|
||||||
details := map[string]interface{}{"pattern": "unusual"}
|
|
||||||
monitor.RecordSuspiciousActivity("192.168.1.5", "test-agent", "/admin", "unusual pattern", "high frequency requests", details)
|
|
||||||
|
|
||||||
metrics := monitor.GetSecurityMetrics()
|
|
||||||
if metrics["suspicious_requests"].(int64) == 0 {
|
|
||||||
t.Error("Expected suspicious activities to be recorded")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Get security metrics", func(t *testing.T) {
|
|
||||||
metrics := monitor.GetSecurityMetrics()
|
|
||||||
|
|
||||||
if metrics["auth_failures"].(int64) == 0 {
|
|
||||||
t.Error("Expected some authentication failures")
|
|
||||||
}
|
|
||||||
if metrics["blocked_ips"] == nil {
|
|
||||||
t.Error("Expected blocked IPs count to be present")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSuspiciousPatternDetector(t *testing.T) {
|
|
||||||
detector := NewSuspiciousPatternDetector()
|
|
||||||
|
|
||||||
t.Run("Add events and detect patterns", func(t *testing.T) {
|
|
||||||
// Add multiple events from same IP
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
event := SecurityEvent{
|
|
||||||
Type: "authentication_failure",
|
|
||||||
ClientIP: "192.168.1.100",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
detector.AddEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
patterns := detector.DetectSuspiciousPatterns()
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, pattern := range patterns {
|
|
||||||
if pattern == "rapid_failures_from_ip_192.168.1.100" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Error("Expected to detect rapid failure pattern")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Detect distributed attack pattern", func(t *testing.T) {
|
|
||||||
// Add failures from many different IPs
|
|
||||||
for i := 0; i < 25; i++ {
|
|
||||||
event := SecurityEvent{
|
|
||||||
Type: "authentication_failure",
|
|
||||||
ClientIP: "192.168.1." + strconv.Itoa(100+i),
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
detector.AddEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
patterns := detector.DetectSuspiciousPatterns()
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, pattern := range patterns {
|
|
||||||
if pattern == "distributed_attack_pattern" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Error("Expected to detect distributed attack pattern")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractClientIP(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
remoteAddr string
|
|
||||||
headers map[string]string
|
|
||||||
expectedIP string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Direct connection",
|
|
||||||
remoteAddr: "192.168.1.1:12345",
|
|
||||||
expectedIP: "192.168.1.1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "X-Forwarded-For header",
|
|
||||||
remoteAddr: "10.0.0.1:12345",
|
|
||||||
headers: map[string]string{"X-Forwarded-For": "203.0.113.1, 10.0.0.1"},
|
|
||||||
expectedIP: "203.0.113.1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "X-Real-IP header",
|
|
||||||
remoteAddr: "10.0.0.1:12345",
|
|
||||||
headers: map[string]string{"X-Real-IP": "203.0.113.2"},
|
|
||||||
expectedIP: "203.0.113.2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiple headers - X-Real-IP takes precedence",
|
|
||||||
remoteAddr: "10.0.0.1:12345",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Forwarded-For": "203.0.113.1",
|
|
||||||
"X-Real-IP": "203.0.113.2",
|
|
||||||
},
|
|
||||||
expectedIP: "203.0.113.2",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
req.RemoteAddr = tt.remoteAddr
|
|
||||||
|
|
||||||
for key, value := range tt.headers {
|
|
||||||
req.Header.Set(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
ip := ExtractClientIP(req)
|
|
||||||
if ip != tt.expectedIP {
|
|
||||||
t.Errorf("Expected IP %s, got %s", tt.expectedIP, ip)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityEventHandlers(t *testing.T) {
|
|
||||||
t.Run("Logging security event handler", func(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
handler := NewLoggingSecurityEventHandler(logger)
|
|
||||||
|
|
||||||
event := SecurityEvent{
|
|
||||||
Type: "authentication_failure",
|
|
||||||
ClientIP: "192.168.1.1",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Message: "Test failure",
|
|
||||||
Severity: "medium",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should not panic
|
|
||||||
handler.HandleSecurityEvent(event)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Metrics security event handler", func(t *testing.T) {
|
|
||||||
handler := NewMetricsSecurityEventHandler()
|
|
||||||
|
|
||||||
event := SecurityEvent{
|
|
||||||
Type: "authentication_failure",
|
|
||||||
ClientIP: "192.168.1.1",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Message: "Test failure",
|
|
||||||
Severity: "medium",
|
|
||||||
}
|
|
||||||
|
|
||||||
handler.HandleSecurityEvent(event)
|
|
||||||
|
|
||||||
metrics := handler.GetMetrics()
|
|
||||||
if metrics["authentication_failure"] != 1 {
|
|
||||||
t.Errorf("Expected 1 authentication failure, got %v", metrics["authentication_failure"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityMonitorEventHandlers(t *testing.T) {
|
|
||||||
config := DefaultSecurityMonitorConfig()
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
monitor := NewSecurityMonitor(config, logger)
|
|
||||||
|
|
||||||
// Add event handler with proper synchronization
|
|
||||||
handlerCalled := make(chan bool, 1)
|
|
||||||
handler := &testSecurityEventHandler{
|
|
||||||
callback: func(event SecurityEvent) {
|
|
||||||
select {
|
|
||||||
case handlerCalled <- true:
|
|
||||||
default:
|
|
||||||
// Channel already has a value, don't block
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
monitor.AddEventHandler(handler)
|
|
||||||
|
|
||||||
monitor.RecordAuthenticationFailure("192.168.1.1", "test-agent", "/login", "test failure", nil)
|
|
||||||
|
|
||||||
// Wait for event handler to be called with timeout
|
|
||||||
select {
|
|
||||||
case <-handlerCalled:
|
|
||||||
// Success - handler was called
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
t.Error("Expected event handler to be called within timeout")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test helper for security event handler
|
|
||||||
type testSecurityEventHandler struct {
|
|
||||||
callback func(SecurityEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *testSecurityEventHandler) HandleSecurityEvent(event SecurityEvent) {
|
|
||||||
h.callback(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultSecurityMonitorConfig(t *testing.T) {
|
|
||||||
config := DefaultSecurityMonitorConfig()
|
|
||||||
|
|
||||||
if config.MaxFailuresPerIP <= 0 {
|
|
||||||
t.Error("Expected positive MaxFailuresPerIP")
|
|
||||||
}
|
|
||||||
if config.BlockDurationMinutes <= 0 {
|
|
||||||
t.Error("Expected positive BlockDurationMinutes")
|
|
||||||
}
|
|
||||||
if config.CleanupIntervalMinutes <= 0 {
|
|
||||||
t.Error("Expected positive CleanupIntervalMinutes")
|
|
||||||
}
|
|
||||||
if config.FailureWindowMinutes <= 0 {
|
|
||||||
t.Error("Expected positive FailureWindowMinutes")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityMonitorCleanup(t *testing.T) {
|
|
||||||
config := DefaultSecurityMonitorConfig()
|
|
||||||
config.CleanupIntervalMinutes = 1
|
|
||||||
config.BlockDurationMinutes = 1
|
|
||||||
config.RetentionHours = 1
|
|
||||||
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
monitor := NewSecurityMonitor(config, logger)
|
|
||||||
|
|
||||||
// Block an IP
|
|
||||||
for i := 0; i < config.MaxFailuresPerIP; i++ {
|
|
||||||
monitor.RecordAuthenticationFailure("192.168.1.99", "test-agent", "/login", "test", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify it's blocked
|
|
||||||
if !monitor.IsIPBlocked("192.168.1.99") {
|
|
||||||
t.Error("IP should be blocked")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait a bit and check if it gets unblocked automatically
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
// The IP should still be blocked since we haven't waited long enough
|
|
||||||
if !monitor.IsIPBlocked("192.168.1.99") {
|
|
||||||
t.Error("IP should still be blocked")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityEventTypes(t *testing.T) {
|
|
||||||
config := DefaultSecurityMonitorConfig()
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
monitor := NewSecurityMonitor(config, logger)
|
|
||||||
|
|
||||||
// Test different event types
|
|
||||||
monitor.RecordAuthenticationFailure("192.168.1.200", "test-agent", "/login", "invalid password", nil)
|
|
||||||
monitor.RecordTokenValidationFailure("192.168.1.200", "test-agent", "/api", "expired token", "abc123")
|
|
||||||
monitor.RecordRateLimitHit("192.168.1.200", "test-agent", "/api")
|
|
||||||
|
|
||||||
details := map[string]interface{}{"pattern": "test"}
|
|
||||||
monitor.RecordSuspiciousActivity("192.168.1.200", "test-agent", "/admin", "unusual pattern", "multiple failed logins", details)
|
|
||||||
|
|
||||||
metrics := monitor.GetSecurityMetrics()
|
|
||||||
|
|
||||||
if metrics["auth_failures"].(int64) == 0 {
|
|
||||||
t.Error("Expected authentication failures to be recorded")
|
|
||||||
}
|
|
||||||
if metrics["token_validation_fails"].(int64) == 0 {
|
|
||||||
t.Error("Expected token validation failures to be recorded")
|
|
||||||
}
|
|
||||||
if metrics["rate_limit_hits"].(int64) == 0 {
|
|
||||||
t.Error("Expected rate limit hits to be recorded")
|
|
||||||
}
|
|
||||||
if metrics["suspicious_requests"].(int64) == 0 {
|
|
||||||
t.Error("Expected suspicious activities to be recorded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
version: 1
|
|
||||||
force:
|
|
||||||
existing: true
|
|
||||||
wording:
|
|
||||||
patch:
|
|
||||||
- patch-release
|
|
||||||
minor:
|
|
||||||
- minor-release
|
|
||||||
major:
|
|
||||||
- breaking
|
|
||||||
-1254
File diff suppressed because it is too large
Load Diff
-385
@@ -1,385 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSessionPoolMemoryLeak(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create session manager: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a fake request
|
|
||||||
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
|
|
||||||
|
|
||||||
// Test 1: Successful session creation and return
|
|
||||||
session, err := sm.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSession failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the session which should return it to the pool
|
|
||||||
session.Clear(req, nil)
|
|
||||||
|
|
||||||
// Test 2: ReturnToPool explicit method
|
|
||||||
session, err = sm.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSession failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call ReturnToPool directly
|
|
||||||
session.ReturnToPool()
|
|
||||||
|
|
||||||
// Test 3: Error path in GetSession
|
|
||||||
// Modify the session store to force an error - use a different encryption key
|
|
||||||
badSM, _ := NewSessionManager("different0123456789abcdef0123456789abcdef0123456789", false, logger)
|
|
||||||
|
|
||||||
// Get session using mismatched manager/request to force error
|
|
||||||
_, err = badSM.GetSession(req)
|
|
||||||
if err == nil {
|
|
||||||
// We don't test the exact error since it could vary, just that we get one
|
|
||||||
t.Log("Note: Expected error when using mismatched encryption keys")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force GC to ensure any objects are cleaned up
|
|
||||||
runtime.GC()
|
|
||||||
|
|
||||||
// Wait a moment for GC to complete
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
// Check if we have objects in the pool
|
|
||||||
// This is just a simple check; in a real scenario, we'd have to
|
|
||||||
// consider that sync.Pool can discard objects at any time.
|
|
||||||
pooledCount := getPooledObjects(sm)
|
|
||||||
t.Logf("Pooled objects count: %d", pooledCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSessionErrorHandling(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create session manager: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a fake request
|
|
||||||
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
|
|
||||||
|
|
||||||
// Call the GetSession method, corrupting the cookie to force an error
|
|
||||||
req.AddCookie(&http.Cookie{
|
|
||||||
Name: mainCookieName,
|
|
||||||
Value: "corrupt-value",
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err = sm.GetSession(req)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Expected error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that the error message contains our expected prefix
|
|
||||||
if err != nil && !strings.Contains(err.Error(), "failed to get main session:") {
|
|
||||||
t.Fatalf("Unexpected error message: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSessionClearAlwaysReturnsToPool(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create session manager: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a test request with the special header that will trigger an error
|
|
||||||
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
|
|
||||||
req.Header.Set("X-Test-Error", "true") // This will trigger the error in session.Clear
|
|
||||||
|
|
||||||
// Get a session
|
|
||||||
session, err := sm.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSession failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a response writer
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Call Clear with the test request (with X-Test-Error header) and response writer
|
|
||||||
// This should trigger the serialization error in Save
|
|
||||||
clearErr := session.Clear(req, w)
|
|
||||||
|
|
||||||
// Verify that Clear returned the error from Save
|
|
||||||
if clearErr == nil {
|
|
||||||
t.Error("Expected an error from Clear with X-Test-Error header, but got nil")
|
|
||||||
} else {
|
|
||||||
t.Logf("Received expected error from Clear: %v", clearErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force GC to ensure any objects are cleaned up
|
|
||||||
runtime.GC()
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
// Create and clear another session (without the error header) to verify the pool is still working
|
|
||||||
normalReq := httptest.NewRequest("GET", "http://example.com/foo", nil)
|
|
||||||
session2, err := sm.GetSession(normalReq)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Second GetSession failed: %v", err)
|
|
||||||
}
|
|
||||||
session2.Clear(normalReq, nil)
|
|
||||||
|
|
||||||
// If we got here without panics, the test is successful
|
|
||||||
t.Log("Session returned to pool despite errors")
|
|
||||||
}
|
|
||||||
|
|
||||||
// This placeholder comment is intentionally left empty since we're removing redundant code
|
|
||||||
|
|
||||||
// Helper function to count objects in the session pool for a given manager
|
|
||||||
func getPooledObjects(sm *SessionManager) int {
|
|
||||||
// Collect objects until we can't get any more from the pool
|
|
||||||
// Set a max limit to avoid potential infinite loops
|
|
||||||
var objects []*SessionData
|
|
||||||
maxAttempts := 100 // Safety limit to prevent infinite loops
|
|
||||||
|
|
||||||
for i := 0; i < maxAttempts; i++ {
|
|
||||||
obj := sm.sessionPool.Get()
|
|
||||||
if obj == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type assertion with validation
|
|
||||||
sessionData, ok := obj.(*SessionData)
|
|
||||||
if !ok {
|
|
||||||
// Return the object even if it's not the right type to avoid leaks
|
|
||||||
sm.sessionPool.Put(obj)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
objects = append(objects, sessionData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count how many objects we found
|
|
||||||
count := len(objects)
|
|
||||||
|
|
||||||
// Return all objects back to the pool to preserve the pool state
|
|
||||||
for _, obj := range objects {
|
|
||||||
sm.sessionPool.Put(obj)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSessionObjectTracking verifies that session objects are properly
|
|
||||||
// returned to the pool in various scenarios including normal usage and error paths
|
|
||||||
func TestSessionObjectTracking(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create session manager: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a fake request
|
|
||||||
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
|
|
||||||
|
|
||||||
// Test that the session pool is used as expected
|
|
||||||
hasNew := sm.sessionPool.New != nil
|
|
||||||
if !hasNew {
|
|
||||||
t.Error("Expected sessionPool.New function to be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and discard 5 sessions
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
session, err := sm.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSession failed: %v", err)
|
|
||||||
}
|
|
||||||
session.ReturnToPool()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a session and get an error when trying to clear it
|
|
||||||
session, err := sm.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSession failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deliberately cause bad state in the session object
|
|
||||||
session.mainSession = nil // This will cause an error in Clear
|
|
||||||
|
|
||||||
// Even with an error, the pool should not leak
|
|
||||||
session.ReturnToPool()
|
|
||||||
|
|
||||||
runtime.GC()
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
// Success - if we got here without crashing, the pool is working as expected
|
|
||||||
t.Log("Session pool handling verified")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLargeIDTokenChunking tests that large ID tokens are properly chunked across multiple cookies
|
|
||||||
func TestLargeIDTokenChunking(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create session manager: %v", err)
|
|
||||||
}
|
|
||||||
// Create a large ID token (>4KB) to force chunking
|
|
||||||
largeIDToken := createLargeIDToken(20000) // 20KB token to ensure chunking after compression
|
|
||||||
t.Logf("Created large ID token with length: %d", len(largeIDToken))
|
|
||||||
|
|
||||||
// Create a request and response recorder
|
|
||||||
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Get session and set large ID token
|
|
||||||
session, err := sm.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the large ID token
|
|
||||||
session.SetIDToken(largeIDToken)
|
|
||||||
t.Logf("Set large ID token in session")
|
|
||||||
|
|
||||||
// Let's check what the GetIDToken returns to confirm it's set
|
|
||||||
retrievedToken := session.GetIDToken()
|
|
||||||
t.Logf("Retrieved ID token length: %d", len(retrievedToken))
|
|
||||||
if len(retrievedToken) != len(largeIDToken) {
|
|
||||||
t.Errorf("Token length mismatch: expected %d, got %d", len(largeIDToken), len(retrievedToken))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let's check what's in the main session directly
|
|
||||||
if idToken, ok := session.mainSession.Values["id_token"].(string); ok {
|
|
||||||
t.Logf("Main session id_token length: %d", len(idToken))
|
|
||||||
if compressed, ok := session.mainSession.Values["id_token_compressed"].(bool); ok {
|
|
||||||
t.Logf("Main session id_token_compressed: %v", compressed)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Logf("Main session id_token not found or not a string")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the session to trigger chunking
|
|
||||||
err = session.Save(req, rr)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to save session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that chunked cookies were created
|
|
||||||
cookies := rr.Result().Cookies()
|
|
||||||
t.Logf("Total cookies in response: %d", len(cookies))
|
|
||||||
|
|
||||||
for _, cookie := range cookies {
|
|
||||||
valuePreview := cookie.Value
|
|
||||||
if len(valuePreview) > 50 {
|
|
||||||
valuePreview = valuePreview[:50] + "..."
|
|
||||||
}
|
|
||||||
t.Logf("Cookie: %s = %s (len=%d)", cookie.Name, valuePreview, len(cookie.Value))
|
|
||||||
}
|
|
||||||
|
|
||||||
var mainCookie *http.Cookie
|
|
||||||
var chunkCookies []*http.Cookie
|
|
||||||
|
|
||||||
for _, cookie := range cookies {
|
|
||||||
if cookie.Name == mainCookieName {
|
|
||||||
mainCookie = cookie
|
|
||||||
} else if strings.HasPrefix(cookie.Name, mainCookieName+"_") {
|
|
||||||
chunkCookies = append(chunkCookies, cookie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify main cookie exists
|
|
||||||
if mainCookie == nil {
|
|
||||||
t.Fatal("Main cookie not found in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify chunk cookies exist (should be at least 2 for a 5KB token)
|
|
||||||
if len(chunkCookies) < 2 {
|
|
||||||
t.Fatalf("Expected at least 2 chunk cookies, got %d", len(chunkCookies))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify chunk cookie naming convention
|
|
||||||
expectedChunkNames := make(map[string]bool)
|
|
||||||
for i := 0; i < len(chunkCookies); i++ {
|
|
||||||
expectedChunkNames[mainCookieName+"_"+fmt.Sprintf("%d", i)] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cookie := range chunkCookies {
|
|
||||||
if !expectedChunkNames[cookie.Name] {
|
|
||||||
t.Errorf("Unexpected chunk cookie name: %s", cookie.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test token retrieval from chunked cookies
|
|
||||||
// Create a new request with all the cookies
|
|
||||||
newReq := httptest.NewRequest("GET", "http://example.com/foo", nil)
|
|
||||||
for _, cookie := range cookies {
|
|
||||||
newReq.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get session and retrieve the ID token
|
|
||||||
retrievedSession, err := sm.GetSession(newReq)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get session from chunked cookies: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
retrievedToken2 := retrievedSession.GetIDToken()
|
|
||||||
|
|
||||||
// Verify the retrieved token matches the original
|
|
||||||
if retrievedToken2 != largeIDToken {
|
|
||||||
t.Errorf("Retrieved ID token doesn't match original. Expected length: %d, got: %d", len(largeIDToken), len(retrievedToken2))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test clearing the ID token removes all chunks
|
|
||||||
retrievedSession.SetIDToken("")
|
|
||||||
|
|
||||||
clearRR := httptest.NewRecorder()
|
|
||||||
err = retrievedSession.Save(newReq, clearRR)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to save session after clearing ID token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify chunks are expired (MaxAge = -1)
|
|
||||||
clearCookies := clearRR.Result().Cookies()
|
|
||||||
for _, cookie := range clearCookies {
|
|
||||||
if strings.HasPrefix(cookie.Name, mainCookieName+"_") {
|
|
||||||
if cookie.MaxAge != -1 {
|
|
||||||
t.Errorf("Expected chunk cookie %s to be expired (MaxAge=-1), got MaxAge=%d", cookie.Name, cookie.MaxAge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createLargeIDToken creates a JWT-like token of specified size for testing
|
|
||||||
func createLargeIDToken(size int) string {
|
|
||||||
// Create truly random data that won't compress well
|
|
||||||
randomBytes := make([]byte, size*3/4) // base64 encoding increases size by ~4/3
|
|
||||||
_, err := rand.Read(randomBytes)
|
|
||||||
if err != nil {
|
|
||||||
// Fallback to pseudo-random if crypto/rand fails
|
|
||||||
for i := range randomBytes {
|
|
||||||
randomBytes[i] = byte(i % 256)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base64 encode the random data to make it look like a JWT
|
|
||||||
encoded := base64.StdEncoding.EncodeToString(randomBytes)
|
|
||||||
|
|
||||||
// Create JWT-like structure with truly random data
|
|
||||||
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
||||||
|
|
||||||
// Truncate or pad to desired size
|
|
||||||
if len(encoded) > size-len(header)-100 {
|
|
||||||
encoded = encoded[:size-len(header)-100]
|
|
||||||
}
|
|
||||||
|
|
||||||
signature := "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
|
||||||
|
|
||||||
return header + "." + encoded + "." + signature
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is intentionally left empty to remove unused code
|
|
||||||
+67
-463
@@ -5,465 +5,109 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TemplatedHeader represents a custom HTTP header with a templated value.
|
|
||||||
// The value can contain template expressions that will be evaluated for each
|
|
||||||
// authenticated request, such as {{.claims.email}} or {{.accessToken}}.
|
|
||||||
type TemplatedHeader struct {
|
|
||||||
// Name is the HTTP header name to set (e.g., "X-Forwarded-Email")
|
|
||||||
Name string `json:"name"`
|
|
||||||
|
|
||||||
// Value is the template string for the header value
|
|
||||||
// Example: "{{.claims.email}}", "Bearer {{.accessToken}}"
|
|
||||||
Value string `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config holds the configuration for the OIDC middleware.
|
|
||||||
// It provides all necessary settings to configure OpenID Connect authentication
|
|
||||||
// with various providers like Auth0, Logto, or any standard OIDC provider.
|
|
||||||
type Config struct {
|
|
||||||
// ProviderURL is the base URL of the OIDC provider (required)
|
|
||||||
// Example: https://accounts.google.com
|
|
||||||
ProviderURL string `json:"providerURL"`
|
|
||||||
|
|
||||||
// RevocationURL is the endpoint for revoking tokens (optional)
|
|
||||||
// If not provided, it will be discovered from provider metadata
|
|
||||||
RevocationURL string `json:"revocationURL"`
|
|
||||||
|
|
||||||
// EnablePKCE enables Proof Key for Code Exchange (PKCE) for the authorization code flow (optional)
|
|
||||||
// This enhances security but might not be supported by all OIDC providers
|
|
||||||
// Default: false
|
|
||||||
EnablePKCE bool `json:"enablePKCE"`
|
|
||||||
|
|
||||||
// CallbackURL is the path where the OIDC provider will redirect after authentication (required)
|
|
||||||
// Example: /oauth2/callback
|
|
||||||
CallbackURL string `json:"callbackURL"`
|
|
||||||
|
|
||||||
// LogoutURL is the path for handling logout requests (optional)
|
|
||||||
// If not provided, it will be set to CallbackURL + "/logout"
|
|
||||||
LogoutURL string `json:"logoutURL"`
|
|
||||||
|
|
||||||
// ClientID is the OAuth 2.0 client identifier (required)
|
|
||||||
ClientID string `json:"clientID"`
|
|
||||||
|
|
||||||
// ClientSecret is the OAuth 2.0 client secret (required)
|
|
||||||
ClientSecret string `json:"clientSecret"`
|
|
||||||
|
|
||||||
// Scopes defines the OAuth 2.0 scopes to request (optional)
|
|
||||||
// Defaults to ["openid", "profile", "email"] if not provided
|
|
||||||
Scopes []string `json:"scopes"`
|
|
||||||
|
|
||||||
// LogLevel sets the logging verbosity (optional)
|
|
||||||
// Valid values: "debug", "info", "error"
|
|
||||||
// Default: "info"
|
|
||||||
LogLevel string `json:"logLevel"`
|
|
||||||
|
|
||||||
// SessionEncryptionKey is used to encrypt session data (required)
|
|
||||||
// Must be a secure random string
|
|
||||||
SessionEncryptionKey string `json:"sessionEncryptionKey"`
|
|
||||||
|
|
||||||
// ForceHTTPS forces the use of HTTPS for all URLs (optional)
|
|
||||||
// Default: false
|
|
||||||
ForceHTTPS bool `json:"forceHTTPS"`
|
|
||||||
|
|
||||||
// RateLimit sets the maximum number of requests per second (optional)
|
|
||||||
// Default: 100
|
|
||||||
RateLimit int `json:"rateLimit"`
|
|
||||||
|
|
||||||
// ExcludedURLs lists paths that bypass authentication (optional)
|
|
||||||
// Example: ["/health", "/metrics"]
|
|
||||||
ExcludedURLs []string `json:"excludedURLs"`
|
|
||||||
|
|
||||||
// AllowedUserDomains restricts access to specific email domains (optional)
|
|
||||||
// Example: ["company.com", "subsidiary.com"]
|
|
||||||
AllowedUserDomains []string `json:"allowedUserDomains"`
|
|
||||||
|
|
||||||
// AllowedUsers restricts access to specific email addresses (optional)
|
|
||||||
// Example: ["[email protected]", "[email protected]"]
|
|
||||||
AllowedUsers []string `json:"allowedUsers"`
|
|
||||||
|
|
||||||
// AllowedRolesAndGroups restricts access to users with specific roles or groups (optional)
|
|
||||||
// Example: ["admin", "developer"]
|
|
||||||
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
|
|
||||||
|
|
||||||
// OIDCEndSessionURL is the provider's end session endpoint (optional)
|
|
||||||
// If not provided, it will be discovered from provider metadata
|
|
||||||
OIDCEndSessionURL string `json:"oidcEndSessionURL"`
|
|
||||||
|
|
||||||
// PostLogoutRedirectURI is the URL to redirect to after logout (optional)
|
|
||||||
// Default: "/"
|
|
||||||
PostLogoutRedirectURI string `json:"postLogoutRedirectURI"`
|
|
||||||
|
|
||||||
// HTTPClient allows customizing the HTTP client used for OIDC operations (optional)
|
|
||||||
HTTPClient *http.Client
|
|
||||||
|
|
||||||
// RefreshGracePeriodSeconds defines how many seconds before a token expires
|
|
||||||
// the plugin should attempt to refresh it proactively (optional)
|
|
||||||
// Default: 60
|
|
||||||
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
|
|
||||||
// Headers defines custom HTTP headers to set with templated values (optional)
|
|
||||||
// Values can reference tokens and claims using Go templates with the following variables:
|
|
||||||
// - {{.AccessToken}} - The access token (ID token)
|
|
||||||
// - {{.IdToken}} - Same as AccessToken (for consistency)
|
|
||||||
// - {{.RefreshToken}} - The refresh token
|
|
||||||
// - {{.Claims.email}} - Access token claims (use proper case for claim names)
|
|
||||||
// Examples:
|
|
||||||
//
|
|
||||||
// [{Name: "X-Forwarded-Email", Value: "{{.Claims.email}}"}]
|
|
||||||
// [{Name: "Authorization", Value: "Bearer {{.AccessToken}}"}]
|
|
||||||
Headers []TemplatedHeader `json:"headers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// DefaultRateLimit defines the default rate limit for requests per second
|
cookieName = "_raczylo_oidc"
|
||||||
DefaultRateLimit = 100
|
|
||||||
|
|
||||||
// MinRateLimit defines the minimum allowed rate limit to prevent DOS
|
|
||||||
MinRateLimit = 10
|
|
||||||
|
|
||||||
// DefaultLogLevel defines the default logging level
|
|
||||||
DefaultLogLevel = "info"
|
|
||||||
|
|
||||||
// MinSessionEncryptionKeyLength defines the minimum length for session encryption key
|
|
||||||
MinSessionEncryptionKeyLength = 32
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateConfig creates a new Config with secure default values.
|
const (
|
||||||
// Default values are set for optional fields:
|
headerXForwardedProto = "X-Forwarded-Proto"
|
||||||
// - Scopes: ["openid", "profile", "email"]
|
headerXForwardedHost = "X-Forwarded-Host"
|
||||||
// - LogLevel: "info"
|
headerXForwardedUser = "X-Forwarded-User"
|
||||||
// - LogoutURL: CallbackURL + "/logout"
|
headerXUserGroups = "X-User-Groups"
|
||||||
// - RateLimit: 100 requests per second
|
headerXUserRoles = "X-User-Roles"
|
||||||
// - PostLogoutRedirectURI: "/"
|
)
|
||||||
// - ForceHTTPS: true (for security)
|
|
||||||
// - EnablePKCE: false (PKCE is opt-in)
|
// Config holds the configuration for the OIDC middleware
|
||||||
//
|
type Config struct {
|
||||||
// CreateConfig initializes a new Config struct with default values for optional fields.
|
ProviderURL string `json:"providerURL"`
|
||||||
// It sets default scopes, log level, rate limit, enables ForceHTTPS, and sets the
|
RevocationURL string `json:"revocationURL"`
|
||||||
// default refresh grace period. Required fields like ProviderURL, ClientID, ClientSecret,
|
CallbackURL string `json:"callbackURL"`
|
||||||
// CallbackURL, and SessionEncryptionKey must be set explicitly after creation.
|
LogoutURL string `json:"logoutURL"`
|
||||||
//
|
ClientID string `json:"clientID"`
|
||||||
// Returns:
|
ClientSecret string `json:"clientSecret"`
|
||||||
// - A pointer to a new Config struct with default settings applied.
|
Scopes []string `json:"scopes"`
|
||||||
|
LogLevel string `json:"logLevel"`
|
||||||
|
SessionEncryptionKey string `json:"sessionEncryptionKey"`
|
||||||
|
ForceHTTPS bool `json:"forceHTTPS"`
|
||||||
|
RateLimit int `json:"rateLimit"`
|
||||||
|
ExcludedURLs []string `json:"excludedURLs"`
|
||||||
|
AllowedUserDomains []string `json:"allowedUserDomains"`
|
||||||
|
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultSessionOptions = &sessions.Options{
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: false,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: ConstSessionTimeout,
|
||||||
|
Path: "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateConfig creates a new Config with default values
|
||||||
func CreateConfig() *Config {
|
func CreateConfig() *Config {
|
||||||
c := &Config{
|
c := &Config{}
|
||||||
Scopes: []string{"openid", "profile", "email"},
|
|
||||||
LogLevel: DefaultLogLevel,
|
if c.Scopes == nil {
|
||||||
RateLimit: DefaultRateLimit,
|
c.Scopes = []string{"openid", "profile", "email"}
|
||||||
ForceHTTPS: true, // Secure by default
|
}
|
||||||
EnablePKCE: false, // PKCE is opt-in
|
|
||||||
RefreshGracePeriodSeconds: 60, // Default grace period of 60 seconds
|
if c.LogLevel == "" {
|
||||||
|
c.LogLevel = "info"
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.LogoutURL == "" {
|
||||||
|
c.LogoutURL = c.CallbackURL + "/logout"
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.RateLimit == 0 {
|
||||||
|
c.RateLimit = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks the configuration settings for validity.
|
// Validate validates the Config
|
||||||
// It ensures that required fields (ProviderURL, CallbackURL, ClientID, ClientSecret, SessionEncryptionKey)
|
|
||||||
// are present and that URLs are well-formed (HTTPS where required). It also validates
|
|
||||||
// the session key length, log level, rate limit, and refresh grace period.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - nil if the configuration is valid.
|
|
||||||
// - An error describing the first validation failure encountered.
|
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
// Validate provider URL
|
|
||||||
if c.ProviderURL == "" {
|
if c.ProviderURL == "" {
|
||||||
return fmt.Errorf("providerURL is required")
|
return fmt.Errorf("providerURL is required")
|
||||||
}
|
}
|
||||||
if !isValidSecureURL(c.ProviderURL) {
|
|
||||||
return fmt.Errorf("providerURL must be a valid HTTPS URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate callback URL
|
|
||||||
if c.CallbackURL == "" {
|
if c.CallbackURL == "" {
|
||||||
return fmt.Errorf("callbackURL is required")
|
return fmt.Errorf("callbackURL is required")
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(c.CallbackURL, "/") {
|
|
||||||
return fmt.Errorf("callbackURL must start with /")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate client credentials
|
|
||||||
if c.ClientID == "" {
|
if c.ClientID == "" {
|
||||||
return fmt.Errorf("clientID is required")
|
return fmt.Errorf("clientID is required")
|
||||||
}
|
}
|
||||||
if c.ClientSecret == "" {
|
if c.ClientSecret == "" {
|
||||||
return fmt.Errorf("clientSecret is required")
|
return fmt.Errorf("clientSecret is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate session encryption key
|
|
||||||
if c.SessionEncryptionKey == "" {
|
if c.SessionEncryptionKey == "" {
|
||||||
return fmt.Errorf("sessionEncryptionKey is required")
|
return fmt.Errorf("sessionEncryptionKey is required")
|
||||||
}
|
}
|
||||||
if len(c.SessionEncryptionKey) < MinSessionEncryptionKeyLength {
|
|
||||||
return fmt.Errorf("sessionEncryptionKey must be at least %d characters long", MinSessionEncryptionKeyLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate log level
|
|
||||||
if c.LogLevel != "" && !isValidLogLevel(c.LogLevel) {
|
|
||||||
return fmt.Errorf("logLevel must be one of: debug, info, error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate excluded URLs
|
|
||||||
for _, url := range c.ExcludedURLs {
|
|
||||||
if !strings.HasPrefix(url, "/") {
|
|
||||||
return fmt.Errorf("excluded URL must start with /: %s", url)
|
|
||||||
}
|
|
||||||
if strings.Contains(url, "..") {
|
|
||||||
return fmt.Errorf("excluded URL must not contain path traversal: %s", url)
|
|
||||||
}
|
|
||||||
if strings.Contains(url, "*") {
|
|
||||||
return fmt.Errorf("excluded URL must not contain wildcards: %s", url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate revocation URL if set
|
|
||||||
if c.RevocationURL != "" && !isValidSecureURL(c.RevocationURL) {
|
|
||||||
return fmt.Errorf("revocationURL must be a valid HTTPS URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate end session URL if set
|
|
||||||
if c.OIDCEndSessionURL != "" && !isValidSecureURL(c.OIDCEndSessionURL) {
|
|
||||||
return fmt.Errorf("oidcEndSessionURL must be a valid HTTPS URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate post-logout redirect URI if set
|
|
||||||
if c.PostLogoutRedirectURI != "" && c.PostLogoutRedirectURI != "/" {
|
|
||||||
if !isValidSecureURL(c.PostLogoutRedirectURI) && !strings.HasPrefix(c.PostLogoutRedirectURI, "/") {
|
|
||||||
return fmt.Errorf("postLogoutRedirectURI must be either a valid HTTPS URL or start with /")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate rate limit
|
|
||||||
if c.RateLimit < MinRateLimit {
|
|
||||||
return fmt.Errorf("rateLimit must be at least %d", MinRateLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate refresh grace period
|
|
||||||
if c.RefreshGracePeriodSeconds < 0 {
|
|
||||||
return fmt.Errorf("refreshGracePeriodSeconds cannot be negative")
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY FIX: Validate headers configuration with enhanced template security
|
|
||||||
for _, header := range c.Headers {
|
|
||||||
if header.Name == "" {
|
|
||||||
return fmt.Errorf("header name cannot be empty")
|
|
||||||
}
|
|
||||||
if header.Value == "" {
|
|
||||||
return fmt.Errorf("header value template cannot be empty")
|
|
||||||
}
|
|
||||||
if !strings.Contains(header.Value, "{{") || !strings.Contains(header.Value, "}}") {
|
|
||||||
return fmt.Errorf("header value '%s' does not appear to be a valid template (missing {{ }})", header.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provide more helpful guidance for common template errors BEFORE security validation
|
|
||||||
if strings.Contains(header.Value, "{{.claims") {
|
|
||||||
return fmt.Errorf("header template '%s' appears to use lowercase 'claims' - use '{{.Claims...' instead (case sensitive)", header.Value)
|
|
||||||
}
|
|
||||||
if strings.Contains(header.Value, "{{.accessToken") {
|
|
||||||
return fmt.Errorf("header template '%s' appears to use lowercase 'accessToken' - use '{{.AccessToken...' instead (case sensitive)", header.Value)
|
|
||||||
}
|
|
||||||
if strings.Contains(header.Value, "{{.idToken") {
|
|
||||||
return fmt.Errorf("header template '%s' appears to use lowercase 'idToken' - use '{{.IdToken...' instead (case sensitive)", header.Value)
|
|
||||||
}
|
|
||||||
if strings.Contains(header.Value, "{{.refreshToken") {
|
|
||||||
return fmt.Errorf("header template '%s' appears to use lowercase 'refreshToken' - use '{{.RefreshToken...' instead (case sensitive)", header.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY FIX: Implement template sandboxing and validation
|
|
||||||
if err := validateTemplateSecure(header.Value); err != nil {
|
|
||||||
return fmt.Errorf("header template '%s' failed security validation: %w", header.Value, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY FIX: validateTemplateSecure implements template sandboxing and validation
|
// Logger is a simple logger with different levels
|
||||||
func validateTemplateSecure(templateStr string) error {
|
|
||||||
// SECURITY FIX: Restrict dangerous template functions and patterns
|
|
||||||
dangerousPatterns := []string{
|
|
||||||
"{{call", // Function calls
|
|
||||||
"{{range", // Range over arbitrary data
|
|
||||||
"{{with", // With statements that could access unexpected data
|
|
||||||
"{{define", // Template definitions
|
|
||||||
"{{template", // Template inclusions
|
|
||||||
"{{block", // Block definitions
|
|
||||||
"{{/*", // Comments that could hide malicious code
|
|
||||||
"{{-", // Trim whitespace (could be used to obfuscate)
|
|
||||||
"-}}", // Trim whitespace (could be used to obfuscate)
|
|
||||||
"{{printf", // Printf functions
|
|
||||||
"{{print", // Print functions
|
|
||||||
"{{println", // Println functions
|
|
||||||
"{{html", // HTML functions
|
|
||||||
"{{js", // JavaScript functions
|
|
||||||
"{{urlquery", // URL query functions
|
|
||||||
"{{index", // Index access to arbitrary data
|
|
||||||
"{{slice", // Slice operations
|
|
||||||
"{{len", // Length operations on arbitrary data
|
|
||||||
"{{eq", // Comparison operations
|
|
||||||
"{{ne", // Comparison operations
|
|
||||||
"{{lt", // Comparison operations
|
|
||||||
"{{le", // Comparison operations
|
|
||||||
"{{gt", // Comparison operations
|
|
||||||
"{{ge", // Comparison operations
|
|
||||||
"{{and", // Logical operations
|
|
||||||
"{{or", // Logical operations
|
|
||||||
"{{not", // Logical operations
|
|
||||||
}
|
|
||||||
|
|
||||||
templateLower := strings.ToLower(templateStr)
|
|
||||||
for _, pattern := range dangerousPatterns {
|
|
||||||
if strings.Contains(templateLower, pattern) {
|
|
||||||
return fmt.Errorf("dangerous template pattern detected: %s", pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY FIX: Whitelist allowed template variables and functions
|
|
||||||
allowedPatterns := []string{
|
|
||||||
"{{.AccessToken}}",
|
|
||||||
"{{.IdToken}}",
|
|
||||||
"{{.RefreshToken}}",
|
|
||||||
"{{.Claims.",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if template contains only allowed patterns
|
|
||||||
hasAllowedPattern := false
|
|
||||||
for _, pattern := range allowedPatterns {
|
|
||||||
if strings.Contains(templateStr, pattern) {
|
|
||||||
hasAllowedPattern = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasAllowedPattern {
|
|
||||||
return fmt.Errorf("template must use only allowed variables: AccessToken, IdToken, RefreshToken, or Claims.*")
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY FIX: Validate Claims access patterns
|
|
||||||
if strings.Contains(templateStr, "{{.Claims.") {
|
|
||||||
// Simple validation - ensure claims access is to known safe fields
|
|
||||||
safeClaimsFields := map[string]bool{
|
|
||||||
"email": true,
|
|
||||||
"name": true,
|
|
||||||
"given_name": true,
|
|
||||||
"family_name": true,
|
|
||||||
"preferred_username": true,
|
|
||||||
"sub": true,
|
|
||||||
"iss": true,
|
|
||||||
"aud": true,
|
|
||||||
"exp": true,
|
|
||||||
"iat": true,
|
|
||||||
"groups": true,
|
|
||||||
"roles": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract field names from Claims access
|
|
||||||
start := strings.Index(templateStr, "{{.Claims.")
|
|
||||||
for start != -1 {
|
|
||||||
end := strings.Index(templateStr[start:], "}}")
|
|
||||||
if end == -1 {
|
|
||||||
return fmt.Errorf("malformed Claims template syntax")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the content between "{{.Claims." and "}}"
|
|
||||||
// start+10 skips "{{.Claims." and start+end is the position of "}}"
|
|
||||||
claimsContent := templateStr[start+10 : start+end]
|
|
||||||
|
|
||||||
// Get the field name (first part before any dots)
|
|
||||||
fieldName := strings.Split(claimsContent, ".")[0]
|
|
||||||
|
|
||||||
if !safeClaimsFields[fieldName] {
|
|
||||||
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", fieldName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix the search for next occurrence
|
|
||||||
nextStart := strings.Index(templateStr[start+end+2:], "{{.Claims.")
|
|
||||||
if nextStart != -1 {
|
|
||||||
start = start + end + 2 + nextStart
|
|
||||||
} else {
|
|
||||||
start = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY FIX: Prevent code injection through template syntax
|
|
||||||
if strings.Contains(templateStr, "{{") && strings.Contains(templateStr, "}}") {
|
|
||||||
// Count opening and closing braces
|
|
||||||
openCount := strings.Count(templateStr, "{{")
|
|
||||||
closeCount := strings.Count(templateStr, "}}")
|
|
||||||
if openCount != closeCount {
|
|
||||||
return fmt.Errorf("unbalanced template braces")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isValidSecureURL checks if a given string represents a valid, absolute HTTPS URL.
|
|
||||||
// It uses url.Parse and checks for a nil error, an "https" scheme, and a non-empty host.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - s: The URL string to validate.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - true if the string is a valid HTTPS URL, false otherwise.
|
|
||||||
func isValidSecureURL(s string) bool {
|
|
||||||
u, err := url.Parse(s)
|
|
||||||
return err == nil && u.Scheme == "https" && u.Host != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// isValidLogLevel checks if the provided log level string is one of the supported values ("debug", "info", "error").
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - level: The log level string to validate.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - true if the log level is valid, false otherwise.
|
|
||||||
func isValidLogLevel(level string) bool {
|
|
||||||
return level == "debug" || level == "info" || level == "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logger provides structured logging capabilities with different severity levels.
|
|
||||||
// It supports error, info, and debug levels with appropriate output streams
|
|
||||||
// and formatting for each level.
|
|
||||||
type Logger struct {
|
type Logger struct {
|
||||||
// logError handles error-level messages, writing to stderr
|
|
||||||
logError *log.Logger
|
logError *log.Logger
|
||||||
// logInfo handles informational messages, writing to stdout
|
logInfo *log.Logger
|
||||||
logInfo *log.Logger
|
|
||||||
// logDebug handles debug-level messages, writing to stdout when debug is enabled
|
|
||||||
logDebug *log.Logger
|
logDebug *log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLogger creates and configures a new Logger instance based on the provided log level.
|
// NewLogger creates a new Logger
|
||||||
// It initializes loggers for ERROR (stderr), INFO (stdout), and DEBUG (stdout) levels,
|
|
||||||
// enabling output based on the specified level:
|
|
||||||
// - "error": Only ERROR messages are output.
|
|
||||||
// - "info": INFO and ERROR messages are output.
|
|
||||||
// - "debug": DEBUG, INFO, and ERROR messages are output.
|
|
||||||
//
|
|
||||||
// If an invalid level is provided, it defaults to behavior similar to "error".
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - logLevel: The desired logging level ("debug", "info", or "error").
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A pointer to the configured Logger instance.
|
|
||||||
func NewLogger(logLevel string) *Logger {
|
func NewLogger(logLevel string) *Logger {
|
||||||
logError := log.New(io.Discard, "ERROR: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
|
logError := log.New(io.Discard, "ERROR: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
|
||||||
logInfo := log.New(io.Discard, "INFO: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
|
logInfo := log.New(io.Discard, "INFO: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
|
||||||
logDebug := log.New(io.Discard, "DEBUG: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
|
logDebug := log.New(io.Discard, "DEBUG: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
|
||||||
|
|
||||||
logError.SetOutput(os.Stderr)
|
logError.SetOutput(os.Stderr)
|
||||||
|
logInfo.SetOutput(os.Stdout)
|
||||||
|
|
||||||
if logLevel == "debug" || logLevel == "info" {
|
|
||||||
logInfo.SetOutput(os.Stdout)
|
|
||||||
}
|
|
||||||
if logLevel == "debug" {
|
if logLevel == "debug" {
|
||||||
logDebug.SetOutput(os.Stdout)
|
logDebug.SetOutput(os.Stdout)
|
||||||
}
|
}
|
||||||
@@ -475,77 +119,37 @@ func NewLogger(logLevel string) *Logger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info logs a message at the INFO level using Printf style formatting.
|
// Info logs an info message
|
||||||
// Output is directed to stdout if the configured log level is "info" or "debug".
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - format: The format string (as in fmt.Printf).
|
|
||||||
// - args: The arguments for the format string.
|
|
||||||
func (l *Logger) Info(format string, args ...interface{}) {
|
func (l *Logger) Info(format string, args ...interface{}) {
|
||||||
l.logInfo.Printf(format, args...)
|
l.logInfo.Printf(format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logs a message at the DEBUG level using Printf style formatting.
|
// Debug logs a debug message
|
||||||
// Output is directed to stdout only if the configured log level is "debug".
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - format: The format string (as in fmt.Printf).
|
|
||||||
// - args: The arguments for the format string.
|
|
||||||
func (l *Logger) Debug(format string, args ...interface{}) {
|
func (l *Logger) Debug(format string, args ...interface{}) {
|
||||||
l.logDebug.Printf(format, args...)
|
l.logDebug.Printf(format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error logs a message at the ERROR level using Printf style formatting.
|
// Error logs an error message
|
||||||
// Output is always directed to stderr, regardless of the configured log level.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - format: The format string (as in fmt.Printf).
|
|
||||||
// - args: The arguments for the format string.
|
|
||||||
func (l *Logger) Error(format string, args ...interface{}) {
|
func (l *Logger) Error(format string, args ...interface{}) {
|
||||||
l.logError.Printf(format, args...)
|
l.logError.Printf(format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infof logs a message at the INFO level using Printf style formatting.
|
// Infof logs an info message
|
||||||
// Equivalent to calling l.Info(format, args...).
|
|
||||||
// Output is directed to stdout if the configured log level is "info" or "debug".
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - format: The format string (as in fmt.Printf).
|
|
||||||
// - args: The arguments for the format string.
|
|
||||||
func (l *Logger) Infof(format string, args ...interface{}) {
|
func (l *Logger) Infof(format string, args ...interface{}) {
|
||||||
l.logInfo.Printf(format, args...)
|
l.logInfo.Printf(format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debugf logs a message at the DEBUG level using Printf style formatting.
|
// Debugf logs a debug message
|
||||||
// Equivalent to calling l.Debug(format, args...).
|
|
||||||
// Output is directed to stdout only if the configured log level is "debug".
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - format: The format string (as in fmt.Printf).
|
|
||||||
// - args: The arguments for the format string.
|
|
||||||
func (l *Logger) Debugf(format string, args ...interface{}) {
|
func (l *Logger) Debugf(format string, args ...interface{}) {
|
||||||
l.logDebug.Printf(format, args...)
|
l.logDebug.Printf(format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errorf logs a message at the ERROR level using Printf style formatting.
|
// Errorf logs an error message
|
||||||
// Equivalent to calling l.Error(format, args...).
|
|
||||||
// Output is always directed to stderr, regardless of the configured log level.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - format: The format string (as in fmt.Printf).
|
|
||||||
// - args: The arguments for the format string.
|
|
||||||
func (l *Logger) Errorf(format string, args ...interface{}) {
|
func (l *Logger) Errorf(format string, args ...interface{}) {
|
||||||
l.logError.Printf(format, args...)
|
l.logError.Printf(format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleError logs an error message using the provided logger and sends an HTTP error
|
// handleError writes an error message to the response and logs it
|
||||||
// response to the client with the specified message and status code.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - w: The http.ResponseWriter to send the error response to.
|
|
||||||
// - message: The error message string.
|
|
||||||
// - code: The HTTP status code for the response.
|
|
||||||
// - logger: The Logger instance to use for logging the error.
|
|
||||||
func handleError(w http.ResponseWriter, message string, code int, logger *Logger) {
|
func handleError(w http.ResponseWriter, message string, code int, logger *Logger) {
|
||||||
logger.Error(message)
|
logger.Error(message)
|
||||||
http.Error(w, message, code)
|
http.Error(w, message, code)
|
||||||
|
|||||||
@@ -1,411 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateConfig(t *testing.T) {
|
|
||||||
t.Run("Default Values", func(t *testing.T) {
|
|
||||||
config := CreateConfig()
|
|
||||||
|
|
||||||
// Check default scopes
|
|
||||||
expectedScopes := []string{"openid", "profile", "email"}
|
|
||||||
if len(config.Scopes) != len(expectedScopes) {
|
|
||||||
t.Errorf("Expected %d default scopes, got %d", len(expectedScopes), len(config.Scopes))
|
|
||||||
}
|
|
||||||
for i, scope := range expectedScopes {
|
|
||||||
if config.Scopes[i] != scope {
|
|
||||||
t.Errorf("Expected scope %s at position %d, got %s", scope, i, config.Scopes[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check default log level
|
|
||||||
if config.LogLevel != DefaultLogLevel {
|
|
||||||
t.Errorf("Expected default log level '%s', got '%s'", DefaultLogLevel, config.LogLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check default rate limit
|
|
||||||
if config.RateLimit != DefaultRateLimit {
|
|
||||||
t.Errorf("Expected default rate limit %d, got %d", DefaultRateLimit, config.RateLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check ForceHTTPS default
|
|
||||||
if !config.ForceHTTPS {
|
|
||||||
t.Error("Expected ForceHTTPS to be true by default")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Custom Values Preserved", func(t *testing.T) {
|
|
||||||
config := CreateConfig()
|
|
||||||
config.Scopes = []string{"custom_scope"}
|
|
||||||
config.LogLevel = "debug"
|
|
||||||
config.RateLimit = 50
|
|
||||||
config.ForceHTTPS = false
|
|
||||||
|
|
||||||
// Verify custom values are not overwritten
|
|
||||||
if len(config.Scopes) != 1 || config.Scopes[0] != "custom_scope" {
|
|
||||||
t.Error("Custom scopes were overwritten")
|
|
||||||
}
|
|
||||||
if config.LogLevel != "debug" {
|
|
||||||
t.Error("Custom log level was overwritten")
|
|
||||||
}
|
|
||||||
if config.RateLimit != 50 {
|
|
||||||
t.Error("Custom rate limit was overwritten")
|
|
||||||
}
|
|
||||||
if config.ForceHTTPS {
|
|
||||||
t.Error("Custom ForceHTTPS value was overwritten")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigValidate(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
config *Config
|
|
||||||
expectedError string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Empty Config",
|
|
||||||
config: &Config{},
|
|
||||||
expectedError: "providerURL is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Missing CallbackURL",
|
|
||||||
config: &Config{
|
|
||||||
ProviderURL: "https://provider.com",
|
|
||||||
},
|
|
||||||
expectedError: "callbackURL is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Missing ClientID",
|
|
||||||
config: &Config{
|
|
||||||
ProviderURL: "https://provider.com",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
},
|
|
||||||
expectedError: "clientID is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Missing ClientSecret",
|
|
||||||
config: &Config{
|
|
||||||
ProviderURL: "https://provider.com",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
ClientID: "client-id",
|
|
||||||
},
|
|
||||||
expectedError: "clientSecret is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Missing SessionEncryptionKey",
|
|
||||||
config: &Config{
|
|
||||||
ProviderURL: "https://provider.com",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
ClientID: "client-id",
|
|
||||||
ClientSecret: "client-secret",
|
|
||||||
},
|
|
||||||
expectedError: "sessionEncryptionKey is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Non-HTTPS ProviderURL",
|
|
||||||
config: &Config{
|
|
||||||
ProviderURL: "http://provider.com",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
ClientID: "client-id",
|
|
||||||
ClientSecret: "client-secret",
|
|
||||||
SessionEncryptionKey: "encryption-key",
|
|
||||||
},
|
|
||||||
expectedError: "providerURL must be a valid HTTPS URL",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid CallbackURL",
|
|
||||||
config: &Config{
|
|
||||||
ProviderURL: "https://provider.com",
|
|
||||||
CallbackURL: "callback", // Missing leading slash
|
|
||||||
ClientID: "client-id",
|
|
||||||
ClientSecret: "client-secret",
|
|
||||||
SessionEncryptionKey: "encryption-key",
|
|
||||||
},
|
|
||||||
expectedError: "callbackURL must start with /",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Short SessionEncryptionKey",
|
|
||||||
config: &Config{
|
|
||||||
ProviderURL: "https://provider.com",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
ClientID: "client-id",
|
|
||||||
ClientSecret: "client-secret",
|
|
||||||
SessionEncryptionKey: "short",
|
|
||||||
},
|
|
||||||
expectedError: "sessionEncryptionKey must be at least 32 characters long",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Low RateLimit",
|
|
||||||
config: &Config{
|
|
||||||
ProviderURL: "https://provider.com",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
ClientID: "client-id",
|
|
||||||
ClientSecret: "client-secret",
|
|
||||||
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
|
|
||||||
RateLimit: 5,
|
|
||||||
},
|
|
||||||
expectedError: "rateLimit must be at least 10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid LogLevel",
|
|
||||||
config: &Config{
|
|
||||||
ProviderURL: "https://provider.com",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
ClientID: "client-id",
|
|
||||||
ClientSecret: "client-secret",
|
|
||||||
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
|
|
||||||
LogLevel: "invalid",
|
|
||||||
},
|
|
||||||
expectedError: "logLevel must be one of: debug, info, error",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Non-HTTPS RevocationURL",
|
|
||||||
config: &Config{
|
|
||||||
ProviderURL: "https://provider.com",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
ClientID: "client-id",
|
|
||||||
ClientSecret: "client-secret",
|
|
||||||
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
|
|
||||||
RevocationURL: "http://revoke.com",
|
|
||||||
},
|
|
||||||
expectedError: "revocationURL must be a valid HTTPS URL",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Non-HTTPS OIDCEndSessionURL",
|
|
||||||
config: &Config{
|
|
||||||
ProviderURL: "https://provider.com",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
ClientID: "client-id",
|
|
||||||
ClientSecret: "client-secret",
|
|
||||||
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
|
|
||||||
OIDCEndSessionURL: "http://endsession.com",
|
|
||||||
},
|
|
||||||
expectedError: "oidcEndSessionURL must be a valid HTTPS URL",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Valid Config",
|
|
||||||
config: &Config{
|
|
||||||
ProviderURL: "https://provider.com",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
ClientID: "client-id",
|
|
||||||
ClientSecret: "client-secret",
|
|
||||||
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
|
|
||||||
LogLevel: "debug",
|
|
||||||
RateLimit: 100,
|
|
||||||
RevocationURL: "https://revoke.com",
|
|
||||||
OIDCEndSessionURL: "https://endsession.com",
|
|
||||||
},
|
|
||||||
expectedError: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Valid Config With AllowedUsers",
|
|
||||||
config: &Config{
|
|
||||||
ProviderURL: "https://provider.com",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
ClientID: "client-id",
|
|
||||||
ClientSecret: "client-secret",
|
|
||||||
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
|
|
||||||
LogLevel: "debug",
|
|
||||||
RateLimit: 100,
|
|
||||||
AllowedUsers: []string{"[email protected]", "[email protected]"},
|
|
||||||
},
|
|
||||||
expectedError: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
err := tc.config.Validate()
|
|
||||||
if tc.expectedError == "" {
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Expected no error, got: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("Expected error containing '%s', got nil", tc.expectedError)
|
|
||||||
} else if err.Error() != tc.expectedError {
|
|
||||||
t.Errorf("Expected error '%s', got '%s'", tc.expectedError, err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogger(t *testing.T) {
|
|
||||||
// Capture log output
|
|
||||||
var debugBuf, infoBuf, errorBuf bytes.Buffer
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
logLevel string
|
|
||||||
testFunc func(*Logger)
|
|
||||||
checkFunc func(t *testing.T, debugOut, infoOut, errorOut string)
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Debug Level",
|
|
||||||
logLevel: "debug",
|
|
||||||
testFunc: func(l *Logger) {
|
|
||||||
l.Debug("debug message")
|
|
||||||
l.Info("info message")
|
|
||||||
l.Error("error message")
|
|
||||||
},
|
|
||||||
checkFunc: func(t *testing.T, debugOut, infoOut, errorOut string) {
|
|
||||||
if debugOut == "" {
|
|
||||||
t.Error("Expected debug message in output")
|
|
||||||
}
|
|
||||||
if infoOut == "" {
|
|
||||||
t.Error("Expected info message in output")
|
|
||||||
}
|
|
||||||
if errorOut == "" {
|
|
||||||
t.Error("Expected error message in output")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Info Level",
|
|
||||||
logLevel: "info",
|
|
||||||
testFunc: func(l *Logger) {
|
|
||||||
l.Debug("debug message")
|
|
||||||
l.Info("info message")
|
|
||||||
l.Error("error message")
|
|
||||||
},
|
|
||||||
checkFunc: func(t *testing.T, debugOut, infoOut, errorOut string) {
|
|
||||||
if debugOut != "" {
|
|
||||||
t.Error("Did not expect debug message in output")
|
|
||||||
}
|
|
||||||
if infoOut == "" {
|
|
||||||
t.Error("Expected info message in output")
|
|
||||||
}
|
|
||||||
if errorOut == "" {
|
|
||||||
t.Error("Expected error message in output")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Error Level",
|
|
||||||
logLevel: "error",
|
|
||||||
testFunc: func(l *Logger) {
|
|
||||||
l.Debug("debug message")
|
|
||||||
l.Info("info message")
|
|
||||||
l.Error("error message")
|
|
||||||
},
|
|
||||||
checkFunc: func(t *testing.T, debugOut, infoOut, errorOut string) {
|
|
||||||
if debugOut != "" {
|
|
||||||
t.Error("Did not expect debug message in output")
|
|
||||||
}
|
|
||||||
if infoOut != "" {
|
|
||||||
t.Error("Did not expect info message in output")
|
|
||||||
}
|
|
||||||
if errorOut == "" {
|
|
||||||
t.Error("Expected error message in output")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Printf Methods",
|
|
||||||
logLevel: "debug",
|
|
||||||
testFunc: func(l *Logger) {
|
|
||||||
l.Debugf("debug %s", "formatted")
|
|
||||||
l.Infof("info %s", "formatted")
|
|
||||||
l.Errorf("error %s", "formatted")
|
|
||||||
},
|
|
||||||
checkFunc: func(t *testing.T, debugOut, infoOut, errorOut string) {
|
|
||||||
if !bytes.Contains([]byte(debugOut), []byte("debug formatted")) {
|
|
||||||
t.Error("Expected formatted debug message")
|
|
||||||
}
|
|
||||||
if !bytes.Contains([]byte(infoOut), []byte("info formatted")) {
|
|
||||||
t.Error("Expected formatted info message")
|
|
||||||
}
|
|
||||||
if !bytes.Contains([]byte(errorOut), []byte("error formatted")) {
|
|
||||||
t.Error("Expected formatted error message")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
// Reset buffers
|
|
||||||
debugBuf.Reset()
|
|
||||||
infoBuf.Reset()
|
|
||||||
errorBuf.Reset()
|
|
||||||
|
|
||||||
// Create logger with test buffers
|
|
||||||
logger := NewLogger(tc.logLevel)
|
|
||||||
logger.logError.SetOutput(&errorBuf)
|
|
||||||
|
|
||||||
if tc.logLevel == "debug" || tc.logLevel == "info" {
|
|
||||||
logger.logInfo.SetOutput(&infoBuf)
|
|
||||||
}
|
|
||||||
if tc.logLevel == "debug" {
|
|
||||||
logger.logDebug.SetOutput(&debugBuf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run test
|
|
||||||
tc.testFunc(logger)
|
|
||||||
|
|
||||||
// Check results
|
|
||||||
tc.checkFunc(t, debugBuf.String(), infoBuf.String(), errorBuf.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleError(t *testing.T) {
|
|
||||||
// Create a test logger with captured output
|
|
||||||
var errorBuf bytes.Buffer
|
|
||||||
logger := &Logger{
|
|
||||||
logError: log.New(&errorBuf, "ERROR: ", log.Ldate|log.Ltime),
|
|
||||||
}
|
|
||||||
logger.logError.SetOutput(&errorBuf)
|
|
||||||
|
|
||||||
// Create a test response recorder
|
|
||||||
rr := &testResponseRecorder{
|
|
||||||
headers: make(map[string][]string),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test error handling
|
|
||||||
message := "test error message"
|
|
||||||
code := 400
|
|
||||||
handleError(rr, message, code, logger)
|
|
||||||
|
|
||||||
// Check response code
|
|
||||||
if rr.statusCode != code {
|
|
||||||
t.Errorf("Expected status code %d, got %d", code, rr.statusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check response body
|
|
||||||
expectedBody := message + "\n"
|
|
||||||
if rr.body != expectedBody {
|
|
||||||
t.Errorf("Expected body %q, got %q", expectedBody, rr.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check error was logged
|
|
||||||
if !bytes.Contains(errorBuf.Bytes(), []byte(message)) {
|
|
||||||
t.Error("Error message was not logged")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test helper types
|
|
||||||
type testResponseRecorder struct {
|
|
||||||
statusCode int
|
|
||||||
body string
|
|
||||||
headers map[string][]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *testResponseRecorder) Header() http.Header {
|
|
||||||
return r.headers
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *testResponseRecorder) Write(b []byte) (int, error) {
|
|
||||||
r.body = string(b)
|
|
||||||
return len(b), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *testResponseRecorder) WriteHeader(code int) {
|
|
||||||
r.statusCode = code
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"text/template"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTemplatedHeaderValidation(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
header TemplatedHeader
|
|
||||||
expectedError string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Empty Name",
|
|
||||||
header: TemplatedHeader{Name: "", Value: "{{.Claims.email}}"},
|
|
||||||
expectedError: "header name cannot be empty",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Empty Value",
|
|
||||||
header: TemplatedHeader{Name: "X-Email", Value: ""},
|
|
||||||
expectedError: "header value template cannot be empty",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Not a Template",
|
|
||||||
header: TemplatedHeader{Name: "X-Email", Value: "static-value"},
|
|
||||||
expectedError: "header value 'static-value' does not appear to be a valid template (missing {{ }})",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Lowercase claims",
|
|
||||||
header: TemplatedHeader{Name: "X-Email", Value: "{{.claims.email}}"},
|
|
||||||
expectedError: "header template '{{.claims.email}}' appears to use lowercase 'claims' - use '{{.Claims...' instead (case sensitive)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Lowercase accessToken",
|
|
||||||
header: TemplatedHeader{Name: "X-Token", Value: "Bearer {{.accessToken}}"},
|
|
||||||
expectedError: "header template 'Bearer {{.accessToken}}' appears to use lowercase 'accessToken' - use '{{.AccessToken...' instead (case sensitive)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Lowercase idToken",
|
|
||||||
header: TemplatedHeader{Name: "X-Token", Value: "Bearer {{.idToken}}"},
|
|
||||||
expectedError: "header template 'Bearer {{.idToken}}' appears to use lowercase 'idToken' - use '{{.IdToken...' instead (case sensitive)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Lowercase refreshToken",
|
|
||||||
header: TemplatedHeader{Name: "X-Refresh", Value: "Bearer {{.refreshToken}}"},
|
|
||||||
expectedError: "header template 'Bearer {{.refreshToken}}' appears to use lowercase 'refreshToken' - use '{{.RefreshToken...' instead (case sensitive)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Valid Template",
|
|
||||||
header: TemplatedHeader{Name: "X-Email", Value: "{{.Claims.email}}"},
|
|
||||||
expectedError: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Valid Bearer Token Template",
|
|
||||||
header: TemplatedHeader{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
|
|
||||||
expectedError: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
config := &Config{
|
|
||||||
ProviderURL: "https://provider.com",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
ClientID: "client-id",
|
|
||||||
ClientSecret: "client-secret",
|
|
||||||
SessionEncryptionKey: "this-is-a-long-enough-encryption-key",
|
|
||||||
RateLimit: 10, // Adding minimum required rate limit
|
|
||||||
Headers: []TemplatedHeader{tc.header},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := config.Validate()
|
|
||||||
if tc.expectedError == "" {
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Expected no error, got: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("Expected error: %s, got nil", tc.expectedError)
|
|
||||||
} else if err.Error() != tc.expectedError {
|
|
||||||
t.Errorf("Expected error: %s, got: %s", tc.expectedError, err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTemplateParsingInNew(t *testing.T) {
|
|
||||||
// Test successful parsing of templates during middleware creation
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
headers []TemplatedHeader
|
|
||||||
expectedTemplates int
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Single Valid Template",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "X-Email", Value: "{{.Claims.email}}"},
|
|
||||||
},
|
|
||||||
expectedTemplates: 1,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiple Valid Templates",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "X-Email", Value: "{{.Claims.email}}"},
|
|
||||||
{Name: "X-User-ID", Value: "{{.Claims.sub}}"},
|
|
||||||
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
|
|
||||||
},
|
|
||||||
expectedTemplates: 3,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid Template",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "X-Email", Value: "{{.Claims.email"}, // Missing closing braces
|
|
||||||
},
|
|
||||||
expectedTemplates: 0,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Mix of Valid and Invalid Templates",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "X-Email", Value: "{{.Claims.email}}"},
|
|
||||||
{Name: "X-Invalid", Value: "{{if .Claims.admin}}Admin{{end"}, // Invalid template
|
|
||||||
},
|
|
||||||
expectedTemplates: 1, // Only the valid template should be parsed
|
|
||||||
expectError: true, // We expect an error for the invalid template, but we'll handle it
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
// For testing template parsing, we'll directly try to parse the templates instead of using New()
|
|
||||||
// This avoids the provider discovery that would fail in tests
|
|
||||||
headerTemplates := make(map[string]*template.Template)
|
|
||||||
|
|
||||||
// Special handling for the mixed valid/invalid templates case
|
|
||||||
if tc.name == "Mix of Valid and Invalid Templates" {
|
|
||||||
// Process templates one at a time so we can still have valid templates
|
|
||||||
for _, header := range tc.headers {
|
|
||||||
tmpl, err := template.New(header.Name).Parse(header.Value)
|
|
||||||
if err != nil {
|
|
||||||
// We expect an error for the invalid template
|
|
||||||
if !tc.expectError {
|
|
||||||
t.Errorf("Unexpected error parsing template %s: %v", header.Name, err)
|
|
||||||
}
|
|
||||||
// Skip this template but continue processing others
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
headerTemplates[header.Name] = tmpl
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Normal handling for other test cases
|
|
||||||
var parseErr error
|
|
||||||
for _, header := range tc.headers {
|
|
||||||
tmpl, err := template.New(header.Name).Parse(header.Value)
|
|
||||||
if err != nil {
|
|
||||||
parseErr = err
|
|
||||||
break
|
|
||||||
}
|
|
||||||
headerTemplates[header.Name] = tmpl
|
|
||||||
}
|
|
||||||
|
|
||||||
if tc.expectError {
|
|
||||||
if parseErr == nil {
|
|
||||||
t.Error("Expected error parsing templates but got nil")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if parseErr != nil {
|
|
||||||
t.Fatalf("Unexpected error: %v", parseErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the number of parsed templates
|
|
||||||
if len(headerTemplates) != tc.expectedTemplates {
|
|
||||||
t.Errorf("Expected %d parsed templates, got %d", tc.expectedTemplates, len(headerTemplates))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check each template was parsed
|
|
||||||
for _, header := range tc.headers {
|
|
||||||
// Skip the known invalid templates
|
|
||||||
if header.Value == "{{.Claims.email" || header.Value == "{{if .Claims.admin}}Admin{{end" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := headerTemplates[header.Name]; !ok {
|
|
||||||
t.Errorf("Template for header %s was not parsed", header.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"testing"
|
|
||||||
"text/template"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestTemplateExecution tests that templates are executed correctly with different types of claims
|
|
||||||
func TestTemplateExecution(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
templateText string
|
|
||||||
data map[string]interface{}
|
|
||||||
expectedValue string
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "String Claim",
|
|
||||||
templateText: "{{.Claims.email}}",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"Claims": map[string]interface{}{
|
|
||||||
"email": "[email protected]",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedValue: "[email protected]",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Number Claim",
|
|
||||||
templateText: "{{.Claims.age}}",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"Claims": map[string]interface{}{
|
|
||||||
"age": 30,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedValue: "30",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Boolean Claim",
|
|
||||||
templateText: "{{.Claims.admin}}",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"Claims": map[string]interface{}{
|
|
||||||
"admin": true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedValue: "true",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Array Claim",
|
|
||||||
templateText: "{{index .Claims.roles 0}}",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"Claims": map[string]interface{}{
|
|
||||||
"roles": []string{"admin", "user"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedValue: "admin",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Nested Object Claim",
|
|
||||||
templateText: "{{.Claims.user.name}}",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"Claims": map[string]interface{}{
|
|
||||||
"user": map[string]interface{}{
|
|
||||||
"name": "John Doe",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedValue: "John Doe",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Access Token",
|
|
||||||
templateText: "Bearer {{.AccessToken}}",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"AccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
|
|
||||||
},
|
|
||||||
expectedValue: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ID Token",
|
|
||||||
templateText: "{{.IdToken}}",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"IdToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
|
|
||||||
},
|
|
||||||
expectedValue: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Refresh Token",
|
|
||||||
templateText: "{{.RefreshToken}}",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"RefreshToken": "refresh-token-value",
|
|
||||||
},
|
|
||||||
expectedValue: "refresh-token-value",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Conditional Template",
|
|
||||||
templateText: "{{if .Claims.admin}}Admin User{{else}}Regular User{{end}}",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"Claims": map[string]interface{}{
|
|
||||||
"admin": true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedValue: "Admin User",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiple Claims",
|
|
||||||
templateText: "{{.Claims.firstName}} {{.Claims.lastName}} <{{.Claims.email}}>",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"Claims": map[string]interface{}{
|
|
||||||
"firstName": "John",
|
|
||||||
"lastName": "Doe",
|
|
||||||
"email": "[email protected]",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedValue: "John Doe <[email protected]>",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Missing Claim",
|
|
||||||
templateText: "{{.Claims.missing}}",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"Claims": map[string]interface{}{},
|
|
||||||
},
|
|
||||||
expectedValue: "<no value>",
|
|
||||||
expectError: false, // Go templates don't error on missing values
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid Template Syntax",
|
|
||||||
templateText: "{{.Claims.email",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"Claims": map[string]interface{}{
|
|
||||||
"email": "[email protected]",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedValue: "",
|
|
||||||
expectError: true, // Parsing should fail
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
tmpl, err := template.New("test").Parse(tc.templateText)
|
|
||||||
|
|
||||||
if tc.expectError {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Expected template parsing error, but got nil")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse template: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
err = tmpl.Execute(&buf, tc.data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to execute template: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := buf.String()
|
|
||||||
if result != tc.expectedValue {
|
|
||||||
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTemplateExecutionContext tests the specific template data context used in processAuthorizedRequest
|
|
||||||
func TestTemplateExecutionContext(t *testing.T) {
|
|
||||||
// Define a test struct that matches the one used in processAuthorizedRequest
|
|
||||||
type templateData struct {
|
|
||||||
AccessToken string
|
|
||||||
IdToken string
|
|
||||||
RefreshToken string
|
|
||||||
Claims map[string]interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test cases
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
templateText string
|
|
||||||
data templateData
|
|
||||||
expectedValue string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Access and ID token distinction",
|
|
||||||
templateText: "Access: {{.AccessToken}} ID: {{.IdToken}}",
|
|
||||||
data: templateData{
|
|
||||||
AccessToken: "access-token-value",
|
|
||||||
IdToken: "id-token-value", // Now these should be distinct values
|
|
||||||
Claims: map[string]interface{}{},
|
|
||||||
},
|
|
||||||
expectedValue: "Access: access-token-value ID: id-token-value",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Combining tokens and claims",
|
|
||||||
templateText: "User: {{.Claims.sub}} Token: {{.AccessToken}}",
|
|
||||||
data: templateData{
|
|
||||||
AccessToken: "access-token",
|
|
||||||
IdToken: "access-token",
|
|
||||||
Claims: map[string]interface{}{
|
|
||||||
"sub": "user123",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedValue: "User: user123 Token: access-token",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
tmpl, err := template.New("test").Parse(tc.templateText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse template: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
err = tmpl.Execute(&buf, tc.data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to execute template: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := buf.String()
|
|
||||||
if result != tc.expectedValue {
|
|
||||||
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,597 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"text/template"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestTemplatedHeadersIntegration tests that templated headers are correctly added to requests
|
|
||||||
// in the actual middleware flow
|
|
||||||
func TestTemplatedHeadersIntegration(t *testing.T) {
|
|
||||||
// Create a TestSuite to use its helper methods and fields
|
|
||||||
ts := &TestSuite{t: t}
|
|
||||||
ts.Setup()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
headers []TemplatedHeader
|
|
||||||
sessionSetup func(*SessionData)
|
|
||||||
claims map[string]interface{}
|
|
||||||
expectedHeaders map[string]string
|
|
||||||
interceptedHeaders map[string]string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Basic Email Header",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
|
|
||||||
},
|
|
||||||
claims: map[string]interface{}{
|
|
||||||
"email": "[email protected]",
|
|
||||||
},
|
|
||||||
expectedHeaders: map[string]string{
|
|
||||||
"X-User-Email": "[email protected]",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiple Headers",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
|
|
||||||
{Name: "X-User-ID", Value: "{{.Claims.sub}}"},
|
|
||||||
{Name: "X-User-Name", Value: "{{.Claims.given_name}} {{.Claims.family_name}}"},
|
|
||||||
},
|
|
||||||
claims: map[string]interface{}{
|
|
||||||
"email": "[email protected]",
|
|
||||||
"sub": "user123",
|
|
||||||
"given_name": "John",
|
|
||||||
"family_name": "Doe",
|
|
||||||
},
|
|
||||||
expectedHeaders: map[string]string{
|
|
||||||
"X-User-Email": "[email protected]",
|
|
||||||
"X-User-ID": "user123",
|
|
||||||
"X-User-Name": "John Doe",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Authorization Header with Bearer Token",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
|
|
||||||
},
|
|
||||||
expectedHeaders: map[string]string{
|
|
||||||
// We'll update this dynamically after generating the token
|
|
||||||
"Authorization": "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ID Token Header",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "X-ID-Token", Value: "{{.IdToken}}"},
|
|
||||||
},
|
|
||||||
expectedHeaders: map[string]string{
|
|
||||||
// We'll update this dynamically after generating the token
|
|
||||||
"X-ID-Token": "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Both Token Types",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "X-Access-Token", Value: "{{.AccessToken}}"},
|
|
||||||
{Name: "X-ID-Token", Value: "{{.IdToken}}"},
|
|
||||||
},
|
|
||||||
expectedHeaders: map[string]string{
|
|
||||||
// We'll update these dynamically after generating the tokens
|
|
||||||
"X-Access-Token": "",
|
|
||||||
"X-ID-Token": "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Missing Claim",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "X-User-Role", Value: "{{.Claims.role}}"},
|
|
||||||
},
|
|
||||||
claims: map[string]interface{}{
|
|
||||||
"email": "[email protected]",
|
|
||||||
// role claim is missing
|
|
||||||
},
|
|
||||||
expectedHeaders: map[string]string{
|
|
||||||
"X-User-Role": "<no value>", // Go templates provide <no value> for missing fields
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Conditional Header",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "X-User-Admin", Value: "{{if .Claims.is_admin}}true{{else}}false{{end}}"},
|
|
||||||
},
|
|
||||||
claims: map[string]interface{}{
|
|
||||||
"email": "[email protected]",
|
|
||||||
"is_admin": true,
|
|
||||||
},
|
|
||||||
expectedHeaders: map[string]string{
|
|
||||||
"X-User-Admin": "true",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Combined Token and Claim",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "X-Auth-Info", Value: "User={{.Claims.email}}, Token={{.AccessToken}}"},
|
|
||||||
},
|
|
||||||
claims: map[string]interface{}{
|
|
||||||
"email": "[email protected]",
|
|
||||||
},
|
|
||||||
expectedHeaders: map[string]string{
|
|
||||||
// We'll update this dynamically after generating the token
|
|
||||||
"X-Auth-Info": "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Opaque Access Token with AccessTokenField",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "X-User-AccessToken", Value: "{{.AccessToken}}"},
|
|
||||||
},
|
|
||||||
claims: map[string]interface{}{ // For ID Token
|
|
||||||
"email": "[email protected]",
|
|
||||||
"sub": "opaque_sub_for_id_token",
|
|
||||||
},
|
|
||||||
expectedHeaders: map[string]string{
|
|
||||||
"X-User-AccessToken": "this_is_an_opaque_access_token",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
// Create token with the test claims
|
|
||||||
token := ts.token
|
|
||||||
if len(tc.claims) > 0 {
|
|
||||||
var err error
|
|
||||||
baseClaims := map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": float64(3000000000), // Far future timestamp
|
|
||||||
"iat": float64(1000000000),
|
|
||||||
"nbf": float64(1000000000),
|
|
||||||
"sub": "test-subject",
|
|
||||||
"nonce": "test-nonce",
|
|
||||||
"jti": generateRandomString(16),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the test-specific claims
|
|
||||||
for k, v := range tc.claims {
|
|
||||||
baseClaims[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err = createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", baseClaims)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create test JWT: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update expectedHeaders for the token-based tests after token generation
|
|
||||||
if tc.name == "Authorization Header with Bearer Token" {
|
|
||||||
tc.expectedHeaders["Authorization"] = "Bearer " + token
|
|
||||||
}
|
|
||||||
|
|
||||||
if tc.name == "Combined Token and Claim" {
|
|
||||||
// If this test case uses specific ID/Access tokens, 'token' here might be just the ID token.
|
|
||||||
// This part might need adjustment if AccessToken is different and opaque.
|
|
||||||
// For now, assuming 'token' is the one to be used if not overridden later.
|
|
||||||
// The specific test "Opaque Access Token with AccessTokenField" will handle its AccessToken.
|
|
||||||
// This generic 'token' is used as a fallback if specific logic isn't hit.
|
|
||||||
// Let's ensure this test case uses the JWT access token if not otherwise specified.
|
|
||||||
accessTokenForHeader := token // Default to the generated JWT 'token'
|
|
||||||
if sessionVal, ok := tc.claims["_accessToken"]; ok { // Check if a specific access token is provided for this test
|
|
||||||
accessTokenForHeader = sessionVal.(string)
|
|
||||||
}
|
|
||||||
tc.expectedHeaders["X-Auth-Info"] = "User=" + tc.claims["email"].(string) + ", Token=" + accessTokenForHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store intercepted headers for verification
|
|
||||||
interceptedHeaders := make(map[string]string)
|
|
||||||
|
|
||||||
// Create a test next handler that captures the headers
|
|
||||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Capture headers for verification
|
|
||||||
for name := range tc.expectedHeaders {
|
|
||||||
if value := r.Header.Get(name); value != "" {
|
|
||||||
interceptedHeaders[name] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
tOidc := &TraefikOidc{
|
|
||||||
next: nextHandler,
|
|
||||||
name: "test",
|
|
||||||
redirURLPath: "/callback",
|
|
||||||
logoutURLPath: "/callback/logout",
|
|
||||||
issuerURL: "https://test-issuer.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
clientSecret: "test-client-secret",
|
|
||||||
jwkCache: ts.mockJWKCache,
|
|
||||||
jwksURL: "https://test-jwks-url.com",
|
|
||||||
tokenBlacklist: NewCache(),
|
|
||||||
tokenCache: NewTokenCache(),
|
|
||||||
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
|
||||||
logger: NewLogger("debug"),
|
|
||||||
allowedUserDomains: map[string]struct{}{"example.com": {}, "[email protected]": {}}, // Ensure domain for opaque test is allowed
|
|
||||||
excludedURLs: map[string]struct{}{"/favicon": {}},
|
|
||||||
httpClient: &http.Client{},
|
|
||||||
initComplete: make(chan struct{}),
|
|
||||||
sessionManager: ts.sessionManager,
|
|
||||||
extractClaimsFunc: extractClaims,
|
|
||||||
headerTemplates: make(map[string]*template.Template),
|
|
||||||
// Default to true, which means PopulateSessionWithIdTokenClaims is true
|
|
||||||
// UseIdTokenForSession: true, // Explicitly can be set if needed
|
|
||||||
}
|
|
||||||
tOidc.tokenVerifier = tOidc
|
|
||||||
tOidc.jwtVerifier = tOidc
|
|
||||||
tOidc.tokenExchanger = tOidc
|
|
||||||
|
|
||||||
// Initialize and parse header templates
|
|
||||||
for _, header := range tc.headers {
|
|
||||||
tmpl, err := template.New(header.Name).Parse(header.Value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse header template for %s: %v", header.Name, err)
|
|
||||||
}
|
|
||||||
tOidc.headerTemplates[header.Name] = tmpl
|
|
||||||
}
|
|
||||||
|
|
||||||
close(tOidc.initComplete)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/protected", nil)
|
|
||||||
req.Header.Set("X-Forwarded-Proto", "https")
|
|
||||||
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
session, err := tOidc.sessionManager.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
session.SetAuthenticated(true)
|
|
||||||
// Set a default email; specific tests might override or rely on ID token population
|
|
||||||
defaultEmail := "[email protected]"
|
|
||||||
if emailClaim, ok := tc.claims["email"].(string); ok {
|
|
||||||
defaultEmail = emailClaim // Use email from claims if available for initial setup
|
|
||||||
}
|
|
||||||
session.SetEmail(defaultEmail)
|
|
||||||
|
|
||||||
// Default token setup (can be overridden by specific test cases below)
|
|
||||||
session.SetIDToken(token)
|
|
||||||
session.SetAccessToken(token)
|
|
||||||
session.SetRefreshToken("test-refresh-token")
|
|
||||||
|
|
||||||
if tc.name == "ID Token Header" || tc.name == "Both Token Types" {
|
|
||||||
idTokenClaims := map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000),
|
|
||||||
"iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject",
|
|
||||||
"nonce": "test-nonce", "jti": generateRandomString(16), "type": "id_token",
|
|
||||||
"email": tc.claims["email"], // Ensure email from test case claims is in ID token
|
|
||||||
}
|
|
||||||
// Add other claims from tc.claims to idTokenClaims
|
|
||||||
for k, v := range tc.claims {
|
|
||||||
if _, exists := idTokenClaims[k]; !exists {
|
|
||||||
idTokenClaims[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
idTokenForSession, idErr := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", idTokenClaims)
|
|
||||||
if idErr != nil {
|
|
||||||
t.Fatalf("Failed to create test ID JWT: %v", idErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessTokenClaims := map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000),
|
|
||||||
"iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject",
|
|
||||||
"jti": generateRandomString(16), "type": "access_token", "scope": "openid email profile",
|
|
||||||
"email": tc.claims["email"], // Include email in access token too for these tests
|
|
||||||
}
|
|
||||||
accessTokenForSession, accessErr := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", accessTokenClaims)
|
|
||||||
if accessErr != nil {
|
|
||||||
t.Fatalf("Failed to create test access JWT: %v", accessErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
session.SetIDToken(idTokenForSession)
|
|
||||||
session.SetAccessToken(accessTokenForSession)
|
|
||||||
|
|
||||||
tOidc.tokenExchanger = &MockTokenExchanger{
|
|
||||||
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
|
|
||||||
return &TokenResponse{
|
|
||||||
IDToken: idTokenForSession, AccessToken: accessTokenForSession,
|
|
||||||
RefreshToken: refreshToken, ExpiresIn: 3600,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
tOidc.tokenVerifier = &MockTokenVerifier{VerifyFunc: func(token string) error { return nil }}
|
|
||||||
|
|
||||||
if tc.name == "ID Token Header" {
|
|
||||||
tc.expectedHeaders["X-ID-Token"] = idTokenForSession
|
|
||||||
} else if tc.name == "Both Token Types" {
|
|
||||||
tc.expectedHeaders["X-ID-Token"] = idTokenForSession
|
|
||||||
tc.expectedHeaders["X-Access-Token"] = accessTokenForSession
|
|
||||||
}
|
|
||||||
} else if tc.name == "Opaque Access Token with AccessTokenField" {
|
|
||||||
idTokenClaims := map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com", "aud": "test-client-id", "exp": float64(3000000000),
|
|
||||||
"iat": float64(1000000000), "nbf": float64(1000000000), "sub": "test-subject", // Default sub
|
|
||||||
"nonce": "test-nonce", "jti": generateRandomString(16), "type": "id_token",
|
|
||||||
}
|
|
||||||
// Populate ID token claims from tc.claims
|
|
||||||
for k, v := range tc.claims {
|
|
||||||
idTokenClaims[k] = v
|
|
||||||
}
|
|
||||||
// Ensure email from tc.claims is used for the ID token
|
|
||||||
session.SetEmail(tc.claims["email"].(string)) // Also set it directly for initial session state
|
|
||||||
|
|
||||||
idTokenForSession, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", idTokenClaims)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create test ID JWT for opaque test: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
opaqueAccessToken := "this_is_an_opaque_access_token"
|
|
||||||
|
|
||||||
session.SetIDToken(idTokenForSession)
|
|
||||||
session.SetAccessToken(opaqueAccessToken)
|
|
||||||
|
|
||||||
tOidc.tokenExchanger = &MockTokenExchanger{
|
|
||||||
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
|
|
||||||
return &TokenResponse{
|
|
||||||
IDToken: idTokenForSession,
|
|
||||||
AccessToken: opaqueAccessToken,
|
|
||||||
RefreshToken: refreshToken,
|
|
||||||
ExpiresIn: 3600,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
tOidc.tokenVerifier = &MockTokenVerifier{
|
|
||||||
VerifyFunc: func(tokenToVerify string) error {
|
|
||||||
if tokenToVerify == idTokenForSession {
|
|
||||||
return nil // ID token is expected to be verified
|
|
||||||
}
|
|
||||||
if tokenToVerify == opaqueAccessToken {
|
|
||||||
t.Errorf("TokenVerifier was incorrectly called with the opaque access token.")
|
|
||||||
return errors.New("opaque access token should not be verified by this path")
|
|
||||||
}
|
|
||||||
t.Logf("TokenVerifier called with unexpected token: %s", tokenToVerify)
|
|
||||||
return errors.New("unexpected token passed to verifier for this test case")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
// Expected header X-User-AccessToken is already set in tc.expectedHeaders
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := session.Save(req, rr); err != nil {
|
|
||||||
t.Fatalf("Failed to save session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cookie := range rr.Result().Cookies() {
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
tOidc.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Errorf("Expected status code %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, expectedValue := range tc.expectedHeaders {
|
|
||||||
if value, exists := interceptedHeaders[name]; !exists {
|
|
||||||
// For <no value> case, it might not be set if template resolves to empty and header is omitted.
|
|
||||||
// However, Go templates usually insert "<no value>" string.
|
|
||||||
if expectedValue == "<no value>" && tc.name == "Missing Claim" { // Special handling for <no value>
|
|
||||||
// If the template {{.Claims.role}} results in an empty string because role is missing,
|
|
||||||
// and the header is not set, this is also acceptable for "<no value>".
|
|
||||||
// The current test expects the literal string "<no value>".
|
|
||||||
// Let's assume for now that if it's missing, it's an error unless specifically handled.
|
|
||||||
// The test as written expects "<no value>" to be present.
|
|
||||||
}
|
|
||||||
t.Errorf("Expected header %s was not set", name)
|
|
||||||
|
|
||||||
} else if value != expectedValue {
|
|
||||||
t.Errorf("Header %s expected value %q, got %q", name, expectedValue, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tc.name == "Opaque Access Token with AccessTokenField" {
|
|
||||||
postReq := httptest.NewRequest("GET", "/protected", nil)
|
|
||||||
for _, cookie := range rr.Result().Cookies() {
|
|
||||||
postReq.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
updatedSession, err := tOidc.sessionManager.GetSession(postReq)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get updated session for opaque test: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedEmail := tc.claims["email"].(string)
|
|
||||||
if updatedSession.GetEmail() != expectedEmail {
|
|
||||||
t.Errorf("Expected session email to be %q (from ID token), got %q", expectedEmail, updatedSession.GetEmail())
|
|
||||||
}
|
|
||||||
if !updatedSession.GetAuthenticated() {
|
|
||||||
t.Errorf("Session should be authenticated after successful flow for opaque test")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEdgeCaseTemplatedHeaders tests edge cases for templated headers
|
|
||||||
func TestEdgeCaseTemplatedHeaders(t *testing.T) {
|
|
||||||
// Create a TestSuite to use its helper methods and fields
|
|
||||||
ts := &TestSuite{t: t}
|
|
||||||
ts.Setup()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
headers []TemplatedHeader
|
|
||||||
claims map[string]interface{}
|
|
||||||
shouldExecuteCheck bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Very Large Template",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{
|
|
||||||
Name: "X-Large-Header",
|
|
||||||
Value: createLargeTemplate(500), // Template with 500 variable references
|
|
||||||
},
|
|
||||||
},
|
|
||||||
claims: createLargeClaims(500), // Map with 500 claims
|
|
||||||
shouldExecuteCheck: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Array Claim Access",
|
|
||||||
headers: []TemplatedHeader{
|
|
||||||
{Name: "X-Roles", Value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"},
|
|
||||||
},
|
|
||||||
claims: map[string]interface{}{
|
|
||||||
"roles": []interface{}{"admin", "user", "manager"},
|
|
||||||
},
|
|
||||||
shouldExecuteCheck: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
// Create token with the test claims
|
|
||||||
claims := map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": float64(3000000000), // Far future timestamp
|
|
||||||
"iat": float64(1000000000),
|
|
||||||
"nbf": float64(1000000000),
|
|
||||||
"sub": "test-subject",
|
|
||||||
"nonce": "test-nonce",
|
|
||||||
"jti": generateRandomString(16),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the test-specific claims
|
|
||||||
for k, v := range tc.claims {
|
|
||||||
claims[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create test JWT: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a test next handler
|
|
||||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
tOidc := &TraefikOidc{
|
|
||||||
next: nextHandler,
|
|
||||||
name: "test",
|
|
||||||
redirURLPath: "/callback",
|
|
||||||
logoutURLPath: "/callback/logout",
|
|
||||||
issuerURL: "https://test-issuer.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
clientSecret: "test-client-secret",
|
|
||||||
jwkCache: ts.mockJWKCache,
|
|
||||||
jwksURL: "https://test-jwks-url.com",
|
|
||||||
tokenBlacklist: NewCache(),
|
|
||||||
tokenCache: NewTokenCache(),
|
|
||||||
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
|
||||||
logger: NewLogger("debug"),
|
|
||||||
allowedUserDomains: map[string]struct{}{"example.com": {}},
|
|
||||||
excludedURLs: map[string]struct{}{"/favicon": {}},
|
|
||||||
httpClient: &http.Client{},
|
|
||||||
initComplete: make(chan struct{}),
|
|
||||||
sessionManager: ts.sessionManager,
|
|
||||||
extractClaimsFunc: extractClaims,
|
|
||||||
headerTemplates: make(map[string]*template.Template),
|
|
||||||
}
|
|
||||||
tOidc.tokenVerifier = tOidc
|
|
||||||
tOidc.jwtVerifier = tOidc
|
|
||||||
|
|
||||||
// Initialize and parse header templates
|
|
||||||
for _, header := range tc.headers {
|
|
||||||
tmpl, err := template.New(header.Name).Parse(header.Value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse header template for %s: %v", header.Name, err)
|
|
||||||
}
|
|
||||||
tOidc.headerTemplates[header.Name] = tmpl
|
|
||||||
}
|
|
||||||
|
|
||||||
close(tOidc.initComplete)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/protected", nil)
|
|
||||||
req.Header.Set("X-Forwarded-Proto", "https")
|
|
||||||
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
session, err := tOidc.sessionManager.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
session.SetAuthenticated(true)
|
|
||||||
session.SetEmail("[email protected]")
|
|
||||||
session.SetIDToken(token) // Use the new method
|
|
||||||
session.SetAccessToken(token) // Also set access token to match
|
|
||||||
session.SetRefreshToken("test-refresh-token")
|
|
||||||
|
|
||||||
tOidc.extractClaimsFunc = extractClaims
|
|
||||||
tOidc.tokenExchanger = &MockTokenExchanger{
|
|
||||||
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
|
|
||||||
return &TokenResponse{
|
|
||||||
IDToken: token,
|
|
||||||
AccessToken: token,
|
|
||||||
RefreshToken: refreshToken,
|
|
||||||
ExpiresIn: 3600,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := session.Save(req, rr); err != nil {
|
|
||||||
t.Fatalf("Failed to save session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cookie := range rr.Result().Cookies() {
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
tOidc.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Errorf("Expected status code %d, got %d", http.StatusOK, rr.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The "Array Claim Access" check previously here was problematic as it didn't correctly
|
|
||||||
// intercept headers in TestEdgeCaseTemplatedHeaders. The primary goal of this
|
|
||||||
// function is to test edge cases for panics/errors, and robust header value
|
|
||||||
// checking is already covered in TestTemplatedHeadersIntegration.
|
|
||||||
// Removing the ineffective check to resolve the "declared and not used" error.
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for edge case tests
|
|
||||||
|
|
||||||
// createLargeTemplate creates a template with many variable references
|
|
||||||
func createLargeTemplate(size int) string {
|
|
||||||
template := "{{with .Claims}}"
|
|
||||||
for i := 0; i < size; i++ {
|
|
||||||
if i > 0 {
|
|
||||||
template += ","
|
|
||||||
}
|
|
||||||
template += "{{.field" + string(rune('a'+i%26)) + string(rune('0'+i%10)) + "}}"
|
|
||||||
}
|
|
||||||
template += "{{end}}"
|
|
||||||
return template
|
|
||||||
}
|
|
||||||
|
|
||||||
// createLargeClaims creates a map with many claims for testing large templates
|
|
||||||
func createLargeClaims(size int) map[string]interface{} {
|
|
||||||
claims := make(map[string]interface{})
|
|
||||||
for i := 0; i < size; i++ {
|
|
||||||
key := "field" + string(rune('a'+i%26)) + string(rune('0'+i%10))
|
|
||||||
claims[key] = "value" + string(rune('a'+i%26)) + string(rune('0'+i%10))
|
|
||||||
}
|
|
||||||
return claims
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"text/template"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestTokenTypeDistinction tests that AccessToken and IdToken are correctly distinguished in templates
|
|
||||||
func TestTokenTypeDistinction(t *testing.T) {
|
|
||||||
// Define test data where AccessToken and IdToken are deliberately different
|
|
||||||
type templateData struct {
|
|
||||||
AccessToken string
|
|
||||||
IdToken string
|
|
||||||
RefreshToken string
|
|
||||||
Claims map[string]interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
testData := templateData{
|
|
||||||
AccessToken: "test-access-token-abc123",
|
|
||||||
IdToken: "test-id-token-xyz789",
|
|
||||||
RefreshToken: "test-refresh-token",
|
|
||||||
Claims: map[string]interface{}{
|
|
||||||
"sub": "test-subject",
|
|
||||||
"email": "[email protected]",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test cases
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
templateText string
|
|
||||||
expectedValue string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Access Token Only",
|
|
||||||
templateText: "Bearer {{.AccessToken}}",
|
|
||||||
expectedValue: "Bearer test-access-token-abc123",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ID Token Only",
|
|
||||||
templateText: "ID: {{.IdToken}}",
|
|
||||||
expectedValue: "ID: test-id-token-xyz789",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Both Tokens",
|
|
||||||
templateText: "Access: {{.AccessToken}} ID: {{.IdToken}}",
|
|
||||||
expectedValue: "Access: test-access-token-abc123 ID: test-id-token-xyz789",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Both Tokens in Authorization Format",
|
|
||||||
templateText: "Bearer {{.AccessToken}} and Bearer {{.IdToken}}",
|
|
||||||
expectedValue: "Bearer test-access-token-abc123 and Bearer test-id-token-xyz789",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
tmpl, err := template.New("test").Parse(tc.templateText)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse template: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
err = tmpl.Execute(&buf, testData)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to execute template: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := buf.String()
|
|
||||||
if result != tc.expectedValue {
|
|
||||||
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTokenTypeIntegration tests the integration of ID and access tokens with the middleware
|
|
||||||
func TestTokenTypeIntegration(t *testing.T) {
|
|
||||||
// Create a TestSuite to use its helper methods and fields
|
|
||||||
ts := &TestSuite{t: t}
|
|
||||||
ts.Setup()
|
|
||||||
|
|
||||||
// Create different tokens for ID and access tokens
|
|
||||||
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": float64(3000000000),
|
|
||||||
"iat": float64(1000000000),
|
|
||||||
"nbf": float64(1000000000),
|
|
||||||
"sub": "test-subject",
|
|
||||||
"nonce": "test-nonce",
|
|
||||||
"jti": generateRandomString(16),
|
|
||||||
"token_type": "id_token",
|
|
||||||
"email": "[email protected]",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create test ID JWT: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
||||||
"iss": "https://test-issuer.com",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"exp": float64(3000000000),
|
|
||||||
"iat": float64(1000000000),
|
|
||||||
"nbf": float64(1000000000),
|
|
||||||
"sub": "test-subject",
|
|
||||||
"jti": generateRandomString(16),
|
|
||||||
"token_type": "access_token",
|
|
||||||
"scope": "openid profile email",
|
|
||||||
"email": "[email protected]", // Add email to access token so it's available in claims
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create test access JWT: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define test headers that use both token types
|
|
||||||
headers := []TemplatedHeader{
|
|
||||||
{Name: "X-ID-Token", Value: "{{.IdToken}}"},
|
|
||||||
{Name: "X-Access-Token", Value: "{{.AccessToken}}"},
|
|
||||||
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
|
|
||||||
{Name: "X-Email-From-Claims", Value: "{{.Claims.email}}"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store intercepted headers for verification
|
|
||||||
interceptedHeaders := make(map[string]string)
|
|
||||||
|
|
||||||
// Create a test next handler that captures the headers
|
|
||||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Capture headers for verification
|
|
||||||
for _, header := range headers {
|
|
||||||
if value := r.Header.Get(header.Name); value != "" {
|
|
||||||
interceptedHeaders[header.Name] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create the TraefikOidc instance
|
|
||||||
tOidc := &TraefikOidc{
|
|
||||||
next: nextHandler,
|
|
||||||
name: "test",
|
|
||||||
redirURLPath: "/callback",
|
|
||||||
logoutURLPath: "/callback/logout",
|
|
||||||
issuerURL: "https://test-issuer.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
clientSecret: "test-client-secret",
|
|
||||||
jwkCache: ts.mockJWKCache,
|
|
||||||
jwksURL: "https://test-jwks-url.com",
|
|
||||||
tokenBlacklist: NewCache(),
|
|
||||||
tokenCache: NewTokenCache(),
|
|
||||||
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
|
||||||
logger: NewLogger("debug"),
|
|
||||||
allowedUserDomains: map[string]struct{}{"example.com": {}},
|
|
||||||
excludedURLs: map[string]struct{}{"/favicon": {}},
|
|
||||||
httpClient: &http.Client{},
|
|
||||||
initComplete: make(chan struct{}),
|
|
||||||
sessionManager: ts.sessionManager,
|
|
||||||
extractClaimsFunc: extractClaims,
|
|
||||||
headerTemplates: make(map[string]*template.Template),
|
|
||||||
}
|
|
||||||
tOidc.tokenVerifier = tOidc
|
|
||||||
tOidc.jwtVerifier = tOidc
|
|
||||||
|
|
||||||
// Initialize and parse header templates
|
|
||||||
for _, header := range headers {
|
|
||||||
tmpl, err := template.New(header.Name).Parse(header.Value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse header template for %s: %v", header.Name, err)
|
|
||||||
}
|
|
||||||
tOidc.headerTemplates[header.Name] = tmpl
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the initComplete channel to bypass the waiting
|
|
||||||
close(tOidc.initComplete)
|
|
||||||
|
|
||||||
// Create a test request
|
|
||||||
req := httptest.NewRequest("GET", "/protected", nil)
|
|
||||||
req.Header.Set("X-Forwarded-Proto", "https")
|
|
||||||
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Create a session
|
|
||||||
session, err := tOidc.sessionManager.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup the session with authentication data
|
|
||||||
session.SetAuthenticated(true)
|
|
||||||
session.SetEmail("[email protected]")
|
|
||||||
session.SetIDToken(idToken) // Set the ID token
|
|
||||||
session.SetAccessToken(accessToken) // Set the access token
|
|
||||||
session.SetRefreshToken("test-refresh-token")
|
|
||||||
|
|
||||||
if err := session.Save(req, rr); err != nil {
|
|
||||||
t.Fatalf("Failed to save session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add session cookies to the request
|
|
||||||
for _, cookie := range rr.Result().Cookies() {
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the response recorder for the main test
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Process the request
|
|
||||||
tOidc.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
// Check status code
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Errorf("Expected status code %d, got %d", http.StatusOK, rr.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify headers were set correctly
|
|
||||||
expectedHeaders := map[string]string{
|
|
||||||
"X-ID-Token": idToken,
|
|
||||||
"X-Access-Token": accessToken,
|
|
||||||
"Authorization": "Bearer " + accessToken,
|
|
||||||
"X-Email-From-Claims": "[email protected]",
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, expectedValue := range expectedHeaders {
|
|
||||||
if value, exists := interceptedHeaders[name]; !exists {
|
|
||||||
t.Errorf("Expected header %s was not set", name)
|
|
||||||
} else if value != expectedValue {
|
|
||||||
t.Errorf("Header %s expected value %q, got %q", name, expectedValue, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSessionIDTokenAccessToken tests that the SessionData correctly stores and retrieves
|
|
||||||
// both ID tokens and access tokens separately
|
|
||||||
func TestSessionIDTokenAccessToken(t *testing.T) {
|
|
||||||
// Create a logger for the session manager
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
|
|
||||||
// Create a session manager
|
|
||||||
sessionManager, err := NewSessionManager("test-session-encryption-key-at-least-32-bytes", false, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create session manager: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a test request
|
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Get a session
|
|
||||||
session, err := sessionManager.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set test tokens
|
|
||||||
idToken := "test-id-token-123"
|
|
||||||
accessToken := "test-access-token-456"
|
|
||||||
refreshToken := "test-refresh-token-789"
|
|
||||||
|
|
||||||
// Store tokens in session
|
|
||||||
session.SetIDToken(idToken)
|
|
||||||
session.SetAccessToken(accessToken)
|
|
||||||
session.SetRefreshToken(refreshToken)
|
|
||||||
|
|
||||||
// Save the session
|
|
||||||
if err := session.Save(req, rr); err != nil {
|
|
||||||
t.Fatalf("Failed to save session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get cookies from response
|
|
||||||
cookies := rr.Result().Cookies()
|
|
||||||
|
|
||||||
// Create a new request with those cookies
|
|
||||||
req2 := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
for _, cookie := range cookies {
|
|
||||||
req2.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the session again
|
|
||||||
session2, err := sessionManager.GetSession(req2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get session from request with cookies: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that the tokens were correctly stored and retrieved
|
|
||||||
retrievedIDToken := session2.GetIDToken()
|
|
||||||
retrievedAccessToken := session2.GetAccessToken()
|
|
||||||
retrievedRefreshToken := session2.GetRefreshToken()
|
|
||||||
|
|
||||||
if retrievedIDToken != idToken {
|
|
||||||
t.Errorf("ID token mismatch: expected %q, got %q", idToken, retrievedIDToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
if retrievedAccessToken != accessToken {
|
|
||||||
t.Errorf("Access token mismatch: expected %q, got %q", accessToken, retrievedAccessToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
if retrievedRefreshToken != refreshToken {
|
|
||||||
t.Errorf("Refresh token mismatch: expected %q, got %q", refreshToken, retrievedRefreshToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that the tokens are distinct
|
|
||||||
if retrievedIDToken == retrievedAccessToken {
|
|
||||||
t.Errorf("ID token and Access token should be different, but both are %q", retrievedIDToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2023 The Gorilla Authors. All rights reserved.
|
Copyright (c) 2024 The Gorilla Authors. All rights reserved.
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
Redistribution and use in source and binary forms, with or without
|
||||||
modification, are permitted provided that the following conditions are
|
modification, are permitted provided that the following conditions are
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,7 @@
|
|||||||
# sessions
|
# Gorilla Sessions
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> The latest version of this repository requires go 1.23 because of the new partitioned attribute. The last version that is compatible with older versions of go is v1.3.0.
|
||||||
|
|
||||||

|

|
||||||
[](https://codecov.io/github/gorilla/sessions)
|
[](https://codecov.io/github/gorilla/sessions)
|
||||||
@@ -74,6 +77,7 @@ Other implementations of the `sessions.Store` interface:
|
|||||||
- [github.com/dsoprea/go-appengine-sessioncascade](https://github.com/dsoprea/go-appengine-sessioncascade) - Memcache/Datastore/Context in AppEngine
|
- [github.com/dsoprea/go-appengine-sessioncascade](https://github.com/dsoprea/go-appengine-sessioncascade) - Memcache/Datastore/Context in AppEngine
|
||||||
- [github.com/kidstuff/mongostore](https://github.com/kidstuff/mongostore) - MongoDB
|
- [github.com/kidstuff/mongostore](https://github.com/kidstuff/mongostore) - MongoDB
|
||||||
- [github.com/srinathgs/mysqlstore](https://github.com/srinathgs/mysqlstore) - MySQL
|
- [github.com/srinathgs/mysqlstore](https://github.com/srinathgs/mysqlstore) - MySQL
|
||||||
|
- [github.com/danielepintore/gorilla-sessions-mysql](https://github.com/danielepintore/gorilla-sessions-mysql) - MySQL
|
||||||
- [github.com/EnumApps/clustersqlstore](https://github.com/EnumApps/clustersqlstore) - MySQL Cluster
|
- [github.com/EnumApps/clustersqlstore](https://github.com/EnumApps/clustersqlstore) - MySQL Cluster
|
||||||
- [github.com/antonlindstrom/pgstore](https://github.com/antonlindstrom/pgstore) - PostgreSQL
|
- [github.com/antonlindstrom/pgstore](https://github.com/antonlindstrom/pgstore) - PostgreSQL
|
||||||
- [github.com/boj/redistore](https://github.com/boj/redistore) - Redis
|
- [github.com/boj/redistore](https://github.com/boj/redistore) - Redis
|
||||||
|
|||||||
+12
-9
@@ -1,5 +1,6 @@
|
|||||||
//go:build !go1.11
|
// Copyright 2012 The Gorilla Authors. All rights reserved.
|
||||||
// +build !go1.11
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
package sessions
|
package sessions
|
||||||
|
|
||||||
@@ -8,13 +9,15 @@ import "net/http"
|
|||||||
// newCookieFromOptions returns an http.Cookie with the options set.
|
// newCookieFromOptions returns an http.Cookie with the options set.
|
||||||
func newCookieFromOptions(name, value string, options *Options) *http.Cookie {
|
func newCookieFromOptions(name, value string, options *Options) *http.Cookie {
|
||||||
return &http.Cookie{
|
return &http.Cookie{
|
||||||
Name: name,
|
Name: name,
|
||||||
Value: value,
|
Value: value,
|
||||||
Path: options.Path,
|
Path: options.Path,
|
||||||
Domain: options.Domain,
|
Domain: options.Domain,
|
||||||
MaxAge: options.MaxAge,
|
MaxAge: options.MaxAge,
|
||||||
Secure: options.Secure,
|
Secure: options.Secure,
|
||||||
HttpOnly: options.HttpOnly,
|
HttpOnly: options.HttpOnly,
|
||||||
|
Partitioned: options.Partitioned,
|
||||||
|
SameSite: options.SameSite,
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
//go:build go1.11
|
|
||||||
// +build go1.11
|
|
||||||
|
|
||||||
package sessions
|
|
||||||
|
|
||||||
import "net/http"
|
|
||||||
|
|
||||||
// newCookieFromOptions returns an http.Cookie with the options set.
|
|
||||||
func newCookieFromOptions(name, value string, options *Options) *http.Cookie {
|
|
||||||
return &http.Cookie{
|
|
||||||
Name: name,
|
|
||||||
Value: value,
|
|
||||||
Path: options.Path,
|
|
||||||
Domain: options.Domain,
|
|
||||||
MaxAge: options.MaxAge,
|
|
||||||
Secure: options.Secure,
|
|
||||||
HttpOnly: options.HttpOnly,
|
|
||||||
SameSite: options.SameSite,
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
+10
-5
@@ -1,8 +1,11 @@
|
|||||||
//go:build !go1.11
|
// Copyright 2012 The Gorilla Authors. All rights reserved.
|
||||||
// +build !go1.11
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
package sessions
|
package sessions
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
// Options stores configuration for a session or session store.
|
// Options stores configuration for a session or session store.
|
||||||
//
|
//
|
||||||
// Fields are a subset of http.Cookie fields.
|
// Fields are a subset of http.Cookie fields.
|
||||||
@@ -13,7 +16,9 @@ type Options struct {
|
|||||||
// deleted after the browser session ends.
|
// deleted after the browser session ends.
|
||||||
// MaxAge<0 means delete cookie immediately.
|
// MaxAge<0 means delete cookie immediately.
|
||||||
// MaxAge>0 means Max-Age attribute present and given in seconds.
|
// MaxAge>0 means Max-Age attribute present and given in seconds.
|
||||||
MaxAge int
|
MaxAge int
|
||||||
Secure bool
|
Secure bool
|
||||||
HttpOnly bool
|
HttpOnly bool
|
||||||
|
Partitioned bool
|
||||||
|
SameSite http.SameSite
|
||||||
}
|
}
|
||||||
|
|||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
//go:build go1.11
|
|
||||||
// +build go1.11
|
|
||||||
|
|
||||||
package sessions
|
|
||||||
|
|
||||||
import "net/http"
|
|
||||||
|
|
||||||
// Options stores configuration for a session or session store.
|
|
||||||
//
|
|
||||||
// Fields are a subset of http.Cookie fields.
|
|
||||||
type Options struct {
|
|
||||||
Path string
|
|
||||||
Domain string
|
|
||||||
// MaxAge=0 means no Max-Age attribute specified and the cookie will be
|
|
||||||
// deleted after the browser session ends.
|
|
||||||
// MaxAge<0 means delete cookie immediately.
|
|
||||||
// MaxAge>0 means Max-Age attribute present and given in seconds.
|
|
||||||
MaxAge int
|
|
||||||
Secure bool
|
|
||||||
HttpOnly bool
|
|
||||||
// Defaults to http.SameSiteDefaultMode
|
|
||||||
SameSite http.SameSite
|
|
||||||
}
|
|
||||||
Vendored
+2
-2
@@ -4,8 +4,8 @@ github.com/google/uuid
|
|||||||
# github.com/gorilla/securecookie v1.1.2
|
# github.com/gorilla/securecookie v1.1.2
|
||||||
## explicit; go 1.20
|
## explicit; go 1.20
|
||||||
github.com/gorilla/securecookie
|
github.com/gorilla/securecookie
|
||||||
# github.com/gorilla/sessions v1.3.0
|
# github.com/gorilla/sessions v1.4.0
|
||||||
## explicit; go 1.20
|
## explicit; go 1.23
|
||||||
github.com/gorilla/sessions
|
github.com/gorilla/sessions
|
||||||
# golang.org/x/time v0.7.0
|
# golang.org/x/time v0.7.0
|
||||||
## explicit; go 1.18
|
## explicit; go 1.18
|
||||||
|
|||||||
Reference in New Issue
Block a user