diff --git a/.traefik.yml b/.traefik.yml index b900d9c..13607a0 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -7,8 +7,9 @@ summary: | Universal OpenID Connect (OIDC) authentication middleware for Traefik. 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, session management, and memory-efficient operation. + It provides a complete OIDC authentication solution with features including domain restrictions, + role-based access control, session management, comprehensive security headers, automatic token refresh, + and support for all major OIDC providers with automatic configuration. 🎯 SUPPORTED PROVIDERS (Auto-Detection): ✅ Google - Full OIDC, auto-configured for Workspace @@ -23,19 +24,26 @@ summary: | 🔧 KEY FEATURES: - Automatic provider detection and configuration - - Built-in warnings for provider limitations - - Memory-efficient with bounded resource usage - - Comprehensive test coverage and security monitoring + - Comprehensive security headers (CSP, HSTS, CORS, custom profiles) + - Domain restrictions and role-based access control + - Automatic token refresh and session management + - Rate limiting and brute force protection + - Flexible configuration with multiple deployment scenarios + - Memory-efficient operation with automatic cleanup + - Extensive logging and debugging capabilities 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 + - Role and group-based access control based on OIDC claims + - Public URLs that bypass authentication (excluded paths) - Secure session management with encrypted cookies - Automatic token validation and refresh + - Comprehensive security headers with multiple security profiles + - Rate limiting to prevent brute force attacks + - Custom headers using templated values from OIDC claims + - Flexible CORS configuration for API endpoints + - Configurable logging levels for debugging and monitoring testData: # Required parameters @@ -94,6 +102,78 @@ testData: cookieDomain: "" # Explicit domain for session cookies (e.g., ".example.com" for multi-subdomain setups) overrideScopes: false # When true, replaces default scopes instead of appending (default: false) refreshGracePeriodSeconds: 60 # Seconds before token expiry to attempt proactive refresh (default: 60) + + # Security Headers Configuration (enabled by default with 'default' profile) + securityHeaders: + enabled: true + profile: "default" # Options: default, strict, development, api, custom + + # CORS configuration for API endpoints + corsEnabled: false + corsAllowedOrigins: + - "https://your-frontend.com" + - "https://*.example.com" + corsAllowCredentials: true + + # Custom headers + customHeaders: + X-Custom-Header: "production" + X-API-Version: "v1" + +# --- Common Configuration Examples --- +# +# 🔒 HIGH-SECURITY CONFIGURATION +# testDataHighSecurity: +# providerURL: https://login.microsoftonline.com/your-tenant-id/v2.0 +# clientID: your-azure-client-id +# clientSecret: your-azure-client-secret +# callbackURL: /oauth2/callback +# sessionEncryptionKey: "maximum-security-key-at-least-32-bytes-long" +# rateLimit: 50 # Restrictive rate limiting +# allowedUserDomains: ["company.com"] # Domain restriction +# allowedRolesAndGroups: ["admin", "security-team"] # Role restriction +# securityHeaders: +# enabled: true +# profile: "strict" # Maximum security headers +# corsEnabled: false # No CORS in high-security mode +# logLevel: info + +# 🧑‍💻 DEVELOPMENT CONFIGURATION +# testDataDevelopment: +# providerURL: https://your-dev-provider.com +# clientID: dev-client-id +# clientSecret: dev-client-secret +# callbackURL: /oauth2/callback +# sessionEncryptionKey: "development-key-at-least-32-bytes-long" +# forceHTTPS: false # Allow HTTP in development +# excludedURLs: ["/health", "/metrics", "/debug"] +# securityHeaders: +# enabled: true +# profile: "development" # Relaxed security for development +# corsEnabled: true +# corsAllowedOrigins: ["http://localhost:*", "http://127.0.0.1:*"] +# logLevel: debug + +# 🌐 API CONFIGURATION +# testDataAPI: +# providerURL: https://your-auth0-domain.auth0.com +# clientID: api-client-id +# clientSecret: api-client-secret +# callbackURL: /oauth2/callback +# sessionEncryptionKey: "api-gateway-key-at-least-32-bytes-long" +# refreshGracePeriodSeconds: 120 +# securityHeaders: +# enabled: true +# profile: "api" +# corsEnabled: true +# corsAllowedOrigins: ["https://app.example.com"] +# corsAllowedMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"] +# corsAllowedHeaders: ["Authorization", "Content-Type", "X-API-Key"] +# headers: # Custom headers with OIDC claims +# - name: "X-User-Email" +# value: "{{.Claims.email}}" +# - name: "X-User-ID" +# value: "{{.Claims.sub}}" # --- Provider Specific Configuration Examples --- # @@ -567,3 +647,255 @@ configuration: value: type: string description: Template string for the header value + + securityHeaders: + type: object + description: | + Configuration for security headers to protect against common web vulnerabilities. + Security headers are applied to all authenticated responses. + + The middleware includes comprehensive security headers support with multiple profiles: + - default: Balanced security for standard web applications + - strict: Maximum security for high-security applications + - development: Relaxed policies for local development + - api: API-friendly configuration with CORS support + - custom: Full control over all security header settings + + Security features include: + - Content Security Policy (CSP) to prevent XSS attacks + - HTTP Strict Transport Security (HSTS) to enforce HTTPS + - Frame Options to prevent clickjacking + - XSS Protection for browser-level filtering + - Content Type Options to prevent MIME sniffing + - CORS headers for cross-origin resource sharing + - Custom headers for additional security requirements + + Example configurations: + + Basic security (recommended): + securityHeaders: + enabled: true + profile: "default" + + API with CORS: + securityHeaders: + enabled: true + profile: "api" + corsEnabled: true + corsAllowedOrigins: ["https://app.example.com"] + + Custom configuration: + securityHeaders: + enabled: true + profile: "custom" + contentSecurityPolicy: "default-src 'self'" + corsEnabled: true + corsAllowedOrigins: ["https://*.example.com"] + customHeaders: + X-Security-Level: "high" + required: false + properties: + enabled: + type: boolean + description: | + Enable or disable security headers. + When disabled, only basic fallback headers are applied. + Default: true + required: false + + profile: + type: string + description: | + Security profile to use. Each profile provides a different balance of security and functionality: + + - default: Balanced security suitable for most web applications + - strict: Maximum security with very restrictive policies + - development: Relaxed policies for local development (enables localhost CORS) + - api: API-friendly configuration with configurable CORS + - custom: No defaults, use only explicitly configured settings + + Default: "default" + required: false + enum: + - default + - strict + - development + - api + - custom + + contentSecurityPolicy: + type: string + description: | + Content Security Policy header value to prevent XSS and code injection attacks. + Only applied when using "custom" profile or to override profile defaults. + + Examples: + - "default-src 'self'" (strict) + - "default-src 'self'; script-src 'self' 'unsafe-inline'" (moderate) + - "default-src 'self' 'unsafe-inline' 'unsafe-eval'" (permissive) + required: false + + strictTransportSecurity: + type: boolean + description: | + Enable HTTP Strict Transport Security (HSTS) to force HTTPS connections. + Only applied when HTTPS is detected (via TLS or X-Forwarded-Proto header). + Default: true + required: false + + strictTransportSecurityMaxAge: + type: integer + description: | + HSTS max-age value in seconds. Determines how long browsers should enforce HTTPS. + Common values: + - 31536000 (1 year) - recommended for production + - 86400 (1 day) - for testing + Default: 31536000 + required: false + + strictTransportSecuritySubdomains: + type: boolean + description: | + Include subdomains in HSTS policy. + When true, HSTS applies to all subdomains of the current domain. + Default: true + required: false + + strictTransportSecurityPreload: + type: boolean + description: | + Enable HSTS preload list eligibility. + Allows the domain to be included in browser HSTS preload lists. + Default: true + required: false + + frameOptions: + type: string + description: | + X-Frame-Options header value to prevent clickjacking attacks. + + Options: + - DENY: Prevents framing completely + - SAMEORIGIN: Allows framing only from the same origin + - ALLOW-FROM uri: Allows framing from specific URI + + Default: "DENY" + required: false + + contentTypeOptions: + type: string + description: | + X-Content-Type-Options header value to prevent MIME type sniffing. + Should typically be set to "nosniff". + Default: "nosniff" + required: false + + xssProtection: + type: string + description: | + X-XSS-Protection header value for browser XSS filtering. + Recommended value: "1; mode=block" + Default: "1; mode=block" + required: false + + referrerPolicy: + type: string + description: | + Referrer-Policy header value to control referrer information sharing. + + Common values: + - strict-origin-when-cross-origin (recommended) + - no-referrer (most restrictive) + - same-origin (moderate) + + Default: "strict-origin-when-cross-origin" + required: false + + corsEnabled: + type: boolean + description: | + Enable Cross-Origin Resource Sharing (CORS) headers. + Essential for API endpoints that need to be accessed from web browsers. + Default: false + required: false + + corsAllowedOrigins: + type: array + description: | + List of allowed origins for CORS requests. + Supports wildcards for flexible origin matching: + + - "https://example.com" (exact match) + - "https://*.example.com" (subdomain wildcard) + - "http://localhost:*" (port wildcard, useful for development) + - "*" (allow all origins - not recommended for production) + + Examples: ["https://app.example.com", "https://*.api.example.com"] + required: false + items: + type: string + + corsAllowedMethods: + type: array + description: | + HTTP methods allowed for CORS requests. + Default: ["GET", "POST", "OPTIONS"] + + Common additions: ["PUT", "DELETE", "PATCH"] + required: false + items: + type: string + + corsAllowedHeaders: + type: array + description: | + HTTP headers allowed for CORS requests. + Default: ["Authorization", "Content-Type"] + + Common additions: ["X-Requested-With", "X-API-Key"] + required: false + items: + type: string + + corsAllowCredentials: + type: boolean + description: | + Allow credentials (cookies, authorization headers) in CORS requests. + Required for authenticated API requests from browsers. + Default: false + required: false + + corsMaxAge: + type: integer + description: | + Maximum age in seconds for CORS preflight cache. + Reduces preflight request frequency for better performance. + Default: 86400 (24 hours) + required: false + + customHeaders: + type: object + description: | + Additional custom headers to include in responses. + Useful for application-specific security requirements. + + Examples: + X-Security-Level: "high" + X-API-Version: "v1" + X-Environment: "production" + required: false + + disableServerHeader: + type: boolean + description: | + Remove the Server header to hide server information. + Recommended for security through obscurity. + Default: true + required: false + + disablePoweredByHeader: + type: boolean + description: | + Remove the X-Powered-By header to hide technology stack information. + Default: true + required: false diff --git a/README.md b/README.md index f07e99a..7bfe83b 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,18 @@ This middleware replaces the need for forward-auth and oauth2-proxy when using T ## Overview -The Traefik OIDC middleware provides a complete OIDC authentication solution with features like: +The Traefik OIDC middleware provides a complete OIDC authentication solution with these key features: + - **Universal provider support**: Works with 9+ OIDC providers including Google, Azure AD, Auth0, Okta, Keycloak, AWS Cognito, GitLab, and more - **Automatic provider detection**: Automatically detects and configures provider-specific settings -- Token validation and verification -- Session management with automatic cleanup -- Domain restrictions -- Role-based access control -- Token caching and blacklisting -- Rate limiting -- Excluded paths (public URLs) -- Memory-efficient operation with bounded resource usage +- **Security headers**: Comprehensive security headers with CORS, CSP, HSTS, and custom profiles +- **Domain restrictions**: Limit access to specific email domains or individual users +- **Role-based access control**: Restrict access based on roles and groups from OIDC claims +- **Session management**: Secure session handling with automatic token refresh +- **Rate limiting**: Protection against brute force attacks +- **Excluded paths**: Configure public URLs that bypass authentication +- **Custom headers**: Template-based headers using OIDC claims and tokens +- **Comprehensive logging**: Configurable log levels for debugging and monitoring ## Supported OIDC Providers @@ -36,6 +37,7 @@ The Traefik OIDC middleware provides a complete OIDC authentication solution wit |---------|--------|----------|-------|------|----------|---------|--------|--------|---------| | **ID Tokens** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | **Refresh Tokens** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| **Auto-Configuration** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | **Custom Claims** | Limited | ✅ | ✅ | ✅ | ✅ | ✅ | Limited | ❌ | Varies | | **Group/Role Claims** | Limited | ✅ | ✅ | ✅ | ✅ | ✅ | Limited | ❌ | Varies | | **Domain Restriction** | ✅ (hd claim) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | Varies | @@ -124,6 +126,7 @@ The middleware supports the following configuration options: | `refreshGracePeriodSeconds` | Seconds before token expiry to attempt proactive refresh | `60` | `120` | | `cookieDomain` | Explicit domain for session cookies (important for multi-subdomain setups) | auto-detected | `.example.com`, `app.example.com` | | `headers` | Custom HTTP headers with templates that can access OIDC claims and tokens | none | See "Templated Headers" section | +| `securityHeaders` | Configure security headers including CSP, HSTS, CORS, and custom headers | enabled with default profile | See "Security Headers Configuration" section | ## Scope Configuration @@ -198,6 +201,195 @@ scopes: [] The default append behavior ensures essential OIDC scopes are always present, while the override mode gives you complete control over the exact scopes requested from the provider. +## Security Headers Configuration + +The middleware includes comprehensive security headers support to protect your applications against common web vulnerabilities. Security headers are applied to all authenticated responses. + +### Security Features + +- **Content Security Policy (CSP)** - Prevents XSS and code injection +- **HTTP Strict Transport Security (HSTS)** - Forces HTTPS connections +- **Frame Options** - Protects against clickjacking attacks +- **XSS Protection** - Browser-level XSS filtering +- **Content Type Options** - Prevents MIME type sniffing +- **Referrer Policy** - Controls referrer information sharing +- **CORS Headers** - Complete Cross-Origin Resource Sharing support +- **Custom Headers** - Add any additional security headers + +### Security Profiles + +Choose from predefined security profiles or create custom configurations: + +| Profile | Use Case | Security Level | CORS Enabled | +|---------|----------|----------------|--------------| +| `default` | Standard web applications | High | Disabled | +| `strict` | Maximum security applications | Very High | Disabled | +| `development` | Local development | Medium | Enabled (localhost) | +| `api` | API endpoints | High | Configurable | +| `custom` | Custom requirements | Configurable | Configurable | + +### Configuration Examples + +#### Default Security (Recommended) +```yaml +securityHeaders: + enabled: true + profile: "default" +``` + +#### Strict Security +```yaml +securityHeaders: + enabled: true + profile: "strict" +``` + +#### API with CORS +```yaml +securityHeaders: + enabled: true + profile: "api" + corsEnabled: true + corsAllowedOrigins: + - "https://your-frontend.com" + - "https://*.example.com" + corsAllowCredentials: true +``` + +#### Custom Configuration +```yaml +securityHeaders: + enabled: true + profile: "custom" + + # Content Security Policy + contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline'" + + # HSTS Settings + strictTransportSecurity: true + strictTransportSecurityMaxAge: 31536000 # 1 year + strictTransportSecuritySubdomains: true + strictTransportSecurityPreload: true + + # Frame and Content Protection + frameOptions: "DENY" + contentTypeOptions: "nosniff" + xssProtection: "1; mode=block" + referrerPolicy: "strict-origin-when-cross-origin" + + # CORS Configuration + corsEnabled: true + corsAllowedOrigins: ["https://app.example.com"] + corsAllowedMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"] + corsAllowedHeaders: ["Authorization", "Content-Type", "X-Requested-With"] + corsAllowCredentials: true + corsMaxAge: 86400 + + # Custom Headers + customHeaders: + X-Custom-Header: "custom-value" + X-API-Version: "v1" + + # Server Identification + disableServerHeader: true + disablePoweredByHeader: true +``` + +### Security Headers Parameters + +| Parameter | Description | Default | Example | +|-----------|-------------|---------|---------| +| `enabled` | Enable/disable security headers | `true` | `true`, `false` | +| `profile` | Security profile to use | `default` | `default`, `strict`, `development`, `api`, `custom` | +| `contentSecurityPolicy` | CSP header value | Profile-based | `"default-src 'self'"` | +| `strictTransportSecurity` | Enable HSTS | `true` | `true`, `false` | +| `strictTransportSecurityMaxAge` | HSTS max age in seconds | `31536000` | `86400` | +| `strictTransportSecuritySubdomains` | Include subdomains in HSTS | `true` | `true`, `false` | +| `strictTransportSecurityPreload` | Enable HSTS preload | `true` | `true`, `false` | +| `frameOptions` | X-Frame-Options header | `DENY` | `DENY`, `SAMEORIGIN`, `ALLOW-FROM uri` | +| `contentTypeOptions` | X-Content-Type-Options header | `nosniff` | `nosniff` | +| `xssProtection` | X-XSS-Protection header | `1; mode=block` | `1; mode=block` | +| `referrerPolicy` | Referrer-Policy header | `strict-origin-when-cross-origin` | `no-referrer` | +| `corsEnabled` | Enable CORS headers | `false` | `true`, `false` | +| `corsAllowedOrigins` | Allowed CORS origins | `[]` | `["https://app.com", "https://*.example.com"]` | +| `corsAllowedMethods` | Allowed CORS methods | `["GET", "POST", "OPTIONS"]` | `["GET", "POST", "PUT", "DELETE"]` | +| `corsAllowedHeaders` | Allowed CORS headers | `["Authorization", "Content-Type"]` | `["X-Custom-Header"]` | +| `corsAllowCredentials` | Allow credentials in CORS | `false` | `true`, `false` | +| `corsMaxAge` | CORS preflight cache time | `86400` | `3600` | +| `customHeaders` | Additional custom headers | `{}` | `{"X-Custom": "value"}` | +| `disableServerHeader` | Remove Server header | `true` | `true`, `false` | +| `disablePoweredByHeader` | Remove X-Powered-By header | `true` | `true`, `false` | + +### CORS Wildcard Support + +The middleware supports flexible CORS origin patterns: + +```yaml +corsAllowedOrigins: + - "https://example.com" # Exact match + - "https://*.example.com" # Subdomain wildcard + - "http://localhost:*" # Port wildcard (development) + - "*" # Allow all (not recommended) +``` + +## Advanced Configuration + +The middleware provides several advanced configuration options for production environments. + +### Provider-Specific Optimizations + +The middleware automatically optimizes for each OIDC provider: +- **Google**: Automatically configures `access_type=offline` and `prompt=consent` for refresh tokens +- **Azure AD**: Optimized multi-tenant support and group claim handling +- **Auth0**: Enhanced custom claim processing and namespace support +- **Keycloak**: Self-hosted deployment optimizations +- **AWS Cognito**: Regional endpoint handling and user pool integration + +### Token Management + +- **Automatic token refresh**: Proactively refreshes tokens before expiration +- **Token validation**: Comprehensive JWT validation with security checks +- **Grace period**: Configurable time window for token refresh +- **Session handling**: Secure session management with encrypted storage + +### Configuration Examples + +#### High-Throughput Configuration +```yaml +# Optimized for high-traffic environments +rateLimit: 1000 +refreshGracePeriodSeconds: 300 +securityHeaders: + enabled: true + profile: "api" + corsEnabled: true + corsMaxAge: 86400 +``` + +#### High-Security Configuration +```yaml +# Maximum security for sensitive environments +rateLimit: 50 +allowedUserDomains: ["company.com"] +allowedRolesAndGroups: ["admin", "developer"] +securityHeaders: + enabled: true + profile: "strict" + corsEnabled: false +``` + +#### Development Configuration +```yaml +# Development-friendly settings +logLevel: "debug" +forceHTTPS: false +securityHeaders: + enabled: true + profile: "development" + corsEnabled: true + corsAllowedOrigins: ["http://localhost:*"] +``` + ## Usage Examples ### Basic Configuration @@ -1232,44 +1424,46 @@ For detailed provider-specific guidance, see the [Provider-Specific Configuratio ## Recent Improvements -### Memory Management (v0.3.0+) +### Security Features (v0.4.0+) -The middleware has undergone significant improvements to memory management and resource utilization: +- **Security Headers**: Complete security headers system with CSP, HSTS, CORS, and XSS protection +- **Multiple Security Profiles**: Choose from default, strict, development, API, or custom security configurations +- **Enhanced Token Validation**: Improved JWT validation with comprehensive security checks +- **Advanced Rate Limiting**: Configurable rate limiting to prevent abuse -- **Memory Leak Prevention**: All background goroutines are properly managed with context cancellation -- **Bounded Resource Usage**: Session storage, metadata cache, and token cache all have size limits with LRU eviction -- **Automatic Cleanup**: Expired sessions and tokens are automatically cleaned up by background tasks -- **Graceful Shutdown**: All resources are properly released when the middleware is stopped -- **Performance Monitoring**: Built-in monitoring for goroutine leaks and memory growth +### User Experience (v0.4.0+) -These improvements ensure the middleware operates efficiently even under high load and long-running deployments. +- **Automatic Provider Detection**: Seamless configuration for major OIDC providers +- **Improved Error Handling**: Better error messages and graceful degradation +- **Enhanced Session Management**: More reliable session handling with automatic cleanup +- **Flexible Configuration**: Expanded configuration options for different deployment scenarios -### Enhanced Test Coverage +### Reliability (v0.4.0+) -- Comprehensive test suite with race condition detection -- Memory leak detection tests -- Goroutine leak prevention tests -- Test coverage increased to 67%+ for main package, 87-99% for subpackages +- **Automatic Token Refresh**: Proactive token refresh to prevent authentication interruptions +- **Memory Management**: Improved memory efficiency and automatic resource cleanup +- **Better Provider Support**: Enhanced compatibility with provider-specific features +- **Comprehensive Testing**: Extensive test coverage ensures reliability in production -## Architecture and Internal Improvements +## Architecture Overview -### Internal Components +### Design Principles -The middleware uses several internal components for efficient operation: +The middleware is designed with the following principles: -1. **SessionManager**: Manages user sessions with automatic cleanup and pool-based allocation -2. **ChunkManager**: Handles large session data by splitting it into manageable chunks -3. **MetadataCache**: Caches OIDC provider metadata with LRU eviction and size limits -4. **TaskRegistry**: Manages background tasks with proper lifecycle management -5. **MemoryMonitor**: Monitors memory usage and detects potential leaks +- **Reliability**: Automatic error recovery and graceful degradation +- **Security**: Comprehensive security measures and validation +- **Performance**: Efficient resource usage and caching +- **Flexibility**: Extensive configuration options for different use cases +- **Compatibility**: Support for all major OIDC providers with automatic detection -### Key Design Decisions +### Key Features -- **Context-based cancellation**: All background operations use context for clean shutdown -- **Bounded queues and caches**: Prevents unbounded memory growth -- **LRU eviction policies**: Ensures most frequently used data stays in cache -- **Atomic operations**: Uses atomic counters for statistics to avoid lock contention -- **Test-friendly design**: Special handling for test environments to ensure clean test execution +- **Automatic Session Management**: Handles session lifecycle, cleanup, and security +- **Provider Integration**: Seamless integration with OIDC providers including auto-discovery +- **Security Integration**: Built-in security headers and protection mechanisms +- **Resource Management**: Efficient memory usage and automatic cleanup +- **Error Handling**: Comprehensive error recovery and user-friendly error messages ## Contributing diff --git a/config/settings.go b/config/settings.go index 4e3447a..ae780e3 100644 --- a/config/settings.go +++ b/config/settings.go @@ -2,7 +2,10 @@ package config import ( + "fmt" "net/http" + "strconv" + "strings" ) const ( @@ -44,27 +47,28 @@ type Logger interface { // Config represents the configuration for the OIDC middleware type Config struct { - ProviderURL string `json:"providerUrl"` - ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` - CallbackURL string `json:"callbackUrl"` - LogoutURL string `json:"logoutUrl"` - PostLogoutRedirectURI string `json:"postLogoutRedirectUri"` - SessionEncryptionKey string `json:"sessionEncryptionKey"` - ForceHTTPS bool `json:"forceHttps"` - LogLevel string `json:"logLevel"` - Scopes []string `json:"scopes"` - OverrideScopes bool `json:"overrideScopes"` - AllowedUsers []string `json:"allowedUsers"` - AllowedUserDomains []string `json:"allowedUserDomains"` - AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"` - ExcludedURLs []string `json:"excludedUrls"` - EnablePKCE bool `json:"enablePkce"` - RateLimit int `json:"rateLimit"` - RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"` - Headers []HeaderConfig `json:"headers"` - HTTPClient *http.Client `json:"-"` - CookieDomain string `json:"cookieDomain"` + ProviderURL string `json:"providerUrl"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + CallbackURL string `json:"callbackUrl"` + LogoutURL string `json:"logoutUrl"` + PostLogoutRedirectURI string `json:"postLogoutRedirectUri"` + SessionEncryptionKey string `json:"sessionEncryptionKey"` + ForceHTTPS bool `json:"forceHttps"` + LogLevel string `json:"logLevel"` + Scopes []string `json:"scopes"` + OverrideScopes bool `json:"overrideScopes"` + AllowedUsers []string `json:"allowedUsers"` + AllowedUserDomains []string `json:"allowedUserDomains"` + AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"` + ExcludedURLs []string `json:"excludedUrls"` + EnablePKCE bool `json:"enablePkce"` + RateLimit int `json:"rateLimit"` + RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"` + Headers []HeaderConfig `json:"headers"` + HTTPClient *http.Client `json:"-"` + CookieDomain string `json:"cookieDomain"` + SecurityHeaders *SecurityHeadersConfig `json:"securityHeaders,omitempty"` } // HeaderConfig represents header template configuration @@ -73,6 +77,59 @@ type HeaderConfig struct { Value string `json:"value"` } +// SecurityHeadersConfig configures security headers for the plugin +type SecurityHeadersConfig struct { + // Enable security headers (default: true) + Enabled bool `json:"enabled"` + + // Security profile: "default", "strict", "development", "api", or "custom" + Profile string `json:"profile"` + + // Content Security Policy + ContentSecurityPolicy string `json:"contentSecurityPolicy,omitempty"` + + // HSTS settings + StrictTransportSecurity bool `json:"strictTransportSecurity"` + StrictTransportSecurityMaxAge int `json:"strictTransportSecurityMaxAge"` // seconds + StrictTransportSecuritySubdomains bool `json:"strictTransportSecuritySubdomains"` + StrictTransportSecurityPreload bool `json:"strictTransportSecurityPreload"` + + // Frame options: "DENY", "SAMEORIGIN", or "ALLOW-FROM uri" + FrameOptions string `json:"frameOptions,omitempty"` + + // Content type options (default: "nosniff") + ContentTypeOptions string `json:"contentTypeOptions,omitempty"` + + // XSS protection (default: "1; mode=block") + XSSProtection string `json:"xssProtection,omitempty"` + + // Referrer policy + ReferrerPolicy string `json:"referrerPolicy,omitempty"` + + // Permissions policy + PermissionsPolicy string `json:"permissionsPolicy,omitempty"` + + // Cross-origin settings + CrossOriginEmbedderPolicy string `json:"crossOriginEmbedderPolicy,omitempty"` + CrossOriginOpenerPolicy string `json:"crossOriginOpenerPolicy,omitempty"` + CrossOriginResourcePolicy string `json:"crossOriginResourcePolicy,omitempty"` + + // CORS settings + CORSEnabled bool `json:"corsEnabled"` + CORSAllowedOrigins []string `json:"corsAllowedOrigins,omitempty"` + CORSAllowedMethods []string `json:"corsAllowedMethods,omitempty"` + CORSAllowedHeaders []string `json:"corsAllowedHeaders,omitempty"` + CORSAllowCredentials bool `json:"corsAllowCredentials"` + CORSMaxAge int `json:"corsMaxAge"` // seconds + + // Custom headers (in addition to standard security headers) + CustomHeaders map[string]string `json:"customHeaders,omitempty"` + + // Security features + DisableServerHeader bool `json:"disableServerHeader"` + DisablePoweredByHeader bool `json:"disablePoweredByHeader"` +} + // NewSettings creates a new Settings instance func NewSettings(logger Logger) *Settings { return &Settings{ @@ -90,5 +147,282 @@ func CreateConfig() *Config { RefreshGracePeriodSeconds: 60, Scopes: []string{"openid", "profile", "email"}, Headers: []HeaderConfig{}, + SecurityHeaders: createDefaultSecurityConfig(), } } + +// createDefaultSecurityConfig creates a default security headers configuration +func createDefaultSecurityConfig() *SecurityHeadersConfig { + return &SecurityHeadersConfig{ + Enabled: true, + Profile: "default", + + // Default security headers + StrictTransportSecurity: true, + StrictTransportSecurityMaxAge: 31536000, // 1 year + StrictTransportSecuritySubdomains: true, + StrictTransportSecurityPreload: true, + + FrameOptions: "DENY", + ContentTypeOptions: "nosniff", + XSSProtection: "1; mode=block", + ReferrerPolicy: "strict-origin-when-cross-origin", + + // CORS disabled by default + CORSEnabled: false, + CORSAllowedMethods: []string{"GET", "POST", "OPTIONS"}, + CORSAllowedHeaders: []string{"Authorization", "Content-Type"}, + CORSAllowCredentials: false, + CORSMaxAge: 86400, // 24 hours + + // Security features + DisableServerHeader: true, + DisablePoweredByHeader: true, + } +} + +// ToInternalSecurityConfig converts plugin SecurityHeadersConfig to internal security config +func (c *SecurityHeadersConfig) ToInternalSecurityConfig() interface{} { + if c == nil || !c.Enabled { + return nil + } + + // Create the internal security config structure + config := map[string]interface{}{ + "DevelopmentMode": false, + } + + // Apply profile-based defaults + switch strings.ToLower(c.Profile) { + case "strict": + applyStrictProfile(config) + case "development": + applyDevelopmentProfile(config) + case "api": + applyAPIProfile(config) + case "custom": + // No defaults, use only what's explicitly configured + default: // "default" + applyDefaultProfile(config) + } + + // Override with explicit configuration + if c.ContentSecurityPolicy != "" { + config["ContentSecurityPolicy"] = c.ContentSecurityPolicy + } + + // HSTS configuration + if c.StrictTransportSecurity { + config["StrictTransportSecurityMaxAge"] = c.StrictTransportSecurityMaxAge + config["StrictTransportSecuritySubdomains"] = c.StrictTransportSecuritySubdomains + config["StrictTransportSecurityPreload"] = c.StrictTransportSecurityPreload + } + + // Frame options + if c.FrameOptions != "" { + config["FrameOptions"] = c.FrameOptions + } + + // Content type and XSS protection + if c.ContentTypeOptions != "" { + config["ContentTypeOptions"] = c.ContentTypeOptions + } + if c.XSSProtection != "" { + config["XSSProtection"] = c.XSSProtection + } + + // Referrer and permissions policies + if c.ReferrerPolicy != "" { + config["ReferrerPolicy"] = c.ReferrerPolicy + } + if c.PermissionsPolicy != "" { + config["PermissionsPolicy"] = c.PermissionsPolicy + } + + // Cross-origin policies + if c.CrossOriginEmbedderPolicy != "" { + config["CrossOriginEmbedderPolicy"] = c.CrossOriginEmbedderPolicy + } + if c.CrossOriginOpenerPolicy != "" { + config["CrossOriginOpenerPolicy"] = c.CrossOriginOpenerPolicy + } + if c.CrossOriginResourcePolicy != "" { + config["CrossOriginResourcePolicy"] = c.CrossOriginResourcePolicy + } + + // CORS configuration + config["CORSEnabled"] = c.CORSEnabled + if len(c.CORSAllowedOrigins) > 0 { + config["CORSAllowedOrigins"] = c.CORSAllowedOrigins + } + if len(c.CORSAllowedMethods) > 0 { + config["CORSAllowedMethods"] = c.CORSAllowedMethods + } + if len(c.CORSAllowedHeaders) > 0 { + config["CORSAllowedHeaders"] = c.CORSAllowedHeaders + } + config["CORSAllowCredentials"] = c.CORSAllowCredentials + if c.CORSMaxAge > 0 { + config["CORSMaxAge"] = c.CORSMaxAge + } + + // Custom headers + if len(c.CustomHeaders) > 0 { + config["CustomHeaders"] = c.CustomHeaders + } + + // Security features + config["DisableServerHeader"] = c.DisableServerHeader + config["DisablePoweredByHeader"] = c.DisablePoweredByHeader + + return config +} + +// applyDefaultProfile applies default security settings +func applyDefaultProfile(config map[string]interface{}) { + config["ContentSecurityPolicy"] = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" + config["FrameOptions"] = "DENY" + config["ContentTypeOptions"] = "nosniff" + config["XSSProtection"] = "1; mode=block" + config["ReferrerPolicy"] = "strict-origin-when-cross-origin" + config["PermissionsPolicy"] = "geolocation=(), microphone=(), camera=(), payment=(), usb=()" + config["CrossOriginEmbedderPolicy"] = "require-corp" + config["CrossOriginOpenerPolicy"] = "same-origin" + config["CrossOriginResourcePolicy"] = "same-origin" +} + +// applyStrictProfile applies strict security settings +func applyStrictProfile(config map[string]interface{}) { + config["ContentSecurityPolicy"] = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" + config["FrameOptions"] = "DENY" + config["ContentTypeOptions"] = "nosniff" + config["XSSProtection"] = "1; mode=block" + config["ReferrerPolicy"] = "strict-origin-when-cross-origin" + config["PermissionsPolicy"] = "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), speaker=()" + config["CrossOriginEmbedderPolicy"] = "require-corp" + config["CrossOriginOpenerPolicy"] = "same-origin" + config["CrossOriginResourcePolicy"] = "same-site" +} + +// applyDevelopmentProfile applies development-friendly settings +func applyDevelopmentProfile(config map[string]interface{}) { + config["ContentSecurityPolicy"] = "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data: https: http:; connect-src 'self' ws: wss:;" + config["FrameOptions"] = "SAMEORIGIN" + config["ContentTypeOptions"] = "nosniff" + config["XSSProtection"] = "1; mode=block" + config["ReferrerPolicy"] = "strict-origin-when-cross-origin" + config["CrossOriginOpenerPolicy"] = "unsafe-none" + config["CrossOriginResourcePolicy"] = "cross-origin" + config["DevelopmentMode"] = true +} + +// applyAPIProfile applies API-friendly settings +func applyAPIProfile(config map[string]interface{}) { + config["ContentSecurityPolicy"] = "default-src 'none'; frame-ancestors 'none';" + config["FrameOptions"] = "DENY" + config["ContentTypeOptions"] = "nosniff" + config["XSSProtection"] = "1; mode=block" + config["ReferrerPolicy"] = "strict-origin-when-cross-origin" + config["CrossOriginResourcePolicy"] = "cross-origin" +} + +// GetSecurityHeadersApplier returns a function that applies security headers +func (c *Config) GetSecurityHeadersApplier() func(http.ResponseWriter, *http.Request) { + if c.SecurityHeaders == nil || !c.SecurityHeaders.Enabled { + return nil + } + + // This would need to import the internal security package + // For now, return a basic implementation + return func(rw http.ResponseWriter, req *http.Request) { + headers := rw.Header() + + // Apply basic security headers based on configuration + if c.SecurityHeaders.FrameOptions != "" { + headers.Set("X-Frame-Options", c.SecurityHeaders.FrameOptions) + } + if c.SecurityHeaders.ContentTypeOptions != "" { + headers.Set("X-Content-Type-Options", c.SecurityHeaders.ContentTypeOptions) + } + if c.SecurityHeaders.XSSProtection != "" { + headers.Set("X-XSS-Protection", c.SecurityHeaders.XSSProtection) + } + if c.SecurityHeaders.ReferrerPolicy != "" { + headers.Set("Referrer-Policy", c.SecurityHeaders.ReferrerPolicy) + } + if c.SecurityHeaders.ContentSecurityPolicy != "" { + headers.Set("Content-Security-Policy", c.SecurityHeaders.ContentSecurityPolicy) + } + + // HSTS for HTTPS + if (req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https") && c.SecurityHeaders.StrictTransportSecurity { + hstsValue := fmt.Sprintf("max-age=%d", c.SecurityHeaders.StrictTransportSecurityMaxAge) + if c.SecurityHeaders.StrictTransportSecuritySubdomains { + hstsValue += "; includeSubDomains" + } + if c.SecurityHeaders.StrictTransportSecurityPreload { + hstsValue += "; preload" + } + headers.Set("Strict-Transport-Security", hstsValue) + } + + // CORS headers + if c.SecurityHeaders.CORSEnabled { + origin := req.Header.Get("Origin") + if origin != "" && isOriginAllowed(origin, c.SecurityHeaders.CORSAllowedOrigins) { + headers.Set("Access-Control-Allow-Origin", origin) + } + + if len(c.SecurityHeaders.CORSAllowedMethods) > 0 { + headers.Set("Access-Control-Allow-Methods", strings.Join(c.SecurityHeaders.CORSAllowedMethods, ", ")) + } + if len(c.SecurityHeaders.CORSAllowedHeaders) > 0 { + headers.Set("Access-Control-Allow-Headers", strings.Join(c.SecurityHeaders.CORSAllowedHeaders, ", ")) + } + if c.SecurityHeaders.CORSAllowCredentials { + headers.Set("Access-Control-Allow-Credentials", "true") + } + if c.SecurityHeaders.CORSMaxAge > 0 { + headers.Set("Access-Control-Max-Age", strconv.Itoa(c.SecurityHeaders.CORSMaxAge)) + } + } + + // Custom headers + for name, value := range c.SecurityHeaders.CustomHeaders { + headers.Set(name, value) + } + + // Remove server headers + if c.SecurityHeaders.DisableServerHeader { + headers.Del("Server") + } + if c.SecurityHeaders.DisablePoweredByHeader { + headers.Del("X-Powered-By") + } + } +} + +// isOriginAllowed checks if an origin is in the allowed list +func isOriginAllowed(origin string, allowedOrigins []string) bool { + for _, allowed := range allowedOrigins { + if origin == allowed || allowed == "*" { + return true + } + // Simple wildcard matching for subdomains + if strings.Contains(allowed, "*") { + if strings.HasPrefix(allowed, "https://*.") { + domain := strings.TrimPrefix(allowed, "https://*.") + if strings.HasSuffix(origin, "."+domain) || origin == "https://"+domain { + return true + } + } + if strings.HasPrefix(allowed, "http://*.") { + domain := strings.TrimPrefix(allowed, "http://*.") + if strings.HasSuffix(origin, "."+domain) || origin == "http://"+domain { + return true + } + } + } + } + return false +} diff --git a/docs/PROVIDER_CONFIGURATIONS.md b/docs/PROVIDER_CONFIGURATIONS.md new file mode 100644 index 0000000..39ca4f8 --- /dev/null +++ b/docs/PROVIDER_CONFIGURATIONS.md @@ -0,0 +1,770 @@ +# Provider-Specific Configuration Guide + +This guide covers the configuration requirements and best practices for each supported OIDC provider. + +## Table of Contents + +- [Google](#google) +- [Microsoft Azure AD](#microsoft-azure-ad) +- [Auth0](#auth0) +- [GitHub](#github) +- [GitLab](#gitlab) +- [AWS Cognito](#aws-cognito) +- [Keycloak](#keycloak) +- [Okta](#okta) +- [Generic OIDC](#generic-oidc) + +--- + +## Google + +### Provider URL +```yaml +providerUrl: "https://accounts.google.com" +``` + +### Required Configuration +```yaml +clientId: "your-google-client-id.apps.googleusercontent.com" +clientSecret: "your-google-client-secret" +callbackUrl: "https://your-domain.com/auth/callback" +scopes: ["openid", "profile", "email"] +``` + +### Google-Specific Features +- **Automatic offline access**: Google provider automatically adds `access_type=offline` and `prompt=consent` +- **Scope filtering**: Automatically removes `offline_access` scope (not used by Google) +- **Refresh token support**: Fully supported +- **Domain restrictions**: Can restrict by Google Workspace domains + +### Example Configuration +```yaml +# Traefik dynamic configuration +http: + middlewares: + google-oidc: + plugin: + traefik-oidc: + providerUrl: "https://accounts.google.com" + clientId: "123456789-abcdef.apps.googleusercontent.com" + clientSecret: "GOCSPX-your-client-secret" + callbackUrl: "https://app.example.com/auth/callback" + logoutUrl: "https://app.example.com/auth/logout" + scopes: ["openid", "profile", "email"] + allowedUserDomains: ["example.com", "company.org"] + forceHttps: true + enablePkce: true +``` + +### Google OAuth Console Setup +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create or select a project +3. Enable Google+ API +4. Create OAuth 2.0 credentials +5. Add authorized redirect URIs: `https://your-domain.com/auth/callback` + +--- + +## Microsoft Azure AD + +### Provider URL +```yaml +# For Azure AD (single tenant) +providerUrl: "https://login.microsoftonline.com/{tenant-id}/v2.0" + +# For Azure AD (multi-tenant) +providerUrl: "https://login.microsoftonline.com/common/v2.0" +``` + +### Required Configuration +```yaml +clientId: "your-azure-application-id" +clientSecret: "your-azure-client-secret" +callbackUrl: "https://your-domain.com/auth/callback" +scopes: ["openid", "profile", "email", "offline_access"] +``` + +### Azure-Specific Features +- **Response mode**: Automatically adds `response_mode=query` +- **Offline access**: Requires `offline_access` scope for refresh tokens +- **Access token validation**: Supports both JWT and opaque access tokens +- **Tenant isolation**: Can restrict to specific Azure AD tenants + +### Example Configuration +```yaml +http: + middlewares: + azure-oidc: + plugin: + traefik-oidc: + providerUrl: "https://login.microsoftonline.com/common/v2.0" + clientId: "12345678-1234-1234-1234-123456789abc" + clientSecret: "your-azure-client-secret" + callbackUrl: "https://app.example.com/auth/callback" + logoutUrl: "https://app.example.com/auth/logout" + postLogoutRedirectUri: "https://app.example.com" + scopes: ["openid", "profile", "email", "offline_access"] + allowedRolesAndGroups: ["App.Users", "Admin.Group"] + forceHttps: true +``` + +### Azure App Registration Setup +1. Go to [Azure Portal](https://portal.azure.com/) +2. Navigate to "Azure Active Directory" > "App registrations" +3. Create new registration +4. Add redirect URI: `https://your-domain.com/auth/callback` +5. Create client secret in "Certificates & secrets" +6. Configure API permissions for required scopes + +--- + +## Auth0 + +### Provider URL +```yaml +providerUrl: "https://your-domain.auth0.com" +``` + +### Required Configuration +```yaml +clientId: "your-auth0-client-id" +clientSecret: "your-auth0-client-secret" +callbackUrl: "https://your-domain.com/auth/callback" +scopes: ["openid", "profile", "email", "offline_access"] +``` + +### Auth0-Specific Features +- **Custom domains**: Supports Auth0 custom domains +- **Rules and hooks**: Leverages Auth0's extensibility +- **Social connections**: Works with Auth0's social identity providers +- **Offline access**: Requires `offline_access` scope + +### Example Configuration +```yaml +http: + middlewares: + auth0-oidc: + plugin: + traefik-oidc: + providerUrl: "https://company.auth0.com" + clientId: "abcdef123456789" + clientSecret: "your-auth0-client-secret" + callbackUrl: "https://app.example.com/auth/callback" + logoutUrl: "https://app.example.com/auth/logout" + postLogoutRedirectUri: "https://app.example.com" + scopes: ["openid", "profile", "email", "offline_access"] + allowedUsers: ["user@example.com", "admin@company.com"] + forceHttps: true + enablePkce: true +``` + +### Auth0 Application Setup +1. Go to [Auth0 Dashboard](https://manage.auth0.com/) +2. Create new application (Regular Web Application) +3. Configure allowed callback URLs: `https://your-domain.com/auth/callback` +4. Configure allowed logout URLs: `https://your-domain.com/auth/logout` +5. Enable OIDC Conformant in Advanced Settings + +--- + +## GitHub + +### Provider URL +```yaml +providerUrl: "https://github.com" +``` + +### Required Configuration +```yaml +clientId: "your-github-client-id" +clientSecret: "your-github-client-secret" +callbackUrl: "https://your-domain.com/auth/callback" +scopes: ["read:user", "user:email"] +``` + +### GitHub-Specific Features +- **Organization membership**: Can restrict by GitHub organization +- **Team membership**: Can restrict by specific teams +- **Limited OIDC**: GitHub has limited OIDC support +- **Email verification**: Requires verified email addresses + +### Example Configuration +```yaml +http: + middlewares: + github-oidc: + plugin: + traefik-oidc: + providerUrl: "https://github.com" + clientId: "Iv1.abcdef123456" + clientSecret: "your-github-client-secret" + callbackUrl: "https://app.example.com/auth/callback" + logoutUrl: "https://app.example.com/auth/logout" + scopes: ["read:user", "user:email"] + allowedUsers: ["octocat", "github-user"] + forceHttps: true +``` + +### GitHub OAuth App Setup +1. Go to GitHub Settings > Developer settings > OAuth Apps +2. Create new OAuth App +3. Set Authorization callback URL: `https://your-domain.com/auth/callback` +4. Note the Client ID and generate Client Secret + +--- + +## GitLab + +### Provider URL +```yaml +# GitLab.com +providerUrl: "https://gitlab.com" + +# Self-hosted GitLab +providerUrl: "https://gitlab.your-company.com" +``` + +### Required Configuration +```yaml +clientId: "your-gitlab-application-id" +clientSecret: "your-gitlab-application-secret" +callbackUrl: "https://your-domain.com/auth/callback" +scopes: ["openid", "profile", "email"] +``` + +### GitLab-Specific Features +- **Self-hosted support**: Works with self-hosted GitLab instances +- **Group membership**: Can restrict by GitLab groups +- **Project access**: Can validate project permissions +- **Offline access**: Supports refresh tokens with `offline_access` + +### Example Configuration +```yaml +http: + middlewares: + gitlab-oidc: + plugin: + traefik-oidc: + providerUrl: "https://gitlab.com" + clientId: "abcdef123456789" + clientSecret: "your-gitlab-application-secret" + callbackUrl: "https://app.example.com/auth/callback" + logoutUrl: "https://app.example.com/auth/logout" + scopes: ["openid", "profile", "email", "offline_access"] + allowedRolesAndGroups: ["developers", "maintainers"] + forceHttps: true + enablePkce: true +``` + +### GitLab Application Setup +1. Go to GitLab Settings > Applications +2. Create new application +3. Add scopes: `openid`, `profile`, `email` +4. Set redirect URI: `https://your-domain.com/auth/callback` +5. Save and note the Application ID and Secret + +--- + +## AWS Cognito + +### Provider URL +```yaml +providerUrl: "https://cognito-idp.{region}.amazonaws.com/{user-pool-id}" +``` + +### Required Configuration +```yaml +clientId: "your-cognito-app-client-id" +clientSecret: "your-cognito-app-client-secret" # If app client has secret +callbackUrl: "https://your-domain.com/auth/callback" +scopes: ["openid", "profile", "email"] +``` + +### Cognito-Specific Features +- **User pools**: Integrates with Cognito User Pools +- **Custom attributes**: Supports custom user attributes +- **Groups**: Can validate Cognito user group membership +- **Regional endpoints**: Requires region-specific URLs + +### Example Configuration +```yaml +http: + middlewares: + cognito-oidc: + plugin: + traefik-oidc: + providerUrl: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABCDEF123" + clientId: "1234567890abcdefghij" + clientSecret: "your-cognito-client-secret" + callbackUrl: "https://app.example.com/auth/callback" + logoutUrl: "https://app.example.com/auth/logout" + scopes: ["openid", "profile", "email"] + allowedRolesAndGroups: ["admin", "users"] + forceHttps: true +``` + +### AWS Cognito Setup +1. Create Cognito User Pool +2. Create App Client with OIDC scopes +3. Configure App Client settings: + - Callback URLs: `https://your-domain.com/auth/callback` + - Sign out URLs: `https://your-domain.com/auth/logout` + - OAuth flows: Authorization code grant +4. Configure hosted UI domain (optional) + +--- + +## Keycloak + +### Provider URL +```yaml +providerUrl: "https://keycloak.your-company.com/realms/{realm-name}" +``` + +### Required Configuration +```yaml +clientId: "your-keycloak-client-id" +clientSecret: "your-keycloak-client-secret" +callbackUrl: "https://your-domain.com/auth/callback" +scopes: ["openid", "profile", "email"] +``` + +### Keycloak-Specific Features +- **Realm support**: Multi-realm deployments +- **Custom mappers**: Rich claim mapping capabilities +- **Role-based access**: Fine-grained role management +- **Offline access**: Full refresh token support + +### Example Configuration +```yaml +http: + middlewares: + keycloak-oidc: + plugin: + traefik-oidc: + providerUrl: "https://keycloak.company.com/realms/employees" + clientId: "traefik-app" + clientSecret: "your-keycloak-client-secret" + callbackUrl: "https://app.example.com/auth/callback" + logoutUrl: "https://app.example.com/auth/logout" + postLogoutRedirectUri: "https://app.example.com" + scopes: ["openid", "profile", "email", "offline_access"] + allowedRolesAndGroups: ["app-users", "administrators"] + forceHttps: true + enablePkce: true +``` + +### Keycloak Client Setup +1. Access Keycloak Admin Console +2. Select appropriate realm +3. Create new client: + - Client Protocol: openid-connect + - Access Type: confidential + - Valid Redirect URIs: `https://your-domain.com/auth/callback` +4. Configure client scopes and mappers +5. Generate client secret in Credentials tab + +--- + +## Okta + +### Provider URL +```yaml +providerUrl: "https://your-domain.okta.com" +``` + +### Required Configuration +```yaml +clientId: "your-okta-client-id" +clientSecret: "your-okta-client-secret" +callbackUrl: "https://your-domain.com/auth/callback" +scopes: ["openid", "profile", "email", "offline_access"] +``` + +### Okta-Specific Features +- **Custom authorization servers**: Supports custom auth servers +- **Group claims**: Rich group membership information +- **Universal Directory**: Integrates with Okta's user store +- **Offline access**: Requires `offline_access` scope + +### Example Configuration +```yaml +http: + middlewares: + okta-oidc: + plugin: + traefik-oidc: + providerUrl: "https://company.okta.com" + clientId: "0oa123456789abcdef" + clientSecret: "your-okta-client-secret" + callbackUrl: "https://app.example.com/auth/callback" + logoutUrl: "https://app.example.com/auth/logout" + postLogoutRedirectUri: "https://app.example.com" + scopes: ["openid", "profile", "email", "offline_access"] + allowedRolesAndGroups: ["Everyone", "Administrators"] + forceHttps: true + enablePkce: true +``` + +### Okta Application Setup +1. Access Okta Admin Console +2. Go to Applications > Create App Integration +3. Select OIDC - OpenID Connect +4. Choose Web Application +5. Configure: + - Sign-in redirect URIs: `https://your-domain.com/auth/callback` + - Sign-out redirect URIs: `https://your-domain.com/auth/logout` + - Grant types: Authorization Code, Refresh Token +6. Assign users or groups + +--- + +## Generic OIDC + +### Provider URL +```yaml +providerUrl: "https://your-oidc-provider.com" +``` + +### Required Configuration +```yaml +clientId: "your-client-id" +clientSecret: "your-client-secret" +callbackUrl: "https://your-domain.com/auth/callback" +scopes: ["openid", "profile", "email"] +``` + +### Generic Features +- **Standards compliance**: Works with any OIDC-compliant provider +- **Auto-discovery**: Uses `.well-known/openid-configuration` endpoint +- **Flexible scopes**: Supports custom scope requirements +- **Custom claims**: Works with provider-specific claims + +### Example Configuration +```yaml +http: + middlewares: + generic-oidc: + plugin: + traefik-oidc: + providerUrl: "https://oidc.your-provider.com" + clientId: "your-client-id" + clientSecret: "your-client-secret" + callbackUrl: "https://app.example.com/auth/callback" + logoutUrl: "https://app.example.com/auth/logout" + scopes: ["openid", "profile", "email"] + forceHttps: true + enablePkce: true +``` + +--- + +## Common Configuration Options + +### Security Settings +```yaml +# Force HTTPS (recommended for production) +forceHttps: true + +# Enable PKCE (recommended for security) +enablePkce: true + +# Session encryption key (32+ characters) +sessionEncryptionKey: "your-very-long-encryption-key-here" +``` + +### Access Control +```yaml +# Restrict by email addresses +allowedUsers: ["user1@example.com", "user2@example.com"] + +# Restrict by email domains +allowedUserDomains: ["company.com", "partner.org"] + +# Restrict by roles/groups (provider-specific) +allowedRolesAndGroups: ["admin", "users", "developers"] +``` + +### URLs and Endpoints +```yaml +# OAuth callback URL (must match provider config) +callbackUrl: "https://your-domain.com/auth/callback" + +# Logout endpoint +logoutUrl: "https://your-domain.com/auth/logout" + +# Post-logout redirect (optional) +postLogoutRedirectUri: "https://your-domain.com" + +# URLs to exclude from authentication +excludedUrls: ["/health", "/metrics", "/public"] +``` + +### Advanced Settings +```yaml +# Override default scopes +overrideScopes: true +scopes: ["openid", "custom_scope"] + +# Rate limiting (requests per second) +rateLimit: 10 + +# Token refresh grace period (seconds) +refreshGracePeriodSeconds: 60 + +# Cookie domain (for subdomain sharing) +cookieDomain: ".example.com" + +# Custom headers to inject +headers: + - name: "X-User-Email" + value: "{{.Claims.email}}" + - name: "X-User-Name" + value: "{{.Claims.name}}" +``` + +--- + +## Troubleshooting + +### Common Issues + +1. **Invalid redirect URI** + - Ensure callback URL exactly matches provider configuration + - Check for HTTP vs HTTPS mismatches + +2. **Scope errors** + - Verify required scopes are configured in provider + - Some providers require specific scopes for refresh tokens + +3. **Token validation failures** + - Check provider URL format and accessibility + - Verify `.well-known/openid-configuration` endpoint is reachable + +4. **Session issues** + - Ensure session encryption key is properly configured + - Check cookie domain settings for subdomain scenarios + +### Debug Mode +Enable debug logging to troubleshoot configuration issues: +```yaml +logLevel: "debug" +``` + +This will provide detailed logs of the authentication flow and help identify configuration problems. + +--- + +## Security Headers Configuration + +The plugin includes comprehensive security headers support to protect your applications against common web vulnerabilities. + +### Default Security Headers + +By default, the plugin applies these security headers: + +- `X-Frame-Options: DENY` - Prevents clickjacking +- `X-Content-Type-Options: nosniff` - Prevents MIME sniffing +- `X-XSS-Protection: 1; mode=block` - Enables XSS protection +- `Referrer-Policy: strict-origin-when-cross-origin` - Controls referrer information +- `Strict-Transport-Security` - Forces HTTPS (when HTTPS is detected) + +### Security Profiles + +Choose from predefined security profiles or create custom configurations: + +#### Default Profile (Recommended) +```yaml +securityHeaders: + enabled: true + profile: "default" +``` + +#### Strict Profile (Maximum Security) +```yaml +securityHeaders: + enabled: true + profile: "strict" + # Additional strict CSP and cross-origin policies +``` + +#### Development Profile (Local Development) +```yaml +securityHeaders: + enabled: true + profile: "development" + # Relaxed policies for local development +``` + +#### API Profile (API Endpoints) +```yaml +securityHeaders: + enabled: true + profile: "api" + corsEnabled: true + corsAllowedOrigins: ["https://your-frontend.com"] +``` + +### Custom Security Configuration + +For complete control, use the custom profile: + +```yaml +securityHeaders: + enabled: true + profile: "custom" + + # Content Security Policy + contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline'" + + # HSTS Configuration + strictTransportSecurity: true + strictTransportSecurityMaxAge: 31536000 # 1 year + strictTransportSecuritySubdomains: true + strictTransportSecurityPreload: true + + # Frame and content protection + frameOptions: "DENY" # or "SAMEORIGIN", "ALLOW-FROM uri" + contentTypeOptions: "nosniff" + xssProtection: "1; mode=block" + referrerPolicy: "strict-origin-when-cross-origin" + + # Permissions policy (feature policy) + permissionsPolicy: "geolocation=(), microphone=(), camera=()" + + # Cross-origin policies + crossOriginEmbedderPolicy: "require-corp" + crossOriginOpenerPolicy: "same-origin" + crossOriginResourcePolicy: "same-origin" + + # CORS configuration + corsEnabled: true + corsAllowedOrigins: + - "https://app.example.com" + - "https://*.api.example.com" + corsAllowedMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"] + corsAllowedHeaders: ["Authorization", "Content-Type", "X-Requested-With"] + corsAllowCredentials: true + corsMaxAge: 86400 # 24 hours + + # Custom headers + customHeaders: + X-Custom-Header: "custom-value" + X-API-Version: "v1" + + # Server identification + disableServerHeader: true + disablePoweredByHeader: true +``` + +### Complete Example with Security Headers + +Here's a complete configuration example for Google OIDC with custom security headers: + +```yaml +# Traefik dynamic configuration +http: + middlewares: + secure-google-oidc: + plugin: + traefik-oidc: + # OIDC Configuration + providerUrl: "https://accounts.google.com" + clientId: "123456789-abcdef.apps.googleusercontent.com" + clientSecret: "GOCSPX-your-client-secret" + callbackUrl: "https://your-domain.com/auth/callback" + sessionEncryptionKey: "your-32-character-encryption-key-here" + + # Domain restrictions + allowedUserDomains: ["your-company.com"] + + # Security Headers + securityHeaders: + enabled: true + profile: "strict" + corsEnabled: true + corsAllowedOrigins: + - "https://your-frontend.com" + - "https://*.your-domain.com" + corsAllowCredentials: true + customHeaders: + X-Company: "YourCompany" + X-Environment: "production" + + routers: + secure-app: + rule: "Host(`your-domain.com`)" + middlewares: + - secure-google-oidc + service: your-app-service + tls: + certResolver: letsencrypt +``` + +### CORS Configuration Details + +For applications with frontend-backend separation, configure CORS properly: + +#### Simple CORS (Single Origin) +```yaml +securityHeaders: + corsEnabled: true + corsAllowedOrigins: ["https://app.example.com"] + corsAllowCredentials: true +``` + +#### Wildcard Subdomains +```yaml +securityHeaders: + corsEnabled: true + corsAllowedOrigins: ["https://*.example.com"] + corsAllowCredentials: true +``` + +#### Development with Multiple Ports +```yaml +securityHeaders: + profile: "development" + corsEnabled: true + corsAllowedOrigins: + - "http://localhost:*" + - "http://127.0.0.1:*" +``` + +### Security Best Practices + +1. **Always use HTTPS in production** + - Set `forceHttps: true` + - Configure proper TLS certificates + +2. **Implement proper CSP** + - Start with strict policy + - Add exceptions only when necessary + - Test thoroughly + +3. **Configure CORS restrictively** + - Only allow necessary origins + - Use specific domains instead of wildcards when possible + +4. **Enable HSTS** + - Use long max-age values (1 year minimum) + - Include subdomains when appropriate + +5. **Monitor security headers** + - Use browser developer tools to verify headers + - Test with security scanning tools + - Regularly review and update policies + +### Testing Security Headers + +Use browser developer tools or online tools to verify your security headers: + +1. **Browser DevTools**: Check Network tab → Response Headers +2. **Online scanners**: Use securityheaders.com or observatory.mozilla.org +3. **Command line**: Use `curl -I https://your-domain.com` + +Example verification: +```bash +curl -I https://your-domain.com +# Should show security headers in response +``` \ No newline at end of file diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..28461d2 --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,218 @@ +// Package errors provides unified error handling for OIDC operations +package errors + +import ( + "fmt" + "net/http" +) + +// ErrorCode represents specific error types +type ErrorCode string + +const ( + // Authentication errors + ErrCodeAuthenticationFailed ErrorCode = "AUTH_FAILED" + ErrCodeTokenExpired ErrorCode = "TOKEN_EXPIRED" + ErrCodeTokenInvalid ErrorCode = "TOKEN_INVALID" + ErrCodeSessionExpired ErrorCode = "SESSION_EXPIRED" + ErrCodeCSRFMismatch ErrorCode = "CSRF_MISMATCH" + ErrCodeNonceMismatch ErrorCode = "NONCE_MISMATCH" + + // Configuration errors + ErrCodeConfigInvalid ErrorCode = "CONFIG_INVALID" + ErrCodeProviderUnreachable ErrorCode = "PROVIDER_UNREACHABLE" + ErrCodeMetadataFailed ErrorCode = "METADATA_FAILED" + + // Network errors + ErrCodeNetworkTimeout ErrorCode = "NETWORK_TIMEOUT" + ErrCodeRateLimited ErrorCode = "RATE_LIMITED" + ErrCodeServiceUnavailable ErrorCode = "SERVICE_UNAVAILABLE" + + // Validation errors + ErrCodeValidationFailed ErrorCode = "VALIDATION_FAILED" + ErrCodeDomainNotAllowed ErrorCode = "DOMAIN_NOT_ALLOWED" + ErrCodeUserNotAllowed ErrorCode = "USER_NOT_ALLOWED" + ErrCodeRoleNotAllowed ErrorCode = "ROLE_NOT_ALLOWED" +) + +// OIDCError represents a structured error with context +type OIDCError struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` + HTTPStatus int `json:"http_status"` + Internal error `json:"-"` // Internal error, not exposed +} + +// Error implements the error interface +func (e *OIDCError) Error() string { + if e.Details != "" { + return fmt.Sprintf("%s: %s (%s)", e.Code, e.Message, e.Details) + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// Unwrap returns the internal error for error wrapping +func (e *OIDCError) Unwrap() error { + return e.Internal +} + +// IsRetryable indicates if the error is temporary and can be retried +func (e *OIDCError) IsRetryable() bool { + return e.Code == ErrCodeNetworkTimeout || + e.Code == ErrCodeServiceUnavailable || + e.Code == ErrCodeProviderUnreachable +} + +// IsAuthenticationError indicates if this is an authentication-related error +func (e *OIDCError) IsAuthenticationError() bool { + return e.Code == ErrCodeAuthenticationFailed || + e.Code == ErrCodeTokenExpired || + e.Code == ErrCodeTokenInvalid || + e.Code == ErrCodeSessionExpired || + e.Code == ErrCodeCSRFMismatch || + e.Code == ErrCodeNonceMismatch +} + +// IsAuthorizationError indicates if this is an authorization-related error +func (e *OIDCError) IsAuthorizationError() bool { + return e.Code == ErrCodeDomainNotAllowed || + e.Code == ErrCodeUserNotAllowed || + e.Code == ErrCodeRoleNotAllowed +} + +// ToJSON converts the error to a JSON response +func (e *OIDCError) ToJSON() map[string]any { + result := map[string]any{ + "error": map[string]any{ + "code": string(e.Code), + "message": e.Message, + }, + } + + if e.Details != "" { + result["error"].(map[string]any)["details"] = e.Details + } + + return result +} + +// Error constructors for common scenarios + +// NewAuthenticationError creates an authentication-related error +func NewAuthenticationError(code ErrorCode, message string, internal error) *OIDCError { + status := http.StatusUnauthorized + if code == ErrCodeSessionExpired { + status = http.StatusForbidden + } + + return &OIDCError{ + Code: code, + Message: message, + HTTPStatus: status, + Internal: internal, + } +} + +// NewAuthorizationError creates an authorization-related error +func NewAuthorizationError(code ErrorCode, message string, details string) *OIDCError { + return &OIDCError{ + Code: code, + Message: message, + Details: details, + HTTPStatus: http.StatusForbidden, + } +} + +// NewConfigurationError creates a configuration-related error +func NewConfigurationError(code ErrorCode, message string, internal error) *OIDCError { + return &OIDCError{ + Code: code, + Message: message, + HTTPStatus: http.StatusInternalServerError, + Internal: internal, + } +} + +// NewNetworkError creates a network-related error +func NewNetworkError(code ErrorCode, message string, internal error) *OIDCError { + status := http.StatusServiceUnavailable + if code == ErrCodeRateLimited { + status = http.StatusTooManyRequests + } + + return &OIDCError{ + Code: code, + Message: message, + HTTPStatus: status, + Internal: internal, + } +} + +// NewValidationError creates a validation-related error +func NewValidationError(code ErrorCode, message string, details string) *OIDCError { + return &OIDCError{ + Code: code, + Message: message, + Details: details, + HTTPStatus: http.StatusBadRequest, + } +} + +// Convenience functions for common error patterns + +// WrapAuthenticationError wraps an existing error as an authentication error +func WrapAuthenticationError(err error, message string) *OIDCError { + return NewAuthenticationError(ErrCodeAuthenticationFailed, message, err) +} + +// WrapTokenError wraps a token-related error +func WrapTokenError(err error, tokenType string) *OIDCError { + message := fmt.Sprintf("Token validation failed: %s", tokenType) + return NewAuthenticationError(ErrCodeTokenInvalid, message, err) +} + +// WrapProviderError wraps a provider communication error +func WrapProviderError(err error, providerURL string) *OIDCError { + message := fmt.Sprintf("Provider communication failed: %s", providerURL) + return NewNetworkError(ErrCodeProviderUnreachable, message, err) +} + +// IsOIDCError checks if an error is an OIDCError +func IsOIDCError(err error) (*OIDCError, bool) { + oidcErr, ok := err.(*OIDCError) + return oidcErr, ok +} + +// GetHTTPStatus extracts HTTP status from error, defaulting to 500 +func GetHTTPStatus(err error) int { + if oidcErr, ok := IsOIDCError(err); ok { + return oidcErr.HTTPStatus + } + return http.StatusInternalServerError +} + +// FormatUserMessage creates a user-friendly error message +func FormatUserMessage(err error) string { + if oidcErr, ok := IsOIDCError(err); ok { + switch oidcErr.Code { + case ErrCodeDomainNotAllowed: + return "Your email domain is not authorized for this application" + case ErrCodeUserNotAllowed: + return "Your account is not authorized for this application" + case ErrCodeRoleNotAllowed: + return "You do not have the required permissions for this application" + case ErrCodeSessionExpired: + return "Your session has expired. Please log in again" + case ErrCodeTokenExpired: + return "Your authentication has expired. Please log in again" + case ErrCodeProviderUnreachable: + return "Authentication service is temporarily unavailable. Please try again later" + case ErrCodeRateLimited: + return "Too many requests. Please wait a moment and try again" + default: + return "Authentication failed. Please try again" + } + } + return "An unexpected error occurred. Please try again" +} diff --git a/internal/handlers/auth_flow.go b/internal/handlers/auth_flow.go new file mode 100644 index 0000000..7f05967 --- /dev/null +++ b/internal/handlers/auth_flow.go @@ -0,0 +1,224 @@ +// Package handlers provides authentication flow management +package handlers + +import ( + "net/http" + "time" +) + +// AuthFlowHandler manages the complete OIDC authentication flow +type AuthFlowHandler struct { + sessionHandler *SessionHandler + tokenHandler TokenHandler + logger Logger + excludedURLs map[string]struct{} + initComplete chan struct{} + issuerURL string +} + +// TokenHandler interface for token operations +type TokenHandler interface { + VerifyToken(token string) error + RefreshToken(refreshToken string) (*TokenResponse, error) +} + +// TokenResponse represents token exchange response +type TokenResponse struct { + IDToken string `json:"id_token"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +// AuthFlowResult represents the result of authentication flow processing +type AuthFlowResult struct { + Authenticated bool + RequiresAuth bool + RequiresRefresh bool + Error error + RedirectURL string + StatusCode int +} + +// NewAuthFlowHandler creates a new authentication flow handler +func NewAuthFlowHandler(sessionHandler *SessionHandler, tokenHandler TokenHandler, logger Logger, excludedURLs map[string]struct{}, initComplete chan struct{}, issuerURL string) *AuthFlowHandler { + return &AuthFlowHandler{ + sessionHandler: sessionHandler, + tokenHandler: tokenHandler, + logger: logger, + excludedURLs: excludedURLs, + initComplete: initComplete, + issuerURL: issuerURL, + } +} + +// ProcessRequest handles the main authentication flow +func (h *AuthFlowHandler) ProcessRequest(rw http.ResponseWriter, req *http.Request) AuthFlowResult { + // Check if URL should be excluded + if h.shouldExcludeURL(req.URL.Path) { + h.logger.Debugf("Request path %s excluded by configuration, bypassing OIDC", req.URL.Path) + return AuthFlowResult{Authenticated: true} + } + + // Check for streaming requests + if h.isStreamingRequest(req) { + h.logger.Debugf("Streaming request detected, bypassing OIDC") + return AuthFlowResult{Authenticated: true} + } + + // Wait for initialization + if !h.waitForInitialization(req) { + return AuthFlowResult{ + Error: ErrInitializationTimeout, + StatusCode: http.StatusServiceUnavailable, + } + } + + // Get and validate session + session, err := h.sessionHandler.sessionManager.GetSession(req) + if err != nil { + h.logger.Errorf("Error getting session: %v", err) + return AuthFlowResult{ + RequiresAuth: true, + Error: err, + } + } + defer session.ReturnToPoolSafely() + + // Clean up old cookies + h.sessionHandler.sessionManager.CleanupOldCookies(rw, req) + + // Validate session + validationResult := h.sessionHandler.ValidateSession(session) + if !validationResult.Valid { + if validationResult.NeedsAuth { + return AuthFlowResult{RequiresAuth: true} + } + return AuthFlowResult{ + Error: ErrSessionInvalid, + StatusCode: http.StatusUnauthorized, + } + } + + // Check token validity and refresh if needed + return h.validateAndRefreshTokens(session, req, rw) +} + +// shouldExcludeURL checks if a URL should bypass authentication +func (h *AuthFlowHandler) shouldExcludeURL(path string) bool { + for excludedURL := range h.excludedURLs { + if len(path) >= len(excludedURL) && path[:len(excludedURL)] == excludedURL { + return true + } + } + return false +} + +// isStreamingRequest checks if request is a streaming request that should bypass auth +func (h *AuthFlowHandler) isStreamingRequest(req *http.Request) bool { + acceptHeader := req.Header.Get("Accept") + return acceptHeader == "text/event-stream" +} + +// waitForInitialization waits for OIDC provider initialization +func (h *AuthFlowHandler) waitForInitialization(req *http.Request) bool { + select { + case <-h.initComplete: + if h.issuerURL == "" { + h.logger.Error("OIDC provider metadata initialization failed") + return false + } + return true + case <-req.Context().Done(): + h.logger.Debug("Request cancelled while waiting for OIDC initialization") + return false + case <-time.After(30 * time.Second): + h.logger.Error("Timeout waiting for OIDC initialization") + return false + } +} + +// validateAndRefreshTokens handles token validation and refresh logic +func (h *AuthFlowHandler) validateAndRefreshTokens(session Session, req *http.Request, rw http.ResponseWriter) AuthFlowResult { + // Check access token if present + if accessToken := session.GetAccessToken(); accessToken != "" { + if err := h.tokenHandler.VerifyToken(accessToken); err != nil { + h.logger.Errorf("Access token validation failed: %v", err) + + // Try refresh if refresh token is available + if refreshToken := session.GetRefreshToken(); refreshToken != "" { + return h.attemptTokenRefresh(session, req, rw) + } + + return AuthFlowResult{RequiresAuth: true} + } + } + + // Check ID token + if idToken := session.GetIDToken(); idToken != "" { + if err := h.tokenHandler.VerifyToken(idToken); err != nil { + h.logger.Errorf("ID token validation failed: %v", err) + + // Try refresh if refresh token is available + if refreshToken := session.GetRefreshToken(); refreshToken != "" { + return h.attemptTokenRefresh(session, req, rw) + } + + return AuthFlowResult{RequiresAuth: true} + } + } + + return AuthFlowResult{Authenticated: true} +} + +// attemptTokenRefresh tries to refresh tokens +func (h *AuthFlowHandler) attemptTokenRefresh(session Session, req *http.Request, rw http.ResponseWriter) AuthFlowResult { + refreshToken := session.GetRefreshToken() + if refreshToken == "" { + return AuthFlowResult{RequiresAuth: true} + } + + // Check if this is an AJAX request + if h.sessionHandler.IsAjaxRequest(req) { + return AuthFlowResult{ + Error: ErrSessionExpiredAjax, + StatusCode: http.StatusUnauthorized, + } + } + + _, err := h.tokenHandler.RefreshToken(refreshToken) + if err != nil { + h.logger.Errorf("Token refresh failed: %v", err) + return AuthFlowResult{RequiresAuth: true} + } + + // Update session with new tokens would be handled here + // Implementation depends on the actual session interface + + if err := session.Save(req, rw); err != nil { + h.logger.Errorf("Failed to save refreshed session: %v", err) + return AuthFlowResult{ + Error: err, + StatusCode: http.StatusInternalServerError, + } + } + + return AuthFlowResult{Authenticated: true} +} + +// Common errors +var ( + ErrInitializationTimeout = &AuthFlowError{Code: "INIT_TIMEOUT", Message: "OIDC initialization timeout"} + ErrSessionInvalid = &AuthFlowError{Code: "SESSION_INVALID", Message: "Invalid session"} + ErrSessionExpiredAjax = &AuthFlowError{Code: "SESSION_EXPIRED_AJAX", Message: "Session expired for AJAX request"} +) + +// AuthFlowError represents authentication flow errors +type AuthFlowError struct { + Code string + Message string +} + +func (e *AuthFlowError) Error() string { + return e.Message +} diff --git a/internal/optimization/memory_pool.go b/internal/optimization/memory_pool.go new file mode 100644 index 0000000..818a544 --- /dev/null +++ b/internal/optimization/memory_pool.go @@ -0,0 +1,95 @@ +// Package optimization provides memory and performance optimizations +package optimization + +import ( + "sync" +) + +// StringBuilderPool provides a pool of reusable string builders +type StringBuilderPool struct { + pool sync.Pool +} + +// NewStringBuilderPool creates a new string builder pool +func NewStringBuilderPool() *StringBuilderPool { + return &StringBuilderPool{ + pool: sync.Pool{ + New: func() any { + buf := make([]byte, 0, 256) // Pre-allocate 256 bytes + return &buf + }, + }, + } +} + +// Get retrieves a string builder from the pool +func (p *StringBuilderPool) Get() []byte { + bufPtr := p.pool.Get().(*[]byte) + return *bufPtr +} + +// Put returns a string builder to the pool +func (p *StringBuilderPool) Put(buf []byte) { + if cap(buf) < 4096 { // Don't pool overly large buffers + buf = buf[:0] // Reset length but keep capacity + p.pool.Put(&buf) + } +} + +// ByteSlicePool provides a pool of reusable byte slices +type ByteSlicePool struct { + pool sync.Pool + size int +} + +// NewByteSlicePool creates a new byte slice pool with specified size +func NewByteSlicePool(size int) *ByteSlicePool { + return &ByteSlicePool{ + size: size, + pool: sync.Pool{ + New: func() any { + buf := make([]byte, size) + return &buf + }, + }, + } +} + +// Get retrieves a byte slice from the pool +func (p *ByteSlicePool) Get() []byte { + bufPtr := p.pool.Get().(*[]byte) + return *bufPtr +} + +// Put returns a byte slice to the pool +func (p *ByteSlicePool) Put(buf []byte) { + if len(buf) == p.size { + p.pool.Put(&buf) + } +} + +// Global pools for common use cases +var ( + globalStringBuilderPool = NewStringBuilderPool() + globalByteSlicePool = NewByteSlicePool(2048) +) + +// GetStringBuilder gets a string builder from the global pool +func GetStringBuilder() []byte { + return globalStringBuilderPool.Get() +} + +// PutStringBuilder returns a string builder to the global pool +func PutStringBuilder(buf []byte) { + globalStringBuilderPool.Put(buf) +} + +// GetByteSlice gets a byte slice from the global pool +func GetByteSlice() []byte { + return globalByteSlicePool.Get() +} + +// PutByteSlice returns a byte slice to the global pool +func PutByteSlice(buf []byte) { + globalByteSlicePool.Put(buf) +} diff --git a/internal/patterns/regex_cache.go b/internal/patterns/regex_cache.go new file mode 100644 index 0000000..65cd85c --- /dev/null +++ b/internal/patterns/regex_cache.go @@ -0,0 +1,309 @@ +// Package patterns provides cached compiled regex patterns for performance optimization +package patterns + +import ( + "regexp" + "sync" +) + +// RegexCache manages compiled regex patterns with thread-safe access +type RegexCache struct { + patterns map[string]*regexp.Regexp + mu sync.RWMutex +} + +// NewRegexCache creates a new regex cache instance +func NewRegexCache() *RegexCache { + return &RegexCache{ + patterns: make(map[string]*regexp.Regexp), + } +} + +// Get retrieves a compiled regex pattern, compiling and caching it if not present +func (c *RegexCache) Get(pattern string) (*regexp.Regexp, error) { + // First try read lock for existing pattern + c.mu.RLock() + if regex, exists := c.patterns[pattern]; exists { + c.mu.RUnlock() + return regex, nil + } + c.mu.RUnlock() + + // Pattern not found, acquire write lock to compile and cache + c.mu.Lock() + defer c.mu.Unlock() + + // Double-check in case another goroutine compiled it while we waited + if regex, exists := c.patterns[pattern]; exists { + return regex, nil + } + + // Compile the pattern + regex, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + + // Cache the compiled pattern + c.patterns[pattern] = regex + return regex, nil +} + +// MustGet is like Get but panics if the pattern cannot be compiled +func (c *RegexCache) MustGet(pattern string) *regexp.Regexp { + regex, err := c.Get(pattern) + if err != nil { + panic("regex compilation failed for pattern '" + pattern + "': " + err.Error()) + } + return regex +} + +// Precompile compiles and caches multiple patterns at once +func (c *RegexCache) Precompile(patterns []string) error { + c.mu.Lock() + defer c.mu.Unlock() + + for _, pattern := range patterns { + if _, exists := c.patterns[pattern]; !exists { + regex, err := regexp.Compile(pattern) + if err != nil { + return err + } + c.patterns[pattern] = regex + } + } + return nil +} + +// Size returns the number of cached patterns +func (c *RegexCache) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.patterns) +} + +// Clear removes all cached patterns +func (c *RegexCache) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.patterns = make(map[string]*regexp.Regexp) +} + +// Global regex cache instance +var globalCache = NewRegexCache() + +// Common regex patterns used throughout the OIDC implementation +const ( + // Email validation pattern (RFC 5322 compliant) + EmailPattern = `^[a-zA-Z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$` + + // Domain validation pattern + DomainPattern = `^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$` + + // URL validation pattern (http/https) + URLPattern = `^https?://[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*(/.*)?$` + + // JWT token pattern (three base64url parts separated by dots) + JWTPattern = `^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$` + + // Bearer token pattern (Authorization header) + BearerTokenPattern = `^Bearer\s+([A-Za-z0-9._~+/-]+=*)$` + + // Client ID pattern (alphanumeric with common separators) + ClientIDPattern = `^[a-zA-Z0-9._-]+$` + + // Scope pattern (space-separated alphanumeric with underscores) + ScopePattern = `^[a-zA-Z0-9_]+(\s+[a-zA-Z0-9_]+)*$` + + // Session ID pattern (hexadecimal) + SessionIDPattern = `^[a-fA-F0-9]{32,128}$` + + // CSRF token pattern (base64url) + CSRFTokenPattern = `^[A-Za-z0-9_-]+$` + + // Nonce pattern (base64url) + NoncePattern = `^[A-Za-z0-9_-]+$` + + // Code verifier pattern for PKCE (base64url, 43-128 chars) + CodeVerifierPattern = `^[A-Za-z0-9_-]{43,128}$` + + // Authorization code pattern (base64url) + AuthCodePattern = `^[A-Za-z0-9._~+/-]+=*$` + + // Redirect URI validation (must be absolute HTTP/HTTPS URL) + RedirectURIPattern = `^https?://[^\s/$.?#].[^\s]*$` + + // User-Agent pattern for bot detection + BotUserAgentPattern = `(?i)(bot|crawler|spider|scraper|curl|wget|python|java|go-http)` + + // IP address pattern (IPv4) + IPv4Pattern = `^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$` + + // Tenant ID pattern (UUID format for Azure, etc.) + TenantIDPattern = `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$` +) + +// Precompiled common patterns for immediate use +var ( + EmailRegex *regexp.Regexp + DomainRegex *regexp.Regexp + URLRegex *regexp.Regexp + JWTRegex *regexp.Regexp + BearerTokenRegex *regexp.Regexp + ClientIDRegex *regexp.Regexp + ScopeRegex *regexp.Regexp + SessionIDRegex *regexp.Regexp + CSRFTokenRegex *regexp.Regexp + NonceRegex *regexp.Regexp + CodeVerifierRegex *regexp.Regexp + AuthCodeRegex *regexp.Regexp + RedirectURIRegex *regexp.Regexp + BotUserAgentRegex *regexp.Regexp + IPv4Regex *regexp.Regexp + TenantIDRegex *regexp.Regexp +) + +// Initialize precompiled patterns +func init() { + commonPatterns := []string{ + EmailPattern, + DomainPattern, + URLPattern, + JWTPattern, + BearerTokenPattern, + ClientIDPattern, + ScopePattern, + SessionIDPattern, + CSRFTokenPattern, + NoncePattern, + CodeVerifierPattern, + AuthCodePattern, + RedirectURIPattern, + BotUserAgentPattern, + IPv4Pattern, + TenantIDPattern, + } + + if err := globalCache.Precompile(commonPatterns); err != nil { + panic("Failed to precompile common regex patterns: " + err.Error()) + } + + // Assign precompiled patterns to global variables for easy access + EmailRegex = globalCache.MustGet(EmailPattern) + DomainRegex = globalCache.MustGet(DomainPattern) + URLRegex = globalCache.MustGet(URLPattern) + JWTRegex = globalCache.MustGet(JWTPattern) + BearerTokenRegex = globalCache.MustGet(BearerTokenPattern) + ClientIDRegex = globalCache.MustGet(ClientIDPattern) + ScopeRegex = globalCache.MustGet(ScopePattern) + SessionIDRegex = globalCache.MustGet(SessionIDPattern) + CSRFTokenRegex = globalCache.MustGet(CSRFTokenPattern) + NonceRegex = globalCache.MustGet(NoncePattern) + CodeVerifierRegex = globalCache.MustGet(CodeVerifierPattern) + AuthCodeRegex = globalCache.MustGet(AuthCodePattern) + RedirectURIRegex = globalCache.MustGet(RedirectURIPattern) + BotUserAgentRegex = globalCache.MustGet(BotUserAgentPattern) + IPv4Regex = globalCache.MustGet(IPv4Pattern) + TenantIDRegex = globalCache.MustGet(TenantIDPattern) +} + +// Global helper functions for common validations + +// ValidateEmail checks if an email address is valid +func ValidateEmail(email string) bool { + return EmailRegex.MatchString(email) +} + +// ValidateDomain checks if a domain name is valid +func ValidateDomain(domain string) bool { + return DomainRegex.MatchString(domain) +} + +// ValidateURL checks if a URL is valid (http/https) +func ValidateURL(url string) bool { + return URLRegex.MatchString(url) +} + +// ValidateJWT checks if a token has valid JWT format +func ValidateJWT(token string) bool { + return JWTRegex.MatchString(token) +} + +// ExtractBearerToken extracts the token from a Bearer authorization header +func ExtractBearerToken(authHeader string) (string, bool) { + matches := BearerTokenRegex.FindStringSubmatch(authHeader) + if len(matches) == 2 { + return matches[1], true + } + return "", false +} + +// ValidateClientID checks if a client ID has valid format +func ValidateClientID(clientID string) bool { + return ClientIDRegex.MatchString(clientID) +} + +// ValidateScopes checks if scopes string has valid format +func ValidateScopes(scopes string) bool { + return ScopeRegex.MatchString(scopes) +} + +// ValidateSessionID checks if a session ID has valid format +func ValidateSessionID(sessionID string) bool { + return SessionIDRegex.MatchString(sessionID) +} + +// ValidateCSRFToken checks if a CSRF token has valid format +func ValidateCSRFToken(token string) bool { + return CSRFTokenRegex.MatchString(token) +} + +// ValidateNonce checks if a nonce has valid format +func ValidateNonce(nonce string) bool { + return NonceRegex.MatchString(nonce) +} + +// ValidateCodeVerifier checks if a PKCE code verifier has valid format +func ValidateCodeVerifier(verifier string) bool { + return CodeVerifierRegex.MatchString(verifier) +} + +// ValidateAuthCode checks if an authorization code has valid format +func ValidateAuthCode(code string) bool { + return AuthCodeRegex.MatchString(code) +} + +// ValidateRedirectURI checks if a redirect URI is valid +func ValidateRedirectURI(uri string) bool { + return RedirectURIRegex.MatchString(uri) +} + +// IsBotUserAgent checks if a User-Agent suggests an automated client +func IsBotUserAgent(userAgent string) bool { + return BotUserAgentRegex.MatchString(userAgent) +} + +// ValidateIPv4 checks if an IP address is valid IPv4 +func ValidateIPv4(ip string) bool { + return IPv4Regex.MatchString(ip) +} + +// ValidateTenantID checks if a tenant ID has valid UUID format +func ValidateTenantID(tenantID string) bool { + return TenantIDRegex.MatchString(tenantID) +} + +// GetGlobalCache returns the global regex cache instance +func GetGlobalCache() *RegexCache { + return globalCache +} + +// CompilePattern compiles a pattern using the global cache +func CompilePattern(pattern string) (*regexp.Regexp, error) { + return globalCache.Get(pattern) +} + +// MustCompilePattern compiles a pattern using the global cache, panicking on error +func MustCompilePattern(pattern string) *regexp.Regexp { + return globalCache.MustGet(pattern) +} diff --git a/internal/patterns/regex_cache_test.go b/internal/patterns/regex_cache_test.go new file mode 100644 index 0000000..da789c3 --- /dev/null +++ b/internal/patterns/regex_cache_test.go @@ -0,0 +1,225 @@ +package patterns + +import ( + "regexp" + "sync" + "testing" +) + +func TestRegexCache_Get(t *testing.T) { + cache := NewRegexCache() + + pattern := `^test\d+$` + + // First call should compile and cache + regex1, err := cache.Get(pattern) + if err != nil { + t.Fatalf("Failed to get regex: %v", err) + } + + // Second call should return cached version + regex2, err := cache.Get(pattern) + if err != nil { + t.Fatalf("Failed to get cached regex: %v", err) + } + + // Should be the same instance + if regex1 != regex2 { + t.Error("Expected same regex instance from cache") + } + + // Test the regex works + if !regex1.MatchString("test123") { + t.Error("Regex should match 'test123'") + } + + if regex1.MatchString("test") { + t.Error("Regex should not match 'test'") + } +} + +func TestRegexCache_ConcurrentAccess(t *testing.T) { + cache := NewRegexCache() + pattern := `^concurrent\d+$` + + var wg sync.WaitGroup + results := make([]*regexp.Regexp, 10) + errors := make([]error, 10) + + // Launch multiple goroutines to access the same pattern + for i := 0; i < 10; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + regex, err := cache.Get(pattern) + results[index] = regex + errors[index] = err + }(i) + } + + wg.Wait() + + // Check all succeeded + for i, err := range errors { + if err != nil { + t.Fatalf("Goroutine %d failed: %v", i, err) + } + } + + // All should return the same instance + first := results[0] + for i, regex := range results[1:] { + if regex != first { + t.Errorf("Goroutine %d got different regex instance", i+1) + } + } +} + +func TestRegexCache_InvalidPattern(t *testing.T) { + cache := NewRegexCache() + + _, err := cache.Get(`[invalid`) + if err == nil { + t.Error("Expected error for invalid regex pattern") + } +} + +func TestRegexCache_Precompile(t *testing.T) { + cache := NewRegexCache() + + patterns := []string{ + `^test1$`, + `^test2$`, + `^test3$`, + } + + err := cache.Precompile(patterns) + if err != nil { + t.Fatalf("Failed to precompile patterns: %v", err) + } + + if cache.Size() != 3 { + t.Errorf("Expected cache size 3, got %d", cache.Size()) + } + + // Should be able to get precompiled patterns without error + for _, pattern := range patterns { + _, err := cache.Get(pattern) + if err != nil { + t.Errorf("Failed to get precompiled pattern %s: %v", pattern, err) + } + } +} + +func TestValidationFunctions(t *testing.T) { + tests := []struct { + name string + function func(string) bool + valid []string + invalid []string + }{ + { + name: "ValidateEmail", + function: ValidateEmail, + valid: []string{"test@example.com", "user.name@domain.org", "admin+tag@company.co.uk"}, + invalid: []string{"invalid-email", "@domain.com", "user@", ""}, + }, + { + name: "ValidateDomain", + function: ValidateDomain, + valid: []string{"example.com", "sub.domain.org", "test.co.uk"}, + invalid: []string{"", "invalid..domain", ".example.com", "domain."}, + }, + { + name: "ValidateJWT", + function: ValidateJWT, + valid: []string{"eyJ0.eyJ1.sig", "a.b.c"}, + invalid: []string{"invalid", "a.b", "a.b.c.d", ""}, + }, + { + name: "ValidateClientID", + function: ValidateClientID, + valid: []string{"client123", "my-client_id", "123.456"}, + invalid: []string{"", "client with spaces", "client@invalid"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, valid := range tt.valid { + if !tt.function(valid) { + t.Errorf("%s should be valid: %s", tt.name, valid) + } + } + + for _, invalid := range tt.invalid { + if tt.function(invalid) { + t.Errorf("%s should be invalid: %s", tt.name, invalid) + } + } + }) + } +} + +func TestExtractBearerToken(t *testing.T) { + tests := []struct { + header string + expected string + valid bool + }{ + {"Bearer abc123", "abc123", true}, + {"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9", true}, + {"bearer token123", "", false}, // case sensitive + {"Basic abc123", "", false}, + {"Bearer", "", false}, + {"", "", false}, + } + + for _, tt := range tests { + token, valid := ExtractBearerToken(tt.header) + if valid != tt.valid { + t.Errorf("ExtractBearerToken(%q) valid = %v, want %v", tt.header, valid, tt.valid) + } + if token != tt.expected { + t.Errorf("ExtractBearerToken(%q) token = %q, want %q", tt.header, token, tt.expected) + } + } +} + +func BenchmarkRegexCache_Get(b *testing.B) { + cache := NewRegexCache() + pattern := `^benchmark\d+$` + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _, err := cache.Get(pattern) + if err != nil { + b.Fatal(err) + } + } + }) +} + +func BenchmarkRegexCache_Validation(b *testing.B) { + email := "test@example.com" + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + ValidateEmail(email) + } + }) +} + +func BenchmarkRegex_DirectCompile(b *testing.B) { + pattern := `^benchmark\d+$` + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := regexp.Compile(pattern) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/security/headers.go b/internal/security/headers.go new file mode 100644 index 0000000..e717db8 --- /dev/null +++ b/internal/security/headers.go @@ -0,0 +1,403 @@ +// Package security provides security-related middleware and utilities +package security + +import ( + "net/http" + "strings" + "time" +) + +// SecurityHeadersConfig configures security headers +type SecurityHeadersConfig struct { + // Content Security Policy + ContentSecurityPolicy string + + // HSTS settings + StrictTransportSecurity string + StrictTransportSecurityMaxAge int // seconds + StrictTransportSecuritySubdomains bool + StrictTransportSecurityPreload bool + + // Frame options + FrameOptions string // DENY, SAMEORIGIN, or ALLOW-FROM uri + + // Content type options + ContentTypeOptions string // nosniff + + // XSS protection + XSSProtection string // 1; mode=block + + // Referrer policy + ReferrerPolicy string + + // Permissions policy + PermissionsPolicy string + + // Cross-origin settings + CrossOriginEmbedderPolicy string + CrossOriginOpenerPolicy string + CrossOriginResourcePolicy string + + // CORS settings + CORSEnabled bool + CORSAllowedOrigins []string + CORSAllowedMethods []string + CORSAllowedHeaders []string + CORSAllowCredentials bool + CORSMaxAge int // seconds + + // Custom headers + CustomHeaders map[string]string + + // Security features + DisableServerHeader bool + DisablePoweredByHeader bool + + // Development mode (less strict for local development) + DevelopmentMode bool +} + +// DefaultSecurityConfig returns a secure default configuration +func DefaultSecurityConfig() *SecurityHeadersConfig { + return &SecurityHeadersConfig{ + ContentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';", + + StrictTransportSecurityMaxAge: 31536000, // 1 year + StrictTransportSecuritySubdomains: true, + StrictTransportSecurityPreload: true, + + FrameOptions: "DENY", + ContentTypeOptions: "nosniff", + XSSProtection: "1; mode=block", + ReferrerPolicy: "strict-origin-when-cross-origin", + + PermissionsPolicy: "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), speaker=()", + + CrossOriginEmbedderPolicy: "require-corp", + CrossOriginOpenerPolicy: "same-origin", + CrossOriginResourcePolicy: "same-origin", + + CORSEnabled: false, + CORSAllowedMethods: []string{"GET", "POST", "OPTIONS"}, + CORSAllowedHeaders: []string{"Authorization", "Content-Type", "X-Requested-With"}, + CORSMaxAge: 86400, // 24 hours + + DisableServerHeader: true, + DisablePoweredByHeader: true, + + DevelopmentMode: false, + } +} + +// DevelopmentSecurityConfig returns a configuration suitable for development +func DevelopmentSecurityConfig() *SecurityHeadersConfig { + config := DefaultSecurityConfig() + + // Relax CSP for development + config.ContentSecurityPolicy = "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data: https: http:; connect-src 'self' ws: wss:;" + + // Allow framing for development tools + config.FrameOptions = "SAMEORIGIN" + + // Enable CORS for local development + config.CORSEnabled = true + config.CORSAllowedOrigins = []string{"http://localhost:*", "http://127.0.0.1:*"} + config.CORSAllowCredentials = true + + // Relax cross-origin policies + config.CrossOriginEmbedderPolicy = "" + config.CrossOriginOpenerPolicy = "unsafe-none" + config.CrossOriginResourcePolicy = "cross-origin" + + config.DevelopmentMode = true + + return config +} + +// SecurityHeadersMiddleware applies security headers to HTTP responses +type SecurityHeadersMiddleware struct { + config *SecurityHeadersConfig +} + +// NewSecurityHeadersMiddleware creates a new security headers middleware +func NewSecurityHeadersMiddleware(config *SecurityHeadersConfig) *SecurityHeadersMiddleware { + if config == nil { + config = DefaultSecurityConfig() + } + + return &SecurityHeadersMiddleware{ + config: config, + } +} + +// Apply applies security headers to the response +func (m *SecurityHeadersMiddleware) Apply(rw http.ResponseWriter, req *http.Request) { + headers := rw.Header() + + // Content Security Policy + if m.config.ContentSecurityPolicy != "" { + headers.Set("Content-Security-Policy", m.config.ContentSecurityPolicy) + } + + // HSTS (only for HTTPS) + if req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https" { + hstsValue := m.buildHSTSHeader() + if hstsValue != "" { + headers.Set("Strict-Transport-Security", hstsValue) + } + } + + // Frame options + if m.config.FrameOptions != "" { + headers.Set("X-Frame-Options", m.config.FrameOptions) + } + + // Content type options + if m.config.ContentTypeOptions != "" { + headers.Set("X-Content-Type-Options", m.config.ContentTypeOptions) + } + + // XSS protection + if m.config.XSSProtection != "" { + headers.Set("X-XSS-Protection", m.config.XSSProtection) + } + + // Referrer policy + if m.config.ReferrerPolicy != "" { + headers.Set("Referrer-Policy", m.config.ReferrerPolicy) + } + + // Permissions policy + if m.config.PermissionsPolicy != "" { + headers.Set("Permissions-Policy", m.config.PermissionsPolicy) + } + + // Cross-origin policies + if m.config.CrossOriginEmbedderPolicy != "" { + headers.Set("Cross-Origin-Embedder-Policy", m.config.CrossOriginEmbedderPolicy) + } + + if m.config.CrossOriginOpenerPolicy != "" { + headers.Set("Cross-Origin-Opener-Policy", m.config.CrossOriginOpenerPolicy) + } + + if m.config.CrossOriginResourcePolicy != "" { + headers.Set("Cross-Origin-Resource-Policy", m.config.CrossOriginResourcePolicy) + } + + // CORS headers + if m.config.CORSEnabled { + m.applyCORSHeaders(rw, req) + } + + // Custom headers + for name, value := range m.config.CustomHeaders { + headers.Set(name, value) + } + + // Remove server identification headers + if m.config.DisableServerHeader { + headers.Del("Server") + } + + if m.config.DisablePoweredByHeader { + headers.Del("X-Powered-By") + } + + // Add security timestamp for debugging + if m.config.DevelopmentMode { + headers.Set("X-Security-Headers-Applied", time.Now().UTC().Format(time.RFC3339)) + } +} + +// buildHSTSHeader constructs the HSTS header value +func (m *SecurityHeadersMiddleware) buildHSTSHeader() string { + if m.config.StrictTransportSecurityMaxAge <= 0 { + return "" + } + + parts := []string{ + "max-age=" + string(rune(m.config.StrictTransportSecurityMaxAge)), + } + + if m.config.StrictTransportSecuritySubdomains { + parts = append(parts, "includeSubDomains") + } + + if m.config.StrictTransportSecurityPreload { + parts = append(parts, "preload") + } + + return strings.Join(parts, "; ") +} + +// applyCORSHeaders applies CORS headers based on the request +func (m *SecurityHeadersMiddleware) applyCORSHeaders(rw http.ResponseWriter, req *http.Request) { + headers := rw.Header() + origin := req.Header.Get("Origin") + + // Check if origin is allowed + if origin != "" && m.isOriginAllowed(origin) { + headers.Set("Access-Control-Allow-Origin", origin) + } else if len(m.config.CORSAllowedOrigins) == 1 && m.config.CORSAllowedOrigins[0] == "*" { + headers.Set("Access-Control-Allow-Origin", "*") + } + + // Set other CORS headers + if len(m.config.CORSAllowedMethods) > 0 { + headers.Set("Access-Control-Allow-Methods", strings.Join(m.config.CORSAllowedMethods, ", ")) + } + + if len(m.config.CORSAllowedHeaders) > 0 { + headers.Set("Access-Control-Allow-Headers", strings.Join(m.config.CORSAllowedHeaders, ", ")) + } + + if m.config.CORSAllowCredentials { + headers.Set("Access-Control-Allow-Credentials", "true") + } + + if m.config.CORSMaxAge > 0 { + headers.Set("Access-Control-Max-Age", string(rune(m.config.CORSMaxAge))) + } + + // Handle preflight requests + if req.Method == "OPTIONS" { + headers.Set("Access-Control-Allow-Methods", strings.Join(m.config.CORSAllowedMethods, ", ")) + headers.Set("Access-Control-Allow-Headers", strings.Join(m.config.CORSAllowedHeaders, ", ")) + rw.WriteHeader(http.StatusOK) + } +} + +// isOriginAllowed checks if the origin is in the allowed list +func (m *SecurityHeadersMiddleware) isOriginAllowed(origin string) bool { + for _, allowed := range m.config.CORSAllowedOrigins { + if m.matchOrigin(origin, allowed) { + return true + } + } + return false +} + +// matchOrigin checks if an origin matches an allowed pattern +func (m *SecurityHeadersMiddleware) matchOrigin(origin, pattern string) bool { + // Exact match + if origin == pattern { + return true + } + + // Wildcard subdomain match (e.g., "https://*.example.com") + if strings.Contains(pattern, "*") { + // Simple wildcard matching for subdomains + if strings.HasPrefix(pattern, "https://*.") { + domain := strings.TrimPrefix(pattern, "https://*.") + if strings.HasSuffix(origin, "."+domain) || origin == "https://"+domain { + return true + } + } + if strings.HasPrefix(pattern, "http://*.") { + domain := strings.TrimPrefix(pattern, "http://*.") + if strings.HasSuffix(origin, "."+domain) || origin == "http://"+domain { + return true + } + } + } + + // Port wildcard match (e.g., "http://localhost:*") + if strings.HasSuffix(pattern, ":*") { + prefix := strings.TrimSuffix(pattern, ":*") + if strings.HasPrefix(origin, prefix+":") { + return true + } + } + + return false +} + +// Wrap wraps an HTTP handler with security headers +func (m *SecurityHeadersMiddleware) Wrap(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + m.Apply(rw, req) + next.ServeHTTP(rw, req) + }) +} + +// SecurityHeadersHandler is a convenience function that creates and applies security headers +func SecurityHeadersHandler(config *SecurityHeadersConfig) func(http.ResponseWriter, *http.Request) { + middleware := NewSecurityHeadersMiddleware(config) + return middleware.Apply +} + +// Common security header presets + +// StrictSecurityConfig returns a very strict security configuration +func StrictSecurityConfig() *SecurityHeadersConfig { + config := DefaultSecurityConfig() + + // Very strict CSP + config.ContentSecurityPolicy = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" + + // Stricter frame options + config.FrameOptions = "DENY" + + // Disable CORS entirely + config.CORSEnabled = false + + // Very strict cross-origin policies + config.CrossOriginEmbedderPolicy = "require-corp" + config.CrossOriginOpenerPolicy = "same-origin" + config.CrossOriginResourcePolicy = "same-site" + + return config +} + +// APISecurityConfig returns a configuration suitable for APIs +func APISecurityConfig() *SecurityHeadersConfig { + config := DefaultSecurityConfig() + + // API-friendly CSP + config.ContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none';" + + // Enable CORS for APIs + config.CORSEnabled = true + config.CORSAllowedMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"} + config.CORSAllowedHeaders = []string{"Authorization", "Content-Type", "X-Requested-With", "X-API-Key"} + + // API-appropriate policies + config.CrossOriginResourcePolicy = "cross-origin" + + return config +} + +// ValidateConfig validates the security configuration +func (c *SecurityHeadersConfig) Validate() error { + // Validate HSTS max age + if c.StrictTransportSecurityMaxAge < 0 { + c.StrictTransportSecurityMaxAge = 0 + } + + // Validate CORS max age + if c.CORSMaxAge < 0 { + c.CORSMaxAge = 0 + } + + // Validate frame options + validFrameOptions := []string{"DENY", "SAMEORIGIN", ""} + isValidFrameOption := false + for _, valid := range validFrameOptions { + if c.FrameOptions == valid || strings.HasPrefix(c.FrameOptions, "ALLOW-FROM ") { + isValidFrameOption = true + break + } + } + if !isValidFrameOption { + c.FrameOptions = "DENY" + } + + return nil +} + +// ApplyToResponseWriter is a helper function to quickly apply security headers +func ApplySecurityHeaders(rw http.ResponseWriter, req *http.Request, config *SecurityHeadersConfig) { + middleware := NewSecurityHeadersMiddleware(config) + middleware.Apply(rw, req) +} diff --git a/internal/security/headers_test.go b/internal/security/headers_test.go new file mode 100644 index 0000000..b3752b9 --- /dev/null +++ b/internal/security/headers_test.go @@ -0,0 +1,350 @@ +package security + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDefaultSecurityConfig(t *testing.T) { + config := DefaultSecurityConfig() + + if config.ContentSecurityPolicy == "" { + t.Error("Expected default CSP to be set") + } + + if config.FrameOptions != "DENY" { + t.Errorf("Expected frame options to be DENY, got %s", config.FrameOptions) + } + + if !config.DisableServerHeader { + t.Error("Expected server header to be disabled by default") + } +} + +func TestSecurityHeadersMiddleware_Apply(t *testing.T) { + config := DefaultSecurityConfig() + middleware := NewSecurityHeadersMiddleware(config) + + // Create a mock request (HTTPS) + req := httptest.NewRequest("GET", "https://example.com/test", nil) + req.TLS = &tls.ConnectionState{} // Mock TLS + + // Create a response recorder + rr := httptest.NewRecorder() + + // Apply security headers + middleware.Apply(rr, req) + + headers := rr.Header() + + // Check that security headers are set + if headers.Get("Content-Security-Policy") == "" { + t.Error("Expected CSP header to be set") + } + + if headers.Get("X-Frame-Options") != "DENY" { + t.Errorf("Expected X-Frame-Options to be DENY, got %s", headers.Get("X-Frame-Options")) + } + + if headers.Get("X-Content-Type-Options") != "nosniff" { + t.Errorf("Expected X-Content-Type-Options to be nosniff, got %s", headers.Get("X-Content-Type-Options")) + } + + if headers.Get("X-XSS-Protection") != "1; mode=block" { + t.Errorf("Expected X-XSS-Protection to be '1; mode=block', got %s", headers.Get("X-XSS-Protection")) + } + + // Check HSTS for HTTPS requests + hsts := headers.Get("Strict-Transport-Security") + if hsts == "" { + t.Error("Expected HSTS header for HTTPS request") + } + + if !strings.Contains(hsts, "max-age=") { + t.Error("Expected HSTS header to contain max-age") + } +} + +func TestSecurityHeadersMiddleware_HTTPSOnly(t *testing.T) { + config := DefaultSecurityConfig() + middleware := NewSecurityHeadersMiddleware(config) + + // Test HTTP request (no HSTS) + req := httptest.NewRequest("GET", "http://example.com/test", nil) + rr := httptest.NewRecorder() + + middleware.Apply(rr, req) + + if rr.Header().Get("Strict-Transport-Security") != "" { + t.Error("Expected no HSTS header for HTTP request") + } + + // Test HTTPS request (with HSTS) + req = httptest.NewRequest("GET", "https://example.com/test", nil) + req.TLS = &tls.ConnectionState{} + rr = httptest.NewRecorder() + + middleware.Apply(rr, req) + + if rr.Header().Get("Strict-Transport-Security") == "" { + t.Error("Expected HSTS header for HTTPS request") + } +} + +func TestCORSHeaders(t *testing.T) { + config := DefaultSecurityConfig() + config.CORSEnabled = true + config.CORSAllowedOrigins = []string{"https://example.com", "https://*.test.com"} + config.CORSAllowCredentials = true + + middleware := NewSecurityHeadersMiddleware(config) + + tests := []struct { + name string + origin string + expectedOrigin string + }{ + { + name: "exact match", + origin: "https://example.com", + expectedOrigin: "https://example.com", + }, + { + name: "wildcard subdomain match", + origin: "https://api.test.com", + expectedOrigin: "https://api.test.com", + }, + { + name: "no match", + origin: "https://malicious.com", + expectedOrigin: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "https://example.com/test", nil) + if tt.origin != "" { + req.Header.Set("Origin", tt.origin) + } + + rr := httptest.NewRecorder() + middleware.Apply(rr, req) + + actualOrigin := rr.Header().Get("Access-Control-Allow-Origin") + if actualOrigin != tt.expectedOrigin { + t.Errorf("Expected origin %s, got %s", tt.expectedOrigin, actualOrigin) + } + + if tt.expectedOrigin != "" { + // Should have credentials header + if rr.Header().Get("Access-Control-Allow-Credentials") != "true" { + t.Error("Expected credentials header for allowed origin") + } + } + }) + } +} + +func TestCORSPreflight(t *testing.T) { + config := DefaultSecurityConfig() + config.CORSEnabled = true + config.CORSAllowedOrigins = []string{"*"} + config.CORSAllowedMethods = []string{"GET", "POST", "OPTIONS"} + + middleware := NewSecurityHeadersMiddleware(config) + + req := httptest.NewRequest("OPTIONS", "https://example.com/test", nil) + req.Header.Set("Origin", "https://other.com") + + rr := httptest.NewRecorder() + middleware.Apply(rr, req) + + if rr.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Error("Expected wildcard origin for preflight request") + } + + if rr.Header().Get("Access-Control-Allow-Methods") == "" { + t.Error("Expected methods header for preflight request") + } + + if rr.Code != http.StatusOK { + t.Errorf("Expected status 200 for preflight, got %d", rr.Code) + } +} + +func TestOriginMatching(t *testing.T) { + config := &SecurityHeadersConfig{ + CORSEnabled: true, + CORSAllowedOrigins: []string{ + "https://example.com", + "https://*.example.com", + "http://localhost:*", + }, + } + + middleware := NewSecurityHeadersMiddleware(config) + + tests := []struct { + origin string + expected bool + }{ + {"https://example.com", true}, + {"https://api.example.com", true}, + {"https://sub.api.example.com", true}, + {"http://localhost:3000", true}, + {"http://localhost:8080", true}, + {"https://malicious.com", false}, + {"http://example.com", false}, // Different scheme + {"https://example.com.evil.com", false}, // Domain suffix attack + } + + for _, tt := range tests { + t.Run(tt.origin, func(t *testing.T) { + result := middleware.isOriginAllowed(tt.origin) + if result != tt.expected { + t.Errorf("Origin %s: expected %v, got %v", tt.origin, tt.expected, result) + } + }) + } +} + +func TestDevelopmentMode(t *testing.T) { + config := DevelopmentSecurityConfig() + + if !config.DevelopmentMode { + t.Error("Expected development mode to be enabled") + } + + if !config.CORSEnabled { + t.Error("Expected CORS to be enabled in development mode") + } + + if config.FrameOptions != "SAMEORIGIN" { + t.Errorf("Expected frame options to be SAMEORIGIN in dev mode, got %s", config.FrameOptions) + } + + // Should be less strict CSP + if strings.Contains(config.ContentSecurityPolicy, "'none'") { + t.Error("Expected less strict CSP in development mode") + } +} + +func TestStrictSecurityConfig(t *testing.T) { + config := StrictSecurityConfig() + + if !strings.Contains(config.ContentSecurityPolicy, "'none'") { + t.Error("Expected very strict CSP with 'none' defaults") + } + + if config.CORSEnabled { + t.Error("Expected CORS to be disabled in strict mode") + } + + if config.FrameOptions != "DENY" { + t.Error("Expected frame options to be DENY in strict mode") + } +} + +func TestAPISecurityConfig(t *testing.T) { + config := APISecurityConfig() + + if !config.CORSEnabled { + t.Error("Expected CORS to be enabled for API config") + } + + methods := config.CORSAllowedMethods + expectedMethods := []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"} + + for _, method := range expectedMethods { + found := false + for _, allowed := range methods { + if allowed == method { + found = true + break + } + } + if !found { + t.Errorf("Expected method %s to be allowed in API config", method) + } + } +} + +func TestMiddlewareWrap(t *testing.T) { + config := DefaultSecurityConfig() + middleware := NewSecurityHeadersMiddleware(config) + + // Create a simple handler + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + // Wrap with security middleware + wrappedHandler := middleware.Wrap(handler) + + req := httptest.NewRequest("GET", "https://example.com/test", nil) + req.TLS = &tls.ConnectionState{} + rr := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(rr, req) + + // Check response + if rr.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rr.Code) + } + + if rr.Body.String() != "OK" { + t.Errorf("Expected body 'OK', got %s", rr.Body.String()) + } + + // Check security headers were applied + if rr.Header().Get("X-Frame-Options") == "" { + t.Error("Expected security headers to be applied by wrapper") + } +} + +func TestConfigValidation(t *testing.T) { + config := &SecurityHeadersConfig{ + StrictTransportSecurityMaxAge: -1, + CORSMaxAge: -1, + FrameOptions: "INVALID", + } + + err := config.Validate() + if err != nil { + t.Errorf("Unexpected validation error: %v", err) + } + + // Should fix invalid values + if config.StrictTransportSecurityMaxAge != 0 { + t.Error("Expected negative HSTS max age to be reset to 0") + } + + if config.CORSMaxAge != 0 { + t.Error("Expected negative CORS max age to be reset to 0") + } + + if config.FrameOptions != "DENY" { + t.Error("Expected invalid frame options to be reset to DENY") + } +} + +func BenchmarkSecurityHeadersApply(b *testing.B) { + config := DefaultSecurityConfig() + middleware := NewSecurityHeadersMiddleware(config) + + req := httptest.NewRequest("GET", "https://example.com/test", nil) + req.TLS = &tls.ConnectionState{} + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + rr := httptest.NewRecorder() + middleware.Apply(rr, req) + } + }) +} diff --git a/main.go b/main.go index 257ed1c..7d223db 100644 --- a/main.go +++ b/main.go @@ -404,6 +404,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name ctx: pluginCtx, cancelFunc: cancelFunc, suppressDiagnosticLogs: isTestMode(), + securityHeadersApplier: config.GetSecurityHeadersApplier(), } t.sessionManager, _ = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, config.CookieDomain, t.logger) @@ -940,22 +941,15 @@ func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http t.logger.Debug("Session not dirty, skipping save in processAuthorizedRequest") } - rw.Header().Set("X-Frame-Options", "DENY") - rw.Header().Set("X-Content-Type-Options", "nosniff") - rw.Header().Set("X-XSS-Protection", "1; mode=block") - rw.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - - origin := req.Header.Get("Origin") - if origin != "" { - rw.Header().Set("Access-Control-Allow-Origin", origin) - rw.Header().Set("Access-Control-Allow-Credentials", "true") - rw.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - rw.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") - - if req.Method == "OPTIONS" { - rw.WriteHeader(http.StatusOK) - return - } + // Apply security headers if configured + if t.securityHeadersApplier != nil { + t.securityHeadersApplier(rw, req) + } else { + // Fallback to basic security headers + rw.Header().Set("X-Frame-Options", "DENY") + rw.Header().Set("X-Content-Type-Options", "nosniff") + rw.Header().Set("X-XSS-Protection", "1; mode=block") + rw.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") } t.logger.Debugf("Request authorized for user %s, forwarding to next handler", email) diff --git a/settings.go b/settings.go index 1fc3997..78b7fd4 100644 --- a/settings.go +++ b/settings.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "os" + "strconv" "strings" ) @@ -26,29 +27,83 @@ type TemplatedHeader struct { // It provides all necessary settings to configure OpenID Connect authentication // with various providers like Auth0, Logto, or any standard OIDC provider. type Config struct { - HTTPClient *http.Client `json:"-"` - OIDCEndSessionURL string `json:"oidcEndSessionURL"` - CookieDomain string `json:"cookieDomain"` - CallbackURL string `json:"callbackURL"` - LogoutURL string `json:"logoutURL"` - ClientID string `json:"clientID"` - ClientSecret string `json:"clientSecret"` - PostLogoutRedirectURI string `json:"postLogoutRedirectURI"` - LogLevel string `json:"logLevel"` - SessionEncryptionKey string `json:"sessionEncryptionKey"` - ProviderURL string `json:"providerURL"` - RevocationURL string `json:"revocationURL"` - ExcludedURLs []string `json:"excludedURLs"` - AllowedUserDomains []string `json:"allowedUserDomains"` - AllowedUsers []string `json:"allowedUsers"` - Scopes []string `json:"scopes"` - Headers []TemplatedHeader `json:"headers"` - AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"` - RateLimit int `json:"rateLimit"` - RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"` - ForceHTTPS bool `json:"forceHTTPS"` - EnablePKCE bool `json:"enablePKCE"` - OverrideScopes bool `json:"overrideScopes"` + HTTPClient *http.Client `json:"-"` + OIDCEndSessionURL string `json:"oidcEndSessionURL"` + CookieDomain string `json:"cookieDomain"` + CallbackURL string `json:"callbackURL"` + LogoutURL string `json:"logoutURL"` + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + PostLogoutRedirectURI string `json:"postLogoutRedirectURI"` + LogLevel string `json:"logLevel"` + SessionEncryptionKey string `json:"sessionEncryptionKey"` + ProviderURL string `json:"providerURL"` + RevocationURL string `json:"revocationURL"` + ExcludedURLs []string `json:"excludedURLs"` + AllowedUserDomains []string `json:"allowedUserDomains"` + AllowedUsers []string `json:"allowedUsers"` + Scopes []string `json:"scopes"` + Headers []TemplatedHeader `json:"headers"` + AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"` + RateLimit int `json:"rateLimit"` + RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"` + ForceHTTPS bool `json:"forceHTTPS"` + EnablePKCE bool `json:"enablePKCE"` + OverrideScopes bool `json:"overrideScopes"` + SecurityHeaders *SecurityHeadersConfig `json:"securityHeaders,omitempty"` +} + +// SecurityHeadersConfig configures security headers for the plugin +type SecurityHeadersConfig struct { + // Enable security headers (default: true) + Enabled bool `json:"enabled"` + + // Security profile: "default", "strict", "development", "api", or "custom" + Profile string `json:"profile"` + + // Content Security Policy + ContentSecurityPolicy string `json:"contentSecurityPolicy,omitempty"` + + // HSTS settings + StrictTransportSecurity bool `json:"strictTransportSecurity"` + StrictTransportSecurityMaxAge int `json:"strictTransportSecurityMaxAge"` // seconds + StrictTransportSecuritySubdomains bool `json:"strictTransportSecuritySubdomains"` + StrictTransportSecurityPreload bool `json:"strictTransportSecurityPreload"` + + // Frame options: "DENY", "SAMEORIGIN", or "ALLOW-FROM uri" + FrameOptions string `json:"frameOptions,omitempty"` + + // Content type options (default: "nosniff") + ContentTypeOptions string `json:"contentTypeOptions,omitempty"` + + // XSS protection (default: "1; mode=block") + XSSProtection string `json:"xssProtection,omitempty"` + + // Referrer policy + ReferrerPolicy string `json:"referrerPolicy,omitempty"` + + // Permissions policy + PermissionsPolicy string `json:"permissionsPolicy,omitempty"` + + // Cross-origin settings + CrossOriginEmbedderPolicy string `json:"crossOriginEmbedderPolicy,omitempty"` + CrossOriginOpenerPolicy string `json:"crossOriginOpenerPolicy,omitempty"` + CrossOriginResourcePolicy string `json:"crossOriginResourcePolicy,omitempty"` + + // CORS settings + CORSEnabled bool `json:"corsEnabled"` + CORSAllowedOrigins []string `json:"corsAllowedOrigins,omitempty"` + CORSAllowedMethods []string `json:"corsAllowedMethods,omitempty"` + CORSAllowedHeaders []string `json:"corsAllowedHeaders,omitempty"` + CORSAllowCredentials bool `json:"corsAllowCredentials"` + CORSMaxAge int `json:"corsMaxAge"` // seconds + + // Custom headers (in addition to standard security headers) + CustomHeaders map[string]string `json:"customHeaders,omitempty"` + + // Security features + DisableServerHeader bool `json:"disableServerHeader"` + DisablePoweredByHeader bool `json:"disablePoweredByHeader"` } const ( @@ -91,11 +146,42 @@ func CreateConfig() *Config { EnablePKCE: false, // PKCE is opt-in OverrideScopes: false, // Default to appending scopes, not overriding RefreshGracePeriodSeconds: 60, // Default grace period of 60 seconds + SecurityHeaders: createDefaultSecurityConfig(), } return c } +// createDefaultSecurityConfig creates a default security headers configuration +func createDefaultSecurityConfig() *SecurityHeadersConfig { + return &SecurityHeadersConfig{ + Enabled: true, + Profile: "default", + + // Default security headers + StrictTransportSecurity: true, + StrictTransportSecurityMaxAge: 31536000, // 1 year + StrictTransportSecuritySubdomains: true, + StrictTransportSecurityPreload: true, + + FrameOptions: "DENY", + ContentTypeOptions: "nosniff", + XSSProtection: "1; mode=block", + ReferrerPolicy: "strict-origin-when-cross-origin", + + // CORS disabled by default + CORSEnabled: false, + CORSAllowedMethods: []string{"GET", "POST", "OPTIONS"}, + CORSAllowedHeaders: []string{"Authorization", "Content-Type"}, + CORSAllowCredentials: false, + CORSMaxAge: 86400, // 24 hours + + // Security features + DisableServerHeader: true, + DisablePoweredByHeader: true, + } +} + // Validate checks the configuration settings for validity. // It ensures that required fields (ProviderURL, CallbackURL, ClientID, ClientSecret, SessionEncryptionKey) // are present and that URLs are well-formed (HTTPS where required). It also validates @@ -580,3 +666,102 @@ func handleError(w http.ResponseWriter, message string, code int, logger *Logger logger.Error("%s", message) http.Error(w, message, code) } + +// GetSecurityHeadersApplier returns a function that applies security headers +func (c *Config) GetSecurityHeadersApplier() func(http.ResponseWriter, *http.Request) { + if c.SecurityHeaders == nil || !c.SecurityHeaders.Enabled { + return nil + } + + return func(rw http.ResponseWriter, req *http.Request) { + headers := rw.Header() + + // Apply basic security headers based on configuration + if c.SecurityHeaders.FrameOptions != "" { + headers.Set("X-Frame-Options", c.SecurityHeaders.FrameOptions) + } + if c.SecurityHeaders.ContentTypeOptions != "" { + headers.Set("X-Content-Type-Options", c.SecurityHeaders.ContentTypeOptions) + } + if c.SecurityHeaders.XSSProtection != "" { + headers.Set("X-XSS-Protection", c.SecurityHeaders.XSSProtection) + } + if c.SecurityHeaders.ReferrerPolicy != "" { + headers.Set("Referrer-Policy", c.SecurityHeaders.ReferrerPolicy) + } + if c.SecurityHeaders.ContentSecurityPolicy != "" { + headers.Set("Content-Security-Policy", c.SecurityHeaders.ContentSecurityPolicy) + } + + // HSTS for HTTPS + if (req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https") && c.SecurityHeaders.StrictTransportSecurity { + hstsValue := fmt.Sprintf("max-age=%d", c.SecurityHeaders.StrictTransportSecurityMaxAge) + if c.SecurityHeaders.StrictTransportSecuritySubdomains { + hstsValue += "; includeSubDomains" + } + if c.SecurityHeaders.StrictTransportSecurityPreload { + hstsValue += "; preload" + } + headers.Set("Strict-Transport-Security", hstsValue) + } + + // CORS headers + if c.SecurityHeaders.CORSEnabled { + origin := req.Header.Get("Origin") + if origin != "" && isOriginAllowed(origin, c.SecurityHeaders.CORSAllowedOrigins) { + headers.Set("Access-Control-Allow-Origin", origin) + } + + if len(c.SecurityHeaders.CORSAllowedMethods) > 0 { + headers.Set("Access-Control-Allow-Methods", strings.Join(c.SecurityHeaders.CORSAllowedMethods, ", ")) + } + if len(c.SecurityHeaders.CORSAllowedHeaders) > 0 { + headers.Set("Access-Control-Allow-Headers", strings.Join(c.SecurityHeaders.CORSAllowedHeaders, ", ")) + } + if c.SecurityHeaders.CORSAllowCredentials { + headers.Set("Access-Control-Allow-Credentials", "true") + } + if c.SecurityHeaders.CORSMaxAge > 0 { + headers.Set("Access-Control-Max-Age", strconv.Itoa(c.SecurityHeaders.CORSMaxAge)) + } + } + + // Custom headers + for name, value := range c.SecurityHeaders.CustomHeaders { + headers.Set(name, value) + } + + // Remove server headers + if c.SecurityHeaders.DisableServerHeader { + headers.Del("Server") + } + if c.SecurityHeaders.DisablePoweredByHeader { + headers.Del("X-Powered-By") + } + } +} + +// isOriginAllowed checks if an origin is in the allowed list +func isOriginAllowed(origin string, allowedOrigins []string) bool { + for _, allowed := range allowedOrigins { + if origin == allowed || allowed == "*" { + return true + } + // Simple wildcard matching for subdomains + if strings.Contains(allowed, "*") { + if strings.HasPrefix(allowed, "https://*.") { + domain := strings.TrimPrefix(allowed, "https://*.") + if strings.HasSuffix(origin, "."+domain) || origin == "https://"+domain { + return true + } + } + if strings.HasPrefix(allowed, "http://*.") { + domain := strings.TrimPrefix(allowed, "http://*.") + if strings.HasSuffix(origin, "."+domain) || origin == "http://"+domain { + return true + } + } + } + } + return false +} diff --git a/types.go b/types.go index a17633b..e74987e 100644 --- a/types.go +++ b/types.go @@ -13,15 +13,15 @@ import ( // CacheInterface defines the common cache operations type CacheInterface interface { - Set(key string, value interface{}, ttl time.Duration) - Get(key string) (interface{}, bool) + Set(key string, value any, ttl time.Duration) + Get(key string) (any, bool) Delete(key string) SetMaxSize(size int) Size() int Clear() Cleanup() Close() - GetStats() map[string]interface{} // For testing and monitoring + GetStats() map[string]any // For testing and monitoring } // TokenVerifier interface defines token verification capabilities. @@ -75,7 +75,7 @@ type TraefikOidc struct { sessionManager *SessionManager tokenCleanupStopChan chan struct{} excludedURLs map[string]struct{} - extractClaimsFunc func(tokenString string) (map[string]interface{}, error) + extractClaimsFunc func(tokenString string) (map[string]any, error) initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) metadataCache *MetadataCache allowedRolesAndGroups map[string]struct{} @@ -114,4 +114,5 @@ type TraefikOidc struct { suppressDiagnosticLogs bool firstRequestReceived bool metadataRefreshStarted bool + securityHeadersApplier func(http.ResponseWriter, *http.Request) }