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
-233
@@ -4,242 +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 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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,111 +1,29 @@
|
|||||||
# 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 and Logto, but should work with any standard OIDC provider.
|
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 | `["/favicon"]` | `["/health", "/metrics", "/public"]` |
|
|
||||||
| | `allowedUserDomains` | Restricts access to specific email domains | none | `["company.com", "subsidiary.com"]` |
|
|
||||||
| | `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` |
|
|
||||||
|
|
||||||
## 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:
|
||||||
@@ -114,195 +32,119 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
plugin:
|
plugin:
|
||||||
traefikoidc:
|
traefikoidc:
|
||||||
providerURL: https://accounts.google.com
|
providerURL: urn:k8s:secret:traefik-middleware-oidc:ISSUER
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
clientID: urn:k8s:secret:traefik-middleware-oidc:CLIENT_ID
|
||||||
clientSecret: your-client-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:
|
excludedURLs: # Determines the list of URLs which are NOT a subject to authentication
|
||||||
- /login # covers /login, /login/me, /login/reminder etc.
|
- /login # covers /login, /login/me, /login/reminder etc.
|
||||||
- /public-data
|
- /my-public-data
|
||||||
- /health
|
|
||||||
- /metrics
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### With Email Domain Restrictions
|
##### Excluded URLs with open access
|
||||||
|
|
||||||
```yaml
|
```
|
||||||
apiVersion: traefik.io/v1alpha1
|
apiVersion: traefik.io/v1alpha1
|
||||||
kind: Middleware
|
kind: Middleware
|
||||||
metadata:
|
metadata:
|
||||||
name: oidc-domain-restricted
|
name: oidc-with-open-urls
|
||||||
namespace: traefik
|
namespace: traefik
|
||||||
spec:
|
spec:
|
||||||
plugin:
|
plugin:
|
||||||
traefikoidc:
|
traefikoidc:
|
||||||
providerURL: https://accounts.google.com
|
providerURL: xxx
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
clientID: yyy
|
||||||
clientSecret: your-client-secret
|
clientSecret: zzz
|
||||||
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:
|
||||||
|
- 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
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
##### 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:
|
scopes:
|
||||||
- openid
|
- openid
|
||||||
- email
|
- email
|
||||||
- profile
|
- profile
|
||||||
allowedUserDomains:
|
allowedUserDomains:
|
||||||
- company.com
|
- raczylo.com
|
||||||
- subsidiary.com
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### With Role-Based Access Control
|
|
||||||
|
|
||||||
```yaml
|
##### 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
|
apiVersion: traefik.io/v1alpha1
|
||||||
kind: Middleware
|
kind: Middleware
|
||||||
metadata:
|
metadata:
|
||||||
name: oidc-rbac
|
name: oidc-guest-endpoints
|
||||||
namespace: traefik
|
namespace: traefik
|
||||||
spec:
|
spec:
|
||||||
plugin:
|
plugin:
|
||||||
traefikoidc:
|
traefikoidc:
|
||||||
providerURL: https://accounts.google.com
|
providerURL: xxx
|
||||||
clientID: 1234567890.apps.googleusercontent.com
|
clientID: yyy
|
||||||
clientSecret: your-client-secret
|
clientSecret: zzz
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
sessionEncryptionKey: vvv
|
||||||
callbackURL: /oauth2/callback
|
callbackURL: /my-oidc/callback
|
||||||
logoutURL: /oauth2/logout
|
logoutURL: /my-oidc/logout
|
||||||
scopes:
|
scopes:
|
||||||
- openid
|
- openid
|
||||||
- email
|
- email
|
||||||
- profile
|
- profile
|
||||||
- roles # Include this to get role information from the provider
|
- roles # This line queries the OIDC provider for roles
|
||||||
|
forceHTTPS: true
|
||||||
allowedRolesAndGroups:
|
allowedRolesAndGroups:
|
||||||
- admin
|
- guest-endpoints # This line specifies the roles or groups allowed to access content
|
||||||
- developer
|
allowedUserDomains:
|
||||||
|
- raczylo.com
|
||||||
```
|
```
|
||||||
|
|
||||||
### With Custom Logging and Rate Limiting
|
|
||||||
|
|
||||||
```yaml
|
#### Docker compose example
|
||||||
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
|
`docker-compose.yaml`
|
||||||
|
|
||||||
```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 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Keeping Secrets Secret in Kubernetes
|
|
||||||
|
|
||||||
For Kubernetes environments, you can reference secrets instead of hardcoding sensitive values:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: oidc-with-secrets
|
|
||||||
namespace: traefik
|
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
traefikoidc:
|
|
||||||
providerURL: urn:k8s:secret:traefik-middleware-oidc:ISSUER
|
|
||||||
clientID: urn:k8s:secret:traefik-middleware-oidc:CLIENT_ID
|
|
||||||
clientSecret: urn:k8s:secret:traefik-middleware-oidc:SECRET
|
|
||||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
|
||||||
callbackURL: /oauth2/callback
|
|
||||||
logoutURL: /oauth2/logout
|
|
||||||
scopes:
|
|
||||||
- openid
|
|
||||||
- email
|
|
||||||
- profile
|
|
||||||
```
|
|
||||||
|
|
||||||
Don't forget to create the secret:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl create secret generic traefik-middleware-oidc \
|
|
||||||
--from-literal=ISSUER=https://accounts.google.com \
|
|
||||||
--from-literal=CLIENT_ID=1234567890.apps.googleusercontent.com \
|
|
||||||
--from-literal=SECRET=your-client-secret \
|
|
||||||
-n traefik
|
|
||||||
```
|
|
||||||
|
|
||||||
## Complete Docker Compose Example
|
|
||||||
|
|
||||||
Here's a complete example of using the middleware with Docker Compose:
|
|
||||||
|
|
||||||
```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"
|
||||||
@@ -313,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"
|
||||||
|
|
||||||
@@ -335,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
|
||||||
@@ -364,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:
|
||||||
@@ -373,89 +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
|
||||||
allowedRolesAndGroups:
|
sessionEncryptionKey: potato-secret
|
||||||
- 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
### Token Caching and Blacklisting
|
|
||||||
|
|
||||||
The middleware automatically caches validated tokens to improve performance and maintains a blacklist of revoked tokens.
|
|
||||||
|
|
||||||
### 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`.
|
|
||||||
|
|
||||||
## 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,209 +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{
|
c.items[key] = CacheItem{
|
||||||
Value: value,
|
Value: value,
|
||||||
ExpiresAt: expTime,
|
ExpiresAt: time.Now().Add(expiration).UnixNano(), // Store as UnixNano for faster comparisons
|
||||||
}
|
}
|
||||||
if elem, ok := c.elems[key]; ok {
|
c.mutex.Unlock()
|
||||||
c.order.MoveToBack(elem)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evict oldest item if cache is full.
|
// Get retrieves an item from the cache
|
||||||
if len(c.items) >= c.maxSize {
|
|
||||||
c.evictOldest()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new item.
|
|
||||||
c.items[key] = CacheItem{
|
|
||||||
Value: value,
|
|
||||||
ExpiresAt: expTime,
|
|
||||||
}
|
|
||||||
elem := c.order.PushBack(lruEntry{key: key})
|
|
||||||
c.elems[key] = elem
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get retrieves an item from the cache by its key.
|
|
||||||
// If the item exists and has not expired, its value and true are returned.
|
|
||||||
// Accessing an item moves it to the most recently used position in the LRU list.
|
|
||||||
// If the item does not exist or has expired, nil and false are returned, and the
|
|
||||||
// expired item is removed from the cache.
|
|
||||||
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 or within 10% of expiration
|
if now > item.ExpiresAt {
|
||||||
if now.After(item.ExpiresAt) || now.Add(time.Duration(float64(item.ExpiresAt.Sub(now))*0.1)).After(item.ExpiresAt) {
|
|
||||||
c.removeItem(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// evictOldest removes the least recently used (oldest) item from the cache.
|
|
||||||
// It first attempts to find and remove an expired item from the front of the LRU list.
|
|
||||||
// If no expired items are found at the front, it removes the absolute oldest item (front of the list).
|
|
||||||
// This method is called internally by Set when the cache reaches its maximum size.
|
|
||||||
// Note: This function assumes the write lock is already held.
|
|
||||||
func (c *Cache) evictOldest() {
|
|
||||||
now := time.Now()
|
|
||||||
elem := c.order.Front()
|
|
||||||
|
|
||||||
// First try to find an expired item from the front
|
|
||||||
for elem != nil {
|
|
||||||
entry := elem.Value.(lruEntry)
|
|
||||||
if item, exists := c.items[entry.key]; exists {
|
|
||||||
if now.After(item.ExpiresAt) {
|
|
||||||
c.removeItem(entry.key)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elem = elem.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no expired items found, remove the oldest item
|
|
||||||
if elem = c.order.Front(); elem != nil {
|
|
||||||
entry := elem.Value.(lruEntry)
|
|
||||||
c.removeItem(entry.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
delete(c.items, key)
|
||||||
if elem, ok := c.elems[key]; ok {
|
|
||||||
c.order.Remove(elem)
|
|
||||||
delete(c.elems, key)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
c.mutex.Unlock()
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
-306
@@ -1,306 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCache(t *testing.T) {
|
|
||||||
t.Run("Basic Set and Get", func(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
key := "test-key"
|
|
||||||
value := "test-value"
|
|
||||||
expiration := 1 * time.Second
|
|
||||||
|
|
||||||
// Test Set
|
|
||||||
cache.Set(key, value, expiration)
|
|
||||||
|
|
||||||
// Test Get
|
|
||||||
got, found := cache.Get(key)
|
|
||||||
if !found {
|
|
||||||
t.Error("Expected to find key in cache")
|
|
||||||
}
|
|
||||||
if got != value {
|
|
||||||
t.Errorf("Expected value %v, got %v", value, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Expiration", func(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
key := "test-key"
|
|
||||||
value := "test-value"
|
|
||||||
expiration := 10 * time.Millisecond
|
|
||||||
|
|
||||||
// Set with short expiration
|
|
||||||
cache.Set(key, value, expiration)
|
|
||||||
|
|
||||||
// Wait for expiration
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Should not find expired key
|
|
||||||
_, found := cache.Get(key)
|
|
||||||
if found {
|
|
||||||
t.Error("Expected key to be expired")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Delete", func(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
key := "test-key"
|
|
||||||
value := "test-value"
|
|
||||||
expiration := 1 * time.Second
|
|
||||||
|
|
||||||
// Set and then delete
|
|
||||||
cache.Set(key, value, expiration)
|
|
||||||
cache.Delete(key)
|
|
||||||
|
|
||||||
// Should not find deleted key
|
|
||||||
_, found := cache.Get(key)
|
|
||||||
if found {
|
|
||||||
t.Error("Expected key to be deleted")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Cleanup", func(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
// Add multiple items with different expirations
|
|
||||||
cache.Set("expired1", "value1", 10*time.Millisecond)
|
|
||||||
cache.Set("expired2", "value2", 10*time.Millisecond)
|
|
||||||
cache.Set("valid", "value3", 1*time.Second)
|
|
||||||
|
|
||||||
// Wait for some items to expire
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Run cleanup
|
|
||||||
cache.Cleanup()
|
|
||||||
|
|
||||||
// Check expired items are removed
|
|
||||||
_, found1 := cache.Get("expired1")
|
|
||||||
_, found2 := cache.Get("expired2")
|
|
||||||
_, found3 := cache.Get("valid")
|
|
||||||
|
|
||||||
if found1 {
|
|
||||||
t.Error("Expected expired1 to be cleaned up")
|
|
||||||
}
|
|
||||||
if found2 {
|
|
||||||
t.Error("Expected expired2 to be cleaned up")
|
|
||||||
}
|
|
||||||
if !found3 {
|
|
||||||
t.Error("Expected valid item to remain in cache")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Concurrent Access", func(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
done := make(chan bool)
|
|
||||||
|
|
||||||
// Start multiple goroutines to access cache concurrently
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
go func(id int) {
|
|
||||||
key := "key"
|
|
||||||
value := "value"
|
|
||||||
expiration := 1 * time.Second
|
|
||||||
|
|
||||||
// Perform multiple operations
|
|
||||||
cache.Set(key, value, expiration)
|
|
||||||
cache.Get(key)
|
|
||||||
cache.Delete(key)
|
|
||||||
cache.Cleanup()
|
|
||||||
|
|
||||||
done <- true
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for all goroutines to complete
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
<-done
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Zero Expiration", func(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
key := "test-key"
|
|
||||||
value := "test-value"
|
|
||||||
|
|
||||||
// Set with zero expiration
|
|
||||||
cache.Set(key, value, 0)
|
|
||||||
|
|
||||||
// Should not find the key
|
|
||||||
_, found := cache.Get(key)
|
|
||||||
if found {
|
|
||||||
t.Error("Expected key with zero expiration to be immediately expired")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Negative Expiration", func(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
key := "test-key"
|
|
||||||
value := "test-value"
|
|
||||||
|
|
||||||
// Set with negative expiration
|
|
||||||
cache.Set(key, value, -1*time.Second)
|
|
||||||
|
|
||||||
// Should not find the key
|
|
||||||
_, found := cache.Get(key)
|
|
||||||
if found {
|
|
||||||
t.Error("Expected key with negative expiration to be immediately expired")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Update Existing Key", func(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
key := "test-key"
|
|
||||||
value1 := "value1"
|
|
||||||
value2 := "value2"
|
|
||||||
expiration := 1 * time.Second
|
|
||||||
|
|
||||||
// Set initial value
|
|
||||||
cache.Set(key, value1, expiration)
|
|
||||||
|
|
||||||
// Update value
|
|
||||||
cache.Set(key, value2, expiration)
|
|
||||||
|
|
||||||
// Check updated value
|
|
||||||
got, found := cache.Get(key)
|
|
||||||
if !found {
|
|
||||||
t.Error("Expected to find key in cache")
|
|
||||||
}
|
|
||||||
if got != value2 {
|
|
||||||
t.Errorf("Expected updated value %v, got %v", value2, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Different Value Types", func(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
expiration := 1 * time.Second
|
|
||||||
|
|
||||||
// Test with different value types
|
|
||||||
testCases := []struct {
|
|
||||||
key string
|
|
||||||
value interface{}
|
|
||||||
}{
|
|
||||||
{"string", "test"},
|
|
||||||
{"int", 42},
|
|
||||||
{"float", 3.14},
|
|
||||||
{"bool", true},
|
|
||||||
{"slice", []string{"a", "b", "c"}},
|
|
||||||
{"map", map[string]int{"a": 1, "b": 2}},
|
|
||||||
{"struct", struct{ Name string }{"test"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.key, func(t *testing.T) {
|
|
||||||
cache.Set(tc.key, tc.value, expiration)
|
|
||||||
got, found := cache.Get(tc.key)
|
|
||||||
if !found {
|
|
||||||
t.Error("Expected to find key in cache")
|
|
||||||
}
|
|
||||||
// Use reflect.DeepEqual for comparing complex types like slices and maps
|
|
||||||
if !reflect.DeepEqual(got, tc.value) {
|
|
||||||
t.Errorf("Expected value %v, got %v", tc.value, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTokenCache(t *testing.T) {
|
|
||||||
t.Run("Basic Operations", func(t *testing.T) {
|
|
||||||
tc := NewTokenCache()
|
|
||||||
token := "test-token"
|
|
||||||
claims := map[string]interface{}{
|
|
||||||
"sub": "1234567890",
|
|
||||||
"name": "John Doe",
|
|
||||||
"admin": true,
|
|
||||||
}
|
|
||||||
expiration := 1 * time.Second
|
|
||||||
|
|
||||||
// Test Set and Get
|
|
||||||
tc.Set(token, claims, expiration)
|
|
||||||
gotClaims, found := tc.Get(token)
|
|
||||||
if !found {
|
|
||||||
t.Error("Expected to find token in cache")
|
|
||||||
}
|
|
||||||
if len(gotClaims) != len(claims) {
|
|
||||||
t.Errorf("Expected %d claims, got %d", len(claims), len(gotClaims))
|
|
||||||
}
|
|
||||||
for k, v := range claims {
|
|
||||||
if gotClaims[k] != v {
|
|
||||||
t.Errorf("Expected claim %s to be %v, got %v", k, v, gotClaims[k])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Delete
|
|
||||||
tc.Delete(token)
|
|
||||||
_, found = tc.Get(token)
|
|
||||||
if found {
|
|
||||||
t.Error("Expected token to be deleted")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Expiration", func(t *testing.T) {
|
|
||||||
tc := NewTokenCache()
|
|
||||||
token := "test-token"
|
|
||||||
claims := map[string]interface{}{"sub": "1234567890"}
|
|
||||||
expiration := 10 * time.Millisecond
|
|
||||||
|
|
||||||
// Set with short expiration
|
|
||||||
tc.Set(token, claims, expiration)
|
|
||||||
|
|
||||||
// Wait for expiration
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Should not find expired token
|
|
||||||
_, found := tc.Get(token)
|
|
||||||
if found {
|
|
||||||
t.Error("Expected token to be expired")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Cleanup", func(t *testing.T) {
|
|
||||||
tc := NewTokenCache()
|
|
||||||
|
|
||||||
// Add multiple tokens with different expirations
|
|
||||||
tc.Set("expired1", map[string]interface{}{"sub": "1"}, 10*time.Millisecond)
|
|
||||||
tc.Set("expired2", map[string]interface{}{"sub": "2"}, 10*time.Millisecond)
|
|
||||||
tc.Set("valid", map[string]interface{}{"sub": "3"}, 1*time.Second)
|
|
||||||
|
|
||||||
// Wait for some tokens to expire
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Run cleanup
|
|
||||||
tc.Cleanup()
|
|
||||||
|
|
||||||
// Check expired tokens are removed
|
|
||||||
_, found1 := tc.Get("expired1")
|
|
||||||
_, found2 := tc.Get("expired2")
|
|
||||||
_, found3 := tc.Get("valid")
|
|
||||||
|
|
||||||
if found1 {
|
|
||||||
t.Error("Expected expired1 to be cleaned up")
|
|
||||||
}
|
|
||||||
if found2 {
|
|
||||||
t.Error("Expected expired2 to be cleaned up")
|
|
||||||
}
|
|
||||||
if !found3 {
|
|
||||||
t.Error("Expected valid token to remain in cache")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Token Prefix", func(t *testing.T) {
|
|
||||||
tc := NewTokenCache()
|
|
||||||
token := "test-token"
|
|
||||||
claims := map[string]interface{}{"sub": "1234567890"}
|
|
||||||
expiration := 1 * time.Second
|
|
||||||
|
|
||||||
// Set token
|
|
||||||
tc.Set(token, claims, expiration)
|
|
||||||
|
|
||||||
// Verify internal storage uses prefix
|
|
||||||
_, found := tc.cache.Get("t-" + token)
|
|
||||||
if !found {
|
|
||||||
t.Error("Expected to find prefixed token in underlying cache")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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=
|
||||||
|
|||||||
+238
-275
@@ -3,148 +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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a cookie jar for this request to handle redirects with cookies
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.tokenURL, strings.NewReader(data.Encode()))
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -163,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 {
|
||||||
@@ -214,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
|
||||||
}
|
}
|
||||||
@@ -262,143 +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()
|
||||||
}
|
}
|
||||||
|
|
||||||
// exchangeCodeForToken is a convenience function that wraps exchangeTokens specifically
|
// exchangeCodeForToken exchanges the authorization code for tokens
|
||||||
// for the "authorization_code" grant type. It handles the conditional inclusion of the
|
func (t *TraefikOidc) exchangeCodeForToken(code string) (*TokenResponse, error) {
|
||||||
// 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,67 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Removed tests related to the old TokenBlacklist implementation:
|
|
||||||
// - TestTokenBlacklistSizeLimit
|
|
||||||
// - TestTokenBlacklistExpiredCleanup
|
|
||||||
// - TestTokenBlacklistOldestEviction
|
|
||||||
// - TestTokenBlacklistMemoryUsage
|
|
||||||
// - TestConcurrentTokenBlacklistOperations
|
|
||||||
|
|
||||||
func TestTokenCacheMemoryUsage(t *testing.T) {
|
|
||||||
tc := NewTokenCache()
|
|
||||||
iterations := 10000
|
|
||||||
|
|
||||||
// Force initial GC
|
|
||||||
runtime.GC()
|
|
||||||
|
|
||||||
// Record initial memory stats
|
|
||||||
var m1, m2 runtime.MemStats
|
|
||||||
runtime.ReadMemStats(&m1)
|
|
||||||
|
|
||||||
// Simulate heavy cache usage
|
|
||||||
for i := 0; i < iterations; i++ {
|
|
||||||
claims := map[string]interface{}{
|
|
||||||
"sub": fmt.Sprintf("user%d", i),
|
|
||||||
"exp": time.Now().Add(time.Hour).Unix(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to cache
|
|
||||||
tc.Set(fmt.Sprintf("token%d", i), claims, time.Hour)
|
|
||||||
|
|
||||||
// Periodically retrieve
|
|
||||||
if i%100 == 0 {
|
|
||||||
tc.Get(fmt.Sprintf("token%d", i-50))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Periodically cleanup
|
|
||||||
if i%1000 == 0 {
|
|
||||||
tc.Cleanup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force GC and wait for it to complete
|
|
||||||
runtime.GC()
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
runtime.ReadMemStats(&m2)
|
|
||||||
|
|
||||||
// Check memory growth (using HeapAlloc for more accurate measurement)
|
|
||||||
memoryGrowth := int64(m2.HeapAlloc - m1.HeapAlloc)
|
|
||||||
maxAllowedGrowth := int64(2 * 1024 * 1024) // 2MB max growth
|
|
||||||
|
|
||||||
if memoryGrowth > maxAllowedGrowth {
|
|
||||||
t.Logf("Initial HeapAlloc: %d, Final HeapAlloc: %d", m1.HeapAlloc, m2.HeapAlloc)
|
|
||||||
t.Errorf("Excessive cache memory growth: %d bytes", memoryGrowth)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify cache size stayed within limits
|
|
||||||
if len(tc.cache.items) > tc.cache.maxSize {
|
|
||||||
t.Errorf("Cache exceeded max size: %d", len(tc.cache.items))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,39 +28,25 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 (c *JWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
|
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
if c.jwks != nil && time.Now().Before(c.expiresAt) {
|
if c.jwks != nil && time.Now().Before(c.expiresAt) {
|
||||||
defer c.mutex.RUnlock()
|
defer c.mutex.RUnlock()
|
||||||
@@ -70,56 +56,26 @@ func (c *JWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient *http
|
|||||||
|
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
jwks, err := fetchJWKS(ctx, jwksURL, httpClient)
|
jwks, err := fetchJWKS(jwksURL, httpClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
@@ -137,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 {
|
||||||
@@ -162,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 {
|
||||||
@@ -183,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{
|
// ecJWKToPEM converts an EC JWK to PEM
|
||||||
Type: "PUBLIC KEY",
|
|
||||||
Bytes: pubKeyBytes,
|
|
||||||
})
|
|
||||||
|
|
||||||
return pubKeyPEM, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ecJWKToPEM converts an EC (Elliptic Curve) JWK into PEM format.
|
|
||||||
// It decodes the X and Y coordinates from base64 URL encoding, determines the
|
|
||||||
// elliptic curve based on the "crv" parameter (P-256, P-384, P-521),
|
|
||||||
// constructs an ecdsa.PublicKey, marshals it into PKIX format, and then
|
|
||||||
// encodes it as a PEM block.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - jwk: A pointer to the EC JWK object (must have "kty": "EC").
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A byte slice containing the EC public key in PEM format.
|
|
||||||
// - An error if decoding parameters fails, the curve is unsupported, or key marshaling fails.
|
|
||||||
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 {
|
||||||
@@ -226,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{
|
||||||
@@ -244,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,43 +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 = make(map[string]time.Time)
|
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")
|
||||||
)
|
)
|
||||||
|
|
||||||
// cleanupReplayCache iterates through the replay cache and removes entries
|
// JWT represents a JSON Web Token
|
||||||
// whose expiration time is before the current time. This function should be
|
|
||||||
// called periodically to prevent the cache from growing indefinitely.
|
|
||||||
// It acquires a mutex to ensure thread safety during cleanup.
|
|
||||||
func cleanupReplayCache() {
|
|
||||||
now := time.Now()
|
|
||||||
for token, expiry := range replayCache {
|
|
||||||
if expiry.Before(now) {
|
|
||||||
delete(replayCache, token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = 2 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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{}
|
||||||
@@ -52,338 +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)
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(headerBytes, &jwt.Header); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal header: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid JWT format: failed to decode claims: %v", err)
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(claimsBytes, &jwt.Claims); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal claims: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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).
|
return json.Unmarshal(bytes, target)
|
||||||
// - 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.
|
// Verify verifies the standard claims in the JWT
|
||||||
// - 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).
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - nil if all standard claims are valid.
|
|
||||||
// - An error describing the first validation failure encountered.
|
|
||||||
func (j *JWT) Verify(issuerURL, clientID string) error {
|
func (j *JWT) Verify(issuerURL, clientID string) error {
|
||||||
// Validate algorithm to prevent algorithm switching attacks
|
if err := verifyIssuer(j.Claims["iss"], issuerURL); err != nil {
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
claims := j.Claims
|
|
||||||
|
|
||||||
iss, ok := claims["iss"].(string)
|
|
||||||
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)
|
|
||||||
if jti, ok := claims["jti"].(string); ok {
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
replayCacheMu.Lock()
|
|
||||||
cleanupReplayCache()
|
|
||||||
if _, exists := replayCache[jti]; exists {
|
|
||||||
replayCacheMu.Unlock()
|
|
||||||
return fmt.Errorf("token replay detected")
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
replayCache[jti] = expTime
|
|
||||||
replayCacheMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
if time.Now().After(time.Unix(int64(exp), 0)) {
|
||||||
// Token is invalid if Now is before (ClaimTime - PastTolerance)
|
return ErrTokenExpired
|
||||||
allowedStart := claimTime.Add(-ClockSkewTolerancePast)
|
|
||||||
if now.Before(allowedStart) {
|
|
||||||
reason := "not yet valid"
|
|
||||||
if claimName == "iat" {
|
|
||||||
reason = "used before issued"
|
|
||||||
}
|
|
||||||
err = fmt.Errorf("token %s (%s: %v, now: %v, allowed_from: %v)", reason, claimName, claimTime.UTC(), now.UTC(), allowedStart.UTC())
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
func verifyIssuedAt(issuedAt interface{}) error {
|
||||||
|
iat, ok := issuedAt.(float64)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%w: iat", ErrInvalidClaimType)
|
||||||
|
}
|
||||||
|
if time.Now().Before(time.Unix(int64(iat), 0)) {
|
||||||
|
return ErrTokenUsedBeforeIssued
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyExpiration checks the 'exp' (Expiration Time) claim.
|
|
||||||
// It calls verifyTimeConstraint with future=true.
|
|
||||||
func verifyExpiration(expiration float64) error {
|
|
||||||
return verifyTimeConstraint(expiration, "exp", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
|
}
|
||||||
|
|||||||
+124
-1907
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
version: 1
|
|
||||||
force:
|
|
||||||
existing: true
|
|
||||||
wording:
|
|
||||||
patch:
|
|
||||||
- patch-release
|
|
||||||
minor:
|
|
||||||
- minor-release
|
|
||||||
major:
|
|
||||||
- breaking
|
|
||||||
-759
@@ -1,759 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
|
||||||
)
|
|
||||||
|
|
||||||
// generateSecureRandomString creates a cryptographically secure, hex-encoded random string.
|
|
||||||
// It reads the specified number of bytes from crypto/rand and encodes them as a hexadecimal string.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - length: The number of random bytes to generate (the resulting hex string will be twice this length).
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A hex-encoded random string.
|
|
||||||
// - An error if reading random bytes fails.
|
|
||||||
func generateSecureRandomString(length int) (string, error) {
|
|
||||||
bytes := make([]byte, length)
|
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cookie names and configuration constants used for session management
|
|
||||||
const (
|
|
||||||
// Using fixed prefixes for consistent cookie naming across restarts
|
|
||||||
mainCookieName = "_oidc_raczylo_m"
|
|
||||||
accessTokenCookie = "_oidc_raczylo_a"
|
|
||||||
refreshTokenCookie = "_oidc_raczylo_r"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// maxCookieSize is the maximum size for each cookie chunk.
|
|
||||||
// This value is calculated to ensure the final cookie size stays within browser limits:
|
|
||||||
// 1. Browser cookie size limit is typically 4096 bytes
|
|
||||||
// 2. Cookie content undergoes encryption (adds 28 bytes) and base64 encoding (4/3 ratio)
|
|
||||||
// 3. Calculation:
|
|
||||||
// - Let x be the chunk size
|
|
||||||
// - After encryption: x + 28 bytes
|
|
||||||
// - After base64: ((x + 28) * 4/3) bytes
|
|
||||||
// - Must satisfy: ((x + 28) * 4/3) ≤ 4096
|
|
||||||
// - Solving for x: x ≤ 3044
|
|
||||||
// 4. We use 2000 as a conservative limit to account for cookie metadata
|
|
||||||
maxCookieSize = 2000
|
|
||||||
|
|
||||||
// absoluteSessionTimeout defines the maximum lifetime of a session
|
|
||||||
// regardless of activity (24 hours)
|
|
||||||
absoluteSessionTimeout = 24 * time.Hour
|
|
||||||
|
|
||||||
// minEncryptionKeyLength defines the minimum length for the encryption key
|
|
||||||
minEncryptionKeyLength = 32
|
|
||||||
)
|
|
||||||
|
|
||||||
// compressToken compresses the input string using gzip and then encodes the result using standard base64 encoding.
|
|
||||||
// If any error occurs during compression, it returns the original uncompressed token as a fallback.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - token: The string to compress.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - The base64 encoded, gzipped string, or the original string if compression fails.
|
|
||||||
func compressToken(token string) string {
|
|
||||||
var b bytes.Buffer
|
|
||||||
gz := gzip.NewWriter(&b)
|
|
||||||
if _, err := gz.Write([]byte(token)); err != nil {
|
|
||||||
return token // fallback to uncompressed on error
|
|
||||||
}
|
|
||||||
if err := gz.Close(); err != nil {
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
return base64.StdEncoding.EncodeToString(b.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
// decompressToken decodes a standard base64 encoded string and then decompresses the result using gzip.
|
|
||||||
// If base64 decoding or gzip decompression fails, it returns the original input string as a fallback,
|
|
||||||
// assuming it might not have been compressed.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - compressed: The base64 encoded, gzipped string.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - The decompressed original string, or the input string if decompression fails.
|
|
||||||
func decompressToken(compressed string) string {
|
|
||||||
data, err := base64.StdEncoding.DecodeString(compressed)
|
|
||||||
if err != nil {
|
|
||||||
return compressed // return as-is if not base64
|
|
||||||
}
|
|
||||||
|
|
||||||
gz, err := gzip.NewReader(bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return compressed
|
|
||||||
}
|
|
||||||
defer gz.Close()
|
|
||||||
|
|
||||||
decompressed, err := io.ReadAll(gz)
|
|
||||||
if err != nil {
|
|
||||||
return compressed
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(decompressed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionManager handles the management of multiple session cookies for OIDC authentication.
|
|
||||||
// It provides functionality for storing and retrieving authentication state, tokens,
|
|
||||||
// and other session-related data across multiple cookies.
|
|
||||||
type SessionManager struct {
|
|
||||||
// store is the underlying session store for cookie management.
|
|
||||||
store sessions.Store
|
|
||||||
|
|
||||||
// forceHTTPS enforces secure cookie attributes regardless of request scheme.
|
|
||||||
forceHTTPS bool
|
|
||||||
|
|
||||||
// logger provides structured logging capabilities.
|
|
||||||
logger *Logger
|
|
||||||
|
|
||||||
// sessionPool is a sync.Pool for reusing SessionData objects.
|
|
||||||
sessionPool sync.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSessionManager creates a new session manager with the specified configuration.
|
|
||||||
// Parameters:
|
|
||||||
// - encryptionKey: Key used to encrypt session data (must be at least 32 bytes)
|
|
||||||
// - forceHTTPS: When true, forces secure cookie attributes regardless of request scheme
|
|
||||||
// - logger: Logger instance for recording session-related events
|
|
||||||
//
|
|
||||||
// Returns an error if the encryption key does not meet minimum length requirements.
|
|
||||||
func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) (*SessionManager, error) {
|
|
||||||
// Validate encryption key length.
|
|
||||||
if len(encryptionKey) < minEncryptionKeyLength {
|
|
||||||
return nil, fmt.Errorf("encryption key must be at least %d bytes long", minEncryptionKeyLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SessionManager{
|
|
||||||
store: sessions.NewCookieStore([]byte(encryptionKey)),
|
|
||||||
forceHTTPS: forceHTTPS,
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize session pool.
|
|
||||||
sm.sessionPool.New = func() interface{} {
|
|
||||||
// Initialize SessionData with necessary fields and the mutex.
|
|
||||||
return &SessionData{
|
|
||||||
manager: sm,
|
|
||||||
accessTokenChunks: make(map[int]*sessions.Session),
|
|
||||||
refreshTokenChunks: make(map[int]*sessions.Session),
|
|
||||||
refreshMutex: sync.Mutex{}, // Initialize the mutex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sm, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSessionOptions returns a sessions.Options struct configured with security best practices.
|
|
||||||
// It sets HttpOnly to true, Secure based on the request scheme or forceHTTPS setting,
|
|
||||||
// SameSite to LaxMode, MaxAge to the absoluteSessionTimeout, and Path to "/".
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - isSecure: A boolean indicating if the current request context is secure (HTTPS).
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A pointer to a configured sessions.Options struct.
|
|
||||||
func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options {
|
|
||||||
return &sessions.Options{
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: isSecure || sm.forceHTTPS,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
MaxAge: int(absoluteSessionTimeout.Seconds()),
|
|
||||||
Path: "/",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSession retrieves all session data for the current request.
|
|
||||||
// It loads the main session and token sessions, including any chunked token data,
|
|
||||||
// and combines them into a single SessionData structure for easy access.
|
|
||||||
// Returns an error if any session component cannot be loaded.
|
|
||||||
func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
|
|
||||||
// Get session from pool.
|
|
||||||
sessionData := sm.sessionPool.Get().(*SessionData)
|
|
||||||
sessionData.request = r
|
|
||||||
|
|
||||||
var err error
|
|
||||||
sessionData.mainSession, err = sm.store.Get(r, mainCookieName)
|
|
||||||
if err != nil {
|
|
||||||
sm.sessionPool.Put(sessionData)
|
|
||||||
return nil, fmt.Errorf("failed to get main session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for absolute session timeout.
|
|
||||||
if createdAt, ok := sessionData.mainSession.Values["created_at"].(int64); ok {
|
|
||||||
if time.Since(time.Unix(createdAt, 0)) > absoluteSessionTimeout {
|
|
||||||
sessionData.Clear(r, nil)
|
|
||||||
return nil, fmt.Errorf("session expired")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionData.accessSession, err = sm.store.Get(r, accessTokenCookie)
|
|
||||||
if err != nil {
|
|
||||||
sm.sessionPool.Put(sessionData)
|
|
||||||
return nil, fmt.Errorf("failed to get access token session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionData.refreshSession, err = sm.store.Get(r, refreshTokenCookie)
|
|
||||||
if err != nil {
|
|
||||||
sm.sessionPool.Put(sessionData)
|
|
||||||
return nil, fmt.Errorf("failed to get refresh token session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear and reuse chunk maps.
|
|
||||||
for k := range sessionData.accessTokenChunks {
|
|
||||||
delete(sessionData.accessTokenChunks, k)
|
|
||||||
}
|
|
||||||
for k := range sessionData.refreshTokenChunks {
|
|
||||||
delete(sessionData.refreshTokenChunks, k)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve chunked token sessions.
|
|
||||||
sm.getTokenChunkSessions(r, accessTokenCookie, sessionData.accessTokenChunks)
|
|
||||||
sm.getTokenChunkSessions(r, refreshTokenCookie, sessionData.refreshTokenChunks)
|
|
||||||
|
|
||||||
return sessionData, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getTokenChunkSessions retrieves all cookie chunks associated with a large token (access or refresh).
|
|
||||||
// It iteratively attempts to load cookies named "{baseName}_0", "{baseName}_1", etc., until
|
|
||||||
// a cookie is not found or returns an error. The loaded sessions are stored in the provided chunks map.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - r: The incoming HTTP request containing the cookies.
|
|
||||||
// - baseName: The base name of the cookie (e.g., accessTokenCookie).
|
|
||||||
// - chunks: The map (typically SessionData.accessTokenChunks or SessionData.refreshTokenChunks) to populate with the found session chunks.
|
|
||||||
func (sm *SessionManager) getTokenChunkSessions(r *http.Request, baseName string, chunks map[int]*sessions.Session) {
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
sessionName := fmt.Sprintf("%s_%d", baseName, i)
|
|
||||||
session, err := sm.store.Get(r, sessionName)
|
|
||||||
if err != nil || session.IsNew {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
chunks[i] = session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionData holds all session information for an authenticated user.
|
|
||||||
// It manages multiple session cookies to handle the main session state
|
|
||||||
// and potentially large access and refresh tokens that may need to be
|
|
||||||
// split across multiple cookies due to browser size limitations.
|
|
||||||
type SessionData struct {
|
|
||||||
// manager is the SessionManager that created this SessionData.
|
|
||||||
manager *SessionManager
|
|
||||||
|
|
||||||
// request is the current HTTP request associated with this session.
|
|
||||||
request *http.Request
|
|
||||||
|
|
||||||
// mainSession stores authentication state and basic user info.
|
|
||||||
mainSession *sessions.Session
|
|
||||||
|
|
||||||
// accessSession stores the primary access token cookie.
|
|
||||||
accessSession *sessions.Session
|
|
||||||
|
|
||||||
// refreshSession stores the primary refresh token cookie.
|
|
||||||
refreshSession *sessions.Session
|
|
||||||
|
|
||||||
// accessTokenChunks stores additional chunks of the access token
|
|
||||||
// when it exceeds the maximum cookie size.
|
|
||||||
accessTokenChunks map[int]*sessions.Session
|
|
||||||
|
|
||||||
// refreshTokenChunks stores additional chunks of the refresh token
|
|
||||||
// when it exceeds the maximum cookie size.
|
|
||||||
refreshTokenChunks map[int]*sessions.Session
|
|
||||||
|
|
||||||
// refreshMutex protects refresh token operations within this session instance.
|
|
||||||
refreshMutex sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save persists all parts of the session (main, access token, refresh token, and any chunks)
|
|
||||||
// back to the client as cookies in the HTTP response. It applies secure cookie options
|
|
||||||
// obtained via getSessionOptions based on the request's security context.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - r: The original HTTP request (used to determine security context for cookie options).
|
|
||||||
// - w: The HTTP response writer to which the Set-Cookie headers will be added.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - An error if saving any of the session components fails.
|
|
||||||
func (sd *SessionData) Save(r *http.Request, w http.ResponseWriter) error {
|
|
||||||
isSecure := strings.HasPrefix(r.URL.Scheme, "https") || sd.manager.forceHTTPS
|
|
||||||
|
|
||||||
// Set options for all sessions.
|
|
||||||
options := sd.manager.getSessionOptions(isSecure)
|
|
||||||
sd.mainSession.Options = options
|
|
||||||
sd.accessSession.Options = options
|
|
||||||
sd.refreshSession.Options = options
|
|
||||||
|
|
||||||
// Save main session.
|
|
||||||
if err := sd.mainSession.Save(r, w); err != nil {
|
|
||||||
return fmt.Errorf("failed to save main session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save access token session.
|
|
||||||
if err := sd.accessSession.Save(r, w); err != nil {
|
|
||||||
return fmt.Errorf("failed to save access token session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save refresh token session.
|
|
||||||
if err := sd.refreshSession.Save(r, w); err != nil {
|
|
||||||
return fmt.Errorf("failed to save refresh token session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save access token chunks.
|
|
||||||
for _, session := range sd.accessTokenChunks {
|
|
||||||
session.Options = options
|
|
||||||
if err := session.Save(r, w); err != nil {
|
|
||||||
return fmt.Errorf("failed to save access token chunk session: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save refresh token chunks.
|
|
||||||
for _, session := range sd.refreshTokenChunks {
|
|
||||||
session.Options = options
|
|
||||||
if err := session.Save(r, w); err != nil {
|
|
||||||
return fmt.Errorf("failed to save refresh token chunk session: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear removes all session data associated with this SessionData instance.
|
|
||||||
// It clears the values map of the main, access, and refresh sessions, sets their MaxAge to -1
|
|
||||||
// to expire the cookies immediately, and clears any associated token chunk cookies.
|
|
||||||
// If a ResponseWriter is provided, it attempts to save the expired sessions to send the
|
|
||||||
// expiring Set-Cookie headers. Finally, it clears internal fields and returns the SessionData
|
|
||||||
// object to the pool.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - r: The HTTP request (required by the underlying session store).
|
|
||||||
// - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - An error if saving the expired sessions fails (only if w is not nil).
|
|
||||||
func (sd *SessionData) Clear(r *http.Request, w http.ResponseWriter) error {
|
|
||||||
// Clear and expire all sessions.
|
|
||||||
sd.mainSession.Options.MaxAge = -1
|
|
||||||
sd.accessSession.Options.MaxAge = -1
|
|
||||||
sd.refreshSession.Options.MaxAge = -1
|
|
||||||
|
|
||||||
for k := range sd.mainSession.Values {
|
|
||||||
delete(sd.mainSession.Values, k)
|
|
||||||
}
|
|
||||||
for k := range sd.accessSession.Values {
|
|
||||||
delete(sd.accessSession.Values, k)
|
|
||||||
}
|
|
||||||
for k := range sd.refreshSession.Values {
|
|
||||||
delete(sd.refreshSession.Values, k)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear chunk sessions.
|
|
||||||
sd.clearTokenChunks(r, sd.accessTokenChunks)
|
|
||||||
sd.clearTokenChunks(r, sd.refreshTokenChunks)
|
|
||||||
|
|
||||||
var err error
|
|
||||||
if w != nil {
|
|
||||||
err = sd.Save(r, w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear transient per-request fields.
|
|
||||||
sd.request = nil
|
|
||||||
|
|
||||||
// Return session to pool.
|
|
||||||
sd.manager.sessionPool.Put(sd)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearTokenChunks iterates through a map of session chunks, clears their values,
|
|
||||||
// and sets their MaxAge to -1 to expire them. This is used internally by Clear.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - r: The HTTP request (required by the underlying session store, though not directly used here).
|
|
||||||
// - chunks: The map of session chunks (e.g., sd.accessTokenChunks) to clear and expire.
|
|
||||||
func (sd *SessionData) clearTokenChunks(r *http.Request, chunks map[int]*sessions.Session) {
|
|
||||||
for _, session := range chunks {
|
|
||||||
session.Options.MaxAge = -1
|
|
||||||
for k := range session.Values {
|
|
||||||
delete(session.Values, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAuthenticated checks if the session is marked as authenticated and has not exceeded
|
|
||||||
// the absolute session timeout.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - true if the "authenticated" flag is set to true and the session creation time is within the allowed timeout.
|
|
||||||
// - false otherwise.
|
|
||||||
func (sd *SessionData) GetAuthenticated() bool {
|
|
||||||
auth, _ := sd.mainSession.Values["authenticated"].(bool)
|
|
||||||
if !auth {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check session expiration.
|
|
||||||
createdAt, ok := sd.mainSession.Values["created_at"].(int64)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return time.Since(time.Unix(createdAt, 0)) <= absoluteSessionTimeout
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetAuthenticated sets the authentication status of the session.
|
|
||||||
// If setting to true, it generates a new secure session ID for the main session
|
|
||||||
// to prevent session fixation attacks and records the current time as the creation time.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - value: The boolean authentication status (true for authenticated, false otherwise).
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - An error if generating a new session ID fails when setting value to true.
|
|
||||||
func (sd *SessionData) SetAuthenticated(value bool) error {
|
|
||||||
if value {
|
|
||||||
id, err := generateSecureRandomString(32)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to generate secure session id: %w", err)
|
|
||||||
}
|
|
||||||
sd.mainSession.ID = id
|
|
||||||
sd.mainSession.Values["created_at"] = time.Now().Unix()
|
|
||||||
}
|
|
||||||
sd.mainSession.Values["authenticated"] = value
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAccessToken retrieves the access token stored in the session.
|
|
||||||
// It handles reassembling the token from multiple cookie chunks if necessary
|
|
||||||
// and decompresses it if it was stored compressed.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - The complete, decompressed access token string, or an empty string if not found.
|
|
||||||
func (sd *SessionData) GetAccessToken() string {
|
|
||||||
token, _ := sd.accessSession.Values["token"].(string)
|
|
||||||
if token != "" {
|
|
||||||
compressed, _ := sd.accessSession.Values["compressed"].(bool)
|
|
||||||
if compressed {
|
|
||||||
return decompressToken(token)
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reassemble token from chunks.
|
|
||||||
if len(sd.accessTokenChunks) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var chunks []string
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
session, ok := sd.accessTokenChunks[i]
|
|
||||||
if !ok {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
chunk, _ := session.Values["token_chunk"].(string)
|
|
||||||
chunks = append(chunks, chunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
token = strings.Join(chunks, "")
|
|
||||||
compressed, _ := sd.accessSession.Values["compressed"].(bool)
|
|
||||||
if compressed {
|
|
||||||
return decompressToken(token)
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetAccessToken stores the provided access token in the session.
|
|
||||||
// It first expires any existing access token chunk cookies.
|
|
||||||
// It then compresses the token. If the compressed token fits within a single cookie (maxCookieSize),
|
|
||||||
// it's stored directly in the primary access token session. Otherwise, the compressed token
|
|
||||||
// is split into chunks, and each chunk is stored in a separate numbered cookie (_oidc_raczylo_a_0, _oidc_raczylo_a_1, etc.).
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - token: The access token string to store.
|
|
||||||
func (sd *SessionData) SetAccessToken(token string) {
|
|
||||||
// Expire any existing chunk cookies first.
|
|
||||||
if sd.request != nil {
|
|
||||||
sd.expireAccessTokenChunks(nil) // Will be saved when Save() is called.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear and prepare chunks map for new token.
|
|
||||||
sd.accessTokenChunks = make(map[int]*sessions.Session)
|
|
||||||
|
|
||||||
// Compress token.
|
|
||||||
compressed := compressToken(token)
|
|
||||||
|
|
||||||
if len(compressed) <= maxCookieSize {
|
|
||||||
sd.accessSession.Values["token"] = compressed
|
|
||||||
sd.accessSession.Values["compressed"] = true
|
|
||||||
} else {
|
|
||||||
// Split compressed token into chunks.
|
|
||||||
sd.accessSession.Values["token"] = ""
|
|
||||||
sd.accessSession.Values["compressed"] = true
|
|
||||||
chunks := splitIntoChunks(compressed, maxCookieSize)
|
|
||||||
for i, chunk := range chunks {
|
|
||||||
sessionName := fmt.Sprintf("%s_%d", accessTokenCookie, i)
|
|
||||||
session, _ := sd.manager.store.Get(sd.request, sessionName)
|
|
||||||
session.Values["token_chunk"] = chunk
|
|
||||||
sd.accessTokenChunks[i] = session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRefreshToken retrieves the refresh token stored in the session.
|
|
||||||
// It handles reassembling the token from multiple cookie chunks if necessary
|
|
||||||
// and decompresses it if it was stored compressed.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - The complete, decompressed refresh token string, or an empty string if not found.
|
|
||||||
func (sd *SessionData) GetRefreshToken() string {
|
|
||||||
token, _ := sd.refreshSession.Values["token"].(string)
|
|
||||||
if token != "" {
|
|
||||||
compressed, _ := sd.refreshSession.Values["compressed"].(bool)
|
|
||||||
if compressed {
|
|
||||||
return decompressToken(token)
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reassemble token from chunks.
|
|
||||||
if len(sd.refreshTokenChunks) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var chunks []string
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
session, ok := sd.refreshTokenChunks[i]
|
|
||||||
if !ok {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
chunk, _ := session.Values["token_chunk"].(string)
|
|
||||||
chunks = append(chunks, chunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
token = strings.Join(chunks, "")
|
|
||||||
compressed, _ := sd.refreshSession.Values["compressed"].(bool)
|
|
||||||
if compressed {
|
|
||||||
return decompressToken(token)
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetRefreshToken stores the provided refresh token in the session.
|
|
||||||
// It first expires any existing refresh token chunk cookies.
|
|
||||||
// It then compresses the token. If the compressed token fits within a single cookie (maxCookieSize),
|
|
||||||
// it's stored directly in the primary refresh token session. Otherwise, the compressed token
|
|
||||||
// is split into chunks, and each chunk is stored in a separate numbered cookie (_oidc_raczylo_r_0, _oidc_raczylo_r_1, etc.).
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - token: The refresh token string to store.
|
|
||||||
func (sd *SessionData) SetRefreshToken(token string) {
|
|
||||||
// Expire any existing chunk cookies first.
|
|
||||||
if sd.request != nil {
|
|
||||||
sd.expireRefreshTokenChunks(nil) // Will be saved when Save() is called.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear and prepare chunks map for new token.
|
|
||||||
sd.refreshTokenChunks = make(map[int]*sessions.Session)
|
|
||||||
|
|
||||||
// Compress token.
|
|
||||||
compressed := compressToken(token)
|
|
||||||
|
|
||||||
if len(compressed) <= maxCookieSize {
|
|
||||||
sd.refreshSession.Values["token"] = compressed
|
|
||||||
sd.refreshSession.Values["compressed"] = true
|
|
||||||
} else {
|
|
||||||
// Split compressed token into chunks.
|
|
||||||
sd.refreshSession.Values["token"] = ""
|
|
||||||
sd.refreshSession.Values["compressed"] = true
|
|
||||||
chunks := splitIntoChunks(compressed, maxCookieSize)
|
|
||||||
for i, chunk := range chunks {
|
|
||||||
sessionName := fmt.Sprintf("%s_%d", refreshTokenCookie, i)
|
|
||||||
session, _ := sd.manager.store.Get(sd.request, sessionName)
|
|
||||||
session.Values["token_chunk"] = chunk
|
|
||||||
sd.refreshTokenChunks[i] = session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// expireAccessTokenChunks finds all existing access token chunk cookies (_oidc_raczylo_a_N)
|
|
||||||
// associated with the current request, clears their values, and sets their MaxAge to -1.
|
|
||||||
// If a ResponseWriter is provided, it attempts to save the expired chunk sessions to send
|
|
||||||
// the expiring Set-Cookie headers. This is used internally when setting a new access token.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent.
|
|
||||||
func (sd *SessionData) expireAccessTokenChunks(w http.ResponseWriter) {
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
sessionName := fmt.Sprintf("%s_%d", accessTokenCookie, i)
|
|
||||||
session, err := sd.manager.store.Get(sd.request, sessionName)
|
|
||||||
if err != nil || session.IsNew {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
session.Options.MaxAge = -1
|
|
||||||
session.Values = make(map[interface{}]interface{})
|
|
||||||
if w != nil {
|
|
||||||
if err := session.Save(sd.request, w); err != nil {
|
|
||||||
sd.manager.logger.Errorf("failed to save expired access token cookie: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// expireRefreshTokenChunks finds all existing refresh token chunk cookies (_oidc_raczylo_r_N)
|
|
||||||
// associated with the current request, clears their values, and sets their MaxAge to -1.
|
|
||||||
// If a ResponseWriter is provided, it attempts to save the expired chunk sessions to send
|
|
||||||
// the expiring Set-Cookie headers. This is used internally when setting a new refresh token.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent.
|
|
||||||
func (sd *SessionData) expireRefreshTokenChunks(w http.ResponseWriter) {
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
sessionName := fmt.Sprintf("%s_%d", refreshTokenCookie, i)
|
|
||||||
session, err := sd.manager.store.Get(sd.request, sessionName)
|
|
||||||
if err != nil || session.IsNew {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
session.Options.MaxAge = -1
|
|
||||||
session.Values = make(map[interface{}]interface{})
|
|
||||||
if w != nil {
|
|
||||||
if err := session.Save(sd.request, w); err != nil {
|
|
||||||
sd.manager.logger.Errorf("failed to save expired refresh token cookie: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitIntoChunks divides a string `s` into a slice of strings, where each element
|
|
||||||
// has a maximum length of `chunkSize`.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - s: The string to split.
|
|
||||||
// - chunkSize: The maximum size of each chunk.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A slice of strings representing the chunks.
|
|
||||||
func splitIntoChunks(s string, chunkSize int) []string {
|
|
||||||
var chunks []string
|
|
||||||
for len(s) > 0 {
|
|
||||||
if len(s) > chunkSize {
|
|
||||||
chunks = append(chunks, s[:chunkSize])
|
|
||||||
s = s[chunkSize:]
|
|
||||||
} else {
|
|
||||||
chunks = append(chunks, s)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chunks
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCSRF retrieves the Cross-Site Request Forgery (CSRF) token stored in the main session.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - The CSRF token string, or an empty string if not set.
|
|
||||||
func (sd *SessionData) GetCSRF() string {
|
|
||||||
csrf, _ := sd.mainSession.Values["csrf"].(string)
|
|
||||||
return csrf
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCSRF stores the provided CSRF token string in the main session.
|
|
||||||
// This token is typically generated at the start of the authentication flow.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - token: The CSRF token to store.
|
|
||||||
func (sd *SessionData) SetCSRF(token string) {
|
|
||||||
sd.mainSession.Values["csrf"] = token
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNonce retrieves the OIDC nonce value stored in the main session.
|
|
||||||
// The nonce is used to associate an ID token with the specific authentication request.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - The nonce string, or an empty string if not set.
|
|
||||||
func (sd *SessionData) GetNonce() string {
|
|
||||||
nonce, _ := sd.mainSession.Values["nonce"].(string)
|
|
||||||
return nonce
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetNonce stores the provided OIDC nonce string in the main session.
|
|
||||||
// This nonce is typically generated at the start of the authentication flow.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - nonce: The nonce string to store.
|
|
||||||
func (sd *SessionData) SetNonce(nonce string) {
|
|
||||||
sd.mainSession.Values["nonce"] = nonce
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCodeVerifier retrieves the PKCE (Proof Key for Code Exchange) code verifier
|
|
||||||
// stored in the main session. This is only relevant if PKCE is enabled.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - The code verifier string, or an empty string if not set or PKCE is disabled.
|
|
||||||
func (sd *SessionData) GetCodeVerifier() string {
|
|
||||||
codeVerifier, _ := sd.mainSession.Values["code_verifier"].(string)
|
|
||||||
return codeVerifier
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCodeVerifier stores the provided PKCE code verifier string in the main session.
|
|
||||||
// This is typically called at the start of the authentication flow if PKCE is enabled.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - codeVerifier: The PKCE code verifier string to store.
|
|
||||||
func (sd *SessionData) SetCodeVerifier(codeVerifier string) {
|
|
||||||
sd.mainSession.Values["code_verifier"] = codeVerifier
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEmail retrieves the authenticated user's email address stored in the main session.
|
|
||||||
// This is typically extracted from the ID token claims after successful authentication.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - The user's email address string, or an empty string if not set.
|
|
||||||
func (sd *SessionData) GetEmail() string {
|
|
||||||
email, _ := sd.mainSession.Values["email"].(string)
|
|
||||||
return email
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetEmail stores the provided user email address string in the main session.
|
|
||||||
// This is typically called after successful authentication and claim extraction.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - email: The user's email address to store.
|
|
||||||
func (sd *SessionData) SetEmail(email string) {
|
|
||||||
sd.mainSession.Values["email"] = email
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetIncomingPath retrieves the original request URI (including query parameters)
|
|
||||||
// that the user was trying to access before being redirected for authentication.
|
|
||||||
// This is stored in the main session to allow redirection back after successful login.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - The original request URI string, or an empty string if not set.
|
|
||||||
func (sd *SessionData) GetIncomingPath() string {
|
|
||||||
path, _ := sd.mainSession.Values["incoming_path"].(string)
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetIncomingPath stores the original request URI (path and query parameters)
|
|
||||||
// in the main session. This is typically called at the start of the authentication flow.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - path: The original request URI string (e.g., "/protected/resource?id=123").
|
|
||||||
func (sd *SessionData) SetIncomingPath(path string) {
|
|
||||||
sd.mainSession.Values["incoming_path"] = path
|
|
||||||
}
|
|
||||||
-389
@@ -1,389 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// generateRandomString creates a random string of specified length
|
|
||||||
func generateRandomString(length int) string {
|
|
||||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
||||||
b := make([]byte, length)
|
|
||||||
for i := range b {
|
|
||||||
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
|
||||||
if err != nil {
|
|
||||||
// Handle error appropriately in a real application, maybe panic in test helper
|
|
||||||
panic(fmt.Sprintf("crypto/rand failed: %v", err))
|
|
||||||
}
|
|
||||||
b[i] = charset[num.Int64()]
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTokenCompression tests the token compression functionality
|
|
||||||
func TestTokenCompression(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
token string
|
|
||||||
wantSize int // Expected size after compression (approximate)
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Short token",
|
|
||||||
token: "shorttoken",
|
|
||||||
wantSize: 50, // Base64 encoded gzip has overhead for small content
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Repeating content",
|
|
||||||
token: strings.Repeat("abcdef", 1000),
|
|
||||||
wantSize: 100, // Should compress well due to repetition
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Random content",
|
|
||||||
token: generateRandomString(1000),
|
|
||||||
wantSize: 2000, // Random content won't compress much
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
compressed := compressToken(tt.token)
|
|
||||||
decompressed := decompressToken(compressed)
|
|
||||||
|
|
||||||
// Only verify compression ratio for non-short tokens
|
|
||||||
if len(tt.token) > 100 {
|
|
||||||
compressionRatio := float64(len(compressed)) / float64(len(tt.token))
|
|
||||||
t.Logf("Compression ratio for %s: %.2f", tt.name, compressionRatio)
|
|
||||||
|
|
||||||
if compressionRatio > 1.1 { // Allow up to 10% size increase
|
|
||||||
t.Errorf("Compression increased size too much: original=%d, compressed=%d, ratio=%.2f",
|
|
||||||
len(tt.token), len(compressed), compressionRatio)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify decompression restores original
|
|
||||||
if decompressed != tt.token {
|
|
||||||
t.Error("Decompression failed to restore original token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify approximate compression ratio
|
|
||||||
if len(compressed) > tt.wantSize*2 {
|
|
||||||
t.Errorf("Compression ratio worse than expected: got=%d, want<%d", len(compressed), tt.wantSize*2)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSessionManager tests the SessionManager functionality
|
|
||||||
|
|
||||||
func TestCookiePrefix(t *testing.T) {
|
|
||||||
// Create a session and verify cookie names
|
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
sm, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, NewLogger("debug"))
|
|
||||||
session, err := sm.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set some data to ensure cookies are created
|
|
||||||
session.SetAuthenticated(true)
|
|
||||||
|
|
||||||
// Expire any existing cookies
|
|
||||||
session.expireAccessTokenChunks(rr)
|
|
||||||
session.expireRefreshTokenChunks(rr)
|
|
||||||
|
|
||||||
// Set new tokens
|
|
||||||
session.SetAccessToken("test_token")
|
|
||||||
session.SetRefreshToken("test_refresh_token")
|
|
||||||
|
|
||||||
if err := session.Save(req, rr); err != nil {
|
|
||||||
t.Fatalf("Failed to save session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cookie prefixes
|
|
||||||
cookies := rr.Result().Cookies()
|
|
||||||
for _, cookie := range cookies {
|
|
||||||
if !strings.HasPrefix(cookie.Name, "_oidc_raczylo_") {
|
|
||||||
t.Errorf("Cookie %s does not have expected prefix '_oidc_raczylo_'", cookie.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTokenRefreshCleanup(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
sm, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, NewLogger("debug"))
|
|
||||||
session, err := sm.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set a large token that will be split into chunks
|
|
||||||
largeToken := strings.Repeat("x", 5000)
|
|
||||||
session.SetAccessToken(largeToken)
|
|
||||||
|
|
||||||
if err := session.Save(req, rr); err != nil {
|
|
||||||
t.Fatalf("Failed to save session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get initial cookies
|
|
||||||
initialCookies := rr.Result().Cookies()
|
|
||||||
|
|
||||||
// Create a new request with the initial cookies
|
|
||||||
newReq := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
for _, cookie := range initialCookies {
|
|
||||||
newReq.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
newRr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Get session with cookies and set a new token
|
|
||||||
newSession, err := sm.GetSession(newReq)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get new session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a response recorder for expired cookies
|
|
||||||
expiredRr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Expire old chunk cookies
|
|
||||||
newSession.expireAccessTokenChunks(expiredRr)
|
|
||||||
|
|
||||||
// Set a smaller token that won't need chunks
|
|
||||||
newSession.SetAccessToken("small_token")
|
|
||||||
|
|
||||||
// Save session with new token
|
|
||||||
if err := newSession.Save(newReq, newRr); err != nil {
|
|
||||||
t.Fatalf("Failed to save new session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cookies in response where old cookies are expired
|
|
||||||
intermediateResponse := expiredRr.Result()
|
|
||||||
intermediateCount := 0
|
|
||||||
chunkCount := 0
|
|
||||||
expiredCount := 0
|
|
||||||
|
|
||||||
for _, cookie := range intermediateResponse.Cookies() {
|
|
||||||
if strings.Contains(cookie.Name, "_oidc_raczylo_a_") && strings.Count(cookie.Name, "_") > 3 {
|
|
||||||
chunkCount++
|
|
||||||
if cookie.MaxAge < 0 {
|
|
||||||
expiredCount++
|
|
||||||
t.Logf("Found expired chunk cookie: %s (MaxAge=%d)", cookie.Name, cookie.MaxAge)
|
|
||||||
}
|
|
||||||
} else if cookie.MaxAge >= 0 {
|
|
||||||
intermediateCount++
|
|
||||||
t.Logf("Found active cookie: %s (MaxAge=%d)", cookie.Name, cookie.MaxAge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// All chunk cookies should be expired
|
|
||||||
if chunkCount > 0 && chunkCount != expiredCount {
|
|
||||||
t.Errorf("Not all chunk cookies are expired: %d chunks, %d expired", chunkCount, expiredCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have fewer active cookies after setting smaller token
|
|
||||||
if intermediateCount >= len(initialCookies) {
|
|
||||||
t.Errorf("Expected fewer active cookies after token refresh, got %d, want less than %d", intermediateCount, len(initialCookies))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSessionManager(t *testing.T) {
|
|
||||||
ts := &TestSuite{t: t}
|
|
||||||
ts.Setup()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
authenticated bool
|
|
||||||
email string
|
|
||||||
accessToken string
|
|
||||||
refreshToken string
|
|
||||||
expectedCookieCount int
|
|
||||||
wantCompressed bool // Whether tokens should be compressed
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Short tokens",
|
|
||||||
authenticated: true,
|
|
||||||
email: "[email protected]",
|
|
||||||
accessToken: "shortaccesstoken",
|
|
||||||
refreshToken: "shortrefreshtoken",
|
|
||||||
expectedCookieCount: 3, // main, access, refresh
|
|
||||||
wantCompressed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Long tokens exceeding 4096 bytes",
|
|
||||||
authenticated: true,
|
|
||||||
email: "[email protected]",
|
|
||||||
accessToken: strings.Repeat("x", 5000),
|
|
||||||
refreshToken: strings.Repeat("y", 6000),
|
|
||||||
expectedCookieCount: calculateExpectedCookieCount(strings.Repeat("x", 5000), strings.Repeat("y", 6000)),
|
|
||||||
wantCompressed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "REALLY long tokens, exceeding 25000 bytes",
|
|
||||||
authenticated: true,
|
|
||||||
email: "[email protected]",
|
|
||||||
accessToken: strings.Repeat("x", 25000),
|
|
||||||
refreshToken: strings.Repeat("y", 25000),
|
|
||||||
expectedCookieCount: calculateExpectedCookieCount(strings.Repeat("x", 25000), strings.Repeat("y", 25000)),
|
|
||||||
wantCompressed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Unauthenticated session",
|
|
||||||
authenticated: false,
|
|
||||||
email: "",
|
|
||||||
accessToken: "",
|
|
||||||
refreshToken: "",
|
|
||||||
expectedCookieCount: 3, // main, access, refresh
|
|
||||||
wantCompressed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Random content tokens",
|
|
||||||
authenticated: true,
|
|
||||||
email: "[email protected]",
|
|
||||||
accessToken: generateRandomString(5000),
|
|
||||||
refreshToken: generateRandomString(5000),
|
|
||||||
expectedCookieCount: calculateExpectedCookieCount(generateRandomString(5000), generateRandomString(5000)),
|
|
||||||
wantCompressed: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
tc := tc // Capture range variable
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
session, err := ts.sessionManager.GetSession(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set session values
|
|
||||||
session.SetAuthenticated(tc.authenticated)
|
|
||||||
session.SetEmail(tc.email)
|
|
||||||
|
|
||||||
// Expire any existing cookies
|
|
||||||
session.expireAccessTokenChunks(rr)
|
|
||||||
session.expireRefreshTokenChunks(rr)
|
|
||||||
|
|
||||||
// Set new tokens
|
|
||||||
session.SetAccessToken(tc.accessToken)
|
|
||||||
session.SetRefreshToken(tc.refreshToken)
|
|
||||||
|
|
||||||
// Save session
|
|
||||||
if err := session.Save(req, rr); err != nil {
|
|
||||||
t.Fatalf("Failed to save session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify cookies are set and compression is used when appropriate
|
|
||||||
cookies := rr.Result().Cookies()
|
|
||||||
if len(cookies) != tc.expectedCookieCount {
|
|
||||||
t.Errorf("Expected %d cookies, got %d", tc.expectedCookieCount, len(cookies))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify compression is working by checking token sizes
|
|
||||||
for _, cookie := range cookies {
|
|
||||||
if strings.Contains(cookie.Name, accessTokenCookie) {
|
|
||||||
// Get original and stored sizes
|
|
||||||
originalSize := len(tc.accessToken)
|
|
||||||
storedSize := len(cookie.Value)
|
|
||||||
|
|
||||||
if originalSize > 100 && tc.wantCompressed {
|
|
||||||
// For large tokens, verify some compression occurred
|
|
||||||
compressionRatio := float64(storedSize) / float64(originalSize)
|
|
||||||
t.Logf("Access token compression ratio: %.2f (original: %d, stored: %d)",
|
|
||||||
compressionRatio, originalSize, storedSize)
|
|
||||||
|
|
||||||
if compressionRatio > 0.9 { // Allow some overhead, but should see compression
|
|
||||||
t.Errorf("Expected compression for large token in cookie %s (ratio: %.2f)",
|
|
||||||
cookie.Name, compressionRatio)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if strings.Contains(cookie.Name, refreshTokenCookie) {
|
|
||||||
originalSize := len(tc.refreshToken)
|
|
||||||
storedSize := len(cookie.Value)
|
|
||||||
|
|
||||||
if originalSize > 100 && tc.wantCompressed {
|
|
||||||
compressionRatio := float64(storedSize) / float64(originalSize)
|
|
||||||
t.Logf("Refresh token compression ratio: %.2f (original: %d, stored: %d)",
|
|
||||||
compressionRatio, originalSize, storedSize)
|
|
||||||
|
|
||||||
if compressionRatio > 0.9 {
|
|
||||||
t.Errorf("Expected compression for large token in cookie %s (ratio: %.2f)",
|
|
||||||
cookie.Name, compressionRatio)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new request with the cookies
|
|
||||||
newReq := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
for _, cookie := range cookies {
|
|
||||||
newReq.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the session again and verify values
|
|
||||||
newSession, err := ts.sessionManager.GetSession(newReq)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get new session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify session values
|
|
||||||
if newSession.GetAuthenticated() != tc.authenticated {
|
|
||||||
t.Errorf("Authentication status not preserved")
|
|
||||||
}
|
|
||||||
if email := newSession.GetEmail(); email != tc.email {
|
|
||||||
t.Errorf("Expected email %s, got %s", tc.email, email)
|
|
||||||
}
|
|
||||||
if token := newSession.GetAccessToken(); token != tc.accessToken {
|
|
||||||
t.Errorf("Access token not preserved: got len=%d, want len=%d", len(token), len(tc.accessToken))
|
|
||||||
}
|
|
||||||
if token := newSession.GetRefreshToken(); token != tc.refreshToken {
|
|
||||||
t.Errorf("Refresh token not preserved: got len=%d, want len=%d", len(token), len(tc.refreshToken))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify session pooling by checking if the session is reused
|
|
||||||
session2, _ := ts.sessionManager.GetSession(newReq)
|
|
||||||
if session2 == newSession {
|
|
||||||
t.Error("Session not properly pooled")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func calculateExpectedCookieCount(accessToken, refreshToken string) int {
|
|
||||||
count := 3 // main, access, refresh
|
|
||||||
|
|
||||||
// Helper to calculate chunks for compressed token
|
|
||||||
calculateChunks := func(token string) int {
|
|
||||||
// Compress token (matching the actual implementation)
|
|
||||||
compressed := compressToken(token)
|
|
||||||
|
|
||||||
// If compressed token fits in one cookie, no additional chunks needed
|
|
||||||
if len(compressed) <= maxCookieSize {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate chunks needed for compressed token
|
|
||||||
return len(splitIntoChunks(compressed, maxCookieSize))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add chunks for access token if needed
|
|
||||||
accessChunks := calculateChunks(accessToken)
|
|
||||||
if accessChunks > 0 {
|
|
||||||
count += accessChunks
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add chunks for refresh token if needed
|
|
||||||
refreshChunks := calculateChunks(refreshToken)
|
|
||||||
if refreshChunks > 0 {
|
|
||||||
count += refreshChunks
|
|
||||||
}
|
|
||||||
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
+66
-282
@@ -5,285 +5,109 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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"`
|
|
||||||
|
|
||||||
// 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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidSecureURL checks if a given string represents a valid, absolute HTTPS URL.
|
// Logger is a simple logger with different levels
|
||||||
// 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)
|
||||||
|
|
||||||
if logLevel == "debug" || logLevel == "info" {
|
|
||||||
logInfo.SetOutput(os.Stdout)
|
logInfo.SetOutput(os.Stdout)
|
||||||
}
|
|
||||||
if logLevel == "debug" {
|
if logLevel == "debug" {
|
||||||
logDebug.SetOutput(os.Stdout)
|
logDebug.SetOutput(os.Stdout)
|
||||||
}
|
}
|
||||||
@@ -295,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,397 +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: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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
-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
|
||||||
|
|||||||
+5
-2
@@ -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
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ func newCookieFromOptions(name, value string, options *Options) *http.Cookie {
|
|||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
+7
-2
@@ -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.
|
||||||
@@ -16,4 +19,6 @@ type Options struct {
|
|||||||
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