Compare commits

...

19 Commits

Author SHA1 Message Date
lukaszraczylo 4d28fa01ab perf(jwk,cache): cache parsed public keys + RLock token cache reads
Hot-path JWT verification rebuilt the public key on every call:
  jwk -> ToRSAPublicKey -> x509.MarshalPKIXPublicKey -> pem.Encode
  -> verifySignature -> pem.Decode -> x509.ParsePKIXPublicKey -> verify
Under yaegi this pinned a CPU when many concurrent dashboard panels
poll behind the middleware. The PEM round trip is pure waste.

* jwk.go: cache pre-parsed crypto.PublicKey per kid alongside the
  raw JWKSet (parallel cache entry, same 1h TTL, invalidates together).
* jwt.go: split verifySignatureWithKey from verifySignature; existing
  PEM-input entry point preserved for backchannel-logout callers.
* token_manager.go: VerifyJWTSignatureAndClaims now goes straight from
  jwks cache to verifySignatureWithKey, no PEM round trip and no
  per-request availableKids slice.
* universal_cache.go: token/JWK/session Get() takes RLock when the
  entry is unexpired, so concurrent token verifications no longer
  serialize on a single mutex. LRU semantics for general and metadata
  caches are unchanged (tests cover the strict-LRU contract there).
* mocks: MockJWKCache, EnhancedMockJWKCache, mockJWKCacheForLogout,
  staticJWKCache satisfy the extended interface.
2026-04-30 10:14:10 +01:00
lukaszraczylo 2d1b04c637 review fixes apr 2026 (#130)
* Multiple fixes

- refresh coordinator dedup + memory pressure wire
- middleware sse consolidation + timer leak + claim cache
- universal cache sync backfill + isDebug gate
- lazy background task race
- memory monitor stw cached + refresh() api

* fix(auth): suppress OIDC redirects on non-navigation requests

- [x] Add isNonNavigationRequest using Sec-Fetch-Mode and Accept headers
- [x] Add comprehensive TestIsNonNavigationRequest
- [x] Update ServeHTTP to 401 non-navigation and AJAX requests

Fixes #129

* feat(config): add custom CA and insecure skip verify for OIDC TLS

- [x] Add CACertPath, CACertPEM, InsecureSkipVerify to Config
- [x] Implement loadCACertPool for CA bundle loading
- [x] Update HTTPClientConfig with RootCAs and InsecureSkipVerify
- [x] Apply CA pool and skip verify to pooled HTTP clients
- [x] Enhance configKey to distinguish TLS configs
- [x] Add comprehensive ca_cert_test.go

Fixes #125

* feat(oidc): add custom CA certificate support for private OIDC providers

- [x] Add caCertPath, caCertPEM, insecureSkipVerify config options
- [x] Update traefik.yml with new OIDC client config fields
- [x] Add configuration schema descriptions for new options
- [x] Update README table and add Custom CA Certificates section

* Fix the documentation.

* test(redis): add oversized argument rejection test

- [x] Add TestRedisConn_RejectOversizedArgumentBytes
- [x] Import strings package

* Dependencies cleanup
2026-04-19 10:12:00 +01:00
lukaszraczylo ccbb98b9dd fix-issue-122 (#128) 2026-03-04 00:23:30 +00:00
Serhii Vasyliev 1362cc0dac Improve debug logging around callback URL matching (#126)
* Add debug logging around callback URL matching in ServeHTTP

The callback URL comparison at the core of OIDC flow had zero logging,
making it extremely difficult to diagnose redirect loop issues caused
by misconfigured callbackURL (e.g., full URL vs path-only).

Every other path comparison in ServeHTTP already logs debug info
(logout, backchannel, frontchannel, excluded URLs), but the callback
URL check was completely silent.

Added debug logs that show:
- The values being compared (request path vs configured callback)
- Whether the match succeeded or failed
- Configured redirURLPath during initialization

This would have immediately revealed the root cause of issue #1
where callbackURL was set as a full URL but compared against
req.URL.Path which only contains the path component.

Closes #3

* improve-callback-url-logging: Add init-time logging for callbackURL config
2026-02-23 10:36:37 +00:00
Yuval Bar-On 249dcad1b3 fix: prevent deadlock in SessionData.Clear method (#114)
Move mutex unlock before calling Save() to prevent potential deadlock
when Save() method needs to acquire the same mutex.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-16 15:02:33 +00:00
lukaszraczylo de4b4d7258 fix(cache): remove sync.Pool for Yaegi compatibility (#121)
- [x] Remove sync.Pool implementation that causes reflection panics
- [x] Replace pool-based NewRESPWriter with direct instantiation
- [x] Replace pool-based NewRESPReader with direct instantiation
- [x] Convert Release() methods to no-ops for API compatibility
- [x] Add documentation explaining sync.Pool removal for Yaegi
- [x] Remove "sync" import

Resolves #120
2026-01-19 17:52:31 +00:00
lukaszraczylo 9d52f1b018 feat(core): refactor linters config and improve code quality (#119)
- [x] Reorganize golangci-lint configuration with documented disable reasons
- [x] Simplify errcheck and revive linter rules with targeted exclusions
- [x] Pre-compile regex patterns in input_validation.go for performance
- [x] Fix type assertions in memory_shard.go and resp.go with safety checks
- [x] Replace string comparison with EqualFold for case-insensitive matching
- [x] Fix loop variable captures in jwk.go and logout.go
- [x] Change high goroutine log level from Info to Debug in autocleanup.go
- [x] Replace deprecated "cancelled" spelling with "canceled" throughout
- [x] Add nolint annotations for intentional unused parameters
- [x] Improve comment formatting for deprecated functions
- [x] Fix comment spelling: "marshalling" → "marshaling"
- [x] Refactor provider warnings formatting in internal/providers/warnings.go
- [x] Simplify metrics summary building in internal/recovery/metrics.go
- [x] Pre-allocate slice in error_recovery.go GetDegradedServices
- [x] Refactor context cancellation checks in redis.go
2026-01-15 10:40:49 +00:00
lukaszraczylo 57724918fe fix 116 (#118)
* Fix cache serialisation

* fix(cache): add integer overflow protection for serialization

- [x] Add maxCacheEntrySize constant (64 MiB) to prevent memory overflow
- [x] Validate byte slice size before adding marker byte
- [x] Validate JSON-serialized data size before marker addition
- [x] Add comprehensive overflow protection test cases

* docs: add security fix documentation for integer overflow protection

* test: fix goroutine tests to use mock OIDC servers

The TestContextAwareGoroutineManagement tests were making real HTTP
calls to hardcoded URLs like https://example.com, causing failures
in CI when those requests timeout or return HTTP errors.

Changes:
- Added createMockOIDCServer() helper function using httptest
- Updated GoroutineCleanupOnContextCancel to use mock server
- Updated NoGoroutineLeakOnMultipleInstances to use 3 mock servers
- Updated SingletonTasksAcrossInstances to use mock servers array

This prevents network calls and makes tests more reliable and faster.

Fixes test failures in GitHub Actions CI.
2026-01-08 22:50:46 +00:00
lukaszraczylo 775de2ada1 Fix cache serialisation (#117)
* Fix cache serialisation

* fix(cache): add integer overflow protection for serialization

- [x] Add maxCacheEntrySize constant (64 MiB) to prevent memory overflow
- [x] Validate byte slice size before adding marker byte
- [x] Validate JSON-serialized data size before marker addition
- [x] Add comprehensive overflow protection test cases
2026-01-08 22:06:19 +00:00
lukaszraczylo 7816e05c98 fix issue with logout url (#112)
* fix(logout): handle logout requests before OIDC initialization

- [x] Add debug logging to logout handler entry point
- [x] Move logout path check before OIDC initialization to enable logout when provider unavailable
- [x] Move excluded URL and SSE checks before initialization wait
- [x] Add debug logging for initialization wait to diagnose hanging requests
- [x] Add test for logout functionality without OIDC provider availability

* feat(logout): implement OIDC backchannel and front-channel logout

- [x] Add logout token validation and backchannel logout handler
- [x] Add front-channel logout handler with iframe support
- [x] Implement session invalidation cache for distributed deployments
- [x] Add comprehensive logout token claim verification (issuer, audience, events, iat, sid/sub)
- [x] Integrate session invalidation checks into authorization flow
- [x] Add configuration options for enabling backchannel/front-channel logout
- [x] Add extensive test coverage for logout flows and edge cases
- [x] Update documentation with logout configuration examples
- [x] Add middleware routing for logout endpoints
- [x] Extend cache manager with session invalidation cache support

Resolves #110

* fixup! feat(logout): implement OIDC backchannel and front-channel logout

* fixup! Merge branch 'main' into fix-issue-with-logout-url
2026-01-04 01:59:50 +00:00
Dominik Chilla 8bf7998150 Fix for Hashicorp Vault - accept opaque access tokens with dot-characters (#113) 2026-01-02 16:42:22 +00:00
muffn_ 22c4323fcb fix: set X-Forwarded-User header for SSE requests from existing session (#111)
Co-authored-by: muffin <MonsterMuffin@users.noreply.github.com>
2026-01-02 02:50:11 +00:00
lukaszraczylo 06b219d1f8 feat(dcr): Add Redis storage support for multi-replica deployments (#109)
- [x] Add file and Redis storage backends for DCR credentials
- [x] Implement storage abstraction with FileStore and RedisStore
- [x] Add factory function for automatic backend selection (auto/file/redis)
- [x] Integrate DCR credentials cache into UniversalCacheManager
- [x] Add comprehensive tests for storage backends and factory
- [x] Update configuration schema with storage backend options
- [x] Update documentation with multi-replica deployment guidance
- [x] Add Redis key prefix configuration for credential isolation
2025-12-31 12:52:39 +00:00
lukaszraczylo 413e4a1b7d LRU + cache conflicts prevention. (#104)
* LRU + cache conflicts prevention.

* Bugfix universalCache flooding ( issue #105 )

  1. Traefik cancels the context for old plugin instances
  2. Each plugin's Close() method is called
  3. The CacheInterfaceWrapper.Close() was calling cache.Close() on the shared singleton caches
  4. Each Close() triggered Clear() which logged "Cleared all items" at INFO level
2025-12-24 18:54:39 +00:00
lukaszraczylo 69e0d98c67 fixup! Add signing of the plugin on release. 2025-12-24 12:33:33 +00:00
lukaszraczylo 6d893df12b Add signing of the plugin on release. 2025-12-15 00:38:35 +00:00
lukaszraczylo 6efb78b7a8 Smarter approach to the cookies (#103)
* Smarter approach to the cookies

  - Single maxCookieSize = 1400 constant with clear documentation
  - Combined cookie storage for ~40-45% size reduction
  - Backward compatible migration from legacy cookies

* Tuneup the code.
2025-12-12 18:35:06 +00:00
lukaszraczylo d0b920c4f0 multiple realms fix (#102)
* Allow to use multiple realms

This change is a ressurection of PR #88 which can't be merged due to significant refactor of the codebase.

* Fix the autocleanup routine to handle multiple realms correctly, update tests.

* Metadata rediscovery when provider is unavailable for any reason during the start.

This one prevents the permanent 503 from the plugin when OIDC provider was for some reason unavailable during the start.
2025-12-10 13:07:22 +00:00
lukaszraczylo c474bbafd6 Cleanup [dec2025] (#101)
* Cleanup excessive comments.

* Remove leftovers hanging around from previous refactor

* Improve test coverage
2025-12-09 01:38:02 +00:00
328 changed files with 37150 additions and 60443 deletions
+2
View File
@@ -11,7 +11,9 @@ on:
workflow_dispatch:
permissions:
id-token: write
contents: write
packages: write
jobs:
release:
+3 -1
View File
@@ -1,2 +1,4 @@
docker/
.claude/
.claude/*.out
*.test
.leann/
+49 -32
View File
@@ -14,21 +14,22 @@ linters:
- gosec
- misspell
- noctx
- nolintlint
- prealloc
- revive
- rowserrcheck
- sqlclosecheck
- unconvert
- unparam
- whitespace
disable:
- exhaustive
- funlen
- gocognit
- gocyclo # Disabled: OAuth/OIDC flows are inherently complex
- goprintffuncname # Disabled: naming convention is project-specific
- lll
- mnd
- testpackage
- whitespace # Disabled: style preference about newlines
- wsl
settings:
dupl:
@@ -47,29 +48,13 @@ linters:
- fmt.Fprintln
goconst:
min-len: 3
min-occurrences: 10 # Increased to reduce noise for standard OAuth2/OIDC strings
min-occurrences: 15 # Increased to reduce noise for standard OAuth2/OIDC strings and common patterns like "true"
ignore-tests: true
gocritic:
# Using default enabled checks in v2
enabled-checks:
- appendCombine
- boolExprSimplify
- builtinShadow
- commentedOutCode
- emptyFallthrough
- equalFold
- hexLiteral
- indexAlloc
- initClause
- methodExprCall
- nestingReduce
- rangeExprCopy
- rangeValCopy
- stringXbytes
- typeAssertChain
- typeUnparen
- unlabelStmt
- yodaStyleExpr
# Disable style-only checks that add noise
disabled-checks:
- ifElseChain # Style preference, switch not always clearer
- elseif # Style preference
gocyclo:
min-complexity: 30 # OAuth/OIDC flows are inherently complex; set higher for Yaegi compatibility
gosec:
@@ -106,23 +91,23 @@ linters:
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
- name: if-return
# - name: exported # Disabled: too noisy, not all exported functions need comments
# - name: if-return # Disabled: style preference
- name: increment-decrement
- name: var-naming
- name: var-declaration
- name: package-comments
# - name: var-naming # Disabled: too strict for legacy code (IP vs Ip)
# - name: var-declaration # Disabled: explicit zero values can be clearer
# - name: package-comments # Disabled: handled by other tools
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
- name: indent-error-flow
# - name: indent-error-flow # Disabled: style preference
- name: errorf
- name: empty-block
# - name: empty-block # Disabled: sometimes empty blocks are intentional
- name: superfluous-else
- name: unused-parameter
# - name: unused-parameter # Disabled: test callbacks and interface implementations often have required unused params
- name: unreachable-code
- name: redefines-builtin-id
# - name: redefines-builtin-id # Disabled: min/max helpers are common before Go 1.21
unparam:
check-exported: false
staticcheck:
@@ -132,8 +117,15 @@ linters:
- -QF1003 # Tagged switch - style preference, may affect Yaegi
- -QF1007 # Merge conditional assignment - style preference
- -QF1008 # Remove embedded field - may break Yaegi compatibility
- -QF1011 # Omit type from declaration - style preference
- -QF1012 # Use fmt.Fprintf - style preference
- -SA9003 # Empty branch - sometimes intentional for future work
- -ST1000 # Package comment format - not required for all packages
- -ST1003 # Package name format - allowed for test packages
- -ST1016 # Receiver name consistency - legacy code
- -ST1020 # Comment format for methods - style preference
- -ST1021 # Comment format for types - style preference
- -ST1023 # Omit type from declaration - style preference
exclusions:
generated: lax
rules:
@@ -144,18 +136,43 @@ linters:
- goconst
- gocyclo
- gosec
- govet
- ineffassign
- noctx
- prealloc
- unparam
- revive
- gocritic
path: _test\.go
- linters:
- dupl
- gocyclo
- govet
- noctx
- prealloc
- unparam
- revive
- gocritic
path: test.*\.go
- linters:
- gocritic
- unused
- errcheck
- revive
path: mocks.*\.go
- linters:
- errcheck
- revive
- gocritic
- govet
- unparam
path: internal/testutil/
- linters:
- govet
- unparam
- noctx
- prealloc
path: integration/
- linters:
- gosec
text: 'G404:'
+11
View File
@@ -47,3 +47,14 @@ release:
name_template: "v{{ .Version }}"
draft: false
prerelease: auto
signs:
- cmd: cosign
signature: "${artifact}.sigstore.json"
args:
- sign-blob
- "--bundle=${signature}"
- "${artifact}"
- "--yes"
artifacts: checksum
output: true
+47 -1610
View File
File diff suppressed because it is too large Load Diff
-286
View File
@@ -1,286 +0,0 @@
# CI/CD Setup Guide
## 📋 Overview
This repository now has a comprehensive CI/CD pipeline that runs **20+ parallel checks** on every pull request to ensure code quality, security, and reliability.
## 🎯 What Was Added
### GitHub Actions Workflow
- **`.github/workflows/pr-validation.yml`** - Main CI/CD pipeline (single file, all parallel)
### Configuration Files
- **`.golangci.yml`** - Linter configuration with 30+ enabled checks
- **`.github/dependabot.yml`** - Automated dependency updates
- **`.github/CODEOWNERS`** - Automatic PR reviewer assignment
- **`.github/PULL_REQUEST_TEMPLATE.md`** - Standardized PR descriptions
- **`.github/workflows/README.md`** - Detailed workflow documentation
- **`.github/workflows/.gitattributes`** - Consistent line endings
## ✅ What Gets Tested (All in Parallel)
### Code Quality (3 checks)
- **Format & Basic Checks** - gofmt, go vet, go mod
- **golangci-lint** - 30+ linters including style, complexity, bugs
- **Staticcheck** - Advanced static analysis
### Security (3 checks)
- **Gosec** - Security vulnerability scanning with SARIF reports
- **Govulncheck** - Go vulnerability database scanning
- **CodeQL** - GitHub's semantic code analysis
### Testing (9 test suites)
- **Race Detector** - Concurrent access bugs
- **Coverage** - 75% threshold with PR comments
- **Memory Leaks** - Goroutine and memory leak detection
- **Integration Tests** - Full integration suite
- **Regression Tests** - Prevent old bugs from returning
- **Security Edge Cases** - Security-specific scenarios
- **Session Tests** - Session management
- **Token Tests** - Token validation
- **CSRF Tests** - CSRF protection
### Provider Testing (9 providers in parallel)
- Google, Azure AD, Auth0, Okta, Keycloak, AWS Cognito, GitLab, GitHub, Generic
### Performance & Build (3 checks)
- **Benchmarks** - Performance regression detection
- **Multi-platform Build** - 4 combinations (linux/darwin × amd64/arm64)
- **Go Version Compatibility** - Go 1.23 & 1.24
## 🚀 Quick Start
### 1. Push to GitHub
```bash
git add .github .golangci.yml CI_SETUP.md
git commit -m "Add comprehensive CI/CD pipeline"
git push origin main
```
### 2. Create a Test PR
```bash
# Create a feature branch
git checkout -b feature/test-ci
echo "# Test" >> test.md
git add test.md
git commit -m "Test CI pipeline"
git push origin feature/test-ci
# Create PR on GitHub
# Watch all 20+ checks run in parallel! ⚡
```
### 3. Monitor Results
- Go to Actions tab: `https://github.com/{owner}/{repo}/actions`
- Click on latest workflow run
- See all parallel checks in action
- Review coverage comment on PR
## 📊 Key Features
### ⚡ Maximum Speed
- **Parallel execution** - All checks run simultaneously
- **Smart caching** - Go modules and build cache
- **Optimized order** - Quick checks first for fast feedback
- **Expected runtime**: 5-10 minutes for full suite
### 🔒 Security First
- **3 security scanners** - gosec, govulncheck, CodeQL
- **SARIF integration** - Results in GitHub Security tab
- **Dependency scanning** - Automated with Dependabot
- **Security edge case tests**
### 📈 Coverage Tracking
- **Automatic PR comments** with coverage stats
- **Per-package breakdown** included
- **75% threshold** enforced (configurable)
- **Codecov integration** ready (optional)
### 🎨 Developer Experience
- **Clear PR template** guides contributors
- **Auto code owners** assignment
- **Detailed error messages** for failures
- **Benchmark tracking** for performance
## 🛠️ Local Development
### Install Required Tools
```bash
# golangci-lint (comprehensive linting)
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# staticcheck (static analysis)
go install honnef.co/go/tools/cmd/staticcheck@latest
# gosec (security scanning)
go install github.com/securego/gosec/v2/cmd/gosec@latest
# govulncheck (vulnerability scanning)
go install golang.org/x/vuln/cmd/govulncheck@latest
```
### Run Checks Locally
```bash
# Quick validation (before committing)
gofmt -s -w . # Format code
go vet ./... # Basic checks
go mod tidy # Clean dependencies
# Linting
golangci-lint run # Full lint suite
staticcheck ./... # Static analysis
# Testing
go test -race -timeout=15m ./... # Tests with race detector
go test -coverprofile=coverage.out ./... # Coverage
go tool cover -func=coverage.out # View coverage
# Security
gosec ./... # Security scan
govulncheck ./... # Vulnerability check
# Benchmarks
go test -bench=. -benchmem ./... # Performance tests
```
### Pre-commit Checklist
```bash
# Run this before every commit
gofmt -s -w . && \
go mod tidy && \
golangci-lint run && \
go test -race -short ./... && \
echo "✅ Ready to commit!"
```
## 📝 Configuration
### Adjust Coverage Threshold
Edit `.github/workflows/pr-validation.yml`:
```yaml
THRESHOLD=75 # Change to desired percentage
```
### Modify Linter Rules
Edit `.golangci.yml`:
```yaml
linters:
enable:
- newlinter # Add new linters here
```
### Update Go Version
Edit `.github/workflows/pr-validation.yml`:
```yaml
go-version: '1.24' # Update version
```
## 🐛 Troubleshooting
### Coverage Below Threshold
```bash
# See uncovered lines in browser
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
```
### Race Condition Found
```bash
# Run specific test with race detector
go test -race -v -run=TestName ./...
```
### Linter Errors
```bash
# See detailed lint errors
golangci-lint run -v
# Auto-fix some issues
golangci-lint run --fix
```
### Provider Test Fails
```bash
# Test specific provider
go test -v -run='.*Azure.*' ./internal/providers/
```
## 📈 Metrics & Monitoring
### GitHub Actions Dashboard
- View all runs: `Actions` tab
- Filter by workflow, branch, status
- Download logs and artifacts
### Status Badge
Add to README.md:
```markdown
[![PR Validation](https://github.com/lukaszraczylo/traefikoidc/actions/workflows/pr-validation.yml/badge.svg)](https://github.com/lukaszraczylo/traefikoidc/actions/workflows/pr-validation.yml)
```
### Notifications
- Configure in: Settings → Notifications
- Email alerts for workflow failures
- Slack/Discord webhooks supported
## 🔄 Continuous Improvement
### Dependabot Updates
- Automatic weekly dependency checks (Mondays 9 AM)
- Security updates prioritized
- Groups patch updates together
### Code Owners
- Auto-assigns reviewers based on file paths
- Ensures expertise reviews changes
- Speeds up PR review process
## 📚 Additional Resources
- [Workflow Documentation](.github/workflows/README.md)
- [golangci-lint Rules](.golangci.yml)
- [PR Template](.github/PULL_REQUEST_TEMPLATE.md)
- [Dependabot Config](.github/dependabot.yml)
## 🎉 Benefits
### For Contributors
- Clear expectations via PR template
- Fast feedback (5-10 min)
- Comprehensive local tooling
- Detailed error messages
### For Maintainers
- Automated code review
- Security scanning
- Performance tracking
- Quality gates enforcement
### For Users
- Higher code quality
- Fewer bugs in production
- Better security
- Consistent performance
## 🚦 Success Criteria
All PRs must pass:
- ✅ All 20+ parallel checks
- ✅ 75% test coverage minimum
- ✅ Zero security vulnerabilities
- ✅ No race conditions
- ✅ No memory leaks
- ✅ All providers tested
- ✅ Builds on all platforms
## 💡 Tips
1. **Run checks locally** before pushing to save CI time
2. **Watch for PR comments** - coverage stats posted automatically
3. **Check Security tab** for gosec/CodeQL findings
4. **Review benchmark results** in artifacts
5. **Use draft PRs** for work-in-progress to skip some checks
---
**Ready to go!** 🚀 Push your changes and create a PR to see it in action.
+210 -1952
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
# Security Fix: Integer Overflow Protection in Cache Serialization
## Summary
Fixed **High severity** integer overflow vulnerability identified by GitHub Advanced Security in PR #117.
## Vulnerability
**Locations**: `universal_cache.go` lines 789 and 811
- `result := make([]byte, len(bytes)+1)` - Raw bytes path
- `result := make([]byte, len(jsonData)+1)` - JSON encoding path
**Risk**: Potential integer overflow when allocating memory for very large cache entries.
## Fix Applied
1. **Added size limit constant**:
```go
maxCacheEntrySize = 64 * 1024 * 1024 // 64 MiB
```
2. **Size validation before allocation**:
- Validates entry size doesn't exceed limit
- Validates adding marker byte won't overflow
- Returns descriptive error messages
3. **Comprehensive test coverage**:
- Oversized byte slices (>64 MiB)
- Exact max size edge case
- Safe sizes (normal operation)
- Large JSON data structures
## Verification
✅ All tests pass with race detection
✅ No security issues (golangci-lint, gosec)
✅ 76.3% test coverage maintained
## Impact
- No breaking changes
- Negligible performance overhead
- Prevents potential buffer overflows
- Predictable memory usage
---
**Date**: January 8, 2026
**Severity**: High → Resolved
+1376 -1
View File
File diff suppressed because it is too large Load Diff
-932
View File
@@ -1,932 +0,0 @@
package traefikoidc
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"golang.org/x/time/rate"
)
// TestConfigAudienceValidation tests the Config.Validate() method for the audience field
func TestConfigAudienceValidation(t *testing.T) {
tests := []struct {
name string
audience string
wantErr bool
errContains string
}{
{
name: "Empty audience is valid for backward compatibility",
audience: "",
wantErr: false,
},
{
name: "Valid HTTPS URL audience Auth0 format",
audience: "https://api.example.com",
wantErr: false,
},
{
name: "Valid identifier audience",
audience: "my-api",
wantErr: false,
},
{
name: "Valid Azure AD Application ID URI format",
audience: "api://12345-guid-67890",
wantErr: false,
},
{
name: "Valid Auth0 API identifier",
audience: "https://my-company.auth0.com/api/v2/",
wantErr: false,
},
{
name: "HTTP URL audience should fail",
audience: "http://api.example.com",
wantErr: true,
errContains: "must use HTTPS",
},
{
name: "Audience with wildcard should fail",
audience: "https://api.*.example.com",
wantErr: true,
errContains: "must not contain wildcards",
},
{
name: "Audience with single asterisk should fail",
audience: "*",
wantErr: true,
errContains: "must not contain wildcards",
},
{
name: "Audience over 256 characters should fail",
audience: strings.Repeat("a", 257),
wantErr: true,
errContains: "must not exceed 256 characters",
},
{
name: "Audience with newline should fail",
audience: "my-api\ninjection",
wantErr: true,
errContains: "contains invalid characters",
},
{
name: "Audience with carriage return should fail",
audience: "my-api\rinjection",
wantErr: true,
errContains: "contains invalid characters",
},
{
name: "Audience with tab should fail",
audience: "my-api\tinjection",
wantErr: true,
errContains: "contains invalid characters",
},
{
name: "Valid audience exactly 256 characters",
audience: strings.Repeat("a", 256),
wantErr: false,
},
{
name: "Valid simple identifier",
audience: "my-service-api",
wantErr: false,
},
{
name: "Valid URN format",
audience: "urn:myservice:api:v1",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := CreateConfig()
config.ProviderURL = "https://provider.example.com"
config.ClientID = "test-client-id"
config.ClientSecret = "test-client-secret"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = strings.Repeat("a", MinSessionEncryptionKeyLength)
config.Audience = tt.audience
err := config.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Error message should contain %q, got: %v", tt.errContains, err)
}
})
}
}
// TestJWTAudienceVerification tests JWT verification with custom audience values
func TestJWTAudienceVerification(t *testing.T) {
// Create cleanup helper
tc := newTestCleanup(t)
// Generate RSA key for signing JWTs
rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
rsaPublicKey := &rsaPrivateKey.PublicKey
// Create JWK
jwk := JWK{
Kty: "RSA",
Kid: "test-key-id",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),
}
jwks := &JWKSet{
Keys: []JWK{jwk},
}
mockJWKCache := &MockJWKCache{
JWKS: jwks,
Err: nil,
}
logger := NewLogger("debug")
tokenBlacklist := tc.addCache(NewCache())
tokenCache := tc.addTokenCache(NewTokenCache())
tests := []struct {
name string
configAudience string
tokenAudience interface{}
wantErr bool
errContains string
skipReplayCheck bool
}{
{
name: "JWT with string aud matching configured audience",
configAudience: "https://api.example.com",
tokenAudience: "https://api.example.com",
wantErr: false,
skipReplayCheck: true,
},
{
name: "JWT with array aud containing configured audience",
configAudience: "https://api.example.com",
tokenAudience: []interface{}{"https://other.com", "https://api.example.com", "https://another.com"},
wantErr: false,
skipReplayCheck: true,
},
{
name: "JWT with string aud NOT matching configured audience",
configAudience: "https://api.example.com",
tokenAudience: "https://wrong-api.example.com",
wantErr: true,
errContains: "invalid audience",
skipReplayCheck: true,
},
{
name: "JWT with array aud NOT containing configured audience",
configAudience: "https://api.example.com",
tokenAudience: []interface{}{"https://other.com", "https://another.com"},
wantErr: true,
errContains: "invalid audience",
skipReplayCheck: true,
},
{
name: "JWT with clientID as aud when no custom audience configured",
configAudience: "",
tokenAudience: "test-client-id",
wantErr: false,
skipReplayCheck: true,
},
{
name: "JWT with empty string aud",
configAudience: "https://api.example.com",
tokenAudience: "",
wantErr: true,
errContains: "invalid audience",
skipReplayCheck: true,
},
{
name: "Azure AD Application ID URI format",
configAudience: "api://12345-app-id",
tokenAudience: "api://12345-app-id",
wantErr: false,
skipReplayCheck: true,
},
{
name: "Auth0 custom API audience",
configAudience: "https://mycompany.com/api",
tokenAudience: "https://mycompany.com/api",
wantErr: false,
skipReplayCheck: true,
},
{
name: "Token confusion attack - audience for different service",
configAudience: "https://service-a.example.com",
tokenAudience: "https://service-b.example.com",
wantErr: true,
errContains: "invalid audience",
skipReplayCheck: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create TraefikOidc instance
tOidc := &TraefikOidc{
issuerURL: "https://test-issuer.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
jwkCache: mockJWKCache,
jwksURL: "https://test-jwks-url.com",
tokenBlacklist: tokenBlacklist,
tokenCache: tokenCache,
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
logger: logger,
httpClient: &http.Client{},
}
// Set up the token verifier and JWT verifier
tOidc.jwtVerifier = tOidc
tOidc.tokenVerifier = tOidc
// Determine the expected audience for validation
expectedAudience := tt.configAudience
if expectedAudience == "" {
expectedAudience = tOidc.clientID
}
// Set the audience field on the tOidc instance
tOidc.audience = expectedAudience
// Create JWT with specified audience
jti := generateRandomString(16)
if tt.skipReplayCheck {
// Use a unique JTI for each test to avoid replay detection
jti = fmt.Sprintf("test-%s-%s", tt.name, jti)
}
jwt, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": tt.tokenAudience,
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "user@example.com",
"jti": jti,
})
if err != nil {
t.Fatalf("Failed to create test JWT: %v", err)
}
// Verify the token
err = tOidc.VerifyToken(jwt)
if (err != nil) != tt.wantErr {
t.Errorf("VerifyToken() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Error message should contain %q, got: %v", tt.errContains, err)
}
})
}
}
// TestJWTAudienceBackwardCompatibility tests that existing behavior is preserved
// when the Audience field is not set
func TestJWTAudienceBackwardCompatibility(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
// Test with no custom audience configured - should use clientID
jwt, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id", // Should match clientID
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-subject",
"email": "user@example.com",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create test JWT: %v", err)
}
err = ts.tOidc.VerifyToken(jwt)
if err != nil {
t.Errorf("Backward compatibility broken: VerifyToken() error = %v, expected nil", err)
}
}
// TestAudienceIntegrationAuth0Scenario tests Auth0-specific use case
func TestAudienceIntegrationAuth0Scenario(t *testing.T) {
// Create cleanup helper
tc := newTestCleanup(t)
// Simulate Auth0 scenario: custom audience for API access
config := CreateConfig()
config.ProviderURL = "https://mycompany.auth0.com"
config.ClientID = "auth0-client-id"
config.ClientSecret = "auth0-client-secret"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = strings.Repeat("a", MinSessionEncryptionKeyLength)
config.Audience = "https://api.mycompany.com" // Custom API audience
// Validate config
if err := config.Validate(); err != nil {
t.Fatalf("Auth0 config validation failed: %v", err)
}
// Generate test keys
rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
rsaPublicKey := &rsaPrivateKey.PublicKey
jwk := JWK{
Kty: "RSA",
Kid: "auth0-key-id",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),
}
jwks := &JWKSet{
Keys: []JWK{jwk},
}
mockJWKCache := &MockJWKCache{
JWKS: jwks,
Err: nil,
}
logger := NewLogger("debug")
tokenBlacklist := tc.addCache(NewCache())
tokenCache := tc.addTokenCache(NewTokenCache())
tOidc := &TraefikOidc{
issuerURL: config.ProviderURL,
clientID: config.ClientID,
clientSecret: config.ClientSecret,
audience: config.Audience, // Set audience from config
jwkCache: mockJWKCache,
jwksURL: "https://mycompany.auth0.com/.well-known/jwks.json",
tokenBlacklist: tokenBlacklist,
tokenCache: tokenCache,
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
logger: logger,
httpClient: &http.Client{},
}
// Default audience to clientID if not specified
if tOidc.audience == "" {
tOidc.audience = tOidc.clientID
}
tOidc.jwtVerifier = tOidc
tOidc.tokenVerifier = tOidc
t.Run("Valid Auth0 API access token with custom audience", func(t *testing.T) {
jwt, err := createTestJWT(rsaPrivateKey, "RS256", "auth0-key-id", map[string]interface{}{
"iss": config.ProviderURL,
"aud": config.Audience, // Matches configured audience
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "auth0|123456",
"email": "user@example.com",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create Auth0 JWT: %v", err)
}
err = tOidc.VerifyToken(jwt)
if err != nil {
t.Errorf("Auth0 token verification failed: %v", err)
}
})
t.Run("Auth0 ACCESS token with clientID instead of API audience should fail", func(t *testing.T) {
jwt, err := createTestJWT(rsaPrivateKey, "RS256", "auth0-key-id", map[string]interface{}{
"iss": config.ProviderURL,
"aud": config.ClientID, // Using clientID instead of API audience
"scope": "openid profile email", // Mark as access token
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "auth0|123456",
"email": "user@example.com",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create Auth0 JWT: %v", err)
}
err = tOidc.VerifyToken(jwt)
if err == nil {
t.Error("Auth0 access token with wrong audience should have been rejected")
} else if !strings.Contains(err.Error(), "invalid audience") {
t.Errorf("Expected 'invalid audience' error, got: %v", err)
}
})
}
// TestAudienceIntegrationAzureADScenario tests Azure AD-specific use case
func TestAudienceIntegrationAzureADScenario(t *testing.T) {
// Create cleanup helper
tc := newTestCleanup(t)
// Simulate Azure AD scenario: Application ID URI format
config := CreateConfig()
config.ProviderURL = "https://login.microsoftonline.com/tenant-id/v2.0"
config.ClientID = "azure-client-id"
config.ClientSecret = "azure-client-secret"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = strings.Repeat("a", MinSessionEncryptionKeyLength)
config.Audience = "api://12345-abcd-6789-efgh" // Azure AD Application ID URI
// Validate config
if err := config.Validate(); err != nil {
t.Fatalf("Azure AD config validation failed: %v", err)
}
// Generate test keys
rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
rsaPublicKey := &rsaPrivateKey.PublicKey
jwk := JWK{
Kty: "RSA",
Kid: "azure-key-id",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),
}
jwks := &JWKSet{
Keys: []JWK{jwk},
}
mockJWKCache := &MockJWKCache{
JWKS: jwks,
Err: nil,
}
logger := NewLogger("debug")
tokenBlacklist := tc.addCache(NewCache())
tokenCache := tc.addTokenCache(NewTokenCache())
tOidc := &TraefikOidc{
issuerURL: config.ProviderURL,
clientID: config.ClientID,
clientSecret: config.ClientSecret,
audience: config.Audience, // Set audience from config
jwkCache: mockJWKCache,
jwksURL: config.ProviderURL + "/.well-known/jwks.json",
tokenBlacklist: tokenBlacklist,
tokenCache: tokenCache,
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
logger: logger,
httpClient: &http.Client{},
}
// Default audience to clientID if not specified
if tOidc.audience == "" {
tOidc.audience = tOidc.clientID
}
tOidc.jwtVerifier = tOidc
tOidc.tokenVerifier = tOidc
t.Run("Valid Azure AD token with Application ID URI audience", func(t *testing.T) {
jwt, err := createTestJWT(rsaPrivateKey, "RS256", "azure-key-id", map[string]interface{}{
"iss": config.ProviderURL,
"aud": config.Audience, // Matches Application ID URI
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "azure-user-id",
"email": "user@example.com",
"oid": "object-id-12345",
"tid": "tenant-id",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create Azure AD JWT: %v", err)
}
err = tOidc.VerifyToken(jwt)
if err != nil {
t.Errorf("Azure AD token verification failed: %v", err)
}
})
t.Run("Azure AD token with multiple audiences including correct one", func(t *testing.T) {
jwt, err := createTestJWT(rsaPrivateKey, "RS256", "azure-key-id", map[string]interface{}{
"iss": config.ProviderURL,
"aud": []interface{}{config.ClientID, config.Audience, "https://graph.microsoft.com"},
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "azure-user-id",
"email": "user@example.com",
"oid": "object-id-12345",
"tid": "tenant-id",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create Azure AD JWT: %v", err)
}
err = tOidc.VerifyToken(jwt)
if err != nil {
t.Errorf("Azure AD token with multiple audiences verification failed: %v", err)
}
})
}
// TestAudienceSecurityTokenConfusionAttack tests security against token confusion attacks
func TestAudienceSecurityTokenConfusionAttack(t *testing.T) {
// Create cleanup helper
tc := newTestCleanup(t)
// Generate test keys
rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
rsaPublicKey := &rsaPrivateKey.PublicKey
jwk := JWK{
Kty: "RSA",
Kid: "test-key-id",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),
}
jwks := &JWKSet{
Keys: []JWK{jwk},
}
mockJWKCache := &MockJWKCache{
JWKS: jwks,
Err: nil,
}
logger := NewLogger("debug")
tokenBlacklist := tc.addCache(NewCache())
tokenCache := tc.addTokenCache(NewTokenCache())
// Service A configuration
serviceA := &TraefikOidc{
issuerURL: "https://auth.example.com",
clientID: "service-a-client-id",
clientSecret: "service-a-secret",
audience: "service-a-client-id", // Service A uses its clientID as audience
jwkCache: mockJWKCache,
jwksURL: "https://auth.example.com/.well-known/jwks.json",
tokenBlacklist: tokenBlacklist,
tokenCache: tokenCache,
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
logger: logger,
httpClient: &http.Client{},
}
serviceA.jwtVerifier = serviceA
serviceA.tokenVerifier = serviceA
t.Run("Token confusion - Try to use service B token on service A", func(t *testing.T) {
// Create a token intended for service B
serviceBToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://auth.example.com",
"aud": "https://service-b.example.com", // For service B
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "attacker@example.com",
"email": "attacker@example.com",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create service B token: %v", err)
}
// Try to verify the service B token on service A
err = serviceA.VerifyToken(serviceBToken)
switch {
case err == nil:
t.Error("SECURITY VULNERABILITY: Token confusion attack succeeded - service B token was accepted by service A")
case !strings.Contains(err.Error(), "invalid audience"):
t.Errorf("Expected 'invalid audience' error for token confusion, got: %v", err)
default:
t.Logf("Token confusion attack correctly prevented: %v", err)
}
})
}
// TestAudienceSecurityWildcardInjection tests that wildcards are rejected
func TestAudienceSecurityWildcardInjection(t *testing.T) {
tests := []struct {
name string
audience string
}{
{
name: "Single asterisk",
audience: "*",
},
{
name: "Wildcard in URL",
audience: "https://*.example.com",
},
{
name: "Wildcard in path",
audience: "https://api.example.com/*",
},
{
name: "Multiple wildcards",
audience: "https://*.*.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := CreateConfig()
config.ProviderURL = "https://provider.example.com"
config.ClientID = "test-client-id"
config.ClientSecret = "test-client-secret"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = strings.Repeat("a", MinSessionEncryptionKeyLength)
config.Audience = tt.audience
err := config.Validate()
if err == nil {
t.Errorf("SECURITY VULNERABILITY: Wildcard audience %q was not rejected", tt.audience)
} else if !strings.Contains(err.Error(), "must not contain wildcards") {
t.Errorf("Expected 'must not contain wildcards' error, got: %v", err)
}
})
}
}
// TestAudienceSecurityInjectionAttempts tests various injection attempts
func TestAudienceSecurityInjectionAttempts(t *testing.T) {
tests := []struct {
name string
audience string
errContains string
}{
{
name: "Newline injection",
audience: "api.example.com\nmalicious.com",
errContains: "contains invalid characters",
},
{
name: "Carriage return injection",
audience: "api.example.com\rmalicious.com",
errContains: "contains invalid characters",
},
{
name: "Tab injection",
audience: "api.example.com\tmalicious.com",
errContains: "contains invalid characters",
},
{
name: "Null byte injection",
audience: "api.example.com\x00malicious.com",
errContains: "contains invalid characters",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := CreateConfig()
config.ProviderURL = "https://provider.example.com"
config.ClientID = "test-client-id"
config.ClientSecret = "test-client-secret"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = strings.Repeat("a", MinSessionEncryptionKeyLength)
config.Audience = tt.audience
err := config.Validate()
if err == nil {
t.Errorf("SECURITY VULNERABILITY: Injection attempt with %q was not rejected", tt.name)
} else if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Expected error containing %q, got: %v", tt.errContains, err)
}
})
}
}
// TestAudienceWithReplayProtection tests that replay protection works correctly with custom audiences
func TestAudienceWithReplayProtection(t *testing.T) {
// Create cleanup helper
tc := newTestCleanup(t)
// Generate test keys
rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
rsaPublicKey := &rsaPrivateKey.PublicKey
jwk := JWK{
Kty: "RSA",
Kid: "test-key-id",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),
}
jwks := &JWKSet{
Keys: []JWK{jwk},
}
mockJWKCache := &MockJWKCache{
JWKS: jwks,
Err: nil,
}
logger := NewLogger("debug")
tokenBlacklist := tc.addCache(NewCache())
tokenCache := tc.addTokenCache(NewTokenCache())
tOidc := &TraefikOidc{
issuerURL: "https://auth.example.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
jwkCache: mockJWKCache,
jwksURL: "https://auth.example.com/.well-known/jwks.json",
tokenBlacklist: tokenBlacklist,
tokenCache: tokenCache,
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
logger: logger,
httpClient: &http.Client{},
}
tOidc.jwtVerifier = tOidc
tOidc.tokenVerifier = tOidc
// Create a token with custom audience and fixed JTI
fixedJTI := "replay-test-jti-" + generateRandomString(8)
customAudience := "https://api.example.com"
// Set the audience field to match what we expect
tOidc.audience = customAudience
jwt, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://auth.example.com",
"aud": customAudience,
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-user",
"email": "user@example.com",
"jti": fixedJTI,
})
if err != nil {
t.Fatalf("Failed to create JWT: %v", err)
}
// First verification should succeed
err = tOidc.VerifyToken(jwt)
if err != nil {
t.Fatalf("First verification failed: %v", err)
}
// Verify that the JTI was blacklisted
if blacklisted, exists := tOidc.tokenBlacklist.Get(fixedJTI); !exists || blacklisted == nil {
t.Logf("Note: JTI was not added to blacklist (may be due to test token prefix)")
} else {
t.Logf("Replay protection verified: JTI %s is correctly blacklisted", fixedJTI)
}
}
// TestAudienceEndToEndScenario tests a complete end-to-end scenario with middleware
func TestAudienceEndToEndScenario(t *testing.T) {
// Create cleanup helper
tc := newTestCleanup(t)
// Create a test next handler
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("Authenticated with custom audience"))
})
// Generate test keys
rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
rsaPublicKey := &rsaPrivateKey.PublicKey
jwk := JWK{
Kty: "RSA",
Kid: "test-key-id",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),
}
jwks := &JWKSet{
Keys: []JWK{jwk},
}
mockJWKCache := &MockJWKCache{
JWKS: jwks,
Err: nil,
}
logger := NewLogger("debug")
sm, err := NewSessionManager(strings.Repeat("a", MinSessionEncryptionKeyLength), false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
tokenBlacklist := tc.addCache(NewCache())
tokenCache := tc.addTokenCache(NewTokenCache())
customAudience := "https://api.company.com"
tOidc := &TraefikOidc{
next: nextHandler,
name: "test",
redirURLPath: "/callback",
logoutURLPath: "/callback/logout",
issuerURL: "https://auth.company.com",
clientID: "test-client-id",
clientSecret: "test-client-secret",
audience: customAudience, // Set custom audience
jwkCache: mockJWKCache,
jwksURL: "https://auth.company.com/.well-known/jwks.json",
tokenBlacklist: tokenBlacklist,
tokenCache: tokenCache,
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
logger: logger,
allowedUserDomains: map[string]struct{}{"company.com": {}},
userIdentifierClaim: "email", // Required for user identification
excludedURLs: map[string]struct{}{},
httpClient: &http.Client{},
initComplete: make(chan struct{}),
sessionManager: sm,
extractClaimsFunc: extractClaims,
}
tOidc.jwtVerifier = tOidc
tOidc.tokenVerifier = tOidc
close(tOidc.initComplete)
t.Run("End-to-end with correct custom audience", func(t *testing.T) {
// Create a valid token with the custom audience
validJWT, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://auth.company.com",
"aud": customAudience,
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "user-123",
"email": "user@company.com",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("Failed to create valid JWT: %v", err)
}
// Create a request with authenticated session
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "company.com")
// Create session with token
resp := httptest.NewRecorder()
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
if err := session.SetAuthenticated(true); err != nil {
t.Fatalf("Failed to set authenticated: %v", err)
}
session.SetEmail("user@company.com")
session.SetIDToken(validJWT)
session.SetAccessToken(validJWT)
if err := session.Save(req, resp); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Get cookies and add them to a new request
cookies := resp.Result().Cookies()
req = httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "company.com")
for _, cookie := range cookies {
req.AddCookie(cookie)
}
resp = httptest.NewRecorder()
tOidc.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d. Body: %s", resp.Code, resp.Body.String())
}
})
}
-413
View File
@@ -1,413 +0,0 @@
// Package auth provides authentication-related functionality for the OIDC middleware.
package auth
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/google/uuid"
)
// ScopeFilter interface for filtering OAuth scopes based on provider capabilities
type ScopeFilter interface {
FilterSupportedScopes(requestedScopes, supportedScopes []string, providerURL string) []string
}
// Handler provides core authentication functionality for OIDC flows
type Handler struct {
logger Logger
enablePKCE bool
isGoogleProv func() bool
isAzureProv func() bool
clientID string
authURL string
issuerURL string
scopes []string
overrideScopes bool
scopeFilter ScopeFilter // NEW
scopesSupported []string // NEW - from provider metadata
allowPrivateIPAddresses bool // Allow private IP addresses in URLs (for internal networks)
}
// Logger interface for dependency injection
type Logger interface {
Debugf(format string, args ...interface{})
Errorf(format string, args ...interface{})
}
// NewAuthHandler creates a new Handler instance
func NewAuthHandler(logger Logger, enablePKCE bool, isGoogleProv, isAzureProv func() bool,
clientID, authURL, issuerURL string, scopes []string, overrideScopes bool,
scopeFilter ScopeFilter, scopesSupported []string, allowPrivateIPAddresses bool) *Handler {
return &Handler{
logger: logger,
enablePKCE: enablePKCE,
isGoogleProv: isGoogleProv,
isAzureProv: isAzureProv,
clientID: clientID,
authURL: authURL,
issuerURL: issuerURL,
scopes: scopes,
overrideScopes: overrideScopes,
scopeFilter: scopeFilter,
scopesSupported: scopesSupported,
allowPrivateIPAddresses: allowPrivateIPAddresses,
}
}
// InitiateAuthentication initiates the OIDC authentication flow.
// It generates CSRF tokens, nonce, PKCE parameters (if enabled), clears the session,
// stores authentication state, and redirects the user to the OIDC provider.
func (h *Handler) InitiateAuthentication(rw http.ResponseWriter, req *http.Request,
session SessionData, redirectURL string,
generateNonce, generateCodeVerifier, deriveCodeChallenge func() (string, error)) {
h.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI())
const maxRedirects = 5
redirectCount := session.GetRedirectCount()
if redirectCount >= maxRedirects {
h.logger.Errorf("Maximum redirect limit (%d) exceeded, possible redirect loop detected", maxRedirects)
session.ResetRedirectCount()
http.Error(rw, "Authentication failed: Too many redirects", http.StatusLoopDetected)
return
}
session.IncrementRedirectCount()
csrfToken := uuid.NewString()
nonce, err := generateNonce()
if err != nil {
h.logger.Errorf("Failed to generate nonce: %v", err)
http.Error(rw, "Failed to generate nonce", http.StatusInternalServerError)
return
}
// Generate PKCE code verifier and challenge if PKCE is enabled
var codeVerifier, codeChallenge string
if h.enablePKCE {
codeVerifier, err = generateCodeVerifier()
if err != nil {
h.logger.Errorf("Failed to generate code verifier: %v", err)
http.Error(rw, "Failed to generate code verifier", http.StatusInternalServerError)
return
}
codeChallenge, err = deriveCodeChallenge()
if err != nil {
h.logger.Errorf("Failed to generate code challenge: %v", err)
http.Error(rw, "Failed to generate code challenge", http.StatusInternalServerError)
return
}
h.logger.Debugf("PKCE enabled, generated code challenge")
}
session.SetAuthenticated(false)
session.SetEmail("")
session.SetAccessToken("")
session.SetRefreshToken("")
session.SetIDToken("")
session.SetNonce("")
session.SetCodeVerifier("")
session.SetCSRF(csrfToken)
session.SetNonce(nonce)
if h.enablePKCE {
session.SetCodeVerifier(codeVerifier)
}
session.SetIncomingPath(req.URL.RequestURI())
h.logger.Debugf("Storing incoming path: %s", req.URL.RequestURI())
session.MarkDirty()
if err := session.Save(req, rw); err != nil {
h.logger.Errorf("Failed to save session before redirecting to provider: %v", err)
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
return
}
h.logger.Debugf("Session saved before redirect. CSRF: %s, Nonce: %s",
csrfToken, nonce)
authURL := h.BuildAuthURL(redirectURL, csrfToken, nonce, codeChallenge)
h.logger.Debugf("Redirecting user to OIDC provider: %s", authURL)
http.Redirect(rw, req, authURL, http.StatusFound)
}
// BuildAuthURL constructs the OIDC provider authorization URL.
// It builds the URL with all necessary parameters including client_id, scopes,
// PKCE parameters, and provider-specific parameters for Google and Azure.
func (h *Handler) BuildAuthURL(redirectURL, state, nonce, codeChallenge string) string {
params := url.Values{}
params.Set("client_id", h.clientID)
params.Set("response_type", "code")
params.Set("redirect_uri", redirectURL)
params.Set("state", state)
params.Set("nonce", nonce)
if h.enablePKCE && codeChallenge != "" {
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
}
scopes := make([]string, len(h.scopes))
copy(scopes, h.scopes)
// Apply discovery-based scope filtering if available
if h.scopeFilter != nil && len(h.scopesSupported) > 0 {
scopes = h.scopeFilter.FilterSupportedScopes(scopes, h.scopesSupported, h.issuerURL)
h.logger.Debugf("AuthHandler.BuildAuthURL: After discovery filtering: %v", scopes)
}
// Apply provider-specific modifications
scopes, params = h.applyProviderSpecificConfig(scopes, params)
// Final filtering pass to remove anything the provider doesn't support
if h.scopeFilter != nil && len(h.scopesSupported) > 0 {
scopes = h.scopeFilter.FilterSupportedScopes(scopes, h.scopesSupported, h.issuerURL)
h.logger.Debugf("AuthHandler.BuildAuthURL: After final filtering: %v", scopes)
}
if len(scopes) > 0 {
finalScopeString := strings.Join(scopes, " ")
params.Set("scope", finalScopeString)
h.logger.Debugf("AuthHandler.BuildAuthURL: Final scope string being sent to OIDC provider: %s", finalScopeString)
}
return h.buildURLWithParams(h.authURL, params)
}
// applyProviderSpecificConfig applies provider-specific scope and parameter modifications
func (h *Handler) applyProviderSpecificConfig(scopes []string, params url.Values) ([]string, url.Values) {
switch {
case h.isGoogleProv():
return h.applyGoogleConfig(scopes, params)
case h.isAzureProv():
return h.applyAzureConfig(scopes, params)
default:
return h.applyStandardProviderConfig(scopes, params)
}
}
// applyGoogleConfig applies Google-specific configuration
func (h *Handler) applyGoogleConfig(scopes []string, params url.Values) ([]string, url.Values) {
// Google: Remove offline_access if present, add access_type=offline
filteredScopes := make([]string, 0, len(scopes))
for _, scope := range scopes {
if scope != "offline_access" {
filteredScopes = append(filteredScopes, scope)
}
}
params.Set("access_type", "offline")
h.logger.Debugf("Google OIDC provider detected, added access_type=offline")
params.Set("prompt", "consent")
h.logger.Debugf("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
return filteredScopes, params
}
// applyAzureConfig applies Azure AD-specific configuration
func (h *Handler) applyAzureConfig(scopes []string, params url.Values) ([]string, url.Values) {
params.Set("response_mode", "query")
h.logger.Debugf("Azure AD provider detected, added response_mode=query")
if h.shouldAddOfflineAccess(scopes) {
scopes = append(scopes, "offline_access")
h.logger.Debugf("Azure AD provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)",
h.overrideScopes, len(h.scopes))
} else {
h.logger.Debugf("Azure AD provider: User is overriding scopes (count: %d), offline_access not automatically added.",
len(h.scopes))
}
return scopes, params
}
// applyStandardProviderConfig applies configuration for standard OIDC providers
func (h *Handler) applyStandardProviderConfig(scopes []string, params url.Values) ([]string, url.Values) {
if h.shouldAddOfflineAccess(scopes) {
scopes = append(scopes, "offline_access")
h.logger.Debugf("Standard provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)",
h.overrideScopes, len(h.scopes))
} else {
h.logger.Debugf("Standard provider: User is overriding scopes (count: %d), offline_access not automatically added.",
len(h.scopes))
}
return scopes, params
}
// shouldAddOfflineAccess determines if offline_access scope should be added
func (h *Handler) shouldAddOfflineAccess(scopes []string) bool {
if h.overrideScopes && len(h.scopes) > 0 {
return false
}
for _, scope := range scopes {
if scope == "offline_access" {
return false
}
}
return true
}
// buildURLWithParams constructs a URL by combining a base URL with query parameters.
// It handles both relative and absolute URLs, validates URL security,
// and properly encodes query parameters.
func (h *Handler) buildURLWithParams(baseURL string, params url.Values) string {
if baseURL != "" {
if strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://") {
if err := h.validateURL(baseURL); err != nil {
h.logger.Errorf("URL validation failed for %s: %v", baseURL, err)
return ""
}
}
}
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
issuerURLParsed, err := url.Parse(h.issuerURL)
if err != nil {
h.logger.Errorf("Could not parse issuerURL: %s. Error: %v", h.issuerURL, err)
return ""
}
baseURLParsed, err := url.Parse(baseURL)
if err != nil {
h.logger.Errorf("Could not parse baseURL: %s. Error: %v", baseURL, err)
return ""
}
resolvedURL := issuerURLParsed.ResolveReference(baseURLParsed)
if err := h.validateURL(resolvedURL.String()); err != nil {
h.logger.Errorf("Resolved URL validation failed for %s: %v", resolvedURL.String(), err)
return ""
}
resolvedURL.RawQuery = params.Encode()
return resolvedURL.String()
}
u, err := url.Parse(baseURL)
if err != nil {
h.logger.Errorf("Could not parse absolute baseURL: %s. Error: %v", baseURL, err)
return ""
}
if err := h.validateParsedURL(u); err != nil {
h.logger.Errorf("Parsed URL validation failed for %s: %v", baseURL, err)
return ""
}
u.RawQuery = params.Encode()
return u.String()
}
// validateURL performs security validation on URLs to prevent SSRF attacks.
// It checks for allowed schemes, validates hosts, and prevents access to private networks.
func (h *Handler) validateURL(urlStr string) error {
if urlStr == "" {
return fmt.Errorf("empty URL")
}
u, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL format: %w", err)
}
return h.validateParsedURL(u)
}
// validateParsedURL validates a parsed URL structure for security.
// It checks schemes, hosts, and paths to prevent malicious URLs.
func (h *Handler) validateParsedURL(u *url.URL) error {
allowedSchemes := map[string]bool{
"https": true,
"http": true,
}
if !allowedSchemes[u.Scheme] {
return fmt.Errorf("disallowed URL scheme: %s", u.Scheme)
}
if u.Scheme == "http" {
h.logger.Debugf("Warning: Using HTTP scheme for URL: %s", u.String())
}
if u.Host == "" {
return fmt.Errorf("missing host in URL")
}
if err := h.validateHost(u.Host); err != nil {
return fmt.Errorf("invalid host: %w", err)
}
if strings.Contains(u.Path, "..") {
return fmt.Errorf("path traversal detected in URL path")
}
return nil
}
// validateHost validates a hostname for security and reachability.
// It prevents access to private networks and localhost addresses.
// When allowPrivateIPAddresses is enabled, private IP checks are skipped.
func (h *Handler) validateHost(host string) error {
if host == "" {
return fmt.Errorf("empty host")
}
// Strip port if present
if strings.Contains(host, ":") {
var err error
host, _, err = net.SplitHostPort(host)
if err != nil {
return fmt.Errorf("invalid host:port format: %w", err)
}
}
// Check for localhost variations (always blocked, even with allowPrivateIPAddresses)
localhostVariations := []string{
"localhost", "127.0.0.1", "::1", "0.0.0.0",
}
for _, localhost := range localhostVariations {
if strings.EqualFold(host, localhost) {
return fmt.Errorf("localhost access not allowed: %s", host)
}
}
// Try to parse as IP address
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() {
return fmt.Errorf("loopback IP not allowed: %s", host)
}
// Skip private IP check if allowPrivateIPAddresses is enabled
if !h.allowPrivateIPAddresses && ip.IsPrivate() {
return fmt.Errorf("private IP not allowed: %s", host)
}
if ip.IsLinkLocalUnicast() {
return fmt.Errorf("link-local IP not allowed: %s", host)
}
if ip.IsMulticast() {
return fmt.Errorf("multicast IP not allowed: %s", host)
}
}
return nil
}
// SessionData interface for dependency injection
type SessionData interface {
GetRedirectCount() int
ResetRedirectCount()
IncrementRedirectCount()
SetAuthenticated(bool)
SetEmail(string)
SetAccessToken(string)
SetRefreshToken(string)
SetIDToken(string)
SetNonce(string)
SetCodeVerifier(string)
SetCSRF(string)
SetIncomingPath(string)
MarkDirty()
Save(req *http.Request, rw http.ResponseWriter) error
}
File diff suppressed because it is too large Load Diff
-660
View File
@@ -1,660 +0,0 @@
package auth
import (
"net/url"
"strings"
"testing"
)
// TestAuthHandler_validateURL tests URL validation functionality
func TestAuthHandler_validateURL(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
tests := []struct {
name string
url string
wantErr bool
errMsg string
}{
{
name: "Valid HTTPS URL",
url: "https://example.com/auth",
wantErr: false,
},
{
name: "Valid HTTP URL",
url: "http://example.com/auth",
wantErr: false,
},
{
name: "Empty URL",
url: "",
wantErr: true,
errMsg: "empty URL",
},
{
name: "Invalid URL format",
url: "not-a-url",
wantErr: true,
errMsg: "disallowed URL scheme",
},
{
name: "Disallowed scheme - javascript",
url: "javascript:alert('xss')",
wantErr: true,
errMsg: "disallowed URL scheme",
},
{
name: "Disallowed scheme - data",
url: "data:text/html,<script>alert('xss')</script>",
wantErr: true,
errMsg: "disallowed URL scheme",
},
{
name: "Disallowed scheme - file",
url: "file:///etc/passwd",
wantErr: true,
errMsg: "disallowed URL scheme",
},
{
name: "Disallowed scheme - ftp",
url: "ftp://example.com/file",
wantErr: true,
errMsg: "disallowed URL scheme",
},
{
name: "Missing host",
url: "https:///path",
wantErr: true,
errMsg: "missing host",
},
{
name: "Path traversal attempt",
url: "https://example.com/../../../etc/passwd",
wantErr: true,
errMsg: "path traversal detected",
},
{
name: "Path traversal in middle",
url: "https://example.com/path/../sensitive/file",
wantErr: true,
errMsg: "path traversal detected",
},
{
name: "Localhost attempt",
url: "https://localhost/auth",
wantErr: true,
errMsg: "localhost access not allowed",
},
{
name: "127.0.0.1 attempt",
url: "https://127.0.0.1/auth",
wantErr: true,
errMsg: "localhost access not allowed",
},
{
name: "IPv6 localhost attempt",
url: "https://[::1]/auth",
wantErr: true,
errMsg: "invalid host:port format",
},
{
name: "0.0.0.0 attempt",
url: "https://0.0.0.0/auth",
wantErr: true,
errMsg: "localhost access not allowed",
},
{
name: "Private IP - 192.168.x.x",
url: "https://192.168.1.1/auth",
wantErr: true,
errMsg: "private IP not allowed",
},
{
name: "Private IP - 10.x.x.x",
url: "https://10.0.0.1/auth",
wantErr: true,
errMsg: "private IP not allowed",
},
{
name: "Private IP - 172.16.x.x",
url: "https://172.16.0.1/auth",
wantErr: true,
errMsg: "private IP not allowed",
},
{
name: "Link-local IP",
url: "https://169.254.1.1/auth",
wantErr: true,
errMsg: "link-local IP not allowed",
},
{
name: "Multicast IP",
url: "https://224.0.0.1/auth",
wantErr: true,
errMsg: "multicast IP not allowed",
},
{
name: "Valid public IP",
url: "https://8.8.8.8/auth",
wantErr: false,
},
{
name: "Valid domain with port",
url: "https://example.com:8443/auth",
wantErr: false,
},
{
name: "localhost with case variation",
url: "https://LOCALHOST/auth",
wantErr: true,
errMsg: "localhost access not allowed",
},
{
name: "Invalid host:port format",
url: "https://example.com:notanumber/auth",
wantErr: true,
errMsg: "invalid URL format",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := handler.validateURL(tt.url)
if tt.wantErr {
if err == nil {
t.Errorf("validateURL() expected error but got none")
return
}
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("validateURL() error = %v, expected error containing %v", err, tt.errMsg)
}
} else {
if err != nil {
t.Errorf("validateURL() unexpected error = %v", err)
}
}
})
}
}
// TestAuthHandler_validateHost tests host validation specifically
func TestAuthHandler_validateHost(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
tests := []struct {
name string
host string
wantErr bool
errMsg string
}{
{
name: "Valid hostname",
host: "example.com",
wantErr: false,
},
{
name: "Valid hostname with subdomain",
host: "api.example.com",
wantErr: false,
},
{
name: "Valid hostname with port",
host: "example.com:8080",
wantErr: false,
},
{
name: "Empty host",
host: "",
wantErr: true,
errMsg: "empty host",
},
{
name: "localhost",
host: "localhost",
wantErr: true,
errMsg: "localhost access not allowed",
},
{
name: "LOCALHOST (case insensitive)",
host: "LOCALHOST",
wantErr: true,
errMsg: "localhost access not allowed",
},
{
name: "localhost with port",
host: "localhost:8080",
wantErr: true,
errMsg: "localhost access not allowed",
},
{
name: "127.0.0.1",
host: "127.0.0.1",
wantErr: true,
errMsg: "localhost access not allowed",
},
{
name: "127.0.0.1 with port",
host: "127.0.0.1:8080",
wantErr: true,
errMsg: "localhost access not allowed",
},
{
name: "IPv6 localhost",
host: "::1",
wantErr: true,
errMsg: "invalid host:port format",
},
{
name: "0.0.0.0",
host: "0.0.0.0",
wantErr: true,
errMsg: "localhost access not allowed",
},
{
name: "Private IP 192.168.1.1",
host: "192.168.1.1",
wantErr: true,
errMsg: "private IP not allowed",
},
{
name: "Private IP 10.0.0.1",
host: "10.0.0.1",
wantErr: true,
errMsg: "private IP not allowed",
},
{
name: "Private IP 172.16.0.1",
host: "172.16.0.1",
wantErr: true,
errMsg: "private IP not allowed",
},
{
name: "Public IP 8.8.8.8",
host: "8.8.8.8",
wantErr: false,
},
{
name: "Link-local IP",
host: "169.254.1.1",
wantErr: true,
errMsg: "link-local IP not allowed",
},
{
name: "Multicast IP",
host: "224.0.0.1",
wantErr: true,
errMsg: "multicast IP not allowed",
},
{
name: "Invalid host:port format",
host: "example.com::",
wantErr: true,
errMsg: "invalid host:port format",
},
{
name: "Valid international domain",
host: "example.org",
wantErr: false,
},
{
name: "Valid ccTLD",
host: "example.co.uk",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := handler.validateHost(tt.host)
if tt.wantErr {
if err == nil {
t.Errorf("validateHost() expected error but got none")
return
}
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("validateHost() error = %v, expected error containing %v", err, tt.errMsg)
}
} else {
if err != nil {
t.Errorf("validateHost() unexpected error = %v", err)
}
}
})
}
}
// TestAuthHandler_buildURLWithParams tests URL building with parameters
func TestAuthHandler_buildURLWithParams(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
tests := []struct {
name string
baseURL string
params url.Values
expected string
expectEmpty bool
}{
{
name: "Absolute HTTPS URL",
baseURL: "https://provider.com/auth",
params: url.Values{
"client_id": []string{"test-client"},
"response_type": []string{"code"},
},
expected: "https://provider.com/auth?client_id=test-client&response_type=code",
},
{
name: "Absolute HTTP URL",
baseURL: "http://provider.com/auth",
params: url.Values{
"state": []string{"test-state"},
},
expected: "http://provider.com/auth?state=test-state",
},
{
name: "Relative URL resolved against issuer",
baseURL: "/oauth2/authorize",
params: url.Values{
"scope": []string{"openid"},
},
expected: "https://example.com/oauth2/authorize?scope=openid",
},
{
name: "Root relative URL",
baseURL: "/auth",
params: url.Values{
"nonce": []string{"test-nonce"},
},
expected: "https://example.com/auth?nonce=test-nonce",
},
{
name: "Invalid absolute URL",
baseURL: "https://localhost/auth",
params: url.Values{},
expectEmpty: true, // Should return empty string due to validation failure
},
{
name: "Invalid relative URL when resolved",
baseURL: "/auth",
params: url.Values{},
expected: "", // Should be empty because issuer validation would be tested separately
expectEmpty: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := handler.buildURLWithParams(tt.baseURL, tt.params)
if tt.expectEmpty {
if result != "" {
t.Errorf("buildURLWithParams() expected empty string, got %v", result)
}
return
}
// For relative URLs, we expect them to be resolved against the issuer URL
if !strings.HasPrefix(tt.baseURL, "http") {
// Verify it starts with the issuer URL
if !strings.HasPrefix(result, handler.issuerURL) {
t.Errorf("buildURLWithParams() relative URL not resolved against issuer URL. Got %v", result)
}
}
// Parse the result to verify parameters
parsedURL, err := url.Parse(result)
if err != nil {
t.Fatalf("buildURLWithParams() produced invalid URL: %v", err)
}
// Verify all expected parameters are present
resultParams := parsedURL.Query()
for key, expectedValues := range tt.params {
actualValues := resultParams[key]
if len(actualValues) != len(expectedValues) {
t.Errorf("Parameter %s: expected %d values, got %d", key, len(expectedValues), len(actualValues))
continue
}
for i, expectedValue := range expectedValues {
if actualValues[i] != expectedValue {
t.Errorf("Parameter %s[%d]: expected %v, got %v", key, i, expectedValue, actualValues[i])
}
}
}
})
}
}
// TestAuthHandler_buildURLWithParams_ParameterEncoding tests proper parameter encoding
func TestAuthHandler_buildURLWithParams_ParameterEncoding(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
// Test special characters that need encoding
params := url.Values{
"redirect_uri": []string{"https://example.com/callback?test=value&other=data"},
"state": []string{"state with spaces and & special chars"},
"scope": []string{"openid profile email"},
"special": []string{"value+with+plus&ampersand=equals"},
}
result := handler.buildURLWithParams("https://provider.com/auth", params)
parsedURL, err := url.Parse(result)
if err != nil {
t.Fatalf("Failed to parse result URL: %v", err)
}
// Verify parameters are correctly encoded/decoded
resultParams := parsedURL.Query()
expectedParams := map[string]string{
"redirect_uri": "https://example.com/callback?test=value&other=data",
"state": "state with spaces and & special chars",
"scope": "openid profile email",
"special": "value+with+plus&ampersand=equals",
}
for key, expectedValue := range expectedParams {
actualValue := resultParams.Get(key)
if actualValue != expectedValue {
t.Errorf("Parameter %s: expected %v, got %v", key, expectedValue, actualValue)
}
}
}
// TestAuthHandler_validateParsedURL tests validateParsedURL method
func TestAuthHandler_validateParsedURL(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
tests := []struct {
name string
url string
wantErr bool
errMsg string
}{
{
name: "Valid HTTPS URL",
url: "https://example.com/path",
wantErr: false,
},
{
name: "Valid HTTP URL with warning",
url: "http://example.com/path",
wantErr: false, // Should not error but should log warning
},
{
name: "Invalid scheme",
url: "javascript:alert('xss')",
wantErr: true,
errMsg: "disallowed URL scheme",
},
{
name: "Missing host",
url: "https:///path",
wantErr: true,
errMsg: "missing host",
},
{
name: "Path traversal",
url: "https://example.com/path/../../../etc",
wantErr: true,
errMsg: "path traversal detected",
},
{
name: "Invalid host (private IP)",
url: "https://192.168.1.1/path",
wantErr: true,
errMsg: "invalid host",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parsedURL, err := url.Parse(tt.url)
if err != nil {
t.Fatalf("Failed to parse test URL: %v", err)
}
err = handler.validateParsedURL(parsedURL)
if tt.wantErr {
if err == nil {
t.Errorf("validateParsedURL() expected error but got none")
return
}
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("validateParsedURL() error = %v, expected error containing %v", err, tt.errMsg)
}
} else {
if err != nil {
t.Errorf("validateParsedURL() unexpected error = %v", err)
}
// Check for HTTP warning in debug logs
if parsedURL.Scheme == "http" && len(logger.debugMessages) > 0 {
found := false
for _, msg := range logger.debugMessages {
if strings.Contains(msg, "Warning: Using HTTP scheme") {
found = true
break
}
}
if !found {
t.Error("Expected HTTP scheme warning in debug logs")
}
}
}
})
}
}
// TestAuthHandler_validateHost_AllowPrivateIPAddresses tests the allowPrivateIPAddresses flag
func TestAuthHandler_validateHost_AllowPrivateIPAddresses(t *testing.T) {
logger := &mockLogger{}
// Test with allowPrivateIPAddresses = false (default)
t.Run("Private IPs blocked by default", func(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
privateIPs := []string{
"192.168.1.1",
"10.0.0.1",
"172.16.0.1",
"172.31.255.255",
}
for _, ip := range privateIPs {
err := handler.validateHost(ip)
if err == nil {
t.Errorf("Expected private IP %s to be blocked, but it was allowed", ip)
}
if err != nil && !strings.Contains(err.Error(), "private IP not allowed") {
t.Errorf("Expected 'private IP not allowed' error for %s, got: %v", ip, err)
}
}
})
// Test with allowPrivateIPAddresses = true
t.Run("Private IPs allowed when flag enabled", func(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, true)
privateIPs := []string{
"192.168.1.1",
"10.0.0.1",
"172.16.0.1",
"172.31.255.255",
}
for _, ip := range privateIPs {
err := handler.validateHost(ip)
if err != nil {
t.Errorf("Expected private IP %s to be allowed with flag enabled, but got error: %v", ip, err)
}
}
})
// Test that loopback is still blocked even with flag enabled
t.Run("Loopback always blocked", func(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, true)
loopbackAddresses := []string{
"127.0.0.1",
"localhost",
"::1",
"0.0.0.0",
}
for _, addr := range loopbackAddresses {
err := handler.validateHost(addr)
if err == nil {
t.Errorf("Expected loopback address %s to be blocked even with allowPrivateIPAddresses=true", addr)
}
}
})
// Test that link-local is still blocked even with flag enabled
t.Run("Link-local always blocked", func(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, true)
err := handler.validateHost("169.254.1.1")
if err == nil {
t.Error("Expected link-local address to be blocked even with allowPrivateIPAddresses=true")
}
})
// Test that public IPs work with flag enabled
t.Run("Public IPs allowed", func(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, true)
publicIPs := []string{
"8.8.8.8",
"1.1.1.1",
"142.250.185.68",
}
for _, ip := range publicIPs {
err := handler.validateHost(ip)
if err != nil {
t.Errorf("Expected public IP %s to be allowed, but got error: %v", ip, err)
}
}
})
}
-428
View File
@@ -1,428 +0,0 @@
// Package traefikoidc provides OIDC authentication middleware for Traefik.
// This file contains tests for Auth0-specific audience validation scenarios.
package traefikoidc
import (
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestAuth0Scenario1WithCustomAudience tests Auth0 scenario 1:
// - Custom audience configured in plugin
// - Authorize endpoint called WITH audience parameter
// - ID token: aud = client_id
// - Access token: aud = [userinfo, custom_audience]
// Expected: Both tokens validate correctly
func TestAuth0Scenario1WithCustomAudience(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
customAudience := "https://my-api.example.com"
ts.tOidc.audience = customAudience
// Create ID token with aud = client_id (OIDC standard)
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id", // ID token always has client_id
"nonce": "test-nonce-scenario1", // ID tokens have nonce per OIDC spec
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "test@example.com",
"jti": "id-token-jti",
})
if err != nil {
t.Fatalf("Failed to create ID token: %v", err)
}
// Create access token with aud = [userinfo, custom_audience]
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": []interface{}{
"https://test-issuer.com/userinfo",
customAudience, // Custom API audience
},
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"scope": "openid profile email read:data", // Access tokens have scope
"jti": "access-token-jti",
})
if err != nil {
t.Fatalf("Failed to create access token: %v", err)
}
// Verify ID token validates against client_id
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(idToken)
if err != nil {
t.Errorf("ID token validation failed (should validate against client_id): %v", err)
}
// Verify access token validates against custom audience
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(accessToken)
if err != nil {
t.Errorf("Access token validation failed (should validate against custom audience): %v", err)
}
// Verify buildAuthURL includes audience parameter (URL-encoded)
authURL := ts.tOidc.buildAuthURL("https://example.com/callback", "state", "nonce", "")
if !strings.Contains(authURL, "audience=") {
t.Errorf("Auth URL should contain audience parameter when custom audience is configured, got: %s", authURL)
}
// Verify the audience is properly URL-encoded (contains %3A for :, %2F for /)
if !strings.Contains(authURL, "audience=https%3A%2F%2Fmy-api.example.com") {
t.Errorf("Auth URL should contain URL-encoded custom audience, got: %s", authURL)
}
}
// TestAuth0Scenario2DefaultAudience tests Auth0 scenario 2:
// - No custom audience configured (defaults to client_id)
// - Authorize endpoint called WITHOUT audience parameter
// - ID token: aud = client_id
// - Access token: aud = [userinfo, default_audience] (no client_id)
// Expected: ID token validates, access token falls back to ID token validation
func TestAuth0Scenario2DefaultAudience(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
// No custom audience - defaults to client_id
ts.tOidc.audience = ts.tOidc.clientID
// Create ID token with aud = client_id
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"nonce": "test-nonce-scenario2", // ID tokens have nonce per OIDC spec
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "test@example.com",
"jti": "id-token-jti-2",
})
if err != nil {
t.Fatalf("Failed to create ID token: %v", err)
}
// Create access token with aud = [userinfo, some_default_audience]
// This represents Auth0's default audience behavior
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": []interface{}{
"https://test-issuer.com/userinfo",
"https://test-issuer.com/api/v2/", // Default Auth0 Management API
},
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"scope": "openid profile email",
"jti": "access-token-jti-2",
})
if err != nil {
t.Fatalf("Failed to create access token: %v", err)
}
// Verify ID token validates
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(idToken)
if err != nil {
t.Errorf("ID token validation failed: %v", err)
}
// Access token won't have client_id in aud, so it will fail validation
// This is expected for scenario 2 - the session validation relies on ID token
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(accessToken)
if err == nil {
t.Logf("Access token validation passed (unexpected but OK if client_id is in aud array)")
} else {
// Expected failure - access token doesn't have client_id in aud
t.Logf("Access token validation failed as expected (aud doesn't contain client_id): %v", err)
}
// Verify buildAuthURL does NOT include audience parameter (since audience == client_id)
authURL := ts.tOidc.buildAuthURL("https://example.com/callback", "state", "nonce", "")
if strings.Contains(authURL, "audience=") {
t.Errorf("Auth URL should NOT contain audience parameter when audience equals client_id, got: %s", authURL)
}
}
// TestAuth0Scenario3OpaqueAccessToken tests Auth0 scenario 3:
// - No custom audience configured
// - No default audience in Auth0
// - ID token: aud = client_id
// - Access token: opaque (not JWT)
// Expected: ID token validates, opaque access token is accepted
func TestAuth0Scenario3OpaqueAccessToken(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
// Enable opaque tokens for this scenario (Option C requirement)
ts.tOidc.allowOpaqueTokens = true
// No custom audience
ts.tOidc.audience = ts.tOidc.clientID
// Create ID token
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"nonce": "test-nonce-scenario3", // ID tokens have nonce per OIDC spec
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "test@example.com",
"jti": "id-token-jti-3",
})
if err != nil {
t.Fatalf("Failed to create ID token: %v", err)
}
// Opaque access token (not a JWT - just a random string)
opaqueAccessToken := "opaque_access_token_random_string_12345"
// Verify ID token validates
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(idToken)
if err != nil {
t.Errorf("ID token validation failed: %v", err)
}
// Opaque access token should fail JWT validation (expected)
err = ts.tOidc.VerifyToken(opaqueAccessToken)
if err == nil {
t.Error("Opaque access token should fail JWT validation")
} else {
t.Logf("Opaque access token correctly rejected by JWT validator: %v", err)
}
// Test that validateStandardTokens handles opaque tokens correctly
// by falling back to ID token validation
req := httptest.NewRequest("GET", "https://example.com/test", nil)
session, err := ts.tOidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
session.SetAuthenticated(true)
session.SetAccessToken(opaqueAccessToken)
session.SetIDToken(idToken)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
if !authenticated || needsRefresh || expired {
t.Errorf("Session with opaque access token and valid ID token should be authenticated. Got: auth=%v, refresh=%v, expired=%v",
authenticated, needsRefresh, expired)
}
}
// TestAuth0AudienceArrayValidation tests that audience validation
// correctly handles array audiences (common in Auth0)
func TestAuth0AudienceArrayValidation(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
customAudience := "https://my-api.example.com"
ts.tOidc.audience = customAudience
// Access token with audience as array containing our custom audience
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": []interface{}{
"https://test-issuer.com/userinfo",
customAudience,
"https://another-api.example.com",
},
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"scope": "openid profile email read:data write:data",
"jti": "array-aud-token-jti",
})
if err != nil {
t.Fatalf("Failed to create access token: %v", err)
}
// Should validate successfully - custom audience is in the array
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(accessToken)
if err != nil {
t.Errorf("Access token with audience array should validate when custom audience is present: %v", err)
}
}
// TestAuth0MismatchedAudience tests that tokens with wrong audience fail validation
func TestAuth0MismatchedAudience(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
customAudience := "https://my-api.example.com"
ts.tOidc.audience = customAudience
// Access token with WRONG audience
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": []interface{}{
"https://test-issuer.com/userinfo",
"https://different-api.example.com", // Wrong audience
},
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"scope": "openid profile email",
"jti": "wrong-aud-token-jti",
})
if err != nil {
t.Fatalf("Failed to create access token: %v", err)
}
// Should fail validation - audience doesn't match
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(accessToken)
if err == nil {
t.Error("Access token with wrong audience should fail validation")
} else if !strings.Contains(err.Error(), "invalid audience") {
t.Errorf("Expected 'invalid audience' error, got: %v", err)
}
}
// TestAuth0Scenario2StrictMode tests strict audience validation mode:
// - Scenario 2 (access token with wrong audience) should be REJECTED
// - strictAudienceValidation=true prevents fallback to ID token
// - This addresses Allan's security concerns about audience bypass
func TestAuth0Scenario2StrictMode(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
// Enable strict mode to prevent Scenario 2 bypass (Option C)
ts.tOidc.strictAudienceValidation = true
// Configure custom audience
customAudience := "https://my-api.example.com"
ts.tOidc.audience = customAudience
// Create ID token with aud = client_id (valid)
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"nonce": "test-nonce-strict",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "test@example.com",
"jti": "id-token-strict-jti",
})
if err != nil {
t.Fatalf("Failed to create ID token: %v", err)
}
// Create access token with WRONG audience (doesn't include custom audience)
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": []interface{}{
"https://test-issuer.com/userinfo",
"https://wrong-api.example.com", // Wrong audience - not our custom audience
},
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"scope": "openid profile email",
"jti": "access-token-strict-jti",
})
if err != nil {
t.Fatalf("Failed to create access token: %v", err)
}
// Test session validation with wrong access token and valid ID token
req := httptest.NewRequest("GET", "https://example.com/test", nil)
session, err := ts.tOidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
session.SetAuthenticated(true)
session.SetAccessToken(accessToken)
session.SetIDToken(idToken)
session.SetRefreshToken("test-refresh-token") // Add refresh token so it can attempt refresh
// In strict mode, this should FAIL (no fallback to ID token)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
if authenticated {
t.Errorf("Strict mode: Session with wrong access token audience should be rejected, but got authenticated=true")
}
if !needsRefresh {
t.Errorf("Strict mode: Should signal refresh needed after rejection, got needsRefresh=%v", needsRefresh)
}
if expired {
t.Errorf("Strict mode: Should not mark as expired (should try refresh first), got expired=%v", expired)
}
t.Logf("✓ Strict mode correctly rejected Scenario 2 (access token audience mismatch)")
}
// TestIDTokenAlwaysValidatesAgainstClientID verifies that ID tokens
// are ALWAYS validated against client_id, regardless of configured audience
func TestIDTokenAlwaysValidatesAgainstClientID(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
// Configure a custom audience different from client_id
customAudience := "https://my-api.example.com"
ts.tOidc.audience = customAudience
// Create ID token with aud = client_id (per OIDC spec)
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id", // ID token MUST have client_id
"nonce": "test-nonce-123", // ID tokens have nonce for replay protection
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "test@example.com",
"jti": "id-token-client-id-jti",
})
if err != nil {
t.Fatalf("Failed to create ID token: %v", err)
}
// Should validate successfully - ID tokens are checked against client_id
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(idToken)
if err != nil {
t.Errorf("ID token should validate against client_id even when custom audience is configured: %v", err)
}
// Create ID token with WRONG audience (should fail)
wrongIDToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": customAudience, // WRONG - should be client_id
"nonce": "test-nonce-wrong-456", // ID token has nonce, so it will be detected as ID token
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"email": "test@example.com",
"jti": "wrong-id-token-jti",
})
if err != nil {
t.Fatalf("Failed to create wrong ID token: %v", err)
}
// Should fail - ID tokens must have client_id as audience
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(wrongIDToken)
if err == nil {
t.Error("ID token with custom audience (not client_id) should fail validation")
}
}
+29 -7
View File
@@ -4,14 +4,8 @@ import (
"fmt"
"net/http"
"strings"
"github.com/google/uuid"
)
// ============================================================================
// AUTHENTICATION FLOW
// ============================================================================
// validateRedirectCount checks if redirect limit is exceeded and handles the error
func (t *TraefikOidc) validateRedirectCount(session *SessionData, rw http.ResponseWriter, req *http.Request) error {
const maxRedirects = 5
@@ -81,7 +75,12 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
return
}
csrfToken := uuid.NewString()
csrfToken, err := newUUIDv4()
if err != nil {
t.logger.Errorf("Failed to generate CSRF token: %v", err)
http.Error(rw, "Failed to generate CSRF token", http.StatusInternalServerError)
return
}
nonce, err := generateNonce()
if err != nil {
t.logger.Errorf("Failed to generate nonce: %v", err)
@@ -338,6 +337,29 @@ func (t *TraefikOidc) isAjaxRequest(req *http.Request) bool {
strings.Contains(accept, "application/json")
}
// isNonNavigationRequest reports whether the request is a browser
// sub-resource (script, image, stylesheet, fetch, serviceWorker) rather than
// a top-level HTML navigation. Non-navigation requests MUST NOT trigger an
// OIDC redirect flow: several sub-resource loads happening in parallel would
// each call defaultInitiateAuthentication, each overwriting the session's
// CSRF/nonce, breaking the eventual callback (issue #129).
//
// Detection prefers Sec-Fetch-Mode, which all modern browsers send
// (Chrome/Edge/Firefox/Safari). For older or non-browser clients we fall
// back to Accept: if Accept is present and does not list text/html, treat
// it as a sub-resource. An empty/missing Accept is assumed to be navigation
// (safer to redirect than 401 on an ambiguous request).
func (t *TraefikOidc) isNonNavigationRequest(req *http.Request) bool {
if mode := req.Header.Get("Sec-Fetch-Mode"); mode != "" {
return mode != "navigate"
}
accept := req.Header.Get("Accept")
if accept == "" || accept == "*/*" {
return false
}
return !strings.Contains(accept, "text/html")
}
// isRefreshTokenExpired checks if refresh token is likely expired (older than 6 hours)
func (t *TraefikOidc) isRefreshTokenExpired(session *SessionData) bool {
// This is a heuristic check - actual implementation would depend on
File diff suppressed because it is too large Load Diff
+21 -18
View File
@@ -222,17 +222,16 @@ func (bt *BackgroundTask) run() {
// TaskCircuitBreaker implements circuit breaker pattern for background task creation
// It limits concurrent task execution and tracks failures to prevent system overload
type TaskCircuitBreaker struct {
state int32 // CircuitBreakerState
failureCount int32
lastFailureTime int64 // Unix timestamp
failureThreshold int32
timeout time.Duration
logger *Logger
// Concurrency limiting
concurrentTasks int32 // Current number of running tasks
maxConcurrent int32 // Maximum concurrent tasks allowed
activeTasks map[string]struct{} // Track active task names
tasksMu sync.RWMutex // Separate mutex for task tracking
activeTasks map[string]struct{}
lastFailureTime int64
timeout time.Duration
tasksMu sync.RWMutex
state int32
failureCount int32
failureThreshold int32
concurrentTasks int32
maxConcurrent int32
}
// NewTaskCircuitBreaker creates a new circuit breaker for background tasks
@@ -266,18 +265,21 @@ func (cb *TaskCircuitBreaker) CanCreateTask(taskName string) error {
max := atomic.LoadInt32(&cb.maxConcurrent)
// For cleanup tasks, be more restrictive (singleton-like behavior)
// However, allow distinct realm-specific tasks (e.g., singleton-metadata-refresh-abc123 vs singleton-metadata-refresh-def456)
if strings.Contains(taskName, "cleanup") || strings.Contains(taskName, "singleton") {
cb.tasksMu.RLock()
hasCleanupTask := false
hasSameTask := false
for activeTask := range cb.activeTasks {
if strings.Contains(activeTask, "cleanup") || strings.Contains(activeTask, "singleton") {
hasCleanupTask = true
// Only block if the EXACT same task is already running
// This allows realm-specific tasks like singleton-metadata-refresh-{hash} to run concurrently
if activeTask == taskName {
hasSameTask = true
break
}
}
cb.tasksMu.RUnlock()
if hasCleanupTask {
if hasSameTask {
return fmt.Errorf("cleanup/singleton task already running: %s", taskName)
}
}
@@ -377,9 +379,9 @@ func (cb *TaskCircuitBreaker) OnTaskFailure(taskName string, err error) {
// TaskRegistry maintains a registry of all active background tasks to prevent duplicates
type TaskRegistry struct {
tasks map[string]*BackgroundTask
mu sync.RWMutex
cb *TaskCircuitBreaker
logger *Logger
mu sync.RWMutex
}
// GlobalTaskRegistry is the singleton instance for managing all background tasks
@@ -597,8 +599,9 @@ func GetGlobalTaskMemoryMonitor(logger *Logger) *TaskMemoryMonitor {
return globalTaskMemoryMonitor
}
// NewTaskMemoryMonitor creates a new memory monitor for task registry
// Deprecated: Use GetGlobalTaskMemoryMonitor instead for singleton behavior
// NewTaskMemoryMonitor creates a new memory monitor for task registry.
//
// Deprecated: Use GetGlobalTaskMemoryMonitor instead for singleton behavior.
func NewTaskMemoryMonitor(logger *Logger, registry *TaskRegistry) *TaskMemoryMonitor {
return GetGlobalTaskMemoryMonitor(logger)
}
@@ -710,7 +713,7 @@ func (mm *TaskMemoryMonitor) checkForMemoryIssues(stats TaskMemoryStats) {
// Check for goroutine leaks (arbitrary threshold)
if stats.Goroutines > 100 {
mm.logger.Infof("High goroutine count detected: %d", stats.Goroutines)
mm.logger.Debugf("High goroutine count detected: %d", stats.Goroutines)
}
// Check for heap growth without corresponding GC activity
+6 -6
View File
@@ -330,12 +330,12 @@ func TestValidateGoogleTokens(t *testing.T) {
ts.tOidc.refreshGracePeriod = 60 * time.Second
tests := []struct {
name string
setupSession func() *SessionData
name string
description string
expectedAuth bool
expectedRefresh bool
expectedExpired bool
description string
}{
{
name: "ValidGoogleTokens",
@@ -476,13 +476,13 @@ func TestIsUserAuthenticated(t *testing.T) {
ts.tOidc.refreshGracePeriod = 60 * time.Second
tests := []struct {
setupSession func() *SessionData
name string
providerType string
setupSession func() *SessionData
description string
expectedAuth bool
expectedRefresh bool
expectedExpired bool
description string
}{
{
name: "AzureProvider",
@@ -660,12 +660,12 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
ts.tOidc.refreshGracePeriod = 60 * time.Second
tests := []struct {
name string
setupSession func() *SessionData
name string
description string
expectedAuth bool
expectedRefresh bool
expectedExpired bool
description string
}{
{
name: "UnauthenticatedWithRefreshToken",
+30 -21
View File
@@ -29,8 +29,9 @@ func TestMemoryMonitorComprehensive(t *testing.T) {
pressure := monitor.GetMemoryPressure()
assert.Equal(t, MemoryPressureNone, pressure)
// Collect stats to populate lastStats
monitor.GetCurrentStats()
// Explicitly sample to populate lastStats; GetCurrentStats is now a
// cached read and no longer forces a runtime.ReadMemStats.
monitor.Refresh()
// Now should return a valid pressure level
pressure = monitor.GetMemoryPressure()
@@ -46,11 +47,13 @@ func TestMemoryMonitorComprehensive(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
// Start monitoring should not panic
// Start monitoring should not panic. Interval is clamped to the
// minimum (30s); we rely on Refresh() when we need a synchronous
// sample instead of waiting for a tick.
assert.NotPanics(t, func() {
ctx := context.Background()
monitor.StartMonitoring(ctx, 100*time.Millisecond)
time.Sleep(GetTestDuration(50 * time.Millisecond))
monitor.StartMonitoring(ctx, 0)
monitor.Refresh()
})
// Clean up
@@ -97,15 +100,15 @@ func TestMemoryMonitorComprehensive(t *testing.T) {
t.Run("String method returns pressure name", func(t *testing.T) {
pressures := []struct {
level MemoryPressureLevel
name string
level MemoryPressureLevel
}{
{MemoryPressureNone, "None"},
{MemoryPressureLow, "Low"},
{MemoryPressureModerate, "Moderate"},
{MemoryPressureHigh, "High"},
{MemoryPressureCritical, "Critical"},
{MemoryPressureLevel(999), "Unknown"},
{level: MemoryPressureNone, name: "None"},
{level: MemoryPressureLow, name: "Low"},
{level: MemoryPressureModerate, name: "Moderate"},
{level: MemoryPressureHigh, name: "High"},
{level: MemoryPressureCritical, name: "Critical"},
{level: MemoryPressureLevel(999), name: "Unknown"},
}
for _, p := range pressures {
@@ -117,6 +120,9 @@ func TestMemoryMonitorComprehensive(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
// Refresh forces a synchronous sample; GetCurrentStats is a cached
// read, so we sample first to guarantee fresh data.
monitor.Refresh()
stats := monitor.GetCurrentStats()
assert.NotNil(t, stats)
assert.Greater(t, stats.HeapAllocBytes, uint64(0))
@@ -450,12 +456,12 @@ func TestMemoryMonitorIntegration(t *testing.T) {
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
defer monitor.StopMonitoring()
// Start monitoring
// Start monitoring. The interval is clamped to the minimum (30s) so
// the ticker won't fire during the test; drive the sample manually via
// Refresh() instead.
ctx := context.Background()
monitor.StartMonitoring(ctx, 50*time.Millisecond)
// Wait for at least one check
time.Sleep(GetTestDuration(150 * time.Millisecond))
monitor.StartMonitoring(ctx, 0)
monitor.Refresh()
// Get pressure (should be a valid pressure level)
pressure := monitor.GetMemoryPressure()
@@ -488,6 +494,7 @@ func TestMemoryStatsCollection(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
monitor.Refresh()
stats := monitor.GetCurrentStats()
assert.NotNil(t, stats)
@@ -501,6 +508,7 @@ func TestMemoryStatsCollection(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
monitor.Refresh()
stats := monitor.GetCurrentStats()
// Should calculate and include pressure level
@@ -521,13 +529,14 @@ func TestMemoryStatsCollection(t *testing.T) {
// Allocate some memory
_ = make([]byte, 1024*1024) // 1MB
// Get stats before GC
beforeStats := monitor.GetCurrentStats()
// Get stats before GC (explicit Refresh so we have a fresh pre-GC
// snapshot to compare against, not the constructor baseline).
beforeStats := monitor.Refresh()
// Trigger GC
// Trigger GC (internally Refresh()es before and after)
monitor.TriggerGC()
// Get stats after GC
// Get stats after GC from cache (TriggerGC already refreshed it)
afterStats := monitor.GetCurrentStats()
// After GC should have different stats
+137
View File
@@ -0,0 +1,137 @@
package traefikoidc
import (
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// testCertPEM returns a valid PEM-encoded certificate harvested from an
// httptest.NewTLSServer. Using httptest keeps the test free of any
// handwritten static cert that could expire.
func testCertPEM(t *testing.T) string {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
t.Cleanup(srv.Close)
cert := srv.Certificate()
if cert == nil {
t.Fatal("httptest.NewTLSServer did not expose a certificate")
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))
}
func TestLoadCACertPool_Empty(t *testing.T) {
cfg := &Config{}
pool, err := cfg.loadCACertPool()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pool != nil {
t.Errorf("expected nil pool when no CA source configured, got %v", pool)
}
}
func TestLoadCACertPool_InlinePEM(t *testing.T) {
cfg := &Config{CACertPEM: testCertPEM(t)}
pool, err := cfg.loadCACertPool()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pool == nil {
t.Fatal("expected non-nil pool for valid CACertPEM")
}
}
func TestLoadCACertPool_InlinePEM_Garbage(t *testing.T) {
cfg := &Config{CACertPEM: "not a pem"}
pool, err := cfg.loadCACertPool()
if err == nil {
t.Fatal("expected error for garbage CACertPEM, got nil")
}
if pool != nil {
t.Errorf("expected nil pool on error, got %v", pool)
}
if !strings.Contains(err.Error(), "caCertPEM") {
t.Errorf("error should name the failing field, got: %v", err)
}
}
func TestLoadCACertPool_FilePath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ca.pem")
if err := os.WriteFile(path, []byte(testCertPEM(t)), 0o600); err != nil {
t.Fatalf("writing temp PEM: %v", err)
}
cfg := &Config{CACertPath: path}
pool, err := cfg.loadCACertPool()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pool == nil {
t.Fatal("expected non-nil pool for valid CACertPath")
}
}
func TestLoadCACertPool_FilePath_Missing(t *testing.T) {
cfg := &Config{CACertPath: "/does/not/exist/ca.pem"}
pool, err := cfg.loadCACertPool()
if err == nil {
t.Fatal("expected error for missing CACertPath, got nil")
}
if pool != nil {
t.Errorf("expected nil pool on error, got %v", pool)
}
}
func TestLoadCACertPool_Combined(t *testing.T) {
// Both inline and file sources populated — certificates from both should
// be accepted into the same pool.
dir := t.TempDir()
path := filepath.Join(dir, "ca.pem")
if err := os.WriteFile(path, []byte(testCertPEM(t)), 0o600); err != nil {
t.Fatalf("writing temp PEM: %v", err)
}
cfg := &Config{CACertPath: path, CACertPEM: testCertPEM(t)}
pool, err := cfg.loadCACertPool()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pool == nil {
t.Fatal("expected non-nil pool when both sources set")
}
}
func TestSharedTransportPool_ConfigKeyDistinguishesCAAndSkipVerify(t *testing.T) {
p := GetGlobalTransportPool()
cfgSystem := DefaultHTTPClientConfig()
cfgSkip := DefaultHTTPClientConfig()
cfgSkip.InsecureSkipVerify = true
cfgCustomCA := DefaultHTTPClientConfig()
pool, err := (&Config{CACertPEM: testCertPEM(t)}).loadCACertPool()
if err != nil {
t.Fatalf("loadCACertPool: %v", err)
}
cfgCustomCA.RootCAs = pool
keys := map[string]string{
"system": p.configKey(cfgSystem),
"skip": p.configKey(cfgSkip),
"customCA": p.configKey(cfgCustomCA),
}
seen := make(map[string]string, len(keys))
for name, key := range keys {
if dup, ok := seen[key]; ok {
t.Errorf("configKey collision: %s and %s share key %q", name, dup, key)
}
seen[key] = name
}
}
+241
View File
@@ -0,0 +1,241 @@
package traefikoidc
import (
"fmt"
"sync"
"testing"
"time"
)
// =============================================================================
// UNIVERSAL CACHE BENCHMARKS
// =============================================================================
func BenchmarkCacheSet(b *testing.B) {
cache := NewUniversalCache(createTestCacheConfig())
defer cache.Close()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
cache.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i), 1*time.Hour)
i++
}
})
}
func BenchmarkCacheGet(b *testing.B) {
cache := NewUniversalCache(createTestCacheConfig())
defer cache.Close()
for i := 0; i < 1000; i++ {
cache.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i), 1*time.Hour)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
cache.Get(fmt.Sprintf("key%d", i%1000))
i++
}
})
}
func BenchmarkCacheSetGet(b *testing.B) {
cache := NewUniversalCache(createTestCacheConfig())
defer cache.Close()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("key%d", i)
cache.Set(key, fmt.Sprintf("value%d", i), 1*time.Hour)
cache.Get(key)
i++
}
})
}
func BenchmarkCacheLRUEviction(b *testing.B) {
config := createTestCacheConfig()
config.MaxSize = 100
cache := NewUniversalCache(config)
defer cache.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i), 1*time.Hour)
}
}
func BenchmarkCacheConcurrent(b *testing.B) {
cache := NewUniversalCache(createTestCacheConfig())
defer cache.Close()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
switch i % 3 {
case 0:
cache.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i), 1*time.Hour)
case 1:
cache.Get(fmt.Sprintf("key%d", i))
case 2:
cache.Delete(fmt.Sprintf("key%d", i))
}
i++
}
})
}
// =============================================================================
// CACHE MANAGER BENCHMARKS
// =============================================================================
func BenchmarkCacheInterfaceWrapper_Set(b *testing.B) {
t := &testing.T{}
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Set("benchmark-key", "benchmark-value", time.Hour)
}
}
func BenchmarkCacheInterfaceWrapper_Get(b *testing.B) {
t := &testing.T{}
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
cache.Set("benchmark-key", "benchmark-value", time.Hour)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Get("benchmark-key")
}
}
func BenchmarkCacheInterfaceWrapper_Delete(b *testing.B) {
t := &testing.T{}
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.StopTimer()
key := fmt.Sprintf("benchmark-key-%d", i)
cache.Set(key, "value", time.Hour)
b.StartTimer()
cache.Delete(key)
}
}
// =============================================================================
// CACHE COMPATIBILITY BENCHMARKS
// =============================================================================
func BenchmarkNewBoundedCache(b *testing.B) {
for i := 0; i < b.N; i++ {
NewBoundedCache(1000)
}
}
func BenchmarkNewOptimizedCache(b *testing.B) {
for i := 0; i < b.N; i++ {
NewOptimizedCache()
}
}
func BenchmarkLRUStrategy_EstimateSize(b *testing.B) {
strategy := NewLRUStrategy(1000)
item := "test-item"
b.ResetTimer()
for i := 0; i < b.N; i++ {
strategy.EstimateSize(item)
}
}
// =============================================================================
// SHARDED CACHE BENCHMARKS
// =============================================================================
func BenchmarkShardedCache(b *testing.B) {
b.Run("Set", func(b *testing.B) {
cache := NewShardedCache(64, 100000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Set(fmt.Sprintf("key-%d", i), i, 5*time.Minute)
}
})
b.Run("Get", func(b *testing.B) {
cache := NewShardedCache(64, 100000)
for i := 0; i < 10000; i++ {
cache.Set(fmt.Sprintf("key-%d", i), i, 5*time.Minute)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Get(fmt.Sprintf("key-%d", i%10000))
}
})
b.Run("ParallelSetGet", func(b *testing.B) {
cache := NewShardedCache(64, 100000)
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("key-%d", i)
cache.Set(key, i, 5*time.Minute)
cache.Get(key)
i++
}
})
})
}
// BenchmarkShardedVsGlobalMutex compares sharded cache with global mutex approach
func BenchmarkShardedVsGlobalMutex(b *testing.B) {
b.Run("ShardedCache64", func(b *testing.B) {
cache := NewShardedCache(64, 100000)
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("jti-%d", i%10000)
if !cache.Exists(key) {
cache.Set(key, true, 5*time.Minute)
}
i++
}
})
})
b.Run("GlobalMutexCache", func(b *testing.B) {
var mu sync.RWMutex
data := make(map[string]bool)
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("jti-%d", i%10000)
mu.RLock()
_, exists := data[key]
mu.RUnlock()
if !exists {
mu.Lock()
data[key] = true
mu.Unlock()
}
i++
}
})
})
}
+3 -3
View File
@@ -155,9 +155,9 @@ type CacheStrategy interface {
// CacheEntry for backward compatibility
type CacheEntry struct {
Key string
Value interface{}
ExpiresAt time.Time
Value interface{}
Key string
}
// Cache is an alias for backward compatibility
@@ -175,10 +175,10 @@ func NewOptimizedCacheWithConfig(config OptimizedCacheConfig) *CacheInterfaceWra
// ListNode for backward compatibility
type ListNode struct {
Key string
Value interface{}
Next *ListNode
Prev *ListNode
Key string
}
// NewFixedMetadataCache creates a metadata cache with fixed configuration
-369
View File
@@ -1,369 +0,0 @@
package traefikoidc
import (
"testing"
"time"
)
// TestNewBoundedCache tests creation of bounded cache
func TestNewBoundedCache(t *testing.T) {
maxSize := 500
cache := NewBoundedCache(maxSize)
if cache == nil {
t.Fatal("Expected cache to be created, got nil")
}
// Verify we can use basic operations
cache.Set("test-key", "test-value", time.Hour)
value, found := cache.Get("test-key")
if !found {
t.Error("Expected key to be found in cache")
}
if value != "test-value" {
t.Errorf("Expected 'test-value', got %v", value)
}
}
// TestDefaultUnifiedCacheConfig tests default configuration
func TestDefaultUnifiedCacheConfig(t *testing.T) {
config := DefaultUnifiedCacheConfig()
if config.Type != CacheTypeGeneral {
t.Errorf("Expected CacheTypeGeneral, got %v", config.Type)
}
if config.MaxSize != 500 {
t.Errorf("Expected MaxSize 500, got %d", config.MaxSize)
}
if config.MaxMemoryBytes != 64*1024*1024 {
t.Errorf("Expected MaxMemoryBytes 64MB, got %d", config.MaxMemoryBytes)
}
if config.CleanupInterval != 2*time.Minute {
t.Errorf("Expected CleanupInterval 2 minutes, got %v", config.CleanupInterval)
}
if config.Logger == nil {
t.Error("Expected Logger to be set")
}
}
// TestNewUnifiedCache tests unified cache creation
func TestNewUnifiedCache(t *testing.T) {
config := DefaultUnifiedCacheConfig()
cache := NewUnifiedCache(config)
if cache == nil {
t.Fatal("Expected cache to be created, got nil")
}
if cache.UniversalCache == nil {
t.Error("Expected UniversalCache to be set")
}
// Test basic operations
cache.Set("test-key", "test-value", time.Hour)
value, found := cache.Get("test-key")
if !found {
t.Error("Expected key to be found in cache")
}
if value != "test-value" {
t.Errorf("Expected 'test-value', got %v", value)
}
}
// TestUnifiedCache_SetMaxSize tests SetMaxSize method
func TestUnifiedCache_SetMaxSize(t *testing.T) {
config := DefaultUnifiedCacheConfig()
cache := NewUnifiedCache(config)
// Test setting max size
newSize := 1000
cache.SetMaxSize(newSize)
// We can't easily verify the size was set without exposing internal fields,
// but we can ensure the method doesn't panic
}
// TestNewCacheAdapter tests cache adapter creation
func TestNewCacheAdapter(t *testing.T) {
tests := []struct {
name string
cache interface{}
expectNil bool
description string
}{
{
name: "UniversalCache",
cache: NewUniversalCache(DefaultUnifiedCacheConfig()),
expectNil: false,
description: "Should create adapter for UniversalCache",
},
{
name: "UnifiedCache",
cache: NewUnifiedCache(DefaultUnifiedCacheConfig()),
expectNil: false,
description: "Should create adapter for UnifiedCache",
},
{
name: "Invalid cache type",
cache: "not-a-cache",
expectNil: true,
description: "Should return nil for invalid cache type",
},
{
name: "Nil cache",
cache: nil,
expectNil: true,
description: "Should return nil for nil cache",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
adapter := NewCacheAdapter(tt.cache)
if tt.expectNil {
if adapter != nil {
t.Errorf("Expected nil adapter, got %v", adapter)
}
} else {
if adapter == nil {
t.Error("Expected non-nil adapter")
}
// Test basic operations
adapter.Set("test", "value", time.Hour)
value, found := adapter.Get("test")
if !found {
t.Error("Expected key to be found")
}
if value != "value" {
t.Errorf("Expected 'value', got %v", value)
}
}
})
}
}
// TestNewOptimizedCache tests optimized cache creation
func TestNewOptimizedCache(t *testing.T) {
cache := NewOptimizedCache()
if cache == nil {
t.Fatal("Expected cache to be created, got nil")
}
// Verify it works with basic operations
cache.Set("test-key", "test-value", time.Hour)
value, found := cache.Get("test-key")
if !found {
t.Error("Expected key to be found in cache")
}
if value != "test-value" {
t.Errorf("Expected 'test-value', got %v", value)
}
}
// TestNewLRUStrategy tests LRU strategy creation
func TestNewLRUStrategy(t *testing.T) {
maxSize := 100
strategy := NewLRUStrategy(maxSize)
if strategy == nil {
t.Fatal("Expected strategy to be created, got nil")
}
lruStrategy, ok := strategy.(*LRUStrategy)
if !ok {
t.Fatal("Expected LRUStrategy type")
}
if lruStrategy.maxSize != maxSize {
t.Errorf("Expected maxSize %d, got %d", maxSize, lruStrategy.maxSize)
}
if lruStrategy.order == nil {
t.Error("Expected order list to be initialized")
}
if lruStrategy.elements == nil {
t.Error("Expected elements map to be initialized")
}
}
// TestLRUStrategy_Name tests strategy name
func TestLRUStrategy_Name(t *testing.T) {
strategy := NewLRUStrategy(100)
name := strategy.Name()
if name != "LRU" {
t.Errorf("Expected 'LRU', got %s", name)
}
}
// TestLRUStrategy_ShouldEvict tests eviction logic
func TestLRUStrategy_ShouldEvict(t *testing.T) {
strategy := NewLRUStrategy(100)
// LRU strategy always returns false for ShouldEvict
result := strategy.ShouldEvict("test-item", time.Now())
if result != false {
t.Error("Expected ShouldEvict to return false")
}
}
// TestLRUStrategy_OnAccess tests access callback
func TestLRUStrategy_OnAccess(t *testing.T) {
strategy := NewLRUStrategy(100)
// OnAccess should not panic
strategy.OnAccess("test-key", "test-value")
}
// TestLRUStrategy_OnRemove tests removal callback
func TestLRUStrategy_OnRemove(t *testing.T) {
strategy := NewLRUStrategy(100)
// OnRemove should not panic
strategy.OnRemove("test-key")
}
// TestLRUStrategy_EstimateSize tests size estimation
func TestLRUStrategy_EstimateSize(t *testing.T) {
strategy := NewLRUStrategy(100)
size := strategy.EstimateSize("test-item")
if size != 64 {
t.Errorf("Expected size 64, got %d", size)
}
}
// TestLRUStrategy_GetEvictionCandidate tests eviction candidate retrieval
func TestLRUStrategy_GetEvictionCandidate(t *testing.T) {
strategy := NewLRUStrategy(100)
key, found := strategy.GetEvictionCandidate()
if found {
t.Error("Expected no eviction candidate to be found")
}
if key != "" {
t.Errorf("Expected empty key, got %s", key)
}
}
// TestNewOptimizedCacheWithConfig tests optimized cache with custom config
func TestNewOptimizedCacheWithConfig(t *testing.T) {
config := UniversalCacheConfig{
Type: CacheTypeGeneral,
MaxSize: 1000,
MaxMemoryBytes: 128 * 1024 * 1024,
EnableMetrics: true,
Logger: GetSingletonNoOpLogger(),
}
cache := NewOptimizedCacheWithConfig(config)
if cache == nil {
t.Fatal("Expected cache to be created, got nil")
}
// Verify it works with basic operations
cache.Set("test-key", "test-value", time.Hour)
value, found := cache.Get("test-key")
if !found {
t.Error("Expected key to be found in cache")
}
if value != "test-value" {
t.Errorf("Expected 'test-value', got %v", value)
}
}
// TestNewFixedMetadataCache tests fixed metadata cache creation
func TestNewFixedMetadataCache(t *testing.T) {
cache := NewFixedMetadataCache()
if cache == nil {
t.Fatal("Expected cache to be created, got nil")
}
// Verify it works with proper metadata operations
metadata := &ProviderMetadata{
Issuer: "https://example.com",
AuthURL: "https://example.com/auth",
TokenURL: "https://example.com/token",
JWKSURL: "https://example.com/jwks",
}
err := cache.Set("test-provider", metadata, time.Hour)
if err != nil {
t.Errorf("Unexpected error setting metadata: %v", err)
}
// Test that the cache was created (basic verification)
// Note: We can't easily test Get without more complex setup
}
// TestNewDoublyLinkedList tests doubly linked list creation
func TestNewDoublyLinkedList(t *testing.T) {
list := NewDoublyLinkedList()
if list == nil {
t.Fatal("Expected list to be created, got nil")
}
// Test it's a proper list structure
if list.Len() != 0 {
t.Error("Expected empty list initially")
}
}
// TestDoublyLinkedList_PopFront tests front element removal
func TestDoublyLinkedList_PopFront(t *testing.T) {
list := NewDoublyLinkedList()
// Test popping from empty list
element := list.PopFront()
if element != nil {
t.Error("Expected nil when popping from empty list")
}
// Add an element and test popping
added := list.PushBack("test-value")
if added == nil {
t.Fatal("Expected element to be added")
}
popped := list.PopFront()
if popped == nil {
t.Error("Expected element to be popped")
}
if list.Len() != 0 {
t.Error("Expected list to be empty after popping")
}
}
// Benchmark tests for performance
func BenchmarkNewBoundedCache(b *testing.B) {
for i := 0; i < b.N; i++ {
NewBoundedCache(1000)
}
}
func BenchmarkNewOptimizedCache(b *testing.B) {
for i := 0; i < b.N; i++ {
NewOptimizedCache()
}
}
func BenchmarkLRUStrategy_EstimateSize(b *testing.B) {
strategy := NewLRUStrategy(1000)
item := "test-item"
b.ResetTimer()
for i := 0; i < b.N; i++ {
strategy.EstimateSize(item)
}
}
File diff suppressed because it is too large Load Diff
+24 -8
View File
@@ -20,8 +20,9 @@ var (
cacheManagerInitOnce sync.Once
)
// GetGlobalCacheManager returns a singleton CacheManager instance
// Deprecated: Use GetGlobalCacheManagerWithConfig instead
// GetGlobalCacheManager returns a singleton CacheManager instance.
//
// Deprecated: Use GetGlobalCacheManagerWithConfig instead.
func GetGlobalCacheManager(wg *sync.WaitGroup) *CacheManager {
return GetGlobalCacheManagerWithConfig(wg, nil)
}
@@ -61,7 +62,7 @@ func GetGlobalCacheManagerWithConfig(wg *sync.WaitGroup, config *Config) *CacheM
func (cm *CacheManager) GetSharedTokenBlacklist() CacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &CacheInterfaceWrapper{cache: cm.manager.GetBlacklistCache()}
return &CacheInterfaceWrapper{cache: cm.manager.GetBlacklistCache(), managed: true}
}
// GetSharedTokenCache returns the shared token cache
@@ -93,7 +94,7 @@ func (cm *CacheManager) GetSharedJWKCache() JWKCacheInterface {
func (cm *CacheManager) GetSharedIntrospectionCache() CacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &CacheInterfaceWrapper{cache: cm.manager.GetIntrospectionCache()}
return &CacheInterfaceWrapper{cache: cm.manager.GetIntrospectionCache(), managed: true}
}
// GetSharedTokenTypeCache returns the shared token type cache
@@ -101,7 +102,15 @@ func (cm *CacheManager) GetSharedIntrospectionCache() CacheInterface {
func (cm *CacheManager) GetSharedTokenTypeCache() CacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &CacheInterfaceWrapper{cache: cm.manager.GetTokenTypeCache()}
return &CacheInterfaceWrapper{cache: cm.manager.GetTokenTypeCache(), managed: true}
}
// GetSharedSessionInvalidationCache returns the shared session invalidation cache
// for backchannel and front-channel logout (IdP-initiated logout)
func (cm *CacheManager) GetSharedSessionInvalidationCache() CacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &CacheInterfaceWrapper{cache: cm.manager.GetSessionInvalidationCache(), managed: true}
}
// Close gracefully shuts down all cache components
@@ -121,7 +130,8 @@ func CleanupGlobalCacheManager() error {
// CacheInterfaceWrapper wraps UniversalCache to implement CacheInterface
type CacheInterfaceWrapper struct {
cache *UniversalCache
cache *UniversalCache
managed bool // If true, cache is managed globally and Close() is a no-op
}
// Set stores a value
@@ -149,9 +159,15 @@ func (c *CacheInterfaceWrapper) Cleanup() {
c.cache.Cleanup()
}
// Close shuts down the cache
// Close shuts down the cache if it's not managed globally.
// For managed caches (from UniversalCacheManager), this is a no-op to prevent log flooding
// when multiple plugin instances are closed during Traefik configuration reloads.
func (c *CacheInterfaceWrapper) Close() {
// Close the underlying cache to stop goroutines
if c.managed {
// Cache is managed globally by UniversalCacheManager, so we don't close it here.
return
}
// Standalone cache - close it properly to stop cleanup goroutines
if c.cache != nil {
_ = c.cache.Close() // Safe to ignore: closing cache is best-effort during shutdown
}
-314
View File
@@ -1,314 +0,0 @@
package traefikoidc
import (
"fmt"
"sync"
"testing"
"time"
)
// Helper function to ensure we have a working cache manager for tests
func getTestCacheManager(t *testing.T) *CacheManager {
cm := GetGlobalCacheManager(&sync.WaitGroup{})
if cm == nil {
t.Fatal("Failed to get cache manager")
}
if cm.manager == nil {
t.Fatal("Cache manager has nil internal manager")
}
return cm
}
// TestCacheManager_Close tests cache manager close functionality
func TestCacheManager_Close(t *testing.T) {
// Get a fresh cache manager
wg := &sync.WaitGroup{}
cm := GetGlobalCacheManager(wg)
if cm == nil {
t.Fatal("Expected cache manager to be created")
}
// Test closing the cache manager
err := cm.Close()
if err != nil {
t.Errorf("Unexpected error closing cache manager: %v", err)
}
}
// TestCleanupGlobalCacheManager tests global cleanup
func TestCleanupGlobalCacheManager(t *testing.T) {
// Test cleanup when no instance exists (should not error)
originalInstance := globalCacheManagerInstance
globalCacheManagerInstance = nil
err := CleanupGlobalCacheManager()
if err != nil {
t.Errorf("Unexpected error during cleanup of nil instance: %v", err)
}
// Restore original instance
globalCacheManagerInstance = originalInstance
}
// TestCacheInterfaceWrapper_Delete tests delete functionality
func TestCacheInterfaceWrapper_Delete(t *testing.T) {
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
// Add an item
cache.Set("test-key", "test-value", time.Hour)
// Verify it exists
value, found := cache.Get("test-key")
if !found {
t.Fatal("Expected key to be found after setting")
}
if value != "test-value" {
t.Errorf("Expected 'test-value', got %v", value)
}
// Delete it
cache.Delete("test-key")
// Verify it's gone
_, found = cache.Get("test-key")
if found {
t.Error("Expected key to be deleted")
}
}
// TestCacheInterfaceWrapper_Size tests size functionality
func TestCacheInterfaceWrapper_Size(t *testing.T) {
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
// Clear cache first
cache.Clear()
// Check initial size
initialSize := cache.Size()
if initialSize != 0 {
t.Errorf("Expected initial size 0, got %d", initialSize)
}
// Add some items
cache.Set("key1", "value1", time.Hour)
cache.Set("key2", "value2", time.Hour)
// Check size increased
newSize := cache.Size()
if newSize != 2 {
t.Errorf("Expected size 2, got %d", newSize)
}
}
// TestCacheInterfaceWrapper_Clear tests clear functionality
func TestCacheInterfaceWrapper_Clear(t *testing.T) {
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
// Add some items
cache.Set("key1", "value1", time.Hour)
cache.Set("key2", "value2", time.Hour)
// Verify items exist
size := cache.Size()
if size != 2 {
t.Errorf("Expected 2 items before clear, got %d", size)
}
// Clear all
cache.Clear()
// Verify cache is empty
size = cache.Size()
if size != 0 {
t.Errorf("Expected 0 items after clear, got %d", size)
}
// Verify specific items are gone
_, found := cache.Get("key1")
if found {
t.Error("Expected key1 to be cleared")
}
_, found = cache.Get("key2")
if found {
t.Error("Expected key2 to be cleared")
}
}
// TestCacheInterfaceWrapper_Close tests wrapper close functionality
func TestCacheInterfaceWrapper_Close(t *testing.T) {
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
// Test close - should not panic
wrapper, ok := cache.(*CacheInterfaceWrapper)
if !ok {
t.Fatal("Expected CacheInterfaceWrapper")
}
wrapper.Close() // Should not panic
// Test close with nil cache
nilWrapper := &CacheInterfaceWrapper{cache: nil}
nilWrapper.Close() // Should not panic
}
// TestCacheInterfaceWrapper_GetStats tests stats functionality
func TestCacheInterfaceWrapper_GetStats(t *testing.T) {
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
wrapper, ok := cache.(*CacheInterfaceWrapper)
if !ok {
t.Fatal("Expected CacheInterfaceWrapper")
}
// Get stats
stats := wrapper.GetStats()
if stats == nil {
t.Error("Expected non-nil stats")
}
// Stats should be accessible (len() never returns negative values)
// Just verify it's accessible by checking it's not nil (already done above)
}
// TestCacheInterfaceWrapper_Cleanup tests cleanup functionality
func TestCacheInterfaceWrapper_Cleanup(t *testing.T) {
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
// Add an item that will expire quickly
cache.Set("expire-key", "expire-value", time.Millisecond)
// Wait for expiration
time.Sleep(10 * time.Millisecond)
// Trigger cleanup
cache.Cleanup()
// Item should be cleaned up
_, found := cache.Get("expire-key")
if found {
t.Error("Expected expired key to be cleaned up")
}
}
// TestCacheInterfaceWrapper_SetMaxSize tests max size setting
func TestCacheInterfaceWrapper_SetMaxSize(t *testing.T) {
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
// Test setting max size (should not panic)
cache.SetMaxSize(1000)
// We can't easily verify the size was set without exposing internals,
// but we can ensure the method doesn't panic
}
// TestGetSharedCaches tests getting shared cache instances
func TestGetSharedCaches(t *testing.T) {
cm := getTestCacheManager(t)
// Test getting shared token blacklist
blacklist := cm.GetSharedTokenBlacklist()
if blacklist == nil {
t.Error("Expected non-nil token blacklist")
}
// Test getting shared token cache
tokenCache := cm.GetSharedTokenCache()
if tokenCache == nil {
t.Error("Expected non-nil token cache")
}
// Test getting shared metadata cache
metadataCache := cm.GetSharedMetadataCache()
if metadataCache == nil {
t.Error("Expected non-nil metadata cache")
}
// Test getting shared JWK cache
jwkCache := cm.GetSharedJWKCache()
if jwkCache == nil {
t.Error("Expected non-nil JWK cache")
}
}
// TestConcurrentCacheAccess tests thread safety
func TestConcurrentCacheAccess(t *testing.T) {
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
var wg sync.WaitGroup
goroutines := 10
iterations := 10
// Concurrent operations
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
value := fmt.Sprintf("value-%d-%d", id, j)
cache.Set(key, value, time.Hour)
retrieved, found := cache.Get(key)
if found && retrieved != value {
t.Errorf("Concurrent access failed: expected %s, got %v", value, retrieved)
}
cache.Delete(key)
}
}(i)
}
wg.Wait()
}
// Benchmark tests for performance
func BenchmarkCacheInterfaceWrapper_Set(b *testing.B) {
t := &testing.T{}
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Set("benchmark-key", "benchmark-value", time.Hour)
}
}
func BenchmarkCacheInterfaceWrapper_Get(b *testing.B) {
t := &testing.T{}
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
// Pre-populate cache
cache.Set("benchmark-key", "benchmark-value", time.Hour)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Get("benchmark-key")
}
}
func BenchmarkCacheInterfaceWrapper_Delete(b *testing.B) {
t := &testing.T{}
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.StopTimer()
key := fmt.Sprintf("benchmark-key-%d", i)
cache.Set(key, "value", time.Hour)
b.StartTimer()
cache.Delete(key)
}
}
+1854
View File
File diff suppressed because it is too large Load Diff
-319
View File
@@ -1,319 +0,0 @@
// Package circuit_breaker provides circuit breaker implementation for resilience
package circuit_breaker
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
)
// CircuitBreakerState represents the current state of a circuit breaker.
// The circuit breaker pattern prevents cascading failures by monitoring
// error rates and temporarily blocking requests to failing services.
type CircuitBreakerState int
// Circuit breaker states following the standard pattern:
// Closed: Normal operation, requests flow through
// Open: Circuit is tripped, requests are blocked
// HalfOpen: Testing state, limited requests allowed to test recovery
const (
// CircuitBreakerClosed allows all requests through (normal operation)
CircuitBreakerClosed CircuitBreakerState = iota
// CircuitBreakerOpen blocks all requests (service is failing)
CircuitBreakerOpen
// CircuitBreakerHalfOpen allows limited requests to test service recovery
CircuitBreakerHalfOpen
)
// String returns a string representation of the circuit breaker state
func (s CircuitBreakerState) String() string {
switch s {
case CircuitBreakerClosed:
return "closed"
case CircuitBreakerOpen:
return "open"
case CircuitBreakerHalfOpen:
return "half-open"
default:
return "unknown"
}
}
// Logger interface for dependency injection
type Logger interface {
Infof(format string, args ...interface{})
Errorf(format string, args ...interface{})
Debugf(format string, args ...interface{})
}
// BaseRecoveryMechanism interface for common functionality
type BaseRecoveryMechanism interface {
RecordRequest()
RecordSuccess()
RecordFailure()
GetBaseMetrics() map[string]interface{}
LogInfo(format string, args ...interface{})
LogError(format string, args ...interface{})
LogDebug(format string, args ...interface{})
}
// CircuitBreaker implements the circuit breaker pattern for external service calls.
// It monitors failure rates and automatically opens the circuit when failures
// exceed the threshold, preventing further requests until the service recovers.
type CircuitBreaker struct {
// baseRecovery provides common functionality
baseRecovery BaseRecoveryMechanism
// maxFailures is the threshold for opening the circuit
maxFailures int
// timeout is how long to wait before allowing requests in half-open state
timeout time.Duration
// resetTimeout is how long to wait before transitioning from open to half-open
resetTimeout time.Duration
// state tracks the current circuit breaker state
state CircuitBreakerState
// failures counts consecutive failures
failures int64
// lastFailureTime records when the last failure occurred
lastFailureTime time.Time
// mutex protects shared state
mutex sync.RWMutex
// logger for debugging and monitoring
logger Logger
}
// CircuitBreakerConfig holds configuration parameters for circuit breakers.
// These settings control when the circuit opens and how it recovers.
type CircuitBreakerConfig struct {
// MaxFailures is the number of failures before opening the circuit
MaxFailures int `json:"max_failures"`
// Timeout is how long to wait before trying to recover (open -> half-open)
Timeout time.Duration `json:"timeout"`
// ResetTimeout is how long to wait before fully closing the circuit
ResetTimeout time.Duration `json:"reset_timeout"`
}
// DefaultCircuitBreakerConfig returns sensible default configuration for circuit breakers.
// Configured for typical web service scenarios with moderate tolerance for failures.
func DefaultCircuitBreakerConfig() CircuitBreakerConfig {
return CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 60 * time.Second,
ResetTimeout: 30 * time.Second,
}
}
// NewCircuitBreaker creates a new circuit breaker with the specified configuration.
// The circuit breaker starts in the closed state, allowing all requests through.
func NewCircuitBreaker(config CircuitBreakerConfig, logger Logger, baseRecovery BaseRecoveryMechanism) *CircuitBreaker {
return &CircuitBreaker{
baseRecovery: baseRecovery,
maxFailures: config.MaxFailures,
timeout: config.Timeout,
resetTimeout: config.ResetTimeout,
state: CircuitBreakerClosed,
logger: logger,
}
}
// ExecuteWithContext executes a function through the circuit breaker with context.
// It checks if requests are allowed, executes the function, and updates the circuit state
// based on the result. Implements the ErrorRecoveryMechanism interface.
func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() error) error {
if cb.baseRecovery != nil {
cb.baseRecovery.RecordRequest()
}
if !cb.allowRequest() {
return fmt.Errorf("circuit breaker is open")
}
err := fn()
if err != nil {
cb.recordFailure()
if cb.baseRecovery != nil {
cb.baseRecovery.RecordFailure()
}
return err
}
cb.recordSuccess()
if cb.baseRecovery != nil {
cb.baseRecovery.RecordSuccess()
}
return nil
}
// Execute executes a function through the circuit breaker without context.
// This is provided for backward compatibility with existing code.
func (cb *CircuitBreaker) Execute(fn func() error) error {
return cb.ExecuteWithContext(context.Background(), fn)
}
// allowRequest determines whether to allow a request based on the circuit state.
// Handles state transitions from open to half-open based on timeout.
func (cb *CircuitBreaker) allowRequest() bool {
cb.mutex.Lock()
defer cb.mutex.Unlock()
now := time.Now()
switch cb.state {
case CircuitBreakerClosed:
return true
case CircuitBreakerOpen:
if now.Sub(cb.lastFailureTime) > cb.timeout {
cb.state = CircuitBreakerHalfOpen
if cb.logger != nil {
cb.logger.Infof("Circuit breaker transitioning to half-open state")
}
return true
}
return false
case CircuitBreakerHalfOpen:
return true
default:
return false
}
}
// recordFailure records a failure and potentially opens the circuit.
// Updates failure count and triggers state transitions when thresholds are exceeded.
func (cb *CircuitBreaker) recordFailure() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
cb.failures++
cb.lastFailureTime = time.Now()
switch cb.state {
case CircuitBreakerClosed:
if cb.failures >= int64(cb.maxFailures) {
cb.state = CircuitBreakerOpen
if cb.baseRecovery != nil {
cb.baseRecovery.LogError("Circuit breaker opened after %d failures", cb.failures)
}
}
case CircuitBreakerHalfOpen:
cb.state = CircuitBreakerOpen
if cb.baseRecovery != nil {
cb.baseRecovery.LogError("Circuit breaker returned to open state after failure in half-open")
}
}
}
// recordSuccess records a successful request and potentially closes the circuit.
// Resets failure count and transitions from half-open to closed state on success.
func (cb *CircuitBreaker) recordSuccess() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
switch cb.state {
case CircuitBreakerHalfOpen:
cb.failures = 0
cb.state = CircuitBreakerClosed
if cb.baseRecovery != nil {
cb.baseRecovery.LogInfo("Circuit breaker closed after successful request in half-open state")
}
case CircuitBreakerClosed:
cb.failures = 0
}
}
// GetState returns the current state of the circuit breaker.
// Thread-safe method for monitoring circuit breaker status.
func (cb *CircuitBreaker) GetState() CircuitBreakerState {
cb.mutex.RLock()
defer cb.mutex.RUnlock()
return cb.state
}
// Reset resets the circuit breaker to its initial closed state.
// Clears failure count and state, effectively recovering from any open state.
func (cb *CircuitBreaker) Reset() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
cb.state = CircuitBreakerClosed
atomic.StoreInt64(&cb.failures, 0)
if cb.baseRecovery != nil {
cb.baseRecovery.LogInfo("Circuit breaker has been reset")
}
}
// IsAvailable returns whether the circuit breaker is currently allowing requests.
// This provides a quick way to check if the service is available.
func (cb *CircuitBreaker) IsAvailable() bool {
return cb.allowRequest()
}
// GetMetrics returns comprehensive metrics about the circuit breaker.
// Includes state information, failure counts, configuration, and base metrics.
func (cb *CircuitBreaker) GetMetrics() map[string]interface{} {
cb.mutex.RLock()
state := cb.state
failures := cb.failures
lastFailureTime := cb.lastFailureTime
cb.mutex.RUnlock()
var metrics map[string]interface{}
if cb.baseRecovery != nil {
metrics = cb.baseRecovery.GetBaseMetrics()
} else {
metrics = make(map[string]interface{})
}
metrics["state"] = state.String()
metrics["current_failures"] = failures
metrics["max_failures"] = cb.maxFailures
metrics["timeout"] = cb.timeout.String()
metrics["reset_timeout"] = cb.resetTimeout.String()
if !lastFailureTime.IsZero() {
metrics["last_failure_time"] = lastFailureTime
metrics["time_since_last_failure"] = time.Since(lastFailureTime).String()
}
return metrics
}
// GetFailureCount returns the current failure count
func (cb *CircuitBreaker) GetFailureCount() int64 {
cb.mutex.RLock()
defer cb.mutex.RUnlock()
return cb.failures
}
// GetLastFailureTime returns the time of the last failure
func (cb *CircuitBreaker) GetLastFailureTime() time.Time {
cb.mutex.RLock()
defer cb.mutex.RUnlock()
return cb.lastFailureTime
}
// IsOpen returns true if the circuit breaker is in open state
func (cb *CircuitBreaker) IsOpen() bool {
cb.mutex.RLock()
defer cb.mutex.RUnlock()
return cb.state == CircuitBreakerOpen
}
// IsClosed returns true if the circuit breaker is in closed state
func (cb *CircuitBreaker) IsClosed() bool {
cb.mutex.RLock()
defer cb.mutex.RUnlock()
return cb.state == CircuitBreakerClosed
}
// IsHalfOpen returns true if the circuit breaker is in half-open state
func (cb *CircuitBreaker) IsHalfOpen() bool {
cb.mutex.RLock()
defer cb.mutex.RUnlock()
return cb.state == CircuitBreakerHalfOpen
}
-981
View File
@@ -1,981 +0,0 @@
package circuit_breaker
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
)
// Mock implementations for testing
type mockLogger struct {
infoLogs []string
errorLogs []string
debugLogs []string
mu sync.RWMutex
}
func (m *mockLogger) Infof(format string, args ...interface{}) {
m.mu.Lock()
defer m.mu.Unlock()
m.infoLogs = append(m.infoLogs, fmt.Sprintf(format, args...))
}
func (m *mockLogger) Errorf(format string, args ...interface{}) {
m.mu.Lock()
defer m.mu.Unlock()
m.errorLogs = append(m.errorLogs, fmt.Sprintf(format, args...))
}
func (m *mockLogger) Debugf(format string, args ...interface{}) {
m.mu.Lock()
defer m.mu.Unlock()
m.debugLogs = append(m.debugLogs, fmt.Sprintf(format, args...))
}
func (m *mockLogger) getInfoLogs() []string {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]string, len(m.infoLogs))
copy(result, m.infoLogs)
return result
}
//lint:ignore U1000 May be needed for future error log verification tests
func (m *mockLogger) getErrorLogs() []string {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]string, len(m.errorLogs))
copy(result, m.errorLogs)
return result
}
//lint:ignore U1000 May be needed for future test isolation
func (m *mockLogger) reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.infoLogs = nil
m.errorLogs = nil
m.debugLogs = nil
}
type mockBaseRecoveryMechanism struct {
requestCount int64
successCount int64
failureCount int64
infoLogs []string
errorLogs []string
debugLogs []string
baseMetrics map[string]interface{}
mu sync.RWMutex
}
func newMockBaseRecovery() *mockBaseRecoveryMechanism {
return &mockBaseRecoveryMechanism{
baseMetrics: make(map[string]interface{}),
}
}
func (m *mockBaseRecoveryMechanism) RecordRequest() {
atomic.AddInt64(&m.requestCount, 1)
}
func (m *mockBaseRecoveryMechanism) RecordSuccess() {
atomic.AddInt64(&m.successCount, 1)
}
func (m *mockBaseRecoveryMechanism) RecordFailure() {
atomic.AddInt64(&m.failureCount, 1)
}
func (m *mockBaseRecoveryMechanism) GetBaseMetrics() map[string]interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]interface{})
for k, v := range m.baseMetrics {
result[k] = v
}
result["total_requests"] = atomic.LoadInt64(&m.requestCount)
result["total_successes"] = atomic.LoadInt64(&m.successCount)
result["total_failures"] = atomic.LoadInt64(&m.failureCount)
return result
}
func (m *mockBaseRecoveryMechanism) LogInfo(format string, args ...interface{}) {
m.mu.Lock()
defer m.mu.Unlock()
m.infoLogs = append(m.infoLogs, fmt.Sprintf(format, args...))
}
func (m *mockBaseRecoveryMechanism) LogError(format string, args ...interface{}) {
m.mu.Lock()
defer m.mu.Unlock()
m.errorLogs = append(m.errorLogs, fmt.Sprintf(format, args...))
}
func (m *mockBaseRecoveryMechanism) LogDebug(format string, args ...interface{}) {
m.mu.Lock()
defer m.mu.Unlock()
m.debugLogs = append(m.debugLogs, fmt.Sprintf(format, args...))
}
func (m *mockBaseRecoveryMechanism) getRequestCount() int64 {
return atomic.LoadInt64(&m.requestCount)
}
func (m *mockBaseRecoveryMechanism) getSuccessCount() int64 {
return atomic.LoadInt64(&m.successCount)
}
func (m *mockBaseRecoveryMechanism) getFailureCount() int64 {
return atomic.LoadInt64(&m.failureCount)
}
func (m *mockBaseRecoveryMechanism) getInfoLogs() []string {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]string, len(m.infoLogs))
copy(result, m.infoLogs)
return result
}
func (m *mockBaseRecoveryMechanism) getErrorLogs() []string {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]string, len(m.errorLogs))
copy(result, m.errorLogs)
return result
}
func TestCircuitBreakerState_String(t *testing.T) {
tests := []struct {
state CircuitBreakerState
expected string
}{
{CircuitBreakerClosed, "closed"},
{CircuitBreakerOpen, "open"},
{CircuitBreakerHalfOpen, "half-open"},
{CircuitBreakerState(999), "unknown"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
result := tt.state.String()
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
func TestDefaultCircuitBreakerConfig(t *testing.T) {
config := DefaultCircuitBreakerConfig()
if config.MaxFailures != 2 {
t.Errorf("Expected MaxFailures to be 2, got %d", config.MaxFailures)
}
if config.Timeout != 60*time.Second {
t.Errorf("Expected Timeout to be 60s, got %v", config.Timeout)
}
if config.ResetTimeout != 30*time.Second {
t.Errorf("Expected ResetTimeout to be 30s, got %v", config.ResetTimeout)
}
}
func TestNewCircuitBreaker(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 3,
Timeout: 30 * time.Second,
ResetTimeout: 15 * time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
if cb == nil {
t.Fatal("NewCircuitBreaker returned nil")
}
if cb.maxFailures != 3 {
t.Errorf("Expected maxFailures to be 3, got %d", cb.maxFailures)
}
if cb.timeout != 30*time.Second {
t.Errorf("Expected timeout to be 30s, got %v", cb.timeout)
}
if cb.resetTimeout != 15*time.Second {
t.Errorf("Expected resetTimeout to be 15s, got %v", cb.resetTimeout)
}
if cb.state != CircuitBreakerClosed {
t.Errorf("Expected initial state to be Closed, got %v", cb.state)
}
if cb.logger != logger {
t.Error("Expected logger to be set")
}
if cb.baseRecovery != baseRecovery {
t.Error("Expected baseRecovery to be set")
}
}
func TestCircuitBreaker_ExecuteWithContext_Success(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 2,
Timeout: time.Second,
ResetTimeout: time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
callCount := 0
testFunc := func() error {
callCount++
return nil
}
ctx := context.Background()
err := cb.ExecuteWithContext(ctx, testFunc)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if callCount != 1 {
t.Errorf("Expected function to be called once, got %d", callCount)
}
if cb.GetState() != CircuitBreakerClosed {
t.Errorf("Expected state to remain Closed, got %v", cb.GetState())
}
if baseRecovery.getRequestCount() != 1 {
t.Errorf("Expected 1 request recorded, got %d", baseRecovery.getRequestCount())
}
if baseRecovery.getSuccessCount() != 1 {
t.Errorf("Expected 1 success recorded, got %d", baseRecovery.getSuccessCount())
}
}
func TestCircuitBreaker_ExecuteWithContext_Failure(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 2,
Timeout: time.Second,
ResetTimeout: time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
testError := fmt.Errorf("test error")
testFunc := func() error {
return testError
}
ctx := context.Background()
err := cb.ExecuteWithContext(ctx, testFunc)
if err != testError {
t.Errorf("Expected test error, got %v", err)
}
if cb.GetState() != CircuitBreakerClosed {
t.Errorf("Expected state to remain Closed after single failure, got %v", cb.GetState())
}
if baseRecovery.getRequestCount() != 1 {
t.Errorf("Expected 1 request recorded, got %d", baseRecovery.getRequestCount())
}
if baseRecovery.getFailureCount() != 1 {
t.Errorf("Expected 1 failure recorded, got %d", baseRecovery.getFailureCount())
}
}
func TestCircuitBreaker_Execute(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 1,
Timeout: time.Second,
ResetTimeout: time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
callCount := 0
testFunc := func() error {
callCount++
return nil
}
err := cb.Execute(testFunc)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if callCount != 1 {
t.Errorf("Expected function to be called once, got %d", callCount)
}
}
func TestCircuitBreaker_OpenAfterMaxFailures(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 2,
Timeout: time.Second,
ResetTimeout: time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
testError := fmt.Errorf("test error")
testFunc := func() error {
return testError
}
ctx := context.Background()
// First failure
err := cb.ExecuteWithContext(ctx, testFunc)
if err != testError {
t.Errorf("Expected test error on first failure, got %v", err)
}
if cb.GetState() != CircuitBreakerClosed {
t.Errorf("Expected state to remain Closed after first failure, got %v", cb.GetState())
}
// Second failure - should open circuit
err = cb.ExecuteWithContext(ctx, testFunc)
if err != testError {
t.Errorf("Expected test error on second failure, got %v", err)
}
if cb.GetState() != CircuitBreakerOpen {
t.Errorf("Expected state to be Open after max failures, got %v", cb.GetState())
}
// Third attempt - should be blocked
callCount := 0
blockedFunc := func() error {
callCount++
return nil
}
err = cb.ExecuteWithContext(ctx, blockedFunc)
if err == nil {
t.Error("Expected error when circuit is open")
}
if callCount != 0 {
t.Errorf("Expected function not to be called when circuit is open, got %d calls", callCount)
}
}
func TestCircuitBreaker_HalfOpenTransition(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 10 * time.Millisecond, // Very short for testing
ResetTimeout: time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
// Trigger circuit opening
testError := fmt.Errorf("test error")
err := cb.ExecuteWithContext(context.Background(), func() error {
return testError
})
if err != testError {
t.Errorf("Expected test error, got %v", err)
}
if cb.GetState() != CircuitBreakerOpen {
t.Errorf("Expected state to be Open, got %v", cb.GetState())
}
// Wait for timeout
time.Sleep(15 * time.Millisecond)
// Next request should transition to half-open
callCount := 0
testFunc := func() error {
callCount++
return nil
}
err = cb.ExecuteWithContext(context.Background(), testFunc)
if err != nil {
t.Errorf("Expected no error in half-open state, got %v", err)
}
if callCount != 1 {
t.Errorf("Expected function to be called in half-open state, got %d calls", callCount)
}
if cb.GetState() != CircuitBreakerClosed {
t.Errorf("Expected state to be Closed after successful half-open request, got %v", cb.GetState())
}
}
func TestCircuitBreaker_HalfOpenFailureReturnsToOpen(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 10 * time.Millisecond,
ResetTimeout: time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
// Trigger circuit opening
testError := fmt.Errorf("test error")
_ = cb.ExecuteWithContext(context.Background(), func() error {
return testError
})
if cb.GetState() != CircuitBreakerOpen {
t.Errorf("Expected state to be Open, got %v", cb.GetState())
}
// Wait for timeout to allow half-open transition
time.Sleep(15 * time.Millisecond)
// First call should transition to half-open, but we'll force it by checking allowRequest
if !cb.allowRequest() {
t.Error("Expected allowRequest to return true after timeout")
}
if cb.GetState() != CircuitBreakerHalfOpen {
t.Errorf("Expected state to be HalfOpen, got %v", cb.GetState())
}
// Failure in half-open should return to open
err := cb.ExecuteWithContext(context.Background(), func() error {
return testError
})
if err != testError {
t.Errorf("Expected test error, got %v", err)
}
if cb.GetState() != CircuitBreakerOpen {
t.Errorf("Expected state to return to Open after half-open failure, got %v", cb.GetState())
}
}
func TestCircuitBreaker_Reset(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 1,
Timeout: time.Second,
ResetTimeout: time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
// Trigger circuit opening
testError := fmt.Errorf("test error")
_ = cb.ExecuteWithContext(context.Background(), func() error {
return testError
})
if cb.GetState() != CircuitBreakerOpen {
t.Errorf("Expected state to be Open, got %v", cb.GetState())
}
// Reset circuit
cb.Reset()
if cb.GetState() != CircuitBreakerClosed {
t.Errorf("Expected state to be Closed after reset, got %v", cb.GetState())
}
if cb.GetFailureCount() != 0 {
t.Errorf("Expected failure count to be 0 after reset, got %d", cb.GetFailureCount())
}
// Should allow requests again
callCount := 0
err := cb.ExecuteWithContext(context.Background(), func() error {
callCount++
return nil
})
if err != nil {
t.Errorf("Expected no error after reset, got %v", err)
}
if callCount != 1 {
t.Errorf("Expected function to be called after reset, got %d calls", callCount)
}
}
func TestCircuitBreaker_IsAvailable(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 10 * time.Millisecond,
ResetTimeout: time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
// Initially available
if !cb.IsAvailable() {
t.Error("Expected circuit breaker to be available initially")
}
// Trigger opening
testError := fmt.Errorf("test error")
cb.ExecuteWithContext(context.Background(), func() error {
return testError
})
// Should not be available when open
if cb.IsAvailable() {
t.Error("Expected circuit breaker to be unavailable when open")
}
// Wait for timeout
time.Sleep(15 * time.Millisecond)
// Should be available again after timeout (half-open)
if !cb.IsAvailable() {
t.Error("Expected circuit breaker to be available after timeout")
}
}
func TestCircuitBreaker_StateCheckers(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 10 * time.Millisecond,
ResetTimeout: time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
// Initially closed
if !cb.IsClosed() {
t.Error("Expected circuit breaker to be closed initially")
}
if cb.IsOpen() {
t.Error("Expected circuit breaker not to be open initially")
}
if cb.IsHalfOpen() {
t.Error("Expected circuit breaker not to be half-open initially")
}
// Trigger opening
testError := fmt.Errorf("test error")
cb.ExecuteWithContext(context.Background(), func() error {
return testError
})
// Should be open
if cb.IsClosed() {
t.Error("Expected circuit breaker not to be closed when open")
}
if !cb.IsOpen() {
t.Error("Expected circuit breaker to be open")
}
if cb.IsHalfOpen() {
t.Error("Expected circuit breaker not to be half-open when open")
}
// Wait for timeout and trigger half-open
time.Sleep(15 * time.Millisecond)
cb.allowRequest() // This will transition to half-open
// Should be half-open
if cb.IsClosed() {
t.Error("Expected circuit breaker not to be closed when half-open")
}
if cb.IsOpen() {
t.Error("Expected circuit breaker not to be open when half-open")
}
if !cb.IsHalfOpen() {
t.Error("Expected circuit breaker to be half-open")
}
}
func TestCircuitBreaker_GetMetrics(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 30 * time.Second,
ResetTimeout: 15 * time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
baseRecovery.baseMetrics["custom_metric"] = "custom_value"
cb := NewCircuitBreaker(config, logger, baseRecovery)
// Record some activity
testError := fmt.Errorf("test error")
cb.ExecuteWithContext(context.Background(), func() error {
return testError
})
metrics := cb.GetMetrics()
// Check circuit breaker specific metrics
if metrics["state"] != "closed" {
t.Errorf("Expected state to be 'closed', got %v", metrics["state"])
}
if metrics["current_failures"] != int64(1) {
t.Errorf("Expected current_failures to be 1, got %v", metrics["current_failures"])
}
if metrics["max_failures"] != 2 {
t.Errorf("Expected max_failures to be 2, got %v", metrics["max_failures"])
}
if metrics["timeout"] != "30s" {
t.Errorf("Expected timeout to be '30s', got %v", metrics["timeout"])
}
if metrics["reset_timeout"] != "15s" {
t.Errorf("Expected reset_timeout to be '15s', got %v", metrics["reset_timeout"])
}
// Check base metrics are included
if metrics["total_requests"] != int64(1) {
t.Errorf("Expected total_requests to be 1, got %v", metrics["total_requests"])
}
if metrics["custom_metric"] != "custom_value" {
t.Errorf("Expected custom_metric to be 'custom_value', got %v", metrics["custom_metric"])
}
// Check failure time metrics
if _, exists := metrics["last_failure_time"]; !exists {
t.Error("Expected last_failure_time to exist")
}
if _, exists := metrics["time_since_last_failure"]; !exists {
t.Error("Expected time_since_last_failure to exist")
}
}
func TestCircuitBreaker_GetMetrics_NoBaseRecovery(t *testing.T) {
config := DefaultCircuitBreakerConfig()
logger := &mockLogger{}
cb := NewCircuitBreaker(config, logger, nil)
metrics := cb.GetMetrics()
// Should still have circuit breaker metrics
if metrics["state"] != "closed" {
t.Errorf("Expected state to be 'closed', got %v", metrics["state"])
}
if metrics["max_failures"] != 2 {
t.Errorf("Expected max_failures to be 2, got %v", metrics["max_failures"])
}
// Should not have base metrics
if _, exists := metrics["total_requests"]; exists {
t.Error("Expected total_requests not to exist without base recovery")
}
}
func TestCircuitBreaker_GetLastFailureTime(t *testing.T) {
config := DefaultCircuitBreakerConfig()
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
// Initially should be zero
if !cb.GetLastFailureTime().IsZero() {
t.Error("Expected last failure time to be zero initially")
}
// Record a failure
before := time.Now()
testError := fmt.Errorf("test error")
cb.ExecuteWithContext(context.Background(), func() error {
return testError
})
after := time.Now()
lastFailure := cb.GetLastFailureTime()
if lastFailure.IsZero() {
t.Error("Expected last failure time to be set after failure")
}
if lastFailure.Before(before) || lastFailure.After(after) {
t.Errorf("Expected last failure time to be between %v and %v, got %v",
before, after, lastFailure)
}
}
func TestCircuitBreaker_ExecuteWithoutBaseRecovery(t *testing.T) {
config := DefaultCircuitBreakerConfig()
logger := &mockLogger{}
cb := NewCircuitBreaker(config, logger, nil)
callCount := 0
testFunc := func() error {
callCount++
return nil
}
err := cb.ExecuteWithContext(context.Background(), testFunc)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if callCount != 1 {
t.Errorf("Expected function to be called once, got %d", callCount)
}
// Should work fine without base recovery
if cb.GetState() != CircuitBreakerClosed {
t.Errorf("Expected state to be Closed, got %v", cb.GetState())
}
}
func TestCircuitBreaker_ConcurrentAccess(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 10, // Higher threshold for concurrent test
Timeout: 100 * time.Millisecond,
ResetTimeout: 50 * time.Millisecond,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
const numGoroutines = 10
const numOperations = 50
var wg sync.WaitGroup
successCount := int64(0)
errorCount := int64(0)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
err := cb.ExecuteWithContext(context.Background(), func() error {
// Simulate some failures
if j%10 == 9 { // Every 10th operation fails
return fmt.Errorf("simulated error")
}
return nil
})
if err != nil {
atomic.AddInt64(&errorCount, 1)
} else {
atomic.AddInt64(&successCount, 1)
}
// Intermittently check state and metrics
if j%5 == 0 {
cb.GetState()
cb.GetMetrics()
cb.IsAvailable()
}
}
}(i)
}
wg.Wait()
// Verify we got both successes and errors
finalSuccessCount := atomic.LoadInt64(&successCount)
finalErrorCount := atomic.LoadInt64(&errorCount)
if finalSuccessCount == 0 {
t.Error("Expected some successful operations")
}
if finalErrorCount == 0 {
t.Error("Expected some failed operations")
}
totalOperations := finalSuccessCount + finalErrorCount
expectedMax := int64(numGoroutines * numOperations)
if totalOperations > expectedMax {
t.Errorf("Expected at most %d operations, got %d", expectedMax, totalOperations)
}
t.Logf("Concurrent test completed: %d successes, %d errors, final state: %v",
finalSuccessCount, finalErrorCount, cb.GetState())
}
func TestCircuitBreaker_StateTransitionLogging(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 10 * time.Millisecond,
ResetTimeout: time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
// Trigger circuit opening
testError := fmt.Errorf("test error")
cb.ExecuteWithContext(context.Background(), func() error {
return testError
})
// Check that error was logged when circuit opened
errorLogs := baseRecovery.getErrorLogs()
if len(errorLogs) == 0 {
t.Error("Expected error log when circuit breaker opened")
} else {
if !contains(errorLogs, "Circuit breaker opened after") {
t.Errorf("Expected circuit opening log, got %v", errorLogs)
}
}
// Wait and trigger half-open
time.Sleep(15 * time.Millisecond)
// Successful request should close circuit and log
cb.ExecuteWithContext(context.Background(), func() error {
return nil
})
// Check that success was logged when circuit closed
infoLogs := baseRecovery.getInfoLogs()
if len(infoLogs) == 0 {
t.Error("Expected info log when circuit breaker closed")
} else {
if !contains(infoLogs, "Circuit breaker closed after successful request") {
t.Errorf("Expected circuit closing log, got %v", infoLogs)
}
}
// Reset should also be logged
cb.Reset()
infoLogs = baseRecovery.getInfoLogs()
if !contains(infoLogs, "Circuit breaker has been reset") {
t.Errorf("Expected reset log, got %v", infoLogs)
}
}
func TestCircuitBreaker_LoggerTransitionLogging(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 10 * time.Millisecond,
ResetTimeout: time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
// Wait for timeout and check half-open transition logging
testError := fmt.Errorf("test error")
cb.ExecuteWithContext(context.Background(), func() error {
return testError
})
// Wait for timeout
time.Sleep(15 * time.Millisecond)
// Next allowRequest call should log transition to half-open
cb.allowRequest()
infoLogs := logger.getInfoLogs()
if len(infoLogs) == 0 {
t.Error("Expected info log for half-open transition")
} else {
if !contains(infoLogs, "Circuit breaker transitioning to half-open state") {
t.Errorf("Expected half-open transition log, got %v", infoLogs)
}
}
}
// Helper function to check if a slice contains a string with substring
func contains(slice []string, substr string) bool {
for _, s := range slice {
if len(s) >= len(substr) && s[:len(substr)] == substr {
return true
}
}
return false
}
// Benchmark tests
func BenchmarkCircuitBreaker_ExecuteWithContext_Success(b *testing.B) {
config := DefaultCircuitBreakerConfig()
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
testFunc := func() error {
return nil
}
ctx := context.Background()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
cb.ExecuteWithContext(ctx, testFunc)
}
})
}
func BenchmarkCircuitBreaker_ExecuteWithContext_Failure(b *testing.B) {
config := CircuitBreakerConfig{
MaxFailures: 1000, // High threshold to avoid opening during benchmark
Timeout: time.Second,
ResetTimeout: time.Second,
}
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
testError := fmt.Errorf("test error")
testFunc := func() error {
return testError
}
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cb.ExecuteWithContext(ctx, testFunc)
}
}
func BenchmarkCircuitBreaker_GetState(b *testing.B) {
config := DefaultCircuitBreakerConfig()
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
cb.GetState()
}
})
}
func BenchmarkCircuitBreaker_GetMetrics(b *testing.B) {
config := DefaultCircuitBreakerConfig()
logger := &mockLogger{}
baseRecovery := newMockBaseRecovery()
cb := NewCircuitBreaker(config, logger, baseRecovery)
// Add some activity
for i := 0; i < 100; i++ {
if i%2 == 0 {
cb.ExecuteWithContext(context.Background(), func() error { return nil })
} else {
cb.ExecuteWithContext(context.Background(), func() error { return fmt.Errorf("error") })
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
cb.GetMetrics()
}
}
-258
View File
@@ -1,258 +0,0 @@
// Package config provides backward compatibility for legacy configuration
package config
import (
"fmt"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/compat"
"github.com/lukaszraczylo/traefikoidc/internal/features"
)
// LegacyAdapter provides backward compatibility for old Config struct
type LegacyAdapter struct {
unified *UnifiedConfig
adapter *compat.ConfigAdapter
}
// NewLegacyAdapter creates a new legacy adapter from unified config
func NewLegacyAdapter(unified *UnifiedConfig) *LegacyAdapter {
adapter := compat.NewConfigAdapter(unified)
// Register getters for commonly used fields
adapter.RegisterGetter("ProviderURL", func() interface{} {
return unified.Provider.IssuerURL
})
adapter.RegisterGetter("ClientID", func() interface{} {
return unified.Provider.ClientID
})
adapter.RegisterGetter("ClientSecret", func() interface{} {
return unified.Provider.ClientSecret
})
adapter.RegisterGetter("CallbackURL", func() interface{} {
return unified.Provider.RedirectURL
})
adapter.RegisterGetter("LogoutURL", func() interface{} {
return unified.Provider.LogoutURL
})
adapter.RegisterGetter("PostLogoutRedirectURI", func() interface{} {
return unified.Provider.PostLogoutRedirectURI
})
adapter.RegisterGetter("SessionEncryptionKey", func() interface{} {
return unified.Session.EncryptionKey
})
adapter.RegisterGetter("ForceHTTPS", func() interface{} {
return unified.Security.ForceHTTPS
})
adapter.RegisterGetter("LogLevel", func() interface{} {
return unified.Logging.Level
})
adapter.RegisterGetter("Scopes", func() interface{} {
return unified.Provider.Scopes
})
adapter.RegisterGetter("OverrideScopes", func() interface{} {
return unified.Provider.OverrideScopes
})
adapter.RegisterGetter("AllowedUsers", func() interface{} {
return unified.Security.AllowedUsers
})
adapter.RegisterGetter("AllowedUserDomains", func() interface{} {
return unified.Security.AllowedUserDomains
})
adapter.RegisterGetter("AllowedRolesAndGroups", func() interface{} {
return unified.Security.AllowedRolesAndGroups
})
adapter.RegisterGetter("ExcludedURLs", func() interface{} {
return unified.Security.ExcludedURLs
})
adapter.RegisterGetter("EnablePKCE", func() interface{} {
return unified.Security.EnablePKCE
})
adapter.RegisterGetter("RateLimit", func() interface{} {
return unified.RateLimit.RequestsPerSecond
})
adapter.RegisterGetter("RefreshGracePeriodSeconds", func() interface{} {
return int(unified.Token.RefreshGracePeriod.Seconds())
})
adapter.RegisterGetter("CookieDomain", func() interface{} {
return unified.Session.Domain
})
adapter.RegisterGetter("SecurityHeaders", func() interface{} {
return unified.Security.Headers
})
return &LegacyAdapter{
unified: unified,
adapter: adapter,
}
}
// ToOldConfig converts unified config to old Config struct format
func (la *LegacyAdapter) ToOldConfig() *Config {
// Use feature flags to determine behavior
if !features.IsUnifiedConfigEnabled() {
// Return existing Config if unified config not enabled
return CreateConfig()
}
cfg := &Config{
ProviderURL: la.unified.Provider.IssuerURL,
ClientID: la.unified.Provider.ClientID,
ClientSecret: la.unified.Provider.ClientSecret,
CallbackURL: la.unified.Provider.RedirectURL,
LogoutURL: la.unified.Provider.LogoutURL,
PostLogoutRedirectURI: la.unified.Provider.PostLogoutRedirectURI,
SessionEncryptionKey: la.unified.Session.EncryptionKey,
ForceHTTPS: la.unified.Security.ForceHTTPS,
LogLevel: la.unified.Logging.Level,
Scopes: la.unified.Provider.Scopes,
OverrideScopes: la.unified.Provider.OverrideScopes,
AllowedUsers: la.unified.Security.AllowedUsers,
AllowedUserDomains: la.unified.Security.AllowedUserDomains,
AllowedRolesAndGroups: la.unified.Security.AllowedRolesAndGroups,
ExcludedURLs: la.unified.Security.ExcludedURLs,
EnablePKCE: la.unified.Security.EnablePKCE,
RateLimit: la.unified.RateLimit.RequestsPerSecond,
RefreshGracePeriodSeconds: int(la.unified.Token.RefreshGracePeriod.Seconds()),
Headers: la.convertHeaders(),
CookieDomain: la.unified.Session.Domain,
SecurityHeaders: la.unified.Security.Headers,
}
return cfg
}
// convertHeaders converts unified header config to old format
func (la *LegacyAdapter) convertHeaders() []HeaderConfig {
headers := make([]HeaderConfig, 0)
for name, value := range la.unified.Middleware.CustomHeaders {
headers = append(headers, HeaderConfig{
Name: name,
Value: value,
})
}
return headers
}
// FromOldConfig creates unified config from old Config struct
func FromOldConfig(old *Config) *UnifiedConfig {
unified := NewUnifiedConfig()
// Map provider settings
unified.Provider.IssuerURL = old.ProviderURL
unified.Provider.ClientID = old.ClientID
unified.Provider.ClientSecret = old.ClientSecret
unified.Provider.RedirectURL = old.CallbackURL
unified.Provider.LogoutURL = old.LogoutURL
unified.Provider.PostLogoutRedirectURI = old.PostLogoutRedirectURI
unified.Provider.Scopes = old.Scopes
unified.Provider.OverrideScopes = old.OverrideScopes
// Map session settings
unified.Session.EncryptionKey = old.SessionEncryptionKey
unified.Session.Domain = old.CookieDomain
// Map security settings
unified.Security.ForceHTTPS = old.ForceHTTPS
unified.Security.EnablePKCE = old.EnablePKCE
unified.Security.AllowedUsers = old.AllowedUsers
unified.Security.AllowedUserDomains = old.AllowedUserDomains
unified.Security.AllowedRolesAndGroups = old.AllowedRolesAndGroups
unified.Security.ExcludedURLs = old.ExcludedURLs
unified.Security.Headers = old.SecurityHeaders
// Map rate limiting
unified.RateLimit.RequestsPerSecond = old.RateLimit
unified.RateLimit.Enabled = old.RateLimit > 0
// Map token settings
unified.Token.RefreshGracePeriod = timeSecondsToDuration(old.RefreshGracePeriodSeconds)
// Map logging
unified.Logging.Level = old.LogLevel
// Map custom headers
if len(old.Headers) > 0 {
unified.Middleware.CustomHeaders = make(map[string]string)
for _, header := range old.Headers {
unified.Middleware.CustomHeaders[header.Name] = header.Value
}
}
// Store original config in legacy field for reference
unified.Legacy["original"] = old
return unified
}
// timeSecondsToDuration converts seconds to time.Duration
func timeSecondsToDuration(seconds int) time.Duration {
return time.Duration(seconds) * time.Second
}
// GetConfigInterface returns appropriate config based on feature flag
func GetConfigInterface() interface{} {
if features.IsUnifiedConfigEnabled() {
return NewUnifiedConfig()
}
return CreateConfig()
}
// ValidateConfig validates config based on feature flag
func ValidateConfig(cfg interface{}) error {
if features.IsUnifiedConfigEnabled() {
if unified, ok := cfg.(*UnifiedConfig); ok {
return unified.Validate()
}
}
// Fall back to old validation if available
if old, ok := cfg.(*Config); ok {
return old.Validate()
}
return nil
}
// Add Validate method to old Config for compatibility
func (c *Config) Validate() error {
var errors ValidationErrors
// Basic validation for old config
if c.ProviderURL == "" {
errors = append(errors, ValidationError{
Field: "ProviderURL",
Message: "provider URL is required",
})
}
if c.ClientID == "" {
errors = append(errors, ValidationError{
Field: "ClientID",
Message: "client ID is required",
})
}
if c.ClientSecret == "" && !c.EnablePKCE {
errors = append(errors, ValidationError{
Field: "ClientSecret",
Message: "client secret is required (or enable PKCE)",
})
}
if c.SessionEncryptionKey != "" && len(c.SessionEncryptionKey) < minEncryptionKeyLength {
errors = append(errors, ValidationError{
Field: "SessionEncryptionKey",
Message: fmt.Sprintf("encryption key must be at least %d characters", minEncryptionKeyLength),
Value: len(c.SessionEncryptionKey),
})
}
if len(errors) > 0 {
return errors
}
return nil
}
-363
View File
@@ -1,363 +0,0 @@
//go:build !yaegi
package config
import (
"testing"
"github.com/lukaszraczylo/traefikoidc/internal/features"
)
// NewLegacyAdapter Tests
func TestNewLegacyAdapter(t *testing.T) {
unified := NewUnifiedConfig()
unified.Provider.IssuerURL = "https://provider.example.com"
unified.Provider.ClientID = "test-client"
unified.Provider.ClientSecret = "test-secret"
adapter := NewLegacyAdapter(unified)
if adapter == nil {
t.Fatal("Expected NewLegacyAdapter to return non-nil")
}
if adapter.unified != unified {
t.Error("Expected adapter to reference the unified config")
}
if adapter.adapter == nil {
t.Error("Expected internal adapter to be initialized")
}
}
// ToOldConfig Tests
func TestLegacyAdapter_ToOldConfig(t *testing.T) {
unified := NewUnifiedConfig()
unified.Provider.IssuerURL = "https://issuer.example.com"
unified.Provider.ClientID = "client-123"
unified.Provider.ClientSecret = "secret-456"
unified.Provider.RedirectURL = "https://app.example.com/callback"
unified.Provider.LogoutURL = "/logout"
unified.Provider.PostLogoutRedirectURI = "https://app.example.com"
unified.Provider.Scopes = []string{"openid", "profile"}
unified.Provider.OverrideScopes = true
unified.Session.EncryptionKey = "test-encryption-key-32-chars!!"
unified.Session.Domain = "example.com"
unified.Security.ForceHTTPS = true
unified.Security.EnablePKCE = true
unified.Security.AllowedUsers = []string{"user@example.com"}
unified.Security.AllowedUserDomains = []string{"example.com"}
unified.Security.AllowedRolesAndGroups = []string{"admin"}
unified.Security.ExcludedURLs = []string{"/health"}
unified.RateLimit.RequestsPerSecond = 100
unified.Logging.Level = "debug"
unified.Middleware.CustomHeaders = map[string]string{
"X-Header-1": "value1",
"X-Header-2": "value2",
}
adapter := NewLegacyAdapter(unified)
oldConfig := adapter.ToOldConfig()
if oldConfig == nil {
t.Fatal("Expected ToOldConfig to return non-nil")
}
// ToOldConfig behavior depends on feature flag
if !features.IsUnifiedConfigEnabled() {
// When feature is disabled, returns default config
if oldConfig.ProviderURL == "" {
t.Log("Feature flag disabled - ToOldConfig returns default config")
}
return
}
// When feature is enabled, verify all fields were correctly mapped
if oldConfig.ProviderURL != unified.Provider.IssuerURL {
t.Errorf("Expected ProviderURL '%s', got '%s'", unified.Provider.IssuerURL, oldConfig.ProviderURL)
}
if oldConfig.ClientID != unified.Provider.ClientID {
t.Errorf("Expected ClientID '%s', got '%s'", unified.Provider.ClientID, oldConfig.ClientID)
}
if oldConfig.ClientSecret != unified.Provider.ClientSecret {
t.Errorf("Expected ClientSecret '%s', got '%s'", unified.Provider.ClientSecret, oldConfig.ClientSecret)
}
if oldConfig.CallbackURL != unified.Provider.RedirectURL {
t.Error("Expected CallbackURL to match RedirectURL")
}
if oldConfig.LogoutURL != unified.Provider.LogoutURL {
t.Error("Expected LogoutURL to match")
}
if oldConfig.ForceHTTPS != unified.Security.ForceHTTPS {
t.Error("Expected ForceHTTPS to match")
}
if oldConfig.EnablePKCE != unified.Security.EnablePKCE {
t.Error("Expected EnablePKCE to match")
}
if oldConfig.RateLimit != unified.RateLimit.RequestsPerSecond {
t.Errorf("Expected RateLimit %d, got %d", unified.RateLimit.RequestsPerSecond, oldConfig.RateLimit)
}
if len(oldConfig.Headers) != 2 {
t.Errorf("Expected 2 headers, got %d", len(oldConfig.Headers))
}
}
// convertHeaders Tests
func TestLegacyAdapter_convertHeaders(t *testing.T) {
unified := NewUnifiedConfig()
unified.Middleware.CustomHeaders = map[string]string{
"X-Custom-Header-1": "value1",
"X-Custom-Header-2": "value2",
"X-Custom-Header-3": "value3",
}
adapter := NewLegacyAdapter(unified)
headers := adapter.convertHeaders()
if len(headers) != 3 {
t.Errorf("Expected 3 headers, got %d", len(headers))
}
// Check that headers were converted
headerMap := make(map[string]string)
for _, h := range headers {
headerMap[h.Name] = h.Value
}
if headerMap["X-Custom-Header-1"] != "value1" {
t.Error("Expected X-Custom-Header-1 to have value 'value1'")
}
if headerMap["X-Custom-Header-2"] != "value2" {
t.Error("Expected X-Custom-Header-2 to have value 'value2'")
}
}
func TestLegacyAdapter_convertHeaders_Empty(t *testing.T) {
unified := NewUnifiedConfig()
// No custom headers
adapter := NewLegacyAdapter(unified)
headers := adapter.convertHeaders()
if len(headers) != 0 {
t.Errorf("Expected 0 headers, got %d", len(headers))
}
}
// GetConfigInterface Tests
func TestGetConfigInterface(t *testing.T) {
cfg := GetConfigInterface()
if cfg == nil {
t.Fatal("Expected GetConfigInterface to return non-nil")
}
// Should return either UnifiedConfig or Config depending on feature flag
_, isUnified := cfg.(*UnifiedConfig)
_, isOld := cfg.(*Config)
if !isUnified && !isOld {
t.Error("Expected either *UnifiedConfig or *Config")
}
// Verify consistency with feature flag
if features.IsUnifiedConfigEnabled() {
if !isUnified {
t.Error("Expected *UnifiedConfig when unified config is enabled")
}
} else {
if !isOld {
t.Error("Expected *Config when unified config is disabled")
}
}
}
// ValidateConfig Tests
func TestValidateConfig_UnifiedConfig(t *testing.T) {
unified := NewUnifiedConfig()
unified.Provider.IssuerURL = "https://provider.example.com"
unified.Provider.ClientID = "client-id"
unified.Provider.ClientSecret = "client-secret"
unified.Session.EncryptionKey = "encryption-key-32-characters!!"
err := ValidateConfig(unified)
// Should succeed regardless of feature flag since we're passing the right type
if err != nil {
t.Errorf("Expected valid unified config to pass validation, got: %v", err)
}
}
func TestValidateConfig_OldConfig(t *testing.T) {
old := CreateConfig()
old.ProviderURL = "https://provider.example.com"
old.ClientID = "client-id"
old.ClientSecret = "client-secret"
old.SessionEncryptionKey = "encryption-key-32-characters!!"
err := ValidateConfig(old)
if err != nil {
t.Errorf("Expected valid old config to pass validation, got: %v", err)
}
}
func TestValidateConfig_InvalidType(t *testing.T) {
// Pass something that's not a config
err := ValidateConfig("not a config")
if err != nil {
t.Errorf("Expected nil for unknown type, got: %v", err)
}
}
// Config.Validate Tests
func TestConfig_Validate_Valid(t *testing.T) {
cfg := CreateConfig()
cfg.ProviderURL = "https://provider.example.com"
cfg.ClientID = "client-id"
cfg.ClientSecret = "client-secret"
cfg.SessionEncryptionKey = "encryption-key-32-characters!!"
err := cfg.Validate()
if err != nil {
t.Errorf("Expected valid config to pass, got: %v", err)
}
}
func TestConfig_Validate_MissingProviderURL(t *testing.T) {
cfg := CreateConfig()
cfg.ClientID = "client-id"
cfg.ClientSecret = "client-secret"
err := cfg.Validate()
if err == nil {
t.Error("Expected error for missing ProviderURL")
}
// Check if it's a ValidationErrors type
if verrs, ok := err.(ValidationErrors); ok {
found := false
for _, verr := range verrs {
if verr.Field == "ProviderURL" {
found = true
break
}
}
if !found {
t.Error("Expected ProviderURL validation error")
}
}
}
func TestConfig_Validate_MissingClientID(t *testing.T) {
cfg := CreateConfig()
cfg.ProviderURL = "https://provider.example.com"
cfg.ClientSecret = "client-secret"
err := cfg.Validate()
if err == nil {
t.Error("Expected error for missing ClientID")
}
if verrs, ok := err.(ValidationErrors); ok {
found := false
for _, verr := range verrs {
if verr.Field == "ClientID" {
found = true
break
}
}
if !found {
t.Error("Expected ClientID validation error")
}
}
}
func TestConfig_Validate_MissingClientSecret_NoPKCE(t *testing.T) {
cfg := CreateConfig()
cfg.ProviderURL = "https://provider.example.com"
cfg.ClientID = "client-id"
cfg.EnablePKCE = false
err := cfg.Validate()
if err == nil {
t.Error("Expected error for missing ClientSecret without PKCE")
}
if verrs, ok := err.(ValidationErrors); ok {
found := false
for _, verr := range verrs {
if verr.Field == "ClientSecret" {
found = true
break
}
}
if !found {
t.Error("Expected ClientSecret validation error")
}
}
}
func TestConfig_Validate_MissingClientSecret_WithPKCE(t *testing.T) {
cfg := CreateConfig()
cfg.ProviderURL = "https://provider.example.com"
cfg.ClientID = "client-id"
cfg.EnablePKCE = true // PKCE enabled, so ClientSecret not required
err := cfg.Validate()
if err != nil {
t.Errorf("Expected no error with PKCE enabled and no ClientSecret, got: %v", err)
}
}
func TestConfig_Validate_ShortEncryptionKey(t *testing.T) {
cfg := CreateConfig()
cfg.ProviderURL = "https://provider.example.com"
cfg.ClientID = "client-id"
cfg.ClientSecret = "client-secret"
cfg.SessionEncryptionKey = "short" // Too short
err := cfg.Validate()
if err == nil {
t.Error("Expected error for short encryption key")
}
if verrs, ok := err.(ValidationErrors); ok {
found := false
for _, verr := range verrs {
if verr.Field == "SessionEncryptionKey" {
found = true
break
}
}
if !found {
t.Error("Expected SessionEncryptionKey validation error")
}
}
}
func TestConfig_Validate_MultipleErrors(t *testing.T) {
cfg := CreateConfig()
// Missing ProviderURL, ClientID, and ClientSecret
err := cfg.Validate()
if err == nil {
t.Fatal("Expected validation errors")
}
verrs, ok := err.(ValidationErrors)
if !ok {
t.Fatal("Expected ValidationErrors type")
}
if len(verrs) < 2 {
t.Errorf("Expected at least 2 validation errors, got %d", len(verrs))
}
}
File diff suppressed because it is too large Load Diff
-276
View File
@@ -1,276 +0,0 @@
// Package config provides default values and initialization for unified configuration
package config
import (
"time"
)
// NewUnifiedConfig creates a new unified configuration with sensible defaults
func NewUnifiedConfig() *UnifiedConfig {
return &UnifiedConfig{
Provider: DefaultProviderConfig(),
Session: DefaultSessionConfig(),
Token: DefaultTokenConfig(),
Redis: *DefaultRedisConfig(), // Using existing DefaultRedisConfig
Security: DefaultSecurityConfig(),
Middleware: DefaultMiddlewareConfig(),
Cache: DefaultCacheConfig(),
RateLimit: DefaultRateLimitConfig(),
Logging: DefaultLoggingConfig(),
Metrics: DefaultMetricsConfig(),
Health: DefaultHealthConfig(),
Transport: DefaultTransportConfig(),
Pool: DefaultPoolConfig(),
Circuit: DefaultCircuitConfig(),
Legacy: make(map[string]interface{}),
}
}
// DefaultProviderConfig returns default provider configuration
func DefaultProviderConfig() ProviderConfig {
return ProviderConfig{
Scopes: []string{"openid", "profile", "email"},
OverrideScopes: false,
CustomClaims: make(map[string]string),
JWKCachePeriod: 24 * time.Hour,
MetadataCacheTTL: 24 * time.Hour,
Discovery: true,
}
}
// DefaultSessionConfig returns default session configuration
func DefaultSessionConfig() SessionConfig {
return SessionConfig{
Name: "oidc_session",
MaxAge: 86400, // 24 hours
ChunkSize: 4000, // Safe size for cookies
MaxChunks: 5,
Path: "/",
Secure: true,
HttpOnly: true,
SameSite: "Lax",
StorageType: "cookie",
CleanupInterval: 1 * time.Hour,
}
}
// DefaultTokenConfig returns default token configuration
func DefaultTokenConfig() TokenConfig {
return TokenConfig{
AccessTokenTTL: 1 * time.Hour,
RefreshTokenTTL: 24 * time.Hour,
RefreshGracePeriod: 60 * time.Second,
ValidationMode: "jwt",
CacheEnabled: true,
CacheTTL: 5 * time.Minute,
CacheNegativeTTL: 30 * time.Second,
ValidateSignature: true,
ValidateExpiry: true,
ValidateAudience: true,
ValidateIssuer: true,
RequiredClaims: []string{"sub", "iat", "exp"},
ClockSkew: 5 * time.Minute,
}
}
// DefaultSecurityConfig returns default security configuration
func DefaultSecurityConfig() SecurityConfig {
return SecurityConfig{
ForceHTTPS: true,
EnablePKCE: true,
AllowedUsers: []string{},
AllowedUserDomains: []string{},
AllowedRolesAndGroups: []string{},
ExcludedURLs: []string{
"/favicon.ico",
"/robots.txt",
"/health",
"/.well-known/",
"/metrics",
"/ping",
"/static/",
"/assets/",
"/js/",
"/css/",
"/images/",
"/fonts/",
},
Headers: createDefaultSecurityConfig(),
CSRFProtection: true,
CSRFTokenName: "csrf_token",
CSRFTokenTTL: 1 * time.Hour,
MaxLoginAttempts: 5,
LockoutDuration: 15 * time.Minute,
RequireMFA: false,
}
}
// DefaultMiddlewareConfig returns default middleware configuration
func DefaultMiddlewareConfig() MiddlewareConfig {
return MiddlewareConfig{
Priority: 1000,
SkipPaths: []string{},
RequirePaths: []string{},
PassthroughMode: false,
MaxRequestSize: 10 * 1024 * 1024, // 10MB
RequestTimeout: 30 * time.Second,
IdleTimeout: 90 * time.Second,
CustomHeaders: make(map[string]string),
RemoveHeaders: []string{},
}
}
// DefaultCacheConfig returns default cache configuration
func DefaultCacheConfig() CacheConfig {
return CacheConfig{
Enabled: true,
Type: "memory",
DefaultTTL: 5 * time.Minute,
MaxEntries: 10000,
MaxEntrySize: 1024 * 1024, // 1MB
EvictionPolicy: "lru",
CleanupInterval: 10 * time.Minute,
Namespace: "traefikoidc",
Compression: false,
Serialization: "json",
}
}
// DefaultRateLimitConfig returns default rate limiting configuration
func DefaultRateLimitConfig() RateLimitConfig {
return RateLimitConfig{
Enabled: false,
RequestsPerSecond: 10,
Burst: 20,
StorageType: "memory",
WindowDuration: 1 * time.Minute,
KeyType: "ip",
CustomKeyFunc: "",
WhitelistIPs: []string{},
WhitelistUsers: []string{},
}
}
// DefaultLoggingConfig returns default logging configuration
func DefaultLoggingConfig() LoggingConfig {
return LoggingConfig{
Level: "info",
Format: "json",
Output: "stdout",
FilePath: "",
FilterSensitive: true,
MaskFields: []string{
"password",
"secret",
"token",
"key",
"authorization",
"cookie",
},
BufferSize: 8192,
FlushInterval: 5 * time.Second,
AuditEnabled: false,
AuditEvents: []string{
"login",
"logout",
"token_refresh",
"auth_failure",
},
}
}
// DefaultMetricsConfig returns default metrics configuration
func DefaultMetricsConfig() MetricsConfig {
return MetricsConfig{
Enabled: false,
Provider: "prometheus",
Endpoint: "/metrics",
Namespace: "traefikoidc",
Subsystem: "middleware",
CollectInterval: 10 * time.Second,
Histograms: true,
Labels: make(map[string]string),
}
}
// DefaultHealthConfig returns default health check configuration
func DefaultHealthConfig() HealthConfig {
return HealthConfig{
Enabled: true,
Path: "/health",
CheckInterval: 30 * time.Second,
Timeout: 5 * time.Second,
CheckProvider: true,
CheckRedis: true,
CheckCache: true,
MaxLatency: 1 * time.Second,
MinMemory: 100 * 1024 * 1024, // 100MB
}
}
// DefaultTransportConfig returns default HTTP transport configuration
func DefaultTransportConfig() TransportConfig {
return TransportConfig{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 0, // No limit
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
DisableKeepAlives: false,
DisableCompression: false,
TLSInsecureSkipVerify: false,
TLSMinVersion: "TLS1.2",
TLSCipherSuites: []string{},
ProxyURL: "",
NoProxy: []string{},
}
}
// DefaultPoolConfig returns default connection pool configuration
func DefaultPoolConfig() PoolConfig {
return PoolConfig{
Enabled: true,
Size: 10,
MinSize: 2,
MaxSize: 50,
MaxAge: 30 * time.Minute,
IdleTimeout: 5 * time.Minute,
WaitTimeout: 5 * time.Second,
HealthCheckInterval: 30 * time.Second,
MaxRetries: 3,
}
}
// DefaultCircuitConfig returns default circuit breaker configuration
func DefaultCircuitConfig() CircuitConfig {
return CircuitConfig{
Enabled: true,
MaxRequests: 100,
Interval: 10 * time.Second,
Timeout: 60 * time.Second,
ConsecutiveFailures: 5,
FailureRatio: 0.5,
OnOpen: "reject",
OnHalfOpen: "passthrough",
MetricsEnabled: true,
LogStateChanges: true,
}
}
// MergeWithDefaults merges a partial configuration with defaults
func MergeWithDefaults(partial *UnifiedConfig) *UnifiedConfig {
if partial == nil {
return NewUnifiedConfig()
}
// Ensure Legacy field is initialized
if partial.Legacy == nil {
partial.Legacy = make(map[string]interface{})
}
// TODO: Implement deep merge logic with defaults
// For now, just return the partial config
return partial
}
-397
View File
@@ -1,397 +0,0 @@
// Package config provides configuration loading and merging logic
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"github.com/lukaszraczylo/traefikoidc/internal/features"
"gopkg.in/yaml.v3"
)
// ConfigLoader handles loading configuration from various sources
type ConfigLoader struct {
migrator *ConfigMigrator
envPrefix string
configPaths []string
}
// NewConfigLoader creates a new configuration loader
func NewConfigLoader() *ConfigLoader {
return &ConfigLoader{
migrator: NewConfigMigrator(),
envPrefix: "TRAEFIKOIDC_",
configPaths: getDefaultConfigPaths(),
}
}
// getDefaultConfigPaths returns default configuration file paths to check
func getDefaultConfigPaths() []string {
return []string{
"traefik-oidc.yaml",
"traefik-oidc.yml",
"traefik-oidc.json",
"config.yaml",
"config.yml",
"config.json",
"/etc/traefik-oidc/config.yaml",
"/etc/traefik-oidc/config.json",
}
}
// Load loads configuration from all available sources
func (l *ConfigLoader) Load() (*UnifiedConfig, error) {
// Start with defaults
config := NewUnifiedConfig()
// Try to load from file
if fileConfig, err := l.LoadFromFile(); err == nil && fileConfig != nil {
config = l.mergeConfigs(config, fileConfig)
}
// Load from environment variables
l.LoadFromEnv(config)
// Validate the final configuration
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("configuration validation failed: %w", err)
}
return config, nil
}
// LoadFromFile loads configuration from a file
func (l *ConfigLoader) LoadFromFile(paths ...string) (*UnifiedConfig, error) {
// Use provided paths or default paths
searchPaths := paths
if len(searchPaths) == 0 {
searchPaths = l.configPaths
}
// Check for config file in environment variable
if envPath := os.Getenv(l.envPrefix + "CONFIG_FILE"); envPath != "" {
searchPaths = append([]string{envPath}, searchPaths...)
}
// Try each path
for _, path := range searchPaths {
if _, err := os.Stat(path); err == nil {
return l.loadFile(path)
}
}
// No config file found, not an error (use defaults)
return nil, nil
}
// loadFile loads a specific configuration file
func (l *ConfigLoader) loadFile(path string) (*UnifiedConfig, error) {
// Clean and validate path to prevent traversal attacks
cleanPath := filepath.Clean(path)
// Check for path traversal attempts
if strings.Contains(cleanPath, "..") {
return nil, fmt.Errorf("invalid config path: potential path traversal detected in %s", path)
}
// Ensure the path is within expected directories (current dir or subdirs)
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", path, err)
}
// Read the file with validated path
// #nosec G304 -- path is validated via filepath.Abs above
data, err := os.ReadFile(absPath)
if err != nil {
return nil, fmt.Errorf("failed to read config file %s: %w", absPath, err)
}
// Check if unified config is enabled
if features.IsUnifiedConfigEnabled() {
// Use migrator to handle any version
config, warnings, err := l.migrator.Migrate(data)
if err != nil {
return nil, fmt.Errorf("failed to migrate config from %s: %w", path, err)
}
// Log warnings
for _, warning := range warnings {
// In production, use proper logging
fmt.Printf("Config Warning (%s): %s\n", path, warning)
}
return config, nil
}
// Legacy path: load old config and convert
ext := strings.ToLower(filepath.Ext(path))
var oldConfig Config
switch ext {
case ".json":
if err := json.Unmarshal(data, &oldConfig); err != nil {
return nil, fmt.Errorf("failed to parse JSON config: %w", err)
}
case ".yaml", ".yml":
if err := yaml.Unmarshal(data, &oldConfig); err != nil {
return nil, fmt.Errorf("failed to parse YAML config: %w", err)
}
default:
return nil, fmt.Errorf("unsupported config file extension: %s", ext)
}
return FromOldConfig(&oldConfig), nil
}
// LoadFromEnv loads configuration from environment variables
func (l *ConfigLoader) LoadFromEnv(config *UnifiedConfig) {
// Provider configuration
l.loadEnvString(&config.Provider.IssuerURL, "PROVIDER_ISSUER_URL", "PROVIDER_URL")
l.loadEnvString(&config.Provider.ClientID, "PROVIDER_CLIENT_ID", "CLIENT_ID")
l.loadEnvString(&config.Provider.ClientSecret, "PROVIDER_CLIENT_SECRET", "CLIENT_SECRET")
l.loadEnvString(&config.Provider.RedirectURL, "PROVIDER_REDIRECT_URL", "CALLBACK_URL")
l.loadEnvString(&config.Provider.LogoutURL, "PROVIDER_LOGOUT_URL", "LOGOUT_URL")
l.loadEnvString(&config.Provider.PostLogoutRedirectURI, "PROVIDER_POST_LOGOUT_URI", "POST_LOGOUT_REDIRECT_URI")
l.loadEnvStringSlice(&config.Provider.Scopes, "PROVIDER_SCOPES", "SCOPES")
l.loadEnvBool(&config.Provider.OverrideScopes, "PROVIDER_OVERRIDE_SCOPES", "OVERRIDE_SCOPES")
// Session configuration
l.loadEnvString(&config.Session.Name, "SESSION_NAME")
l.loadEnvInt(&config.Session.MaxAge, "SESSION_MAX_AGE")
l.loadEnvString(&config.Session.Secret, "SESSION_SECRET")
l.loadEnvString(&config.Session.EncryptionKey, "SESSION_ENCRYPTION_KEY")
l.loadEnvString(&config.Session.Domain, "SESSION_DOMAIN", "COOKIE_DOMAIN")
l.loadEnvBool(&config.Session.Secure, "SESSION_SECURE")
l.loadEnvBool(&config.Session.HttpOnly, "SESSION_HTTP_ONLY")
l.loadEnvString(&config.Session.SameSite, "SESSION_SAME_SITE")
// Security configuration
l.loadEnvBool(&config.Security.ForceHTTPS, "SECURITY_FORCE_HTTPS", "FORCE_HTTPS")
l.loadEnvBool(&config.Security.EnablePKCE, "SECURITY_ENABLE_PKCE", "ENABLE_PKCE")
l.loadEnvStringSlice(&config.Security.AllowedUsers, "SECURITY_ALLOWED_USERS", "ALLOWED_USERS")
l.loadEnvStringSlice(&config.Security.AllowedUserDomains, "SECURITY_ALLOWED_DOMAINS", "ALLOWED_USER_DOMAINS")
l.loadEnvStringSlice(&config.Security.AllowedRolesAndGroups, "SECURITY_ALLOWED_ROLES", "ALLOWED_ROLES_AND_GROUPS")
l.loadEnvStringSlice(&config.Security.ExcludedURLs, "SECURITY_EXCLUDED_URLS", "EXCLUDED_URLS")
// Cache configuration
l.loadEnvBool(&config.Cache.Enabled, "CACHE_ENABLED")
l.loadEnvString(&config.Cache.Type, "CACHE_TYPE")
l.loadEnvInt(&config.Cache.MaxEntries, "CACHE_MAX_ENTRIES")
// MaxEntrySize is int64, skip for now
// Rate limiting
l.loadEnvBool(&config.RateLimit.Enabled, "RATELIMIT_ENABLED")
l.loadEnvInt(&config.RateLimit.RequestsPerSecond, "RATELIMIT_RPS", "RATE_LIMIT")
l.loadEnvInt(&config.RateLimit.Burst, "RATELIMIT_BURST")
// Logging
l.loadEnvString(&config.Logging.Level, "LOGGING_LEVEL", "LOG_LEVEL")
l.loadEnvString(&config.Logging.Format, "LOGGING_FORMAT")
l.loadEnvString(&config.Logging.Output, "LOGGING_OUTPUT")
// Redis configuration (already handled by its own LoadFromEnv)
config.Redis.LoadFromEnv()
// Feature flags
features.GetManager().LoadFromEnv()
}
// Helper methods for environment variable loading
func (l *ConfigLoader) loadEnvString(target *string, keys ...string) {
for _, key := range keys {
if value := os.Getenv(l.envPrefix + key); value != "" {
*target = value
return
}
// Try without prefix
if value := os.Getenv(key); value != "" {
*target = value
return
}
}
}
func (l *ConfigLoader) loadEnvBool(target *bool, keys ...string) {
for _, key := range keys {
if value := os.Getenv(l.envPrefix + key); value != "" {
*target = strings.ToLower(value) == "true" || value == "1"
return
}
// Try without prefix
if value := os.Getenv(key); value != "" {
*target = strings.ToLower(value) == "true" || value == "1"
return
}
}
}
func (l *ConfigLoader) loadEnvInt(target *int, keys ...string) {
for _, key := range keys {
if value := os.Getenv(l.envPrefix + key); value != "" {
var i int
if _, err := fmt.Sscanf(value, "%d", &i); err == nil {
*target = i
return
}
}
// Try without prefix
if value := os.Getenv(key); value != "" {
var i int
if _, err := fmt.Sscanf(value, "%d", &i); err == nil {
*target = i
return
}
}
}
}
func (l *ConfigLoader) loadEnvStringSlice(target *[]string, keys ...string) {
for _, key := range keys {
if value := os.Getenv(l.envPrefix + key); value != "" {
*target = splitAndTrim(value)
return
}
// Try without prefix
if value := os.Getenv(key); value != "" {
*target = splitAndTrim(value)
return
}
}
}
func splitAndTrim(s string) []string {
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// mergeConfigs merges two configurations, with source overriding target
func (l *ConfigLoader) mergeConfigs(target, source *UnifiedConfig) *UnifiedConfig {
if source == nil {
return target
}
if target == nil {
return source
}
// Use reflection for deep merge
l.mergeStructs(reflect.ValueOf(target).Elem(), reflect.ValueOf(source).Elem())
return target
}
// mergeStructs recursively merges two structs
func (l *ConfigLoader) mergeStructs(target, source reflect.Value) {
for i := 0; i < source.NumField(); i++ {
sourceField := source.Field(i)
targetField := target.Field(i)
// Skip if source field is zero value
if isZeroValue(sourceField) {
continue
}
switch sourceField.Kind() {
case reflect.Struct:
// Recursively merge structs
l.mergeStructs(targetField, sourceField)
case reflect.Slice:
// Replace slice if source has values
if sourceField.Len() > 0 {
targetField.Set(sourceField)
}
case reflect.Map:
// Merge maps
if !sourceField.IsNil() {
if targetField.IsNil() {
targetField.Set(reflect.MakeMap(sourceField.Type()))
}
for _, key := range sourceField.MapKeys() {
targetField.SetMapIndex(key, sourceField.MapIndex(key))
}
}
default:
// Replace value
targetField.Set(sourceField)
}
}
}
// isZeroValue checks if a reflect.Value is a zero value
func isZeroValue(v reflect.Value) bool {
switch v.Kind() {
case reflect.Ptr, reflect.Interface:
return v.IsNil()
case reflect.Slice, reflect.Map:
return v.IsNil() || v.Len() == 0
case reflect.Struct:
// Check if all fields are zero
for i := 0; i < v.NumField(); i++ {
if !isZeroValue(v.Field(i)) {
return false
}
}
return true
default:
zero := reflect.Zero(v.Type())
return reflect.DeepEqual(v.Interface(), zero.Interface())
}
}
// SaveToFile saves the configuration to a file
func (l *ConfigLoader) SaveToFile(config *UnifiedConfig, path string) error {
// Clean and validate path to prevent traversal attacks
cleanPath := filepath.Clean(path)
// Check for path traversal attempts
if strings.Contains(cleanPath, "..") {
return fmt.Errorf("invalid config path: potential path traversal detected in %s", path)
}
// Ensure the path is within expected directories
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return fmt.Errorf("failed to resolve absolute path for %s: %w", path, err)
}
ext := strings.ToLower(filepath.Ext(absPath))
var data []byte
switch ext {
case ".json":
data, err = json.MarshalIndent(config, "", " ")
case ".yaml", ".yml":
data, err = yaml.Marshal(config)
default:
return fmt.Errorf("unsupported file extension: %s", ext)
}
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Create directory if it doesn't exist with secure permissions
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
// Write file with secure permissions (owner read/write only)
if err := os.WriteFile(absPath, data, 0600); err != nil {
return fmt.Errorf("failed to write config file %s: %w", absPath, err)
}
return nil
}
-832
View File
@@ -1,832 +0,0 @@
//go:build !yaegi
package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// TestConfigLoader tests the config loader functionality
func TestConfigLoader(t *testing.T) {
loader := NewConfigLoader()
if loader == nil {
t.Fatal("NewConfigLoader should not return nil")
}
if loader.migrator == nil {
t.Error("ConfigLoader should have a migrator")
}
if loader.envPrefix != "TRAEFIKOIDC_" {
t.Errorf("Expected envPrefix to be 'TRAEFIKOIDC_', got %s", loader.envPrefix)
}
if len(loader.configPaths) == 0 {
t.Error("ConfigLoader should have default config paths")
}
}
// TestLoadFromEnv tests loading configuration from environment variables
func TestLoadFromEnv(t *testing.T) {
// Set up test environment variables
testEnvVars := map[string]string{
"TRAEFIKOIDC_PROVIDER_ISSUER_URL": "https://test.example.com",
"TRAEFIKOIDC_PROVIDER_CLIENT_ID": "test-client-id",
"TRAEFIKOIDC_PROVIDER_CLIENT_SECRET": "test-secret",
"TRAEFIKOIDC_SESSION_ENCRYPTION_KEY": "32-character-encryption-key-12345",
"TRAEFIKOIDC_SESSION_CHUNKED": "true",
"TRAEFIKOIDC_REDIS_ENABLED": "true",
"TRAEFIKOIDC_REDIS_ADDR": "redis.example.com:6379",
"TRAEFIKOIDC_SECURITY_FORCE_HTTPS": "true",
"TRAEFIKOIDC_CACHE_ENABLED": "true",
"TRAEFIKOIDC_CACHE_TYPE": "redis",
"TRAEFIKOIDC_RATELIMIT_ENABLED": "true",
"TRAEFIKOIDC_RATELIMIT_RPS": "100",
}
// Set environment variables
for key, value := range testEnvVars {
os.Setenv(key, value)
defer os.Unsetenv(key)
}
loader := NewConfigLoader()
config := &UnifiedConfig{}
loader.LoadFromEnv(config)
// Verify values were loaded
if config.Provider.IssuerURL != "https://test.example.com" {
t.Errorf("Expected IssuerURL to be 'https://test.example.com', got %s", config.Provider.IssuerURL)
}
if config.Provider.ClientID != "test-client-id" {
t.Errorf("Expected ClientID to be 'test-client-id', got %s", config.Provider.ClientID)
}
if config.Provider.ClientSecret != "test-secret" {
t.Errorf("Expected ClientSecret to be 'test-secret', got %s", config.Provider.ClientSecret)
}
if config.Session.EncryptionKey != "32-character-encryption-key-12345" {
t.Errorf("Expected EncryptionKey to be set, got %s", config.Session.EncryptionKey)
}
if !config.Security.ForceHTTPS {
t.Error("Expected ForceHTTPS to be true")
}
if !config.Cache.Enabled {
t.Error("Expected Cache to be enabled")
}
if config.Cache.Type != "redis" {
t.Errorf("Expected Cache.Type to be 'redis', got %s", config.Cache.Type)
}
if !config.RateLimit.Enabled {
t.Error("Expected RateLimit to be enabled")
}
if config.RateLimit.RequestsPerSecond != 100 {
t.Errorf("Expected RequestsPerSecond to be 100, got %d", config.RateLimit.RequestsPerSecond)
}
}
// TestSaveToFile tests saving configuration to files
func TestSaveToFile(t *testing.T) {
// Create a temporary directory for test files
tmpDir, err := os.MkdirTemp("", "config-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tmpDir)
loader := NewConfigLoader()
config := &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://auth.example.com",
ClientID: "test-client",
ClientSecret: "secret",
},
Session: SessionConfig{
EncryptionKey: "32-character-encryption-key-12345",
},
}
tests := []struct {
name string
filename string
wantErr bool
}{
{
name: "save as JSON",
filename: "config.json",
wantErr: false,
},
{
name: "save as YAML",
filename: "config.yaml",
wantErr: false,
},
{
name: "save as YML",
filename: "config.yml",
wantErr: false,
},
{
name: "unsupported extension",
filename: "config.txt",
wantErr: true,
},
{
name: "path traversal attempt",
filename: "../../../etc/config.json",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filePath := filepath.Join(tmpDir, tt.filename)
err := loader.SaveToFile(config, filePath)
if tt.wantErr {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
// Verify file was created with correct permissions
info, err := os.Stat(filePath)
if err != nil {
t.Errorf("Failed to stat saved file: %v", err)
return
}
// Check file permissions (should be 0600)
mode := info.Mode().Perm()
if mode != 0600 {
t.Errorf("Expected file permissions 0600, got %o", mode)
}
// Verify content can be read back
data, err := os.ReadFile(filePath)
if err != nil {
t.Errorf("Failed to read saved file: %v", err)
return
}
// Verify secrets are redacted
content := string(data)
if strings.Contains(content, "secret") && !strings.Contains(content, "[REDACTED]") {
t.Error("Secrets should be redacted in saved file")
}
})
}
}
// TestLoadFile tests loading configuration from files
func TestLoadFile(t *testing.T) {
// Create a temporary directory for test files
tmpDir, err := os.MkdirTemp("", "config-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tmpDir)
// Test data - using old config format since unified config is not enabled by default
jsonConfig := `{
"providerURL": "https://auth.example.com",
"clientID": "test-client",
"clientSecret": "secret",
"sessionEncryptionKey": "32-character-encryption-key-12345"
}`
yamlConfig := `
providerurl: https://auth.example.com
clientid: test-client
clientsecret: secret
sessionencryptionkey: 32-character-encryption-key-12345
`
tests := []struct {
name string
filename string
content string
wantErr bool
}{
{
name: "load JSON config",
filename: "config.json",
content: jsonConfig,
wantErr: false,
},
{
name: "load YAML config",
filename: "config.yaml",
content: yamlConfig,
wantErr: false,
},
{
name: "path traversal attempt",
filename: "../../../etc/passwd",
content: "",
wantErr: true,
},
{
name: "non-existent file",
filename: "does-not-exist.json",
content: "",
wantErr: true,
},
}
loader := NewConfigLoader()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var filePath string
if tt.content != "" {
filePath = filepath.Join(tmpDir, tt.filename)
err := os.WriteFile(filePath, []byte(tt.content), 0600)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
return
}
} else {
filePath = tt.filename
}
config, err := loader.loadFile(filePath)
if tt.wantErr {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
if !os.IsNotExist(err) && !strings.Contains(err.Error(), "no such file") {
t.Errorf("Unexpected error: %v", err)
}
return
}
// Verify loaded config
if config == nil {
t.Error("Expected config to be loaded")
return
}
if config.Provider.IssuerURL != "https://auth.example.com" {
t.Errorf("Expected IssuerURL to be 'https://auth.example.com', got %s", config.Provider.IssuerURL)
}
if config.Provider.ClientID != "test-client" {
t.Errorf("Expected ClientID to be 'test-client', got %s", config.Provider.ClientID)
}
})
}
}
// ====================================================================================
// Tests for untested functions (0% coverage)
// ====================================================================================
// TestConfigLoader_Load tests the full Load pipeline
func TestConfigLoader_Load(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "config-load-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create a test config file
configPath := filepath.Join(tmpDir, "traefik-oidc.json")
configData := `{
"providerURL": "https://auth.example.com",
"clientID": "test-client",
"clientSecret": "test-secret",
"sessionEncryptionKey": "32-character-encryption-key-12345"
}`
err = os.WriteFile(configPath, []byte(configData), 0600)
if err != nil {
t.Fatalf("Failed to write test config file: %v", err)
}
// Change to temp directory so loader can find the config
oldDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(oldDir)
// Set some environment variables to test merging
os.Setenv("TRAEFIKOIDC_SECURITY_FORCE_HTTPS", "true")
defer os.Unsetenv("TRAEFIKOIDC_SECURITY_FORCE_HTTPS")
loader := NewConfigLoader()
config, err := loader.Load()
if err != nil {
t.Fatalf("Load() failed: %v", err)
}
if config == nil {
t.Fatal("Load() returned nil config")
}
// Verify file was loaded
if config.Provider.IssuerURL != "https://auth.example.com" {
t.Errorf("Expected IssuerURL from file, got %s", config.Provider.IssuerURL)
}
// Verify env vars were loaded
if !config.Security.ForceHTTPS {
t.Error("Expected ForceHTTPS from env var to be true")
}
}
// TestConfigLoader_LoadFromFile tests the LoadFromFile function
func TestConfigLoader_LoadFromFile(t *testing.T) {
t.Run("NoConfigFile", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "config-nofile-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tmpDir)
oldDir, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(oldDir)
loader := NewConfigLoader()
config, err := loader.LoadFromFile()
// Should not error when no config file found
if err != nil {
t.Errorf("LoadFromFile() should not error when no file found: %v", err)
}
// Should return nil config
if config != nil {
t.Error("LoadFromFile() should return nil config when no file found")
}
})
t.Run("LoadFromEnvPath", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "config-envpath-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create config file
configPath := filepath.Join(tmpDir, "custom-config.json")
configData := `{
"providerURL": "https://custom.example.com",
"clientID": "custom-client"
}`
err = os.WriteFile(configPath, []byte(configData), 0600)
if err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
// Set env variable pointing to config
os.Setenv("TRAEFIKOIDC_CONFIG_FILE", configPath)
defer os.Unsetenv("TRAEFIKOIDC_CONFIG_FILE")
loader := NewConfigLoader()
config, err := loader.LoadFromFile()
if err != nil {
t.Fatalf("LoadFromFile() failed: %v", err)
}
if config == nil {
t.Fatal("LoadFromFile() returned nil config")
}
if config.Provider.IssuerURL != "https://custom.example.com" {
t.Errorf("Expected IssuerURL 'https://custom.example.com', got %s", config.Provider.IssuerURL)
}
})
t.Run("LoadWithProvidedPaths", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "config-provided-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create config file
configPath := filepath.Join(tmpDir, "specific.json")
configData := `{
"providerURL": "https://specific.example.com",
"clientID": "specific-client"
}`
err = os.WriteFile(configPath, []byte(configData), 0600)
if err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
loader := NewConfigLoader()
config, err := loader.LoadFromFile(configPath)
if err != nil {
t.Fatalf("LoadFromFile() with path failed: %v", err)
}
if config == nil {
t.Fatal("LoadFromFile() returned nil config")
}
if config.Provider.IssuerURL != "https://specific.example.com" {
t.Errorf("Expected IssuerURL 'https://specific.example.com', got %s", config.Provider.IssuerURL)
}
})
}
// TestSplitAndTrim tests the splitAndTrim helper function
func TestSplitAndTrim(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "Simple comma-separated",
input: "a,b,c",
expected: []string{"a", "b", "c"},
},
{
name: "With spaces",
input: "a, b , c",
expected: []string{"a", "b", "c"},
},
{
name: "Empty strings filtered out",
input: "a,,b, ,c",
expected: []string{"a", "b", "c"},
},
{
name: "Leading and trailing spaces",
input: " a , b , c ",
expected: []string{"a", "b", "c"},
},
{
name: "Single value",
input: "single",
expected: []string{"single"},
},
{
name: "Empty string",
input: "",
expected: []string{},
},
{
name: "Only commas and spaces",
input: " , , , ",
expected: []string{},
},
{
name: "Complex real-world example",
input: "openid, profile, email, groups",
expected: []string{"openid", "profile", "email", "groups"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := splitAndTrim(tt.input)
if len(result) != len(tt.expected) {
t.Errorf("Expected %d items, got %d: %v", len(tt.expected), len(result), result)
return
}
for i, expected := range tt.expected {
if result[i] != expected {
t.Errorf("At index %d: expected %q, got %q", i, expected, result[i])
}
}
})
}
}
// TestConfigLoader_MergeConfigs tests the mergeConfigs function
func TestConfigLoader_MergeConfigs(t *testing.T) {
loader := NewConfigLoader()
t.Run("MergeNilSource", func(t *testing.T) {
target := &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://target.example.com",
},
}
result := loader.mergeConfigs(target, nil)
if result != target {
t.Error("mergeConfigs should return target when source is nil")
}
})
t.Run("MergeNilTarget", func(t *testing.T) {
source := &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://source.example.com",
},
}
result := loader.mergeConfigs(nil, source)
if result != source {
t.Error("mergeConfigs should return source when target is nil")
}
})
t.Run("MergeSimpleFields", func(t *testing.T) {
target := &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://target.example.com",
ClientID: "",
},
}
source := &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://source.example.com",
ClientID: "source-client",
},
}
result := loader.mergeConfigs(target, source)
if result.Provider.IssuerURL != "https://source.example.com" {
t.Errorf("Expected IssuerURL to be overridden, got %s", result.Provider.IssuerURL)
}
if result.Provider.ClientID != "source-client" {
t.Errorf("Expected ClientID to be set, got %s", result.Provider.ClientID)
}
})
t.Run("MergeSlices", func(t *testing.T) {
target := &UnifiedConfig{
Provider: ProviderConfig{
Scopes: []string{"openid", "profile"},
},
}
source := &UnifiedConfig{
Provider: ProviderConfig{
Scopes: []string{"email", "groups"},
},
}
result := loader.mergeConfigs(target, source)
// Source slice should replace target slice
if len(result.Provider.Scopes) != 2 {
t.Errorf("Expected 2 scopes, got %d", len(result.Provider.Scopes))
}
if result.Provider.Scopes[0] != "email" {
t.Errorf("Expected first scope 'email', got %s", result.Provider.Scopes[0])
}
})
t.Run("MergeMaps", func(t *testing.T) {
target := &UnifiedConfig{
Middleware: MiddlewareConfig{
CustomHeaders: map[string]string{
"X-Target-Header": "target-value",
},
},
}
source := &UnifiedConfig{
Middleware: MiddlewareConfig{
CustomHeaders: map[string]string{
"X-Source-Header": "source-value",
"X-Target-Header": "overridden-value",
},
},
}
result := loader.mergeConfigs(target, source)
if len(result.Middleware.CustomHeaders) != 2 {
t.Errorf("Expected 2 headers, got %d", len(result.Middleware.CustomHeaders))
}
if result.Middleware.CustomHeaders["X-Target-Header"] != "overridden-value" {
t.Errorf("Expected X-Target-Header to be overridden")
}
if result.Middleware.CustomHeaders["X-Source-Header"] != "source-value" {
t.Errorf("Expected X-Source-Header to be added")
}
})
}
// TestConfigLoader_MergeStructs tests the mergeStructs function indirectly
func TestConfigLoader_MergeStructs(t *testing.T) {
loader := NewConfigLoader()
t.Run("NestedStructMerge", func(t *testing.T) {
target := &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://target.example.com",
ClientID: "target-client",
},
Session: SessionConfig{
Name: "target-session",
MaxAge: 3600,
},
}
source := &UnifiedConfig{
Provider: ProviderConfig{
ClientID: "source-client",
ClientSecret: "source-secret",
},
Session: SessionConfig{
MaxAge: 7200,
},
}
result := loader.mergeConfigs(target, source)
// Provider.IssuerURL should remain (zero value in source)
if result.Provider.IssuerURL != "https://target.example.com" {
t.Errorf("Expected IssuerURL to remain, got %s", result.Provider.IssuerURL)
}
// Provider.ClientID should be overridden
if result.Provider.ClientID != "source-client" {
t.Errorf("Expected ClientID to be overridden, got %s", result.Provider.ClientID)
}
// Provider.ClientSecret should be added
if result.Provider.ClientSecret != "source-secret" {
t.Errorf("Expected ClientSecret to be added, got %s", result.Provider.ClientSecret)
}
// Session.Name should remain (zero value in source)
if result.Session.Name != "target-session" {
t.Errorf("Expected Session.Name to remain, got %s", result.Session.Name)
}
// Session.MaxAge should be overridden
if result.Session.MaxAge != 7200 {
t.Errorf("Expected Session.MaxAge to be overridden, got %d", result.Session.MaxAge)
}
})
}
// TestIsZeroValue tests the isZeroValue helper function
func TestIsZeroValue(t *testing.T) {
tests := []struct {
name string
value interface{}
expected bool
}{
{
name: "Zero string",
value: "",
expected: true,
},
{
name: "Non-zero string",
value: "hello",
expected: false,
},
{
name: "Zero int",
value: 0,
expected: true,
},
{
name: "Non-zero int",
value: 42,
expected: false,
},
{
name: "Zero bool",
value: false,
expected: true,
},
{
name: "Non-zero bool",
value: true,
expected: false,
},
{
name: "Nil pointer",
value: (*string)(nil),
expected: true,
},
{
name: "Non-nil pointer",
value: stringPtr("test"),
expected: false,
},
{
name: "Nil slice",
value: ([]string)(nil),
expected: true,
},
{
name: "Empty slice",
value: []string{},
expected: true,
},
{
name: "Non-empty slice",
value: []string{"a"},
expected: false,
},
{
name: "Nil map",
value: (map[string]string)(nil),
expected: true,
},
{
name: "Empty map",
value: map[string]string{},
expected: true,
},
{
name: "Non-empty map",
value: map[string]string{"key": "value"},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := reflect.ValueOf(tt.value)
result := isZeroValue(v)
if result != tt.expected {
t.Errorf("Expected isZeroValue to be %v, got %v", tt.expected, result)
}
})
}
}
// TestIsZeroValue_Struct tests isZeroValue with struct types
func TestIsZeroValue_Struct(t *testing.T) {
type TestStruct struct {
Field1 string
Field2 int
}
t.Run("Zero struct", func(t *testing.T) {
s := TestStruct{}
v := reflect.ValueOf(s)
result := isZeroValue(v)
if !result {
t.Error("Expected zero struct to return true")
}
})
t.Run("Non-zero struct - Field1 set", func(t *testing.T) {
s := TestStruct{Field1: "test"}
v := reflect.ValueOf(s)
result := isZeroValue(v)
if result {
t.Error("Expected non-zero struct to return false")
}
})
t.Run("Non-zero struct - Field2 set", func(t *testing.T) {
s := TestStruct{Field2: 42}
v := reflect.ValueOf(s)
result := isZeroValue(v)
if result {
t.Error("Expected non-zero struct to return false")
}
})
t.Run("Non-zero struct - Both fields set", func(t *testing.T) {
s := TestStruct{Field1: "test", Field2: 42}
v := reflect.ValueOf(s)
result := isZeroValue(v)
if result {
t.Error("Expected non-zero struct to return false")
}
})
}
// Helper function for pointer tests
func stringPtr(s string) *string {
return &s
}
-169
View File
@@ -1,169 +0,0 @@
// Package config provides unified configuration management for the OIDC middleware
package config
import (
"encoding/json"
)
// REDACTED is the placeholder value for sensitive information
const REDACTED = "[REDACTED]"
// MarshalJSON implements custom JSON marshalling to redact sensitive fields
func (c UnifiedConfig) MarshalJSON() ([]byte, error) {
// Create an alias to avoid recursion
type Alias UnifiedConfig
// Create a copy with redacted sensitive fields
copy := (Alias)(c)
// Redact provider secrets
if copy.Provider.ClientSecret != "" {
copy.Provider.ClientSecret = REDACTED
}
// Redact session secrets
if copy.Session.Secret != "" {
copy.Session.Secret = REDACTED
}
if copy.Session.EncryptionKey != "" {
copy.Session.EncryptionKey = REDACTED
}
if copy.Session.SigningKey != "" {
copy.Session.SigningKey = REDACTED
}
// Redact Redis passwords
if copy.Redis.Password != "" {
copy.Redis.Password = REDACTED
}
if copy.Redis.SentinelPassword != "" {
copy.Redis.SentinelPassword = REDACTED
}
return json.Marshal(copy)
}
// MarshalJSON for ProviderConfig to redact sensitive fields
func (p ProviderConfig) MarshalJSON() ([]byte, error) {
type Alias ProviderConfig
copy := (Alias)(p)
if copy.ClientSecret != "" {
copy.ClientSecret = REDACTED
}
return json.Marshal(copy)
}
// MarshalJSON for SessionConfig to redact sensitive fields
func (s SessionConfig) MarshalJSON() ([]byte, error) {
type Alias SessionConfig
copy := (Alias)(s)
if copy.Secret != "" {
copy.Secret = REDACTED
}
if copy.EncryptionKey != "" {
copy.EncryptionKey = REDACTED
}
if copy.SigningKey != "" {
copy.SigningKey = REDACTED
}
return json.Marshal(copy)
}
// MarshalJSON for RedisConfig to redact sensitive fields
func (r RedisConfig) MarshalJSON() ([]byte, error) {
type Alias RedisConfig
copy := (Alias)(r)
if copy.Password != "" {
copy.Password = REDACTED
}
if copy.SentinelPassword != "" {
copy.SentinelPassword = REDACTED
}
return json.Marshal(copy)
}
// MarshalYAML implements custom YAML marshalling to redact sensitive fields
func (c UnifiedConfig) MarshalYAML() (interface{}, error) {
// Create an alias to avoid recursion
type Alias UnifiedConfig
// Create a copy with redacted sensitive fields
copy := (Alias)(c)
// Redact provider secrets
if copy.Provider.ClientSecret != "" {
copy.Provider.ClientSecret = REDACTED
}
// Redact session secrets
if copy.Session.Secret != "" {
copy.Session.Secret = REDACTED
}
if copy.Session.EncryptionKey != "" {
copy.Session.EncryptionKey = REDACTED
}
if copy.Session.SigningKey != "" {
copy.Session.SigningKey = REDACTED
}
// Redact Redis passwords
if copy.Redis.Password != "" {
copy.Redis.Password = REDACTED
}
if copy.Redis.SentinelPassword != "" {
copy.Redis.SentinelPassword = REDACTED
}
return copy, nil
}
// MarshalYAML for ProviderConfig to redact sensitive fields
func (p ProviderConfig) MarshalYAML() (interface{}, error) {
type Alias ProviderConfig
copy := (Alias)(p)
if copy.ClientSecret != "" {
copy.ClientSecret = REDACTED
}
return copy, nil
}
// MarshalYAML for SessionConfig to redact sensitive fields
func (s SessionConfig) MarshalYAML() (interface{}, error) {
type Alias SessionConfig
copy := (Alias)(s)
if copy.Secret != "" {
copy.Secret = REDACTED
}
if copy.EncryptionKey != "" {
copy.EncryptionKey = REDACTED
}
if copy.SigningKey != "" {
copy.SigningKey = REDACTED
}
return copy, nil
}
// MarshalYAML for RedisConfig to redact sensitive fields
func (r RedisConfig) MarshalYAML() (interface{}, error) {
type Alias RedisConfig
copy := (Alias)(r)
if copy.Password != "" {
copy.Password = REDACTED
}
if copy.SentinelPassword != "" {
copy.SentinelPassword = REDACTED
}
return copy, nil
}
-408
View File
@@ -1,408 +0,0 @@
// Package config provides configuration migration from old to new format
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/compat"
"github.com/lukaszraczylo/traefikoidc/internal/features"
"gopkg.in/yaml.v3"
)
// ConfigVersion represents the version of a configuration format
type ConfigVersion string
const (
// VersionLegacy represents the original config format
VersionLegacy ConfigVersion = "legacy"
// VersionUnified represents the new unified config format
VersionUnified ConfigVersion = "unified"
// CurrentVersion is the current config version
CurrentVersion ConfigVersion = VersionUnified
)
// ConfigMigrator handles migration between config versions
type ConfigMigrator struct {
compatLayer *compat.CompatibilityLayer
migrations map[ConfigVersion]MigrationFunc
}
// MigrationFunc defines a function that migrates configuration
type MigrationFunc func(data map[string]interface{}) (*UnifiedConfig, error)
// NewConfigMigrator creates a new configuration migrator
func NewConfigMigrator() *ConfigMigrator {
m := &ConfigMigrator{
compatLayer: compat.GetLayer(),
migrations: make(map[ConfigVersion]MigrationFunc),
}
// Register migration functions
m.migrations[VersionLegacy] = m.migrateLegacyToUnified
return m
}
// DetectVersion detects the version of a configuration
func (m *ConfigMigrator) DetectVersion(data []byte) ConfigVersion {
var testMap map[string]interface{}
// Try JSON first
if err := json.Unmarshal(data, &testMap); err != nil {
// Try YAML
if err := yaml.Unmarshal(data, &testMap); err != nil {
return VersionLegacy // Default to legacy if can't parse
}
}
// Check for unified config markers
if _, hasProvider := testMap["provider"]; hasProvider {
if _, hasSession := testMap["session"]; hasSession {
return VersionUnified
}
}
// Check for legacy config markers
if _, hasProviderURL := testMap["providerUrl"]; hasProviderURL {
return VersionLegacy
}
if _, hasProviderURL := testMap["ProviderURL"]; hasProviderURL {
return VersionLegacy
}
return VersionLegacy
}
// Migrate migrates configuration data to the current version
func (m *ConfigMigrator) Migrate(data []byte) (*UnifiedConfig, []string, error) {
warnings := []string{}
// Detect version
version := m.DetectVersion(data)
// If already current version, just unmarshal
if version == CurrentVersion {
var config UnifiedConfig
if err := json.Unmarshal(data, &config); err != nil {
// Try YAML
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, warnings, fmt.Errorf("failed to unmarshal unified config: %w", err)
}
}
return &config, warnings, nil
}
// Parse to generic map
var configMap map[string]interface{}
if err := json.Unmarshal(data, &configMap); err != nil {
// Try YAML
if err := yaml.Unmarshal(data, &configMap); err != nil {
return nil, warnings, fmt.Errorf("failed to unmarshal config: %w", err)
}
}
// Apply migration
migrationFunc, exists := m.migrations[version]
if !exists {
return nil, warnings, fmt.Errorf("no migration path from version %s", version)
}
config, err := migrationFunc(configMap)
if err != nil {
return nil, warnings, fmt.Errorf("migration failed: %w", err)
}
// Collect any deprecation warnings
for key := range configMap {
if warning, deprecated := m.compatLayer.CheckDeprecation(key); deprecated {
warnings = append(warnings, warning)
}
}
return config, warnings, nil
}
// migrateLegacyToUnified migrates legacy config to unified format
func (m *ConfigMigrator) migrateLegacyToUnified(data map[string]interface{}) (*UnifiedConfig, error) {
config := NewUnifiedConfig()
// Use compatibility layer for field mapping
migratedMap, warnings := m.compatLayer.MigrateMap(data)
// Log warnings
for _, warning := range warnings {
// In production, these would be logged
_ = warning
}
// Map provider configuration
if provider, ok := getNestedMap(migratedMap, "Provider"); ok {
_ = mapToStruct(provider, &config.Provider)
} else {
// Direct field mapping for legacy format
config.Provider.IssuerURL = getStringValue(data, "providerUrl", "ProviderURL")
config.Provider.ClientID = getStringValue(data, "clientId", "ClientID")
config.Provider.ClientSecret = getStringValue(data, "clientSecret", "ClientSecret")
config.Provider.RedirectURL = getStringValue(data, "callbackUrl", "CallbackURL")
config.Provider.LogoutURL = getStringValue(data, "logoutUrl", "LogoutURL")
config.Provider.PostLogoutRedirectURI = getStringValue(data, "postLogoutRedirectUri", "PostLogoutRedirectURI")
if scopes := getArrayValue(data, "scopes", "Scopes"); scopes != nil {
config.Provider.Scopes = scopes
}
config.Provider.OverrideScopes = getBoolValue(data, "overrideScopes", "OverrideScopes")
}
// Map session configuration
if session, ok := getNestedMap(migratedMap, "Session"); ok {
_ = mapToStruct(session, &config.Session)
} else {
config.Session.EncryptionKey = getStringValue(data, "sessionEncryptionKey", "SessionEncryptionKey")
config.Session.Domain = getStringValue(data, "cookieDomain", "CookieDomain")
}
// Map security configuration
if security, ok := getNestedMap(migratedMap, "Security"); ok {
_ = mapToStruct(security, &config.Security)
} else {
config.Security.ForceHTTPS = getBoolValue(data, "forceHttps", "ForceHTTPS")
config.Security.EnablePKCE = getBoolValue(data, "enablePkce", "EnablePKCE")
if users := getArrayValue(data, "allowedUsers", "AllowedUsers"); users != nil {
config.Security.AllowedUsers = users
}
if domains := getArrayValue(data, "allowedUserDomains", "AllowedUserDomains"); domains != nil {
config.Security.AllowedUserDomains = domains
}
if roles := getArrayValue(data, "allowedRolesAndGroups", "AllowedRolesAndGroups"); roles != nil {
config.Security.AllowedRolesAndGroups = roles
}
if excluded := getArrayValue(data, "excludedUrls", "ExcludedURLs"); excluded != nil {
config.Security.ExcludedURLs = excluded
}
// Handle security headers
if headers := data["securityHeaders"]; headers != nil {
// Security headers might be in old format
_ = mapToStruct(headers, &config.Security.Headers)
}
}
// Map rate limiting
if rateLimit := getIntValue(data, "rateLimit", "RateLimit"); rateLimit > 0 {
config.RateLimit.Enabled = true
config.RateLimit.RequestsPerSecond = rateLimit
config.RateLimit.Burst = rateLimit * 2 // Default burst to 2x rate
}
// Map token configuration
if refreshGrace := getIntValue(data, "refreshGracePeriodSeconds", "RefreshGracePeriodSeconds"); refreshGrace > 0 {
config.Token.RefreshGracePeriod = time.Duration(refreshGrace) * time.Second
}
// Map logging
config.Logging.Level = strings.ToLower(getStringValue(data, "logLevel", "LogLevel"))
if config.Logging.Level == "" {
config.Logging.Level = "info"
}
// Map custom headers
if headers := data["headers"]; headers != nil {
if headerList, ok := headers.([]interface{}); ok {
config.Middleware.CustomHeaders = make(map[string]string)
for _, h := range headerList {
if headerMap, ok := h.(map[string]interface{}); ok {
name := getStringFromInterface(headerMap["name"])
value := getStringFromInterface(headerMap["value"])
if name != "" {
config.Middleware.CustomHeaders[name] = value
}
}
}
}
}
// Store original data for reference
config.Legacy = data
return config, nil
}
// MigrateFile migrates a configuration file
func (m *ConfigMigrator) MigrateFile(filePath string) (*UnifiedConfig, error) {
// Clean and validate path to prevent traversal attacks
cleanPath := filepath.Clean(filePath)
// Check for path traversal attempts
if strings.Contains(cleanPath, "..") {
return nil, fmt.Errorf("invalid config path: potential path traversal detected in %s", filePath)
}
// Ensure the path is within expected directories
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", filePath, err)
}
// Read the file with validated path
// #nosec G304 -- path is validated via filepath.Abs above
data, err := os.ReadFile(absPath)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
config, warnings, err := m.Migrate(data)
if err != nil {
return nil, err
}
// Log warnings
for _, warning := range warnings {
fmt.Printf("Migration Warning: %s\n", warning)
}
return config, nil
}
// AutoMigrate automatically migrates config based on feature flags
func AutoMigrate(data interface{}) (*UnifiedConfig, error) {
if !features.IsUnifiedConfigEnabled() {
// Feature not enabled, return nil
return nil, nil
}
migrator := NewConfigMigrator()
// Handle different input types
switch v := data.(type) {
case []byte:
config, _, err := migrator.Migrate(v)
return config, err
case string:
config, _, err := migrator.Migrate([]byte(v))
return config, err
case *Config:
// Convert old config to unified
return FromOldConfig(v), nil
case *UnifiedConfig:
// Already unified
return v, nil
case map[string]interface{}:
// Convert map to JSON then migrate
jsonData, err := json.Marshal(v)
if err != nil {
return nil, err
}
config, _, err := migrator.Migrate(jsonData)
return config, err
default:
return nil, fmt.Errorf("unsupported config type: %T", v)
}
}
// Helper functions
func getNestedMap(m map[string]interface{}, key string) (map[string]interface{}, bool) {
if val, exists := m[key]; exists {
if mapped, ok := val.(map[string]interface{}); ok {
return mapped, true
}
}
return nil, false
}
func getStringValue(m map[string]interface{}, keys ...string) string {
for _, key := range keys {
if val, exists := m[key]; exists {
return getStringFromInterface(val)
}
}
return ""
}
func getStringFromInterface(val interface{}) string {
if val == nil {
return ""
}
switch v := val.(type) {
case string:
return v
case []byte:
return string(v)
default:
return fmt.Sprintf("%v", v)
}
}
func getBoolValue(m map[string]interface{}, keys ...string) bool {
for _, key := range keys {
if val, exists := m[key]; exists {
if b, ok := val.(bool); ok {
return b
}
// Try string conversion
if s, ok := val.(string); ok {
return strings.ToLower(s) == "true"
}
}
}
return false
}
func getIntValue(m map[string]interface{}, keys ...string) int {
for _, key := range keys {
if val, exists := m[key]; exists {
switch v := val.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case string:
// Try to parse
var i int
if _, err := fmt.Sscanf(v, "%d", &i); err != nil {
// If parsing fails, return default
return 0
}
return i
}
}
}
return 0
}
func getArrayValue(m map[string]interface{}, keys ...string) []string {
for _, key := range keys {
if val, exists := m[key]; exists {
if arr, ok := val.([]interface{}); ok {
result := make([]string, 0, len(arr))
for _, item := range arr {
result = append(result, getStringFromInterface(item))
}
return result
}
if strArr, ok := val.([]string); ok {
return strArr
}
}
}
return nil
}
func mapToStruct(m interface{}, target interface{}) error {
// Simple mapping using JSON as intermediate
data, err := json.Marshal(m)
if err != nil {
return err
}
return json.Unmarshal(data, target)
}
File diff suppressed because it is too large Load Diff
-297
View File
@@ -1,297 +0,0 @@
// Package config provides configuration structures for the Traefik OIDC plugin.
package config
import (
"os"
"strconv"
"strings"
"time"
)
// RedisMode represents the Redis deployment mode
type RedisMode string
const (
// RedisModeStandalone represents a single Redis instance
RedisModeStandalone RedisMode = "standalone"
// RedisModeCluster represents Redis cluster mode
RedisModeCluster RedisMode = "cluster"
// RedisModeSentinel represents Redis sentinel mode
RedisModeSentinel RedisMode = "sentinel"
)
// RedisConfig holds Redis cache backend configuration
type RedisConfig struct {
// Enabled indicates if Redis backend should be used
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
// Mode specifies the Redis deployment mode
Mode RedisMode `json:"mode,omitempty" yaml:"mode,omitempty"`
// === Standalone Configuration ===
// Addr is the Redis server address (host:port)
Addr string `json:"addr,omitempty" yaml:"addr,omitempty"`
// Password for Redis authentication
Password string `json:"password,omitempty" yaml:"password,omitempty"`
// DB is the database number (0-15)
DB int `json:"db,omitempty" yaml:"db,omitempty"`
// === Cluster Configuration ===
// ClusterAddrs is the list of cluster node addresses
ClusterAddrs []string `json:"clusterAddrs,omitempty" yaml:"clusterAddrs,omitempty"`
// === Sentinel Configuration ===
// MasterName is the name of the master instance
MasterName string `json:"masterName,omitempty" yaml:"masterName,omitempty"`
// SentinelAddrs is the list of sentinel addresses
SentinelAddrs []string `json:"sentinelAddrs,omitempty" yaml:"sentinelAddrs,omitempty"`
// SentinelPassword is the password for sentinel authentication
SentinelPassword string `json:"sentinelPassword,omitempty" yaml:"sentinelPassword,omitempty"`
// === Connection Pool Settings ===
// PoolSize is the maximum number of socket connections
PoolSize int `json:"poolSize,omitempty" yaml:"poolSize,omitempty"`
// MinIdleConns is the minimum number of idle connections
MinIdleConns int `json:"minIdleConns,omitempty" yaml:"minIdleConns,omitempty"`
// MaxRetries is the maximum number of retries before giving up
MaxRetries int `json:"maxRetries,omitempty" yaml:"maxRetries,omitempty"`
// === Timeouts ===
// DialTimeout is the timeout for establishing new connections
DialTimeout time.Duration `json:"dialTimeout,omitempty" yaml:"dialTimeout,omitempty"`
// ReadTimeout is the timeout for socket reads
ReadTimeout time.Duration `json:"readTimeout,omitempty" yaml:"readTimeout,omitempty"`
// WriteTimeout is the timeout for socket writes
WriteTimeout time.Duration `json:"writeTimeout,omitempty" yaml:"writeTimeout,omitempty"`
// PoolTimeout is the timeout for connection pool
PoolTimeout time.Duration `json:"poolTimeout,omitempty" yaml:"poolTimeout,omitempty"`
// ConnMaxIdleTime is the maximum amount of time a connection may be idle
ConnMaxIdleTime time.Duration `json:"connMaxIdleTime,omitempty" yaml:"connMaxIdleTime,omitempty"`
// ConnMaxLifetime is the maximum lifetime of a connection
ConnMaxLifetime time.Duration `json:"connMaxLifetime,omitempty" yaml:"connMaxLifetime,omitempty"`
// === Key Management ===
// KeyPrefix is the prefix for all Redis keys
KeyPrefix string `json:"keyPrefix,omitempty" yaml:"keyPrefix,omitempty"`
// === TLS Configuration ===
// TLSEnabled enables TLS for Redis connections
TLSEnabled bool `json:"tlsEnabled,omitempty" yaml:"tlsEnabled,omitempty"`
// TLSInsecureSkipVerify skips TLS certificate verification
TLSInsecureSkipVerify bool `json:"tlsInsecureSkipVerify,omitempty" yaml:"tlsInsecureSkipVerify,omitempty"`
// === Resilience Settings ===
// EnableCircuitBreaker enables circuit breaker for Redis operations
EnableCircuitBreaker bool `json:"enableCircuitBreaker,omitempty" yaml:"enableCircuitBreaker,omitempty"`
// CircuitBreakerMaxFailures is the number of failures before opening circuit
CircuitBreakerMaxFailures int `json:"circuitBreakerMaxFailures,omitempty" yaml:"circuitBreakerMaxFailures,omitempty"`
// CircuitBreakerTimeout is how long the circuit stays open
CircuitBreakerTimeout time.Duration `json:"circuitBreakerTimeout,omitempty" yaml:"circuitBreakerTimeout,omitempty"`
// EnableHealthCheck enables periodic health checks
EnableHealthCheck bool `json:"enableHealthCheck,omitempty" yaml:"enableHealthCheck,omitempty"`
// HealthCheckInterval is how often to check Redis health
HealthCheckInterval time.Duration `json:"healthCheckInterval,omitempty" yaml:"healthCheckInterval,omitempty"`
}
// DefaultRedisConfig returns default Redis configuration
func DefaultRedisConfig() *RedisConfig {
return &RedisConfig{
Enabled: false,
Mode: RedisModeStandalone,
Addr: "localhost:6379",
DB: 0,
PoolSize: 10,
MinIdleConns: 2,
MaxRetries: 3,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
PoolTimeout: 4 * time.Second,
ConnMaxIdleTime: 5 * time.Minute,
ConnMaxLifetime: 30 * time.Minute,
KeyPrefix: "traefikoidc:",
TLSEnabled: false,
TLSInsecureSkipVerify: false,
EnableCircuitBreaker: true,
CircuitBreakerMaxFailures: 5,
CircuitBreakerTimeout: 30 * time.Second,
EnableHealthCheck: true,
HealthCheckInterval: 30 * time.Second,
}
}
// LoadFromEnv loads Redis configuration from environment variables
func (c *RedisConfig) LoadFromEnv() {
// Enable Redis if environment variable is set
if enabled := os.Getenv("REDIS_ENABLED"); enabled != "" {
c.Enabled = strings.ToLower(enabled) == "true"
}
// Mode
if mode := os.Getenv("REDIS_MODE"); mode != "" {
c.Mode = RedisMode(strings.ToLower(mode))
}
// Standalone configuration
if addr := os.Getenv("REDIS_ADDR"); addr != "" {
c.Addr = addr
}
if password := os.Getenv("REDIS_PASSWORD"); password != "" {
c.Password = password
}
if db := os.Getenv("REDIS_DB"); db != "" {
if dbNum, err := strconv.Atoi(db); err == nil {
c.DB = dbNum
}
}
// Cluster configuration
if clusterAddrs := os.Getenv("REDIS_CLUSTER_ADDRS"); clusterAddrs != "" {
c.ClusterAddrs = strings.Split(clusterAddrs, ",")
for i := range c.ClusterAddrs {
c.ClusterAddrs[i] = strings.TrimSpace(c.ClusterAddrs[i])
}
}
// Sentinel configuration
if masterName := os.Getenv("REDIS_MASTER_NAME"); masterName != "" {
c.MasterName = masterName
}
if sentinelAddrs := os.Getenv("REDIS_SENTINEL_ADDRS"); sentinelAddrs != "" {
c.SentinelAddrs = strings.Split(sentinelAddrs, ",")
for i := range c.SentinelAddrs {
c.SentinelAddrs[i] = strings.TrimSpace(c.SentinelAddrs[i])
}
}
if sentinelPassword := os.Getenv("REDIS_SENTINEL_PASSWORD"); sentinelPassword != "" {
c.SentinelPassword = sentinelPassword
}
// Connection pool settings
if poolSize := os.Getenv("REDIS_POOL_SIZE"); poolSize != "" {
if size, err := strconv.Atoi(poolSize); err == nil {
c.PoolSize = size
}
}
if minIdleConns := os.Getenv("REDIS_MIN_IDLE_CONNS"); minIdleConns != "" {
if conns, err := strconv.Atoi(minIdleConns); err == nil {
c.MinIdleConns = conns
}
}
if maxRetries := os.Getenv("REDIS_MAX_RETRIES"); maxRetries != "" {
if retries, err := strconv.Atoi(maxRetries); err == nil {
c.MaxRetries = retries
}
}
// Timeouts
if dialTimeout := os.Getenv("REDIS_DIAL_TIMEOUT"); dialTimeout != "" {
if timeout, err := time.ParseDuration(dialTimeout); err == nil {
c.DialTimeout = timeout
}
}
if readTimeout := os.Getenv("REDIS_READ_TIMEOUT"); readTimeout != "" {
if timeout, err := time.ParseDuration(readTimeout); err == nil {
c.ReadTimeout = timeout
}
}
if writeTimeout := os.Getenv("REDIS_WRITE_TIMEOUT"); writeTimeout != "" {
if timeout, err := time.ParseDuration(writeTimeout); err == nil {
c.WriteTimeout = timeout
}
}
// Key prefix
if keyPrefix := os.Getenv("REDIS_KEY_PREFIX"); keyPrefix != "" {
c.KeyPrefix = keyPrefix
}
// TLS settings
if tlsEnabled := os.Getenv("REDIS_TLS_ENABLED"); tlsEnabled != "" {
c.TLSEnabled = strings.ToLower(tlsEnabled) == "true"
}
if tlsInsecure := os.Getenv("REDIS_TLS_INSECURE_SKIP_VERIFY"); tlsInsecure != "" {
c.TLSInsecureSkipVerify = strings.ToLower(tlsInsecure) == "true"
}
// Resilience settings
if enableCB := os.Getenv("REDIS_ENABLE_CIRCUIT_BREAKER"); enableCB != "" {
c.EnableCircuitBreaker = strings.ToLower(enableCB) == "true"
}
if cbMaxFailures := os.Getenv("REDIS_CIRCUIT_BREAKER_MAX_FAILURES"); cbMaxFailures != "" {
if failures, err := strconv.Atoi(cbMaxFailures); err == nil {
c.CircuitBreakerMaxFailures = failures
}
}
if cbTimeout := os.Getenv("REDIS_CIRCUIT_BREAKER_TIMEOUT"); cbTimeout != "" {
if timeout, err := time.ParseDuration(cbTimeout); err == nil {
c.CircuitBreakerTimeout = timeout
}
}
if enableHC := os.Getenv("REDIS_ENABLE_HEALTH_CHECK"); enableHC != "" {
c.EnableHealthCheck = strings.ToLower(enableHC) == "true"
}
if hcInterval := os.Getenv("REDIS_HEALTH_CHECK_INTERVAL"); hcInterval != "" {
if interval, err := time.ParseDuration(hcInterval); err == nil {
c.HealthCheckInterval = interval
}
}
}
// Validate checks if the configuration is valid
func (c *RedisConfig) Validate() error {
if !c.Enabled {
return nil
}
switch c.Mode {
case RedisModeStandalone:
if c.Addr == "" {
return &ConfigError{Field: "addr", Message: "Redis address is required for standalone mode"}
}
case RedisModeCluster:
if len(c.ClusterAddrs) == 0 {
return &ConfigError{Field: "clusterAddrs", Message: "At least one cluster address is required"}
}
case RedisModeSentinel:
if c.MasterName == "" {
return &ConfigError{Field: "masterName", Message: "Master name is required for sentinel mode"}
}
if len(c.SentinelAddrs) == 0 {
return &ConfigError{Field: "sentinelAddrs", Message: "At least one sentinel address is required"}
}
default:
return &ConfigError{Field: "mode", Message: "Invalid Redis mode"}
}
return nil
}
// ConfigError represents a configuration validation error
type ConfigError struct {
Field string
Message string
}
// Error implements the error interface
func (e *ConfigError) Error() string {
return "redis config error: " + e.Field + ": " + e.Message
}
-511
View File
@@ -1,511 +0,0 @@
// Package config provides configuration management for the OIDC middleware
package config
import (
"fmt"
"net/http"
"strconv"
"strings"
)
const (
minEncryptionKeyLength = 16
ConstSessionTimeout = 86400
)
//lint:ignore U1000 May be referenced for default exclusion patterns
var defaultExcludedURLs = map[string]struct{}{
"/favicon.ico": {},
"/robots.txt": {},
"/health": {},
"/.well-known/": {},
"/metrics": {},
"/ping": {},
"/api/": {},
"/static/": {},
"/assets/": {},
"/js/": {},
"/css/": {},
"/images/": {},
"/fonts/": {},
}
// Settings manages configuration and initialization for the OIDC middleware
type Settings struct {
logger Logger
}
// Logger interface for dependency injection
type Logger interface {
Debug(msg string)
Debugf(format string, args ...interface{})
Info(msg string)
Infof(format string, args ...interface{})
Error(msg string)
Errorf(format string, args ...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"`
SecurityHeaders *SecurityHeadersConfig `json:"securityHeaders,omitempty"`
// Dynamic Client Registration (RFC 7591) configuration
DynamicClientRegistration *DynamicClientRegistrationConfig `json:"dynamicClientRegistration,omitempty"`
}
// DynamicClientRegistrationConfig configures OIDC Dynamic Client Registration (RFC 7591)
type DynamicClientRegistrationConfig struct {
// Enabled enables automatic client registration with the OIDC provider
Enabled bool `json:"enabled"`
// InitialAccessToken is an optional bearer token for protected registration endpoints
// Some providers require this token to authorize new client registrations
InitialAccessToken string `json:"initialAccessToken,omitempty"`
// RegistrationEndpoint overrides the endpoint discovered from provider metadata
// If empty, uses the registration_endpoint from .well-known/openid-configuration
RegistrationEndpoint string `json:"registrationEndpoint,omitempty"`
// ClientMetadata contains the client metadata to register
ClientMetadata *ClientRegistrationMetadata `json:"clientMetadata,omitempty"`
// PersistCredentials determines whether to save registered credentials to a file
// This allows reusing the same client_id/client_secret across restarts
PersistCredentials bool `json:"persistCredentials"`
// CredentialsFile is the path to store/load registered client credentials
// Defaults to "/tmp/oidc-client-credentials.json" if not specified
CredentialsFile string `json:"credentialsFile,omitempty"`
}
// ClientRegistrationMetadata contains client metadata for dynamic registration (RFC 7591)
type ClientRegistrationMetadata struct {
// RedirectURIs is REQUIRED - array of redirect URIs for authorization
RedirectURIs []string `json:"redirect_uris"`
// ResponseTypes specifies OAuth 2.0 response types (default: ["code"])
ResponseTypes []string `json:"response_types,omitempty"`
// GrantTypes specifies OAuth 2.0 grant types (default: ["authorization_code"])
GrantTypes []string `json:"grant_types,omitempty"`
// ApplicationType is either "web" (default) or "native"
ApplicationType string `json:"application_type,omitempty"`
// Contacts is an array of email addresses for responsible parties
Contacts []string `json:"contacts,omitempty"`
// ClientName is a human-readable name for the client
ClientName string `json:"client_name,omitempty"`
// LogoURI is a URL pointing to a logo for the client
LogoURI string `json:"logo_uri,omitempty"`
// ClientURI is a URL of the home page of the client
ClientURI string `json:"client_uri,omitempty"`
// PolicyURI is a URL pointing to the client's privacy policy
PolicyURI string `json:"policy_uri,omitempty"`
// TOSURI is a URL pointing to the client's terms of service
TOSURI string `json:"tos_uri,omitempty"`
// JWKSURI is a URL for the client's JSON Web Key Set
JWKSURI string `json:"jwks_uri,omitempty"`
// SubjectType is "pairwise" or "public" (provider-specific)
SubjectType string `json:"subject_type,omitempty"`
// TokenEndpointAuthMethod specifies how the client authenticates at token endpoint
// Values: "client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
// DefaultMaxAge is the default maximum authentication age in seconds
DefaultMaxAge int `json:"default_max_age,omitempty"`
// RequireAuthTime specifies whether auth_time claim is required in ID token
RequireAuthTime bool `json:"require_auth_time,omitempty"`
// DefaultACRValues specifies default ACR values
DefaultACRValues []string `json:"default_acr_values,omitempty"`
// Scope is a space-separated list of scopes (alternative to config.Scopes)
Scope string `json:"scope,omitempty"`
}
// HeaderConfig represents header template configuration
type HeaderConfig struct {
Name string `json:"name"`
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{
logger: logger,
}
}
// CreateConfig creates a default configuration
func CreateConfig() *Config {
return &Config{
LogLevel: "INFO",
ForceHTTPS: true,
EnablePKCE: true,
RateLimit: 10,
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
}
-287
View File
@@ -1,287 +0,0 @@
// Package config provides unified configuration management for the OIDC middleware
package config
import (
"time"
)
// UnifiedConfig is the master configuration structure consolidating all config aspects
// This replaces 45 duplicate config structs across the codebase
type UnifiedConfig struct {
// Core Configuration
Provider ProviderConfig `json:"provider" yaml:"provider"`
Session SessionConfig `json:"session" yaml:"session"`
Token TokenConfig `json:"token" yaml:"token"`
Redis RedisConfig `json:"redis" yaml:"redis"`
Security SecurityConfig `json:"security" yaml:"security"`
// Middleware Configuration
Middleware MiddlewareConfig `json:"middleware" yaml:"middleware"`
Cache CacheConfig `json:"cache" yaml:"cache"`
RateLimit RateLimitConfig `json:"rateLimit" yaml:"rateLimit"`
// Operational Configuration
Logging LoggingConfig `json:"logging" yaml:"logging"`
Metrics MetricsConfig `json:"metrics" yaml:"metrics"`
Health HealthConfig `json:"health" yaml:"health"`
// Advanced Configuration
Transport TransportConfig `json:"transport" yaml:"transport"`
Pool PoolConfig `json:"pool" yaml:"pool"`
Circuit CircuitConfig `json:"circuit" yaml:"circuit"`
// Compatibility field for migration
Legacy map[string]interface{} `json:"-" yaml:"-"`
}
// ProviderConfig contains OIDC provider settings
type ProviderConfig struct {
IssuerURL string `json:"issuerURL" yaml:"issuerURL"`
ClientID string `json:"clientID" yaml:"clientID"`
ClientSecret string `json:"clientSecret" yaml:"clientSecret"`
RedirectURL string `json:"redirectURL" yaml:"redirectURL"`
LogoutURL string `json:"logoutURL" yaml:"logoutURL"`
PostLogoutRedirectURI string `json:"postLogoutRedirectURI" yaml:"postLogoutRedirectURI"`
Scopes []string `json:"scopes" yaml:"scopes"`
OverrideScopes bool `json:"overrideScopes" yaml:"overrideScopes"`
CustomClaims map[string]string `json:"customClaims" yaml:"customClaims"`
JWKCachePeriod time.Duration `json:"jwkCachePeriod" yaml:"jwkCachePeriod"`
MetadataCacheTTL time.Duration `json:"metadataCacheTTL" yaml:"metadataCacheTTL"`
Discovery bool `json:"discovery" yaml:"discovery"`
// Provider-specific endpoints
AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty" yaml:"authorizationEndpoint,omitempty"`
TokenEndpoint string `json:"tokenEndpoint,omitempty" yaml:"tokenEndpoint,omitempty"`
UserInfoEndpoint string `json:"userInfoEndpoint,omitempty" yaml:"userInfoEndpoint,omitempty"`
JWKSEndpoint string `json:"jwksEndpoint,omitempty" yaml:"jwksEndpoint,omitempty"`
IntrospectEndpoint string `json:"introspectEndpoint,omitempty" yaml:"introspectEndpoint,omitempty"`
RevocationEndpoint string `json:"revocationEndpoint,omitempty" yaml:"revocationEndpoint,omitempty"`
}
// SessionConfig contains session management settings
type SessionConfig struct {
Name string `json:"name" yaml:"name"`
MaxAge int `json:"maxAge" yaml:"maxAge"`
Secret string `json:"secret" yaml:"secret"`
EncryptionKey string `json:"encryptionKey" yaml:"encryptionKey"`
SigningKey string `json:"signingKey" yaml:"signingKey"`
ChunkSize int `json:"chunkSize" yaml:"chunkSize"`
MaxChunks int `json:"maxChunks" yaml:"maxChunks"`
// Cookie settings
Domain string `json:"domain" yaml:"domain"`
Path string `json:"path" yaml:"path"`
Secure bool `json:"secure" yaml:"secure"`
HttpOnly bool `json:"httpOnly" yaml:"httpOnly"`
SameSite string `json:"sameSite" yaml:"sameSite"`
CookiePrefix string `json:"cookiePrefix" yaml:"cookiePrefix"` // Prefix for cookie names (e.g., "_oidc_myapp_")
// Storage settings
StorageType string `json:"storageType" yaml:"storageType"` // "memory", "redis", "cookie"
CleanupInterval time.Duration `json:"cleanupInterval" yaml:"cleanupInterval"`
}
// TokenConfig contains token handling settings
type TokenConfig struct {
AccessTokenTTL time.Duration `json:"accessTokenTTL" yaml:"accessTokenTTL"`
RefreshTokenTTL time.Duration `json:"refreshTokenTTL" yaml:"refreshTokenTTL"`
RefreshGracePeriod time.Duration `json:"refreshGracePeriod" yaml:"refreshGracePeriod"`
ValidationMode string `json:"validationMode" yaml:"validationMode"` // "jwt", "introspect", "hybrid"
IntrospectURL string `json:"introspectURL" yaml:"introspectURL"`
// Token caching
CacheEnabled bool `json:"cacheEnabled" yaml:"cacheEnabled"`
CacheTTL time.Duration `json:"cacheTTL" yaml:"cacheTTL"`
CacheNegativeTTL time.Duration `json:"cacheNegativeTTL" yaml:"cacheNegativeTTL"`
// Token validation
ValidateSignature bool `json:"validateSignature" yaml:"validateSignature"`
ValidateExpiry bool `json:"validateExpiry" yaml:"validateExpiry"`
ValidateAudience bool `json:"validateAudience" yaml:"validateAudience"`
ValidateIssuer bool `json:"validateIssuer" yaml:"validateIssuer"`
RequiredClaims []string `json:"requiredClaims" yaml:"requiredClaims"`
ClockSkew time.Duration `json:"clockSkew" yaml:"clockSkew"`
}
// SecurityConfig contains security-related settings
type SecurityConfig struct {
ForceHTTPS bool `json:"forceHTTPS" yaml:"forceHTTPS"`
EnablePKCE bool `json:"enablePKCE" yaml:"enablePKCE"`
AllowedUsers []string `json:"allowedUsers" yaml:"allowedUsers"`
AllowedUserDomains []string `json:"allowedUserDomains" yaml:"allowedUserDomains"`
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups" yaml:"allowedRolesAndGroups"`
ExcludedURLs []string `json:"excludedURLs" yaml:"excludedURLs"`
Headers *SecurityHeadersConfig `json:"headers" yaml:"headers"`
// CSRF protection
CSRFProtection bool `json:"csrfProtection" yaml:"csrfProtection"`
CSRFTokenName string `json:"csrfTokenName" yaml:"csrfTokenName"`
CSRFTokenTTL time.Duration `json:"csrfTokenTTL" yaml:"csrfTokenTTL"`
// Additional security
MaxLoginAttempts int `json:"maxLoginAttempts" yaml:"maxLoginAttempts"`
LockoutDuration time.Duration `json:"lockoutDuration" yaml:"lockoutDuration"`
RequireMFA bool `json:"requireMFA" yaml:"requireMFA"`
}
// MiddlewareConfig contains middleware-specific settings
type MiddlewareConfig struct {
Priority int `json:"priority" yaml:"priority"`
SkipPaths []string `json:"skipPaths" yaml:"skipPaths"`
RequirePaths []string `json:"requirePaths" yaml:"requirePaths"`
PassthroughMode bool `json:"passthroughMode" yaml:"passthroughMode"`
// Request handling
MaxRequestSize int64 `json:"maxRequestSize" yaml:"maxRequestSize"`
RequestTimeout time.Duration `json:"requestTimeout" yaml:"requestTimeout"`
IdleTimeout time.Duration `json:"idleTimeout" yaml:"idleTimeout"`
// Response handling
CustomHeaders map[string]string `json:"customHeaders" yaml:"customHeaders"`
RemoveHeaders []string `json:"removeHeaders" yaml:"removeHeaders"`
}
// CacheConfig contains cache configuration
type CacheConfig struct {
Enabled bool `json:"enabled" yaml:"enabled"`
Type string `json:"type" yaml:"type"` // "memory", "redis", "hybrid"
DefaultTTL time.Duration `json:"defaultTTL" yaml:"defaultTTL"`
MaxEntries int `json:"maxEntries" yaml:"maxEntries"`
MaxEntrySize int64 `json:"maxEntrySize" yaml:"maxEntrySize"`
EvictionPolicy string `json:"evictionPolicy" yaml:"evictionPolicy"` // "lru", "lfu", "fifo"
// Memory cache settings
CleanupInterval time.Duration `json:"cleanupInterval" yaml:"cleanupInterval"`
// Distributed cache settings
Namespace string `json:"namespace" yaml:"namespace"`
Compression bool `json:"compression" yaml:"compression"`
Serialization string `json:"serialization" yaml:"serialization"` // "json", "msgpack", "protobuf"
}
// RateLimitConfig contains rate limiting configuration
type RateLimitConfig struct {
Enabled bool `json:"enabled" yaml:"enabled"`
RequestsPerSecond int `json:"requestsPerSecond" yaml:"requestsPerSecond"`
Burst int `json:"burst" yaml:"burst"`
// Rate limit storage
StorageType string `json:"storageType" yaml:"storageType"` // "memory", "redis"
WindowDuration time.Duration `json:"windowDuration" yaml:"windowDuration"`
// Rate limit keys
KeyType string `json:"keyType" yaml:"keyType"` // "ip", "user", "token", "custom"
CustomKeyFunc string `json:"customKeyFunc" yaml:"customKeyFunc"`
// Whitelisting
WhitelistIPs []string `json:"whitelistIPs" yaml:"whitelistIPs"`
WhitelistUsers []string `json:"whitelistUsers" yaml:"whitelistUsers"`
}
// LoggingConfig contains logging configuration
type LoggingConfig struct {
Level string `json:"level" yaml:"level"` // "debug", "info", "warn", "error"
Format string `json:"format" yaml:"format"` // "json", "text", "structured"
Output string `json:"output" yaml:"output"` // "stdout", "stderr", "file"
FilePath string `json:"filePath" yaml:"filePath"`
// Log filtering
FilterSensitive bool `json:"filterSensitive" yaml:"filterSensitive"`
MaskFields []string `json:"maskFields" yaml:"maskFields"`
// Performance
BufferSize int `json:"bufferSize" yaml:"bufferSize"`
FlushInterval time.Duration `json:"flushInterval" yaml:"flushInterval"`
// Audit logging
AuditEnabled bool `json:"auditEnabled" yaml:"auditEnabled"`
AuditEvents []string `json:"auditEvents" yaml:"auditEvents"`
}
// MetricsConfig contains metrics collection configuration
type MetricsConfig struct {
Enabled bool `json:"enabled" yaml:"enabled"`
Provider string `json:"provider" yaml:"provider"` // "prometheus", "statsd", "otlp"
Endpoint string `json:"endpoint" yaml:"endpoint"`
Namespace string `json:"namespace" yaml:"namespace"`
Subsystem string `json:"subsystem" yaml:"subsystem"`
// Collection settings
CollectInterval time.Duration `json:"collectInterval" yaml:"collectInterval"`
Histograms bool `json:"histograms" yaml:"histograms"`
// Custom labels
Labels map[string]string `json:"labels" yaml:"labels"`
}
// HealthConfig contains health check configuration
type HealthConfig struct {
Enabled bool `json:"enabled" yaml:"enabled"`
Path string `json:"path" yaml:"path"`
CheckInterval time.Duration `json:"checkInterval" yaml:"checkInterval"`
Timeout time.Duration `json:"timeout" yaml:"timeout"`
// Checks to perform
CheckProvider bool `json:"checkProvider" yaml:"checkProvider"`
CheckRedis bool `json:"checkRedis" yaml:"checkRedis"`
CheckCache bool `json:"checkCache" yaml:"checkCache"`
// Thresholds
MaxLatency time.Duration `json:"maxLatency" yaml:"maxLatency"`
MinMemory int64 `json:"minMemory" yaml:"minMemory"`
}
// TransportConfig contains HTTP transport configuration
type TransportConfig struct {
MaxIdleConns int `json:"maxIdleConns" yaml:"maxIdleConns"`
MaxIdleConnsPerHost int `json:"maxIdleConnsPerHost" yaml:"maxIdleConnsPerHost"`
MaxConnsPerHost int `json:"maxConnsPerHost" yaml:"maxConnsPerHost"`
IdleConnTimeout time.Duration `json:"idleConnTimeout" yaml:"idleConnTimeout"`
TLSHandshakeTimeout time.Duration `json:"tlsHandshakeTimeout" yaml:"tlsHandshakeTimeout"`
ExpectContinueTimeout time.Duration `json:"expectContinueTimeout" yaml:"expectContinueTimeout"`
ResponseHeaderTimeout time.Duration `json:"responseHeaderTimeout" yaml:"responseHeaderTimeout"`
DisableKeepAlives bool `json:"disableKeepAlives" yaml:"disableKeepAlives"`
DisableCompression bool `json:"disableCompression" yaml:"disableCompression"`
// TLS configuration
TLSInsecureSkipVerify bool `json:"tlsInsecureSkipVerify" yaml:"tlsInsecureSkipVerify"`
TLSMinVersion string `json:"tlsMinVersion" yaml:"tlsMinVersion"`
TLSCipherSuites []string `json:"tlsCipherSuites" yaml:"tlsCipherSuites"`
// Proxy settings
ProxyURL string `json:"proxyURL" yaml:"proxyURL"`
NoProxy []string `json:"noProxy" yaml:"noProxy"`
}
// PoolConfig contains connection pool configuration
type PoolConfig struct {
Enabled bool `json:"enabled" yaml:"enabled"`
Size int `json:"size" yaml:"size"`
MinSize int `json:"minSize" yaml:"minSize"`
MaxSize int `json:"maxSize" yaml:"maxSize"`
MaxAge time.Duration `json:"maxAge" yaml:"maxAge"`
IdleTimeout time.Duration `json:"idleTimeout" yaml:"idleTimeout"`
WaitTimeout time.Duration `json:"waitTimeout" yaml:"waitTimeout"`
// Health checking
HealthCheckInterval time.Duration `json:"healthCheckInterval" yaml:"healthCheckInterval"`
MaxRetries int `json:"maxRetries" yaml:"maxRetries"`
}
// CircuitConfig contains circuit breaker configuration
type CircuitConfig struct {
Enabled bool `json:"enabled" yaml:"enabled"`
MaxRequests uint32 `json:"maxRequests" yaml:"maxRequests"`
Interval time.Duration `json:"interval" yaml:"interval"`
Timeout time.Duration `json:"timeout" yaml:"timeout"`
ConsecutiveFailures uint32 `json:"consecutiveFailures" yaml:"consecutiveFailures"`
FailureRatio float64 `json:"failureRatio" yaml:"failureRatio"`
// Circuit states
OnOpen string `json:"onOpen" yaml:"onOpen"` // "reject", "fallback", "passthrough"
OnHalfOpen string `json:"onHalfOpen" yaml:"onHalfOpen"`
// Monitoring
MetricsEnabled bool `json:"metricsEnabled" yaml:"metricsEnabled"`
LogStateChanges bool `json:"logStateChanges" yaml:"logStateChanges"`
}
-263
View File
@@ -1,263 +0,0 @@
//go:build !yaegi
package config
import (
"encoding/json"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
// TestUnifiedConfigJSONMarshalling tests JSON marshalling with secret redaction
func TestUnifiedConfigJSONMarshalling(t *testing.T) {
config := &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://auth.example.com",
ClientID: "test-client",
ClientSecret: "super-secret-value",
},
Session: SessionConfig{
Secret: "session-secret",
EncryptionKey: "32-character-encryption-key-here",
SigningKey: "signing-key-secret",
},
Redis: RedisConfig{
Password: "redis-password",
SentinelPassword: "sentinel-password",
},
}
// Marshal to JSON
jsonBytes, err := json.Marshal(config)
if err != nil {
t.Fatalf("Failed to marshal config to JSON: %v", err)
}
jsonStr := string(jsonBytes)
// Verify secrets are redacted
if !contains(jsonStr, `"clientSecret":"[REDACTED]"`) {
t.Error("ClientSecret should be redacted in JSON output")
}
if !contains(jsonStr, `"secret":"[REDACTED]"`) {
t.Error("Session.Secret should be redacted in JSON output")
}
if !contains(jsonStr, `"encryptionKey":"[REDACTED]"`) {
t.Error("Session.EncryptionKey should be redacted in JSON output")
}
if !contains(jsonStr, `"signingKey":"[REDACTED]"`) {
t.Error("Session.SigningKey should be redacted in JSON output")
}
if !contains(jsonStr, `"password":"[REDACTED]"`) {
t.Error("Redis.Password should be redacted in JSON output")
}
if !contains(jsonStr, `"sentinelPassword":"[REDACTED]"`) {
t.Error("Redis.SentinelPassword should be redacted in JSON output")
}
// Verify non-secret fields are preserved
if !contains(jsonStr, `"issuerURL":"https://auth.example.com"`) {
t.Error("IssuerURL should be preserved in JSON output")
}
if !contains(jsonStr, `"clientID":"test-client"`) {
t.Error("ClientID should be preserved in JSON output")
}
}
// TestUnifiedConfigYAMLMarshalling tests YAML marshalling with secret redaction
func TestUnifiedConfigYAMLMarshalling(t *testing.T) {
config := &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://auth.example.com",
ClientID: "test-client",
ClientSecret: "super-secret-value",
},
Session: SessionConfig{
Secret: "session-secret",
EncryptionKey: "32-character-encryption-key-here",
SigningKey: "signing-key-secret",
},
Redis: RedisConfig{
Password: "redis-password",
SentinelPassword: "sentinel-password",
},
}
// Marshal to YAML
yamlBytes, err := yaml.Marshal(config)
if err != nil {
t.Fatalf("Failed to marshal config to YAML: %v", err)
}
yamlStr := string(yamlBytes)
// Verify secrets are redacted
if !contains(yamlStr, "clientSecret: '[REDACTED]'") {
t.Error("ClientSecret should be redacted in YAML output")
}
if !contains(yamlStr, "secret: '[REDACTED]'") {
t.Error("Session.Secret should be redacted in YAML output")
}
if !contains(yamlStr, "encryptionKey: '[REDACTED]'") {
t.Error("Session.EncryptionKey should be redacted in YAML output")
}
if !contains(yamlStr, "signingKey: '[REDACTED]'") {
t.Error("Session.SigningKey should be redacted in YAML output")
}
if !contains(yamlStr, "password: '[REDACTED]'") {
t.Error("Redis.Password should be redacted in YAML output")
}
if !contains(yamlStr, "sentinelPassword: '[REDACTED]'") {
t.Error("Redis.SentinelPassword should be redacted in YAML output")
}
// Verify non-secret fields are preserved
if !contains(yamlStr, "issuerURL: https://auth.example.com") {
t.Error("IssuerURL should be preserved in YAML output")
}
if !contains(yamlStr, "clientID: test-client") {
t.Error("ClientID should be preserved in YAML output")
}
}
// TestProviderConfigMarshalling tests individual struct marshalling
func TestProviderConfigMarshalling(t *testing.T) {
provider := ProviderConfig{
IssuerURL: "https://auth.example.com",
ClientID: "test-client",
ClientSecret: "super-secret-value",
}
// Test JSON marshalling
jsonBytes, err := json.Marshal(provider)
if err != nil {
t.Fatalf("Failed to marshal ProviderConfig to JSON: %v", err)
}
jsonStr := string(jsonBytes)
if !contains(jsonStr, `"clientSecret":"[REDACTED]"`) {
t.Error("ClientSecret should be redacted in JSON output")
}
if !contains(jsonStr, `"clientID":"test-client"`) {
t.Error("ClientID should be preserved in JSON output")
}
// Test YAML marshalling
yamlBytes, err := yaml.Marshal(provider)
if err != nil {
t.Fatalf("Failed to marshal ProviderConfig to YAML: %v", err)
}
yamlStr := string(yamlBytes)
if !contains(yamlStr, "clientSecret: '[REDACTED]'") {
t.Error("ClientSecret should be redacted in YAML output")
}
if !contains(yamlStr, "clientID: test-client") {
t.Error("ClientID should be preserved in YAML output")
}
}
// TestSessionConfigMarshalling tests session config marshalling
func TestSessionConfigMarshalling(t *testing.T) {
session := SessionConfig{
Name: "session-cookie",
Secret: "session-secret",
EncryptionKey: "32-character-encryption-key-here",
SigningKey: "signing-key-secret",
Domain: "example.com",
Secure: true,
}
// Test JSON marshalling
jsonBytes, err := json.Marshal(session)
if err != nil {
t.Fatalf("Failed to marshal SessionConfig to JSON: %v", err)
}
jsonStr := string(jsonBytes)
if !contains(jsonStr, `"secret":"[REDACTED]"`) {
t.Error("Secret should be redacted in JSON output")
}
if !contains(jsonStr, `"encryptionKey":"[REDACTED]"`) {
t.Error("EncryptionKey should be redacted in JSON output")
}
if !contains(jsonStr, `"signingKey":"[REDACTED]"`) {
t.Error("SigningKey should be redacted in JSON output")
}
if !contains(jsonStr, `"name":"session-cookie"`) {
t.Error("Name should be preserved in JSON output")
}
if !contains(jsonStr, `"domain":"example.com"`) {
t.Error("Domain should be preserved in JSON output")
}
}
// TestRedisConfigMarshalling tests Redis config marshalling
func TestRedisConfigMarshalling(t *testing.T) {
redis := RedisConfig{
Enabled: true,
Mode: RedisModeCluster,
Password: "redis-password",
SentinelPassword: "sentinel-password",
Addr: "localhost:6379",
DB: 1,
}
// Test JSON marshalling
jsonBytes, err := json.Marshal(redis)
if err != nil {
t.Fatalf("Failed to marshal RedisConfig to JSON: %v", err)
}
jsonStr := string(jsonBytes)
if !contains(jsonStr, `"password":"[REDACTED]"`) {
t.Error("Password should be redacted in JSON output")
}
if !contains(jsonStr, `"sentinelPassword":"[REDACTED]"`) {
t.Error("SentinelPassword should be redacted in JSON output")
}
if !contains(jsonStr, `"addr":"localhost:6379"`) {
t.Error("Addr should be preserved in JSON output")
}
if !contains(jsonStr, `"db":1`) {
t.Error("DB should be preserved in JSON output")
}
}
// TestEmptySecretsNotRedacted tests that empty secrets are not shown as redacted
func TestEmptySecretsNotRedacted(t *testing.T) {
config := &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://auth.example.com",
ClientID: "test-client",
ClientSecret: "", // Empty secret
},
Session: SessionConfig{
Secret: "", // Empty secret
EncryptionKey: "", // Empty secret
},
Redis: RedisConfig{
Password: "", // Empty secret
},
}
// Marshal to JSON
jsonBytes, err := json.Marshal(config)
if err != nil {
t.Fatalf("Failed to marshal config to JSON: %v", err)
}
jsonStr := string(jsonBytes)
// Verify empty secrets are not shown as redacted
if contains(jsonStr, "[REDACTED]") {
t.Error("Empty secrets should not be shown as [REDACTED]")
}
}
// Helper function to check if string contains substring
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
-652
View File
@@ -1,652 +0,0 @@
// Package config provides validation for unified configuration
package config
import (
"fmt"
"net/url"
"regexp"
"strings"
"time"
)
// ValidationError represents a configuration validation error
type ValidationError struct {
Field string
Message string
Value interface{}
}
// Error implements the error interface
func (e *ValidationError) Error() string {
if e.Value != nil {
return fmt.Sprintf("config validation error: %s: %s (value: %v)", e.Field, e.Message, e.Value)
}
return fmt.Sprintf("config validation error: %s: %s", e.Field, e.Message)
}
// ValidationErrors represents multiple validation errors
type ValidationErrors []ValidationError
// Error implements the error interface
func (e ValidationErrors) Error() string {
if len(e) == 0 {
return ""
}
var messages []string
for _, err := range e {
messages = append(messages, err.Error())
}
return strings.Join(messages, "; ")
}
// Validate performs comprehensive validation on the unified configuration
func (c *UnifiedConfig) Validate() error {
var errors ValidationErrors
// Validate Provider configuration
if err := c.validateProvider(); err != nil {
errors = append(errors, err...)
}
// Validate Session configuration
if err := c.validateSession(); err != nil {
errors = append(errors, err...)
}
// Validate Token configuration
if err := c.validateToken(); err != nil {
errors = append(errors, err...)
}
// Validate Redis configuration (uses existing validation)
if err := c.Redis.Validate(); err != nil {
errors = append(errors, ValidationError{
Field: "Redis",
Message: err.Error(),
})
}
// Validate Security configuration
if err := c.validateSecurity(); err != nil {
errors = append(errors, err...)
}
// Validate Middleware configuration
if err := c.validateMiddleware(); err != nil {
errors = append(errors, err...)
}
// Validate Cache configuration
if err := c.validateCache(); err != nil {
errors = append(errors, err...)
}
// Validate RateLimit configuration
if err := c.validateRateLimit(); err != nil {
errors = append(errors, err...)
}
// Validate Logging configuration
if err := c.validateLogging(); err != nil {
errors = append(errors, err...)
}
// Validate Metrics configuration
if err := c.validateMetrics(); err != nil {
errors = append(errors, err...)
}
// Validate Transport configuration
if err := c.validateTransport(); err != nil {
errors = append(errors, err...)
}
// Validate Circuit configuration
if err := c.validateCircuit(); err != nil {
errors = append(errors, err...)
}
if len(errors) > 0 {
return errors
}
return nil
}
// validateProvider validates provider configuration
func (c *UnifiedConfig) validateProvider() ValidationErrors {
var errors ValidationErrors
// IssuerURL is required and must be a valid URL
if c.Provider.IssuerURL == "" {
errors = append(errors, ValidationError{
Field: "Provider.IssuerURL",
Message: "issuer URL is required",
})
} else if _, err := url.Parse(c.Provider.IssuerURL); err != nil {
errors = append(errors, ValidationError{
Field: "Provider.IssuerURL",
Message: "invalid issuer URL",
Value: c.Provider.IssuerURL,
})
}
// ClientID is required
if c.Provider.ClientID == "" {
errors = append(errors, ValidationError{
Field: "Provider.ClientID",
Message: "client ID is required",
})
}
// ClientSecret is required (except for public clients with PKCE)
if c.Provider.ClientSecret == "" && !c.Security.EnablePKCE {
errors = append(errors, ValidationError{
Field: "Provider.ClientSecret",
Message: "client secret is required (or enable PKCE for public clients)",
})
}
// RedirectURL must be valid if provided
if c.Provider.RedirectURL != "" {
if _, err := url.Parse(c.Provider.RedirectURL); err != nil {
errors = append(errors, ValidationError{
Field: "Provider.RedirectURL",
Message: "invalid redirect URL",
Value: c.Provider.RedirectURL,
})
}
}
// Scopes must include 'openid' for OIDC
hasOpenID := false
for _, scope := range c.Provider.Scopes {
if scope == "openid" {
hasOpenID = true
break
}
}
if !hasOpenID && !c.Provider.OverrideScopes {
errors = append(errors, ValidationError{
Field: "Provider.Scopes",
Message: "scopes must include 'openid' for OIDC",
Value: c.Provider.Scopes,
})
}
// JWK cache period must be positive
if c.Provider.JWKCachePeriod < 0 {
errors = append(errors, ValidationError{
Field: "Provider.JWKCachePeriod",
Message: "JWK cache period must be positive",
Value: c.Provider.JWKCachePeriod,
})
}
return errors
}
// validateSession validates session configuration
func (c *UnifiedConfig) validateSession() ValidationErrors {
var errors ValidationErrors
// Session name must not be empty
if c.Session.Name == "" {
errors = append(errors, ValidationError{
Field: "Session.Name",
Message: "session name is required",
})
}
// Session secret or encryption key is required
if c.Session.Secret == "" && c.Session.EncryptionKey == "" {
errors = append(errors, ValidationError{
Field: "Session",
Message: "either session secret or encryption key is required",
})
}
// Encryption key must be at least 32 bytes for security
if c.Session.EncryptionKey != "" && len(c.Session.EncryptionKey) < 32 {
errors = append(errors, ValidationError{
Field: "Session.EncryptionKey",
Message: "encryption key must be at least 32 characters for proper security",
Value: len(c.Session.EncryptionKey),
})
}
// ChunkSize must be reasonable (between 1KB and 10KB)
if c.Session.ChunkSize < 1000 || c.Session.ChunkSize > 10000 {
errors = append(errors, ValidationError{
Field: "Session.ChunkSize",
Message: "chunk size must be between 1000 and 10000 bytes",
Value: c.Session.ChunkSize,
})
}
// MaxChunks must be reasonable (between 1 and 100)
if c.Session.MaxChunks < 1 || c.Session.MaxChunks > 100 {
errors = append(errors, ValidationError{
Field: "Session.MaxChunks",
Message: "max chunks must be between 1 and 100",
Value: c.Session.MaxChunks,
})
}
// SameSite must be valid
validSameSite := map[string]bool{
"": true,
"Lax": true,
"Strict": true,
"None": true,
}
if !validSameSite[c.Session.SameSite] {
errors = append(errors, ValidationError{
Field: "Session.SameSite",
Message: "invalid SameSite value (must be Lax, Strict, or None)",
Value: c.Session.SameSite,
})
}
// StorageType must be valid
validStorage := map[string]bool{
"memory": true,
"redis": true,
"cookie": true,
}
if !validStorage[c.Session.StorageType] {
errors = append(errors, ValidationError{
Field: "Session.StorageType",
Message: "invalid storage type (must be memory, redis, or cookie)",
Value: c.Session.StorageType,
})
}
return errors
}
// validateToken validates token configuration
func (c *UnifiedConfig) validateToken() ValidationErrors {
var errors ValidationErrors
// Token TTLs must be positive
if c.Token.AccessTokenTTL <= 0 {
errors = append(errors, ValidationError{
Field: "Token.AccessTokenTTL",
Message: "access token TTL must be positive",
Value: c.Token.AccessTokenTTL,
})
}
if c.Token.RefreshTokenTTL <= 0 {
errors = append(errors, ValidationError{
Field: "Token.RefreshTokenTTL",
Message: "refresh token TTL must be positive",
Value: c.Token.RefreshTokenTTL,
})
}
// Validation mode must be valid
validModes := map[string]bool{
"jwt": true,
"introspect": true,
"hybrid": true,
}
if !validModes[c.Token.ValidationMode] {
errors = append(errors, ValidationError{
Field: "Token.ValidationMode",
Message: "invalid validation mode (must be jwt, introspect, or hybrid)",
Value: c.Token.ValidationMode,
})
}
// Introspect URL required for introspect or hybrid mode
if (c.Token.ValidationMode == "introspect" || c.Token.ValidationMode == "hybrid") && c.Token.IntrospectURL == "" {
errors = append(errors, ValidationError{
Field: "Token.IntrospectURL",
Message: "introspect URL is required for introspect or hybrid validation mode",
})
}
// Clock skew must be reasonable (0 to 10 minutes)
if c.Token.ClockSkew < 0 || c.Token.ClockSkew > 10*time.Minute {
errors = append(errors, ValidationError{
Field: "Token.ClockSkew",
Message: "clock skew must be between 0 and 10 minutes",
Value: c.Token.ClockSkew,
})
}
return errors
}
// validateSecurity validates security configuration
func (c *UnifiedConfig) validateSecurity() ValidationErrors {
var errors ValidationErrors
// Validate allowed user domains are valid domains
domainRegex := regexp.MustCompile(`^([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.[a-zA-Z]{2,}$`)
for _, domain := range c.Security.AllowedUserDomains {
if !domainRegex.MatchString(domain) {
errors = append(errors, ValidationError{
Field: "Security.AllowedUserDomains",
Message: "invalid domain format",
Value: domain,
})
}
}
// Max login attempts must be reasonable
if c.Security.MaxLoginAttempts < 0 || c.Security.MaxLoginAttempts > 100 {
errors = append(errors, ValidationError{
Field: "Security.MaxLoginAttempts",
Message: "max login attempts must be between 0 and 100",
Value: c.Security.MaxLoginAttempts,
})
}
// Lockout duration must be reasonable
if c.Security.LockoutDuration < 0 || c.Security.LockoutDuration > 24*time.Hour {
errors = append(errors, ValidationError{
Field: "Security.LockoutDuration",
Message: "lockout duration must be between 0 and 24 hours",
Value: c.Security.LockoutDuration,
})
}
return errors
}
// validateMiddleware validates middleware configuration
func (c *UnifiedConfig) validateMiddleware() ValidationErrors {
var errors ValidationErrors
// Max request size must be reasonable (1KB to 100MB)
if c.Middleware.MaxRequestSize < 1024 || c.Middleware.MaxRequestSize > 100*1024*1024 {
errors = append(errors, ValidationError{
Field: "Middleware.MaxRequestSize",
Message: "max request size must be between 1KB and 100MB",
Value: c.Middleware.MaxRequestSize,
})
}
// Request timeout must be reasonable
if c.Middleware.RequestTimeout < time.Second || c.Middleware.RequestTimeout > 5*time.Minute {
errors = append(errors, ValidationError{
Field: "Middleware.RequestTimeout",
Message: "request timeout must be between 1 second and 5 minutes",
Value: c.Middleware.RequestTimeout,
})
}
return errors
}
// validateCache validates cache configuration
func (c *UnifiedConfig) validateCache() ValidationErrors {
var errors ValidationErrors
if !c.Cache.Enabled {
return errors
}
// Cache type must be valid
validTypes := map[string]bool{
"memory": true,
"redis": true,
"hybrid": true,
}
if !validTypes[c.Cache.Type] {
errors = append(errors, ValidationError{
Field: "Cache.Type",
Message: "invalid cache type (must be memory, redis, or hybrid)",
Value: c.Cache.Type,
})
}
// Max entries must be reasonable
if c.Cache.MaxEntries < 10 || c.Cache.MaxEntries > 1000000 {
errors = append(errors, ValidationError{
Field: "Cache.MaxEntries",
Message: "max entries must be between 10 and 1000000",
Value: c.Cache.MaxEntries,
})
}
// Eviction policy must be valid
validEviction := map[string]bool{
"lru": true,
"lfu": true,
"fifo": true,
}
if !validEviction[c.Cache.EvictionPolicy] {
errors = append(errors, ValidationError{
Field: "Cache.EvictionPolicy",
Message: "invalid eviction policy (must be lru, lfu, or fifo)",
Value: c.Cache.EvictionPolicy,
})
}
return errors
}
// validateRateLimit validates rate limiting configuration
func (c *UnifiedConfig) validateRateLimit() ValidationErrors {
var errors ValidationErrors
if !c.RateLimit.Enabled {
return errors
}
// Requests per second must be reasonable
if c.RateLimit.RequestsPerSecond < 1 || c.RateLimit.RequestsPerSecond > 10000 {
errors = append(errors, ValidationError{
Field: "RateLimit.RequestsPerSecond",
Message: "requests per second must be between 1 and 10000",
Value: c.RateLimit.RequestsPerSecond,
})
}
// Burst must be at least as large as requests per second
if c.RateLimit.Burst < c.RateLimit.RequestsPerSecond {
errors = append(errors, ValidationError{
Field: "RateLimit.Burst",
Message: "burst must be at least as large as requests per second",
Value: c.RateLimit.Burst,
})
}
// Key type must be valid
validKeyTypes := map[string]bool{
"ip": true,
"user": true,
"token": true,
"custom": true,
}
if !validKeyTypes[c.RateLimit.KeyType] {
errors = append(errors, ValidationError{
Field: "RateLimit.KeyType",
Message: "invalid key type (must be ip, user, token, or custom)",
Value: c.RateLimit.KeyType,
})
}
return errors
}
// validateLogging validates logging configuration
func (c *UnifiedConfig) validateLogging() ValidationErrors {
var errors ValidationErrors
// Log level must be valid
validLevels := map[string]bool{
"debug": true,
"info": true,
"warn": true,
"error": true,
}
if !validLevels[c.Logging.Level] {
errors = append(errors, ValidationError{
Field: "Logging.Level",
Message: "invalid log level (must be debug, info, warn, or error)",
Value: c.Logging.Level,
})
}
// Format must be valid
validFormats := map[string]bool{
"json": true,
"text": true,
"structured": true,
}
if !validFormats[c.Logging.Format] {
errors = append(errors, ValidationError{
Field: "Logging.Format",
Message: "invalid log format (must be json, text, or structured)",
Value: c.Logging.Format,
})
}
// Output must be valid
validOutputs := map[string]bool{
"stdout": true,
"stderr": true,
"file": true,
}
if !validOutputs[c.Logging.Output] {
errors = append(errors, ValidationError{
Field: "Logging.Output",
Message: "invalid log output (must be stdout, stderr, or file)",
Value: c.Logging.Output,
})
}
// File path required if output is file
if c.Logging.Output == "file" && c.Logging.FilePath == "" {
errors = append(errors, ValidationError{
Field: "Logging.FilePath",
Message: "file path is required when output is 'file'",
})
}
return errors
}
// validateMetrics validates metrics configuration
func (c *UnifiedConfig) validateMetrics() ValidationErrors {
var errors ValidationErrors
if !c.Metrics.Enabled {
return errors
}
// Provider must be valid
validProviders := map[string]bool{
"prometheus": true,
"statsd": true,
"otlp": true,
}
if !validProviders[c.Metrics.Provider] {
errors = append(errors, ValidationError{
Field: "Metrics.Provider",
Message: "invalid metrics provider (must be prometheus, statsd, or otlp)",
Value: c.Metrics.Provider,
})
}
// Endpoint required for some providers
if (c.Metrics.Provider == "statsd" || c.Metrics.Provider == "otlp") && c.Metrics.Endpoint == "" {
errors = append(errors, ValidationError{
Field: "Metrics.Endpoint",
Message: fmt.Sprintf("endpoint is required for %s provider", c.Metrics.Provider),
})
}
return errors
}
// validateTransport validates transport configuration
func (c *UnifiedConfig) validateTransport() ValidationErrors {
var errors ValidationErrors
// Max connections must be reasonable
if c.Transport.MaxIdleConns < 0 || c.Transport.MaxIdleConns > 10000 {
errors = append(errors, ValidationError{
Field: "Transport.MaxIdleConns",
Message: "max idle connections must be between 0 and 10000",
Value: c.Transport.MaxIdleConns,
})
}
// TLS min version must be valid
validTLSVersions := map[string]bool{
"TLS1.0": true,
"TLS1.1": true,
"TLS1.2": true,
"TLS1.3": true,
}
if c.Transport.TLSMinVersion != "" && !validTLSVersions[c.Transport.TLSMinVersion] {
errors = append(errors, ValidationError{
Field: "Transport.TLSMinVersion",
Message: "invalid TLS min version (must be TLS1.0, TLS1.1, TLS1.2, or TLS1.3)",
Value: c.Transport.TLSMinVersion,
})
}
// Proxy URL must be valid if provided
if c.Transport.ProxyURL != "" {
if _, err := url.Parse(c.Transport.ProxyURL); err != nil {
errors = append(errors, ValidationError{
Field: "Transport.ProxyURL",
Message: "invalid proxy URL",
Value: c.Transport.ProxyURL,
})
}
}
return errors
}
// validateCircuit validates circuit breaker configuration
func (c *UnifiedConfig) validateCircuit() ValidationErrors {
var errors ValidationErrors
if !c.Circuit.Enabled {
return errors
}
// Consecutive failures must be reasonable
if c.Circuit.ConsecutiveFailures < 1 || c.Circuit.ConsecutiveFailures > 100 {
errors = append(errors, ValidationError{
Field: "Circuit.ConsecutiveFailures",
Message: "consecutive failures must be between 1 and 100",
Value: c.Circuit.ConsecutiveFailures,
})
}
// Failure ratio must be between 0 and 1
if c.Circuit.FailureRatio < 0 || c.Circuit.FailureRatio > 1 {
errors = append(errors, ValidationError{
Field: "Circuit.FailureRatio",
Message: "failure ratio must be between 0 and 1",
Value: c.Circuit.FailureRatio,
})
}
// OnOpen action must be valid
validActions := map[string]bool{
"reject": true,
"fallback": true,
"passthrough": true,
}
if !validActions[c.Circuit.OnOpen] {
errors = append(errors, ValidationError{
Field: "Circuit.OnOpen",
Message: "invalid OnOpen action (must be reject, fallback, or passthrough)",
Value: c.Circuit.OnOpen,
})
}
return errors
}
-588
View File
@@ -1,588 +0,0 @@
//go:build !yaegi
package config
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestValidateUnifiedConfig tests the validation of UnifiedConfig
func TestValidateUnifiedConfig(t *testing.T) {
tests := []struct {
name string
config *UnifiedConfig
expectError bool
errorField string
}{
{
name: "valid config with minimum requirements",
config: &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://auth.example.com",
ClientID: "test-client",
ClientSecret: "secret",
Scopes: []string{"openid", "profile", "email"},
},
Session: SessionConfig{
Name: "oidc_session",
EncryptionKey: "this-is-a-32-character-key-12345",
ChunkSize: 4000,
MaxChunks: 5,
StorageType: "cookie",
},
Token: TokenConfig{
AccessTokenTTL: time.Hour,
RefreshTokenTTL: 24 * time.Hour,
ValidationMode: "jwt",
},
Middleware: MiddlewareConfig{
MaxRequestSize: 10 * 1024 * 1024,
RequestTimeout: 30 * time.Second,
},
Logging: LoggingConfig{
Level: "info",
Format: "json",
Output: "stdout",
},
},
expectError: false,
},
{
name: "missing provider URL",
config: &UnifiedConfig{
Provider: ProviderConfig{
ClientID: "test-client",
ClientSecret: "secret",
},
Session: SessionConfig{
EncryptionKey: "this-is-a-32-character-key-12345",
},
},
expectError: true,
errorField: "Provider.IssuerURL",
},
{
name: "missing client ID",
config: &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://auth.example.com",
ClientSecret: "secret",
},
Session: SessionConfig{
EncryptionKey: "this-is-a-32-character-key-12345",
},
},
expectError: true,
errorField: "Provider.ClientID",
},
{
name: "encryption key too short",
config: &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://auth.example.com",
ClientID: "test-client",
ClientSecret: "secret",
},
Session: SessionConfig{
EncryptionKey: "too-short",
},
},
expectError: true,
errorField: "Session.EncryptionKey",
},
{
name: "invalid chunk size",
config: &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://auth.example.com",
ClientID: "test-client",
ClientSecret: "secret",
},
Session: SessionConfig{
EncryptionKey: "this-is-a-32-character-key-12345",
ChunkSize: 500, // Too small
},
},
expectError: true,
errorField: "Session.ChunkSize",
},
{
name: "invalid max chunks",
config: &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://auth.example.com",
ClientID: "test-client",
ClientSecret: "secret",
},
Session: SessionConfig{
EncryptionKey: "this-is-a-32-character-key-12345",
ChunkSize: 4000,
MaxChunks: 0, // Too small
},
},
expectError: true,
errorField: "Session.MaxChunks",
},
{
name: "invalid TLS min version",
config: &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://auth.example.com",
ClientID: "test-client",
ClientSecret: "secret",
},
Session: SessionConfig{
EncryptionKey: "this-is-a-32-character-key-12345",
},
Transport: TransportConfig{
TLSMinVersion: "1.0", // Too old
},
},
expectError: true,
errorField: "Transport.TLSMinVersion",
},
{
name: "invalid circuit breaker failure ratio",
config: &UnifiedConfig{
Provider: ProviderConfig{
IssuerURL: "https://auth.example.com",
ClientID: "test-client",
ClientSecret: "secret",
},
Session: SessionConfig{
EncryptionKey: "this-is-a-32-character-key-12345",
},
Circuit: CircuitConfig{
Enabled: true,
FailureRatio: 1.5, // Too high
},
},
expectError: true,
errorField: "Circuit.FailureRatio",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if tt.expectError {
if err == nil {
t.Errorf("Expected validation error for field %s, but got none", tt.errorField)
} else if validationErrs, ok := err.(ValidationErrors); ok {
found := false
for _, e := range validationErrs {
if e.Field == tt.errorField {
found = true
break
}
}
if !found {
t.Errorf("Expected validation error for field %s, but got errors for: %v",
tt.errorField, validationErrs)
}
}
} else {
if err != nil {
t.Errorf("Expected no validation error, but got: %v", err)
}
}
})
}
}
// TestValidationErrorMessage tests validation error formatting
func TestValidationErrorMessage(t *testing.T) {
errs := ValidationErrors{
{
Field: "Provider.IssuerURL",
Message: "is required",
Value: nil,
},
{
Field: "Session.EncryptionKey",
Message: "must be at least 32 characters",
Value: 16,
},
}
errMsg := errs.Error()
if !strings.Contains(errMsg, "Provider.IssuerURL") {
t.Error("Error message should contain field name Provider.IssuerURL")
}
if !strings.Contains(errMsg, "is required") {
t.Error("Error message should contain 'is required'")
}
if !strings.Contains(errMsg, "Session.EncryptionKey") {
t.Error("Error message should contain field name Session.EncryptionKey")
}
if !strings.Contains(errMsg, "must be at least 32 characters") {
t.Error("Error message should contain 'must be at least 32 characters'")
}
}
// TestValidateRedisConfig tests Redis configuration validation
func TestValidateRedisConfig(t *testing.T) {
tests := []struct {
name string
config *RedisConfig
expectError bool
errorMsg string
}{
{
name: "valid standalone config",
config: &RedisConfig{
Enabled: true,
Mode: RedisModeStandalone,
Addr: "localhost:6379",
},
expectError: false,
},
{
name: "missing address for standalone",
config: &RedisConfig{
Enabled: true,
Mode: RedisModeStandalone,
Addr: "",
},
expectError: true,
errorMsg: "Redis address is required",
},
{
name: "valid cluster config",
config: &RedisConfig{
Enabled: true,
Mode: RedisModeCluster,
ClusterAddrs: []string{"localhost:7000", "localhost:7001"},
},
expectError: false,
},
{
name: "missing cluster addresses",
config: &RedisConfig{
Enabled: true,
Mode: RedisModeCluster,
ClusterAddrs: []string{},
},
expectError: true,
errorMsg: "cluster address is required",
},
{
name: "valid sentinel config",
config: &RedisConfig{
Enabled: true,
Mode: RedisModeSentinel,
MasterName: "mymaster",
SentinelAddrs: []string{"localhost:26379"},
},
expectError: false,
},
{
name: "missing master name for sentinel",
config: &RedisConfig{
Enabled: true,
Mode: RedisModeSentinel,
MasterName: "",
SentinelAddrs: []string{"localhost:26379"},
},
expectError: true,
errorMsg: "Master name is required",
},
{
name: "missing sentinel addresses",
config: &RedisConfig{
Enabled: true,
Mode: RedisModeSentinel,
MasterName: "mymaster",
SentinelAddrs: []string{},
},
expectError: true,
errorMsg: "sentinel address is required",
},
{
name: "disabled redis needs no validation",
config: &RedisConfig{
Enabled: false,
},
expectError: false,
},
{
name: "invalid redis mode",
config: &RedisConfig{
Enabled: true,
Mode: "invalid-mode",
},
expectError: true,
errorMsg: "Invalid Redis mode",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if tt.expectError {
if err == nil {
t.Errorf("Expected validation error containing '%s', but got none", tt.errorMsg)
} else if !strings.Contains(err.Error(), tt.errorMsg) {
t.Errorf("Expected error message to contain '%s', but got: %v", tt.errorMsg, err)
}
} else {
if err != nil {
t.Errorf("Expected no validation error, but got: %v", err)
}
}
})
}
}
// ============================================================================
// validateRateLimit Tests
// ============================================================================
func TestValidateRateLimit_Disabled(t *testing.T) {
config := NewUnifiedConfig()
config.RateLimit.Enabled = false
errors := config.validateRateLimit()
assert.Empty(t, errors, "Should have no errors when rate limiting is disabled")
}
func TestValidateRateLimit_ValidConfig(t *testing.T) {
config := NewUnifiedConfig()
config.RateLimit.Enabled = true
config.RateLimit.RequestsPerSecond = 100
config.RateLimit.Burst = 200
config.RateLimit.KeyType = "ip"
errors := config.validateRateLimit()
assert.Empty(t, errors, "Should have no errors for valid rate limit config")
}
func TestValidateRateLimit_RequestsPerSecondTooLow(t *testing.T) {
config := NewUnifiedConfig()
config.RateLimit.Enabled = true
config.RateLimit.RequestsPerSecond = 0
config.RateLimit.Burst = 100
config.RateLimit.KeyType = "ip"
errors := config.validateRateLimit()
require.Len(t, errors, 1)
assert.Equal(t, "RateLimit.RequestsPerSecond", errors[0].Field)
assert.Contains(t, errors[0].Message, "between 1 and 10000")
}
func TestValidateRateLimit_RequestsPerSecondTooHigh(t *testing.T) {
config := NewUnifiedConfig()
config.RateLimit.Enabled = true
config.RateLimit.RequestsPerSecond = 15000
config.RateLimit.Burst = 20000
config.RateLimit.KeyType = "ip"
errors := config.validateRateLimit()
require.Len(t, errors, 1)
assert.Equal(t, "RateLimit.RequestsPerSecond", errors[0].Field)
assert.Contains(t, errors[0].Message, "between 1 and 10000")
}
func TestValidateRateLimit_BurstTooSmall(t *testing.T) {
config := NewUnifiedConfig()
config.RateLimit.Enabled = true
config.RateLimit.RequestsPerSecond = 100
config.RateLimit.Burst = 50 // Less than RequestsPerSecond
config.RateLimit.KeyType = "ip"
errors := config.validateRateLimit()
require.Len(t, errors, 1)
assert.Equal(t, "RateLimit.Burst", errors[0].Field)
assert.Contains(t, errors[0].Message, "at least as large as requests per second")
}
func TestValidateRateLimit_InvalidKeyType(t *testing.T) {
tests := []struct {
name string
keyType string
}{
{"empty key type", ""},
{"invalid key type", "invalid"},
{"random string", "foobar"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := NewUnifiedConfig()
config.RateLimit.Enabled = true
config.RateLimit.RequestsPerSecond = 100
config.RateLimit.Burst = 200
config.RateLimit.KeyType = tt.keyType
errors := config.validateRateLimit()
require.Len(t, errors, 1)
assert.Equal(t, "RateLimit.KeyType", errors[0].Field)
assert.Contains(t, errors[0].Message, "invalid key type")
})
}
}
func TestValidateRateLimit_ValidKeyTypes(t *testing.T) {
validKeyTypes := []string{"ip", "user", "token", "custom"}
for _, keyType := range validKeyTypes {
t.Run(keyType, func(t *testing.T) {
config := NewUnifiedConfig()
config.RateLimit.Enabled = true
config.RateLimit.RequestsPerSecond = 100
config.RateLimit.Burst = 200
config.RateLimit.KeyType = keyType
errors := config.validateRateLimit()
assert.Empty(t, errors, "Should have no errors for valid key type: %s", keyType)
})
}
}
func TestValidateRateLimit_MultipleErrors(t *testing.T) {
config := NewUnifiedConfig()
config.RateLimit.Enabled = true
config.RateLimit.RequestsPerSecond = 0 // Too low
config.RateLimit.Burst = 50 // Will pass (0 < 50)
config.RateLimit.KeyType = "invalid" // Invalid
errors := config.validateRateLimit()
// Should have 2 errors (rps and keyType)
assert.Len(t, errors, 2)
// Check each error is present
fields := make(map[string]bool)
for _, err := range errors {
fields[err.Field] = true
}
assert.True(t, fields["RateLimit.RequestsPerSecond"])
assert.True(t, fields["RateLimit.KeyType"])
}
// ============================================================================
// validateMetrics Tests
// ============================================================================
func TestValidateMetrics_Disabled(t *testing.T) {
config := NewUnifiedConfig()
config.Metrics.Enabled = false
errors := config.validateMetrics()
assert.Empty(t, errors, "Should have no errors when metrics are disabled")
}
func TestValidateMetrics_ValidPrometheus(t *testing.T) {
config := NewUnifiedConfig()
config.Metrics.Enabled = true
config.Metrics.Provider = "prometheus"
config.Metrics.Endpoint = "" // Prometheus doesn't require endpoint
errors := config.validateMetrics()
assert.Empty(t, errors, "Should have no errors for valid prometheus config")
}
func TestValidateMetrics_ValidStatsd(t *testing.T) {
config := NewUnifiedConfig()
config.Metrics.Enabled = true
config.Metrics.Provider = "statsd"
config.Metrics.Endpoint = "localhost:8125"
errors := config.validateMetrics()
assert.Empty(t, errors, "Should have no errors for valid statsd config")
}
func TestValidateMetrics_ValidOTLP(t *testing.T) {
config := NewUnifiedConfig()
config.Metrics.Enabled = true
config.Metrics.Provider = "otlp"
config.Metrics.Endpoint = "localhost:4317"
errors := config.validateMetrics()
assert.Empty(t, errors, "Should have no errors for valid otlp config")
}
func TestValidateMetrics_InvalidProvider(t *testing.T) {
tests := []struct {
name string
provider string
}{
{"empty provider", ""},
{"invalid provider", "invalid"},
{"datadog", "datadog"},
{"influx", "influx"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := NewUnifiedConfig()
config.Metrics.Enabled = true
config.Metrics.Provider = tt.provider
config.Metrics.Endpoint = "localhost:8080"
errors := config.validateMetrics()
require.Len(t, errors, 1)
assert.Equal(t, "Metrics.Provider", errors[0].Field)
assert.Contains(t, errors[0].Message, "invalid metrics provider")
})
}
}
func TestValidateMetrics_StatsdMissingEndpoint(t *testing.T) {
config := NewUnifiedConfig()
config.Metrics.Enabled = true
config.Metrics.Provider = "statsd"
config.Metrics.Endpoint = "" // Missing required endpoint
errors := config.validateMetrics()
require.Len(t, errors, 1)
assert.Equal(t, "Metrics.Endpoint", errors[0].Field)
assert.Contains(t, errors[0].Message, "endpoint is required for statsd provider")
}
func TestValidateMetrics_OTLPMissingEndpoint(t *testing.T) {
config := NewUnifiedConfig()
config.Metrics.Enabled = true
config.Metrics.Provider = "otlp"
config.Metrics.Endpoint = "" // Missing required endpoint
errors := config.validateMetrics()
require.Len(t, errors, 1)
assert.Equal(t, "Metrics.Endpoint", errors[0].Field)
assert.Contains(t, errors[0].Message, "endpoint is required for otlp provider")
}
func TestValidateMetrics_MultipleErrors(t *testing.T) {
config := NewUnifiedConfig()
config.Metrics.Enabled = true
config.Metrics.Provider = "invalid" // Invalid provider
config.Metrics.Endpoint = "" // Would be missing if provider was statsd/otlp
errors := config.validateMetrics()
// Should have at least 1 error for invalid provider
assert.NotEmpty(t, errors)
assert.Equal(t, "Metrics.Provider", errors[0].Field)
}
+2 -2
View File
@@ -7,7 +7,7 @@ import (
// REDACTED is the placeholder value for sensitive information
const REDACTED = "[REDACTED]"
// MarshalJSON implements custom JSON marshalling to redact sensitive fields
// MarshalJSON implements custom JSON marshaling to redact sensitive fields
// Rewritten without type aliases for yaegi compatibility
func (c Config) MarshalJSON() ([]byte, error) {
// Build a map manually to avoid type alias issues with yaegi
@@ -47,7 +47,7 @@ func (c Config) MarshalJSON() ([]byte, error) {
return json.Marshal(result)
}
// MarshalYAML implements custom YAML marshalling to redact sensitive fields
// MarshalYAML implements custom YAML marshaling to redact sensitive fields
// Rewritten without type aliases for yaegi compatibility
func (c Config) MarshalYAML() (interface{}, error) {
// Build a map manually to avoid type alias issues with yaegi
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
// Package traefikoidc provides OIDC authentication middleware for Traefik
package traefikoidc
import (
"context"
"fmt"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/dcrstorage"
)
// DCRStorageBackend represents the type of storage backend for DCR credentials.
// Alias for internal package type for backward compatibility.
type DCRStorageBackend = dcrstorage.StorageBackend
const (
// DCRStorageBackendFile uses file-based storage (default for backward compatibility)
DCRStorageBackendFile DCRStorageBackend = dcrstorage.StorageBackendFile
// DCRStorageBackendRedis uses Redis for distributed storage
DCRStorageBackendRedis DCRStorageBackend = dcrstorage.StorageBackendRedis
// DCRStorageBackendAuto automatically selects Redis if available, otherwise file
DCRStorageBackendAuto DCRStorageBackend = dcrstorage.StorageBackendAuto
)
// DCRCredentialsStore defines the interface for storing DCR credentials.
// This abstraction allows different storage backends (file, Redis) to be used
// for persisting OIDC Dynamic Client Registration credentials across nodes.
type DCRCredentialsStore interface {
// Save stores the client registration response for a provider
// The providerURL is used as a key to support multi-tenant scenarios
Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error
// Load retrieves stored credentials for a provider
// Returns nil, nil if no credentials exist (not an error)
Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error)
// Delete removes stored credentials for a provider
Delete(ctx context.Context, providerURL string) error
// Exists checks if credentials exist for a provider
Exists(ctx context.Context, providerURL string) (bool, error)
}
// loggerAdapter adapts our Logger to the dcrstorage.Logger interface
type loggerAdapter struct {
logger *Logger
}
func (l *loggerAdapter) Debug(msg string) { l.logger.Debug("%s", msg) }
func (l *loggerAdapter) Debugf(format string, args ...any) { l.logger.Debugf(format, args...) }
func (l *loggerAdapter) Info(msg string) { l.logger.Info("%s", msg) }
func (l *loggerAdapter) Infof(format string, args ...any) { l.logger.Infof(format, args...) }
func (l *loggerAdapter) Error(msg string) { l.logger.Error("%s", msg) }
func (l *loggerAdapter) Errorf(format string, args ...any) { l.logger.Errorf(format, args...) }
// cacheAdapter adapts UniversalCache to dcrstorage.Cache interface
type cacheAdapter struct {
cache *UniversalCache
}
func (c *cacheAdapter) Get(key string) (any, bool) {
return c.cache.Get(key)
}
func (c *cacheAdapter) Set(key string, value any, ttl time.Duration) error {
return c.cache.Set(key, value, ttl)
}
func (c *cacheAdapter) Delete(key string) {
c.cache.Delete(key)
}
// fileStoreWrapper wraps dcrstorage.FileStore to implement DCRCredentialsStore
type fileStoreWrapper struct {
inner *dcrstorage.FileStore
}
func (w *fileStoreWrapper) Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error {
innerCreds := convertCredsToInternal(creds)
return w.inner.Save(ctx, providerURL, innerCreds)
}
func (w *fileStoreWrapper) Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error) {
innerCreds, err := w.inner.Load(ctx, providerURL)
if err != nil || innerCreds == nil {
return nil, err
}
return convertCredsFromInternal(innerCreds), nil
}
func (w *fileStoreWrapper) Delete(ctx context.Context, providerURL string) error {
return w.inner.Delete(ctx, providerURL)
}
func (w *fileStoreWrapper) Exists(ctx context.Context, providerURL string) (bool, error) {
return w.inner.Exists(ctx, providerURL)
}
// basePath returns the base path used for storing credentials (for backward compatibility in tests)
func (w *fileStoreWrapper) basePath() string {
return w.inner.BasePath()
}
// getFilePath returns the file path for storing credentials for a specific provider (for backward compatibility in tests)
func (w *fileStoreWrapper) getFilePath(providerURL string) string {
return w.inner.GetFilePath(providerURL)
}
// redisStoreWrapper wraps dcrstorage.RedisStore to implement DCRCredentialsStore
type redisStoreWrapper struct {
inner *dcrstorage.RedisStore
}
func (w *redisStoreWrapper) Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error {
innerCreds := convertCredsToInternal(creds)
return w.inner.Save(ctx, providerURL, innerCreds)
}
func (w *redisStoreWrapper) Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error) {
innerCreds, err := w.inner.Load(ctx, providerURL)
if err != nil || innerCreds == nil {
return nil, err
}
return convertCredsFromInternal(innerCreds), nil
}
func (w *redisStoreWrapper) Delete(ctx context.Context, providerURL string) error {
return w.inner.Delete(ctx, providerURL)
}
func (w *redisStoreWrapper) Exists(ctx context.Context, providerURL string) (bool, error) {
return w.inner.Exists(ctx, providerURL)
}
// FileCredentialsStore implements DCRCredentialsStore using file-based storage.
// This is the default storage backend for backward compatibility with existing deployments.
type FileCredentialsStore = fileStoreWrapper
// RedisCredentialsStore implements DCRCredentialsStore using Redis-backed cache.
// This storage backend enables sharing DCR credentials across multiple Traefik instances.
type RedisCredentialsStore = redisStoreWrapper
// NewFileCredentialsStore creates a new file-based credentials store.
// If basePath is empty, defaults to /tmp/oidc-client-credentials.json
func NewFileCredentialsStore(basePath string, logger *Logger) *FileCredentialsStore {
var dcrLogger dcrstorage.Logger
if logger != nil {
dcrLogger = &loggerAdapter{logger: logger}
}
inner := dcrstorage.NewFileStore(basePath, dcrLogger)
return &fileStoreWrapper{inner: inner}
}
// NewRedisCredentialsStore creates a new Redis-backed credentials store.
// The cache should be configured with a Redis backend for distributed storage.
// If keyPrefix is empty, defaults to "dcr:creds:"
func NewRedisCredentialsStore(cache *UniversalCache, keyPrefix string, logger *Logger) *RedisCredentialsStore {
var dcrLogger dcrstorage.Logger
if logger != nil {
dcrLogger = &loggerAdapter{logger: logger}
}
cacheAdapt := &cacheAdapter{cache: cache}
inner := dcrstorage.NewRedisStore(cacheAdapt, keyPrefix, dcrLogger)
return &redisStoreWrapper{inner: inner}
}
// Helper functions to convert between main package and internal package types
func convertCredsToInternal(creds *ClientRegistrationResponse) *dcrstorage.ClientRegistrationResponse {
if creds == nil {
return nil
}
return &dcrstorage.ClientRegistrationResponse{
SubjectType: creds.SubjectType,
LogoURI: creds.LogoURI,
RegistrationAccessToken: creds.RegistrationAccessToken,
RegistrationClientURI: creds.RegistrationClientURI,
Scope: creds.Scope,
TokenEndpointAuthMethod: creds.TokenEndpointAuthMethod,
TOSURI: creds.TOSURI,
PolicyURI: creds.PolicyURI,
ClientSecret: creds.ClientSecret,
ApplicationType: creds.ApplicationType,
ClientID: creds.ClientID,
ClientName: creds.ClientName,
JWKSURI: creds.JWKSURI,
ClientURI: creds.ClientURI,
Contacts: creds.Contacts,
GrantTypes: creds.GrantTypes,
ResponseTypes: creds.ResponseTypes,
RedirectURIs: creds.RedirectURIs,
ClientSecretExpiresAt: creds.ClientSecretExpiresAt,
ClientIDIssuedAt: creds.ClientIDIssuedAt,
}
}
func convertCredsFromInternal(creds *dcrstorage.ClientRegistrationResponse) *ClientRegistrationResponse {
if creds == nil {
return nil
}
return &ClientRegistrationResponse{
SubjectType: creds.SubjectType,
LogoURI: creds.LogoURI,
RegistrationAccessToken: creds.RegistrationAccessToken,
RegistrationClientURI: creds.RegistrationClientURI,
Scope: creds.Scope,
TokenEndpointAuthMethod: creds.TokenEndpointAuthMethod,
TOSURI: creds.TOSURI,
PolicyURI: creds.PolicyURI,
ClientSecret: creds.ClientSecret,
ApplicationType: creds.ApplicationType,
ClientID: creds.ClientID,
ClientName: creds.ClientName,
JWKSURI: creds.JWKSURI,
ClientURI: creds.ClientURI,
Contacts: creds.Contacts,
GrantTypes: creds.GrantTypes,
ResponseTypes: creds.ResponseTypes,
RedirectURIs: creds.RedirectURIs,
ClientSecretExpiresAt: creds.ClientSecretExpiresAt,
ClientIDIssuedAt: creds.ClientIDIssuedAt,
}
}
// NewDCRCredentialsStore creates a DCRCredentialsStore based on configuration.
// This factory function handles backend selection logic:
// - "file": Use file-based storage (default for backward compatibility)
// - "redis": Use Redis exclusively (fails if Redis unavailable)
// - "auto": Use Redis if available, fallback to file
func NewDCRCredentialsStore(
config *DynamicClientRegistrationConfig,
cacheManager *CacheManager,
logger *Logger,
) (DCRCredentialsStore, error) {
if config == nil {
return nil, fmt.Errorf("DCR config is nil")
}
if logger == nil {
logger = GetSingletonNoOpLogger()
}
backend := config.StorageBackend
if backend == "" {
backend = string(DCRStorageBackendAuto) // Default to auto selection
}
switch DCRStorageBackend(backend) {
case DCRStorageBackendFile:
logger.Info("Using file-based storage for DCR credentials")
return NewFileCredentialsStore(config.CredentialsFile, logger), nil
case DCRStorageBackendRedis:
cache := getDCRCache(cacheManager)
if cache == nil {
return nil, fmt.Errorf("redis storage requested but Redis/cache not configured")
}
logger.Info("Using Redis storage for DCR credentials")
return NewRedisCredentialsStore(cache, config.RedisKeyPrefix, logger), nil
case DCRStorageBackendAuto:
// Try Redis first, fallback to file
cache := getDCRCache(cacheManager)
if cache != nil && cache.backend != nil {
logger.Info("Auto-selected Redis storage for DCR credentials")
return NewRedisCredentialsStore(cache, config.RedisKeyPrefix, logger), nil
}
logger.Info("Redis not available, using file storage for DCR credentials")
return NewFileCredentialsStore(config.CredentialsFile, logger), nil
default:
return nil, fmt.Errorf("unknown DCR storage backend: %s", backend)
}
}
// getDCRCache safely retrieves the DCR credentials cache from the cache manager
func getDCRCache(cacheManager *CacheManager) *UniversalCache {
if cacheManager == nil {
return nil
}
cacheManager.mu.RLock()
defer cacheManager.mu.RUnlock()
if cacheManager.manager == nil {
return nil
}
return cacheManager.manager.GetDCRCredentialsCache()
}
+663
View File
@@ -0,0 +1,663 @@
// Package traefikoidc provides OIDC authentication middleware for Traefik
package traefikoidc
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
// TestFileCredentialsStore_SaveLoad tests the file-based credentials store
func TestFileCredentialsStore_SaveLoad(t *testing.T) {
t.Parallel()
// Create a temp directory for test files
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
testCreds := &ClientRegistrationResponse{
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
RegistrationAccessToken: "test-access-token",
RegistrationClientURI: "https://example.com/register/test-client-id",
RedirectURIs: []string{"https://app.example.com/callback"},
GrantTypes: []string{"authorization_code", "refresh_token"},
ResponseTypes: []string{"code"},
TokenEndpointAuthMethod: "client_secret_basic",
}
ctx := context.Background()
providerURL := "https://auth.example.com"
t.Run("save and load credentials", func(t *testing.T) {
// Save credentials
err := store.Save(ctx, providerURL, testCreds)
if err != nil {
t.Fatalf("Failed to save credentials: %v", err)
}
// Load credentials
loaded, err := store.Load(ctx, providerURL)
if err != nil {
t.Fatalf("Failed to load credentials: %v", err)
}
if loaded == nil {
t.Fatal("Expected credentials but got nil")
}
// Verify fields
if loaded.ClientID != testCreds.ClientID {
t.Errorf("ClientID mismatch: got %s, want %s", loaded.ClientID, testCreds.ClientID)
}
if loaded.ClientSecret != testCreds.ClientSecret {
t.Errorf("ClientSecret mismatch: got %s, want %s", loaded.ClientSecret, testCreds.ClientSecret)
}
if loaded.RegistrationAccessToken != testCreds.RegistrationAccessToken {
t.Errorf("RegistrationAccessToken mismatch: got %s, want %s", loaded.RegistrationAccessToken, testCreds.RegistrationAccessToken)
}
})
t.Run("load non-existent credentials", func(t *testing.T) {
tempDir2 := t.TempDir()
store2 := NewFileCredentialsStore(filepath.Join(tempDir2, "nonexistent.json"), logger)
loaded, err := store2.Load(ctx, "https://nonexistent.example.com")
if err != nil {
t.Fatalf("Unexpected error for non-existent file: %v", err)
}
if loaded != nil {
t.Error("Expected nil for non-existent credentials")
}
})
t.Run("exists check", func(t *testing.T) {
exists, err := store.Exists(ctx, providerURL)
if err != nil {
t.Fatalf("Exists check failed: %v", err)
}
if !exists {
t.Error("Expected credentials to exist")
}
exists, err = store.Exists(ctx, "https://nonexistent.example.com")
if err != nil {
t.Fatalf("Exists check failed: %v", err)
}
if exists {
t.Error("Expected credentials to not exist")
}
})
t.Run("delete credentials", func(t *testing.T) {
err := store.Delete(ctx, providerURL)
if err != nil {
t.Fatalf("Failed to delete credentials: %v", err)
}
exists, _ := store.Exists(ctx, providerURL)
if exists {
t.Error("Expected credentials to be deleted")
}
})
t.Run("delete non-existent credentials", func(t *testing.T) {
// Should not error
err := store.Delete(ctx, "https://nonexistent.example.com")
if err != nil {
t.Fatalf("Delete should not error for non-existent: %v", err)
}
})
}
// TestFileCredentialsStore_MultiProvider tests multi-provider support
func TestFileCredentialsStore_MultiProvider(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
ctx := context.Background()
provider1 := "https://auth1.example.com"
provider2 := "https://auth2.example.com"
creds1 := &ClientRegistrationResponse{
ClientID: "client-1",
ClientSecret: "secret-1",
}
creds2 := &ClientRegistrationResponse{
ClientID: "client-2",
ClientSecret: "secret-2",
}
// Save credentials for both providers
if err := store.Save(ctx, provider1, creds1); err != nil {
t.Fatalf("Failed to save creds1: %v", err)
}
if err := store.Save(ctx, provider2, creds2); err != nil {
t.Fatalf("Failed to save creds2: %v", err)
}
// Load and verify each provider's credentials
loaded1, err := store.Load(ctx, provider1)
if err != nil {
t.Fatalf("Failed to load creds1: %v", err)
}
if loaded1.ClientID != "client-1" {
t.Errorf("Provider 1 ClientID mismatch: got %s", loaded1.ClientID)
}
loaded2, err := store.Load(ctx, provider2)
if err != nil {
t.Fatalf("Failed to load creds2: %v", err)
}
if loaded2.ClientID != "client-2" {
t.Errorf("Provider 2 ClientID mismatch: got %s", loaded2.ClientID)
}
// Delete one shouldn't affect the other
if err := store.Delete(ctx, provider1); err != nil {
t.Fatalf("Failed to delete creds1: %v", err)
}
exists, _ := store.Exists(ctx, provider2)
if !exists {
t.Error("Provider 2 credentials should still exist")
}
}
// TestFileCredentialsStore_ConcurrentAccess tests thread safety
func TestFileCredentialsStore_ConcurrentAccess(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
ctx := context.Background()
providerURL := "https://auth.example.com"
creds := &ClientRegistrationResponse{
ClientID: "test-client",
ClientSecret: "test-secret",
}
var wg sync.WaitGroup
concurrency := 10
// Concurrent saves
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = store.Save(ctx, providerURL, creds)
}()
}
wg.Wait()
// Concurrent loads
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = store.Load(ctx, providerURL)
}()
}
wg.Wait()
// Final verification
loaded, err := store.Load(ctx, providerURL)
if err != nil {
t.Fatalf("Failed to load after concurrent access: %v", err)
}
if loaded == nil || loaded.ClientID != "test-client" {
t.Error("Credentials corrupted after concurrent access")
}
}
// TestFileCredentialsStore_InvalidInput tests error handling
func TestFileCredentialsStore_InvalidInput(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
ctx := context.Background()
t.Run("save nil credentials", func(t *testing.T) {
err := store.Save(ctx, "https://example.com", nil)
if err == nil {
t.Error("Expected error for nil credentials")
}
})
t.Run("empty provider URL uses default path", func(t *testing.T) {
creds := &ClientRegistrationResponse{ClientID: "test"}
err := store.Save(ctx, "", creds)
if err != nil {
t.Fatalf("Save with empty provider URL failed: %v", err)
}
loaded, err := store.Load(ctx, "")
if err != nil {
t.Fatalf("Load with empty provider URL failed: %v", err)
}
if loaded == nil || loaded.ClientID != "test" {
t.Error("Failed to load credentials with empty provider URL")
}
})
}
// TestFileCredentialsStore_DefaultPath tests default path behavior
func TestFileCredentialsStore_DefaultPath(t *testing.T) {
t.Parallel()
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore("", logger)
// Just verify we can create with empty path and it has a default
if store.basePath() == "" {
t.Error("Expected default base path")
}
}
// TestRedisCredentialsStore_WithMemoryCache tests Redis store with in-memory cache
func TestRedisCredentialsStore_WithMemoryCache(t *testing.T) {
t.Parallel()
// Create an in-memory cache for testing
cache := NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeGeneral,
MaxSize: 100,
DefaultTTL: time.Hour,
Logger: GetSingletonNoOpLogger(),
})
defer cache.Close()
logger := GetSingletonNoOpLogger()
store := NewRedisCredentialsStore(cache, "", logger)
ctx := context.Background()
providerURL := "https://auth.example.com"
testCreds := &ClientRegistrationResponse{
ClientID: "redis-test-client",
ClientSecret: "redis-test-secret",
ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
RegistrationAccessToken: "redis-test-token",
RedirectURIs: []string{"https://app.example.com/callback"},
}
t.Run("save and load credentials", func(t *testing.T) {
err := store.Save(ctx, providerURL, testCreds)
if err != nil {
t.Fatalf("Failed to save credentials: %v", err)
}
loaded, err := store.Load(ctx, providerURL)
if err != nil {
t.Fatalf("Failed to load credentials: %v", err)
}
if loaded == nil {
t.Fatal("Expected credentials but got nil")
}
if loaded.ClientID != testCreds.ClientID {
t.Errorf("ClientID mismatch: got %s, want %s", loaded.ClientID, testCreds.ClientID)
}
if loaded.ClientSecret != testCreds.ClientSecret {
t.Errorf("ClientSecret mismatch: got %s, want %s", loaded.ClientSecret, testCreds.ClientSecret)
}
})
t.Run("exists check", func(t *testing.T) {
exists, err := store.Exists(ctx, providerURL)
if err != nil {
t.Fatalf("Exists check failed: %v", err)
}
if !exists {
t.Error("Expected credentials to exist")
}
})
t.Run("delete credentials", func(t *testing.T) {
err := store.Delete(ctx, providerURL)
if err != nil {
t.Fatalf("Failed to delete credentials: %v", err)
}
exists, _ := store.Exists(ctx, providerURL)
if exists {
t.Error("Expected credentials to be deleted")
}
})
t.Run("load non-existent credentials", func(t *testing.T) {
loaded, err := store.Load(ctx, "https://nonexistent.example.com")
if err != nil {
t.Fatalf("Unexpected error for non-existent: %v", err)
}
if loaded != nil {
t.Error("Expected nil for non-existent credentials")
}
})
}
// TestRedisCredentialsStore_TTLFromExpiry tests TTL calculation
func TestRedisCredentialsStore_TTLFromExpiry(t *testing.T) {
t.Parallel()
cache := NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeGeneral,
MaxSize: 100,
DefaultTTL: time.Hour,
Logger: GetSingletonNoOpLogger(),
})
defer cache.Close()
logger := GetSingletonNoOpLogger()
store := NewRedisCredentialsStore(cache, "", logger)
ctx := context.Background()
t.Run("expired credentials should fail", func(t *testing.T) {
expiredCreds := &ClientRegistrationResponse{
ClientID: "expired-client",
ClientSecret: "expired-secret",
ClientSecretExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), // Already expired
}
err := store.Save(ctx, "https://expired.example.com", expiredCreds)
if err == nil {
t.Error("Expected error for expired credentials")
}
})
t.Run("credentials without expiry use default TTL", func(t *testing.T) {
creds := &ClientRegistrationResponse{
ClientID: "no-expiry-client",
ClientSecret: "no-expiry-secret",
ClientSecretExpiresAt: 0, // No expiry
}
err := store.Save(ctx, "https://noexpiry.example.com", creds)
if err != nil {
t.Fatalf("Failed to save credentials without expiry: %v", err)
}
})
}
// TestRedisCredentialsStore_InvalidInput tests error handling
func TestRedisCredentialsStore_InvalidInput(t *testing.T) {
t.Parallel()
cache := NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeGeneral,
MaxSize: 100,
DefaultTTL: time.Hour,
Logger: GetSingletonNoOpLogger(),
})
defer cache.Close()
logger := GetSingletonNoOpLogger()
store := NewRedisCredentialsStore(cache, "", logger)
ctx := context.Background()
t.Run("save nil credentials", func(t *testing.T) {
err := store.Save(ctx, "https://example.com", nil)
if err == nil {
t.Error("Expected error for nil credentials")
}
})
}
// TestDCRStorageFactory tests the factory function
func TestDCRStorageFactory(t *testing.T) {
t.Parallel()
logger := GetSingletonNoOpLogger()
t.Run("nil config returns error", func(t *testing.T) {
_, err := NewDCRCredentialsStore(nil, nil, logger)
if err == nil {
t.Error("Expected error for nil config")
}
})
t.Run("file backend creates file store", func(t *testing.T) {
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
StorageBackend: "file",
CredentialsFile: "/tmp/test-creds.json",
}
store, err := NewDCRCredentialsStore(config, nil, logger)
if err != nil {
t.Fatalf("Failed to create file store: %v", err)
}
if store == nil {
t.Error("Expected store but got nil")
}
_, ok := store.(*FileCredentialsStore)
if !ok {
t.Error("Expected FileCredentialsStore")
}
})
t.Run("redis backend without cache manager returns error", func(t *testing.T) {
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
StorageBackend: "redis",
}
_, err := NewDCRCredentialsStore(config, nil, logger)
if err == nil {
t.Error("Expected error for redis backend without cache manager")
}
})
t.Run("auto backend without redis falls back to file", func(t *testing.T) {
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
StorageBackend: "auto",
}
store, err := NewDCRCredentialsStore(config, nil, logger)
if err != nil {
t.Fatalf("Failed to create auto store: %v", err)
}
_, ok := store.(*FileCredentialsStore)
if !ok {
t.Error("Expected FileCredentialsStore for auto without redis")
}
})
t.Run("unknown backend returns error", func(t *testing.T) {
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
StorageBackend: "unknown",
}
_, err := NewDCRCredentialsStore(config, nil, logger)
if err == nil {
t.Error("Expected error for unknown backend")
}
})
t.Run("empty backend defaults to auto", func(t *testing.T) {
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
StorageBackend: "",
}
store, err := NewDCRCredentialsStore(config, nil, logger)
if err != nil {
t.Fatalf("Failed to create store with empty backend: %v", err)
}
// Should default to file (auto without redis)
_, ok := store.(*FileCredentialsStore)
if !ok {
t.Error("Expected FileCredentialsStore for empty backend")
}
})
}
// TestDynamicClientRegistrar_WithStore tests registrar with store
func TestDynamicClientRegistrar_WithStore(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
}
registrar := NewDynamicClientRegistrarWithStore(
nil, // httpClient
logger,
config,
"https://auth.example.com",
store,
)
if registrar == nil {
t.Fatal("Expected registrar but got nil")
}
if registrar.store == nil {
t.Error("Expected store to be set")
}
// Test SetStore
newStore := NewFileCredentialsStore(filepath.Join(tempDir, "new.json"), logger)
registrar.SetStore(newStore)
if registrar.store != newStore {
t.Error("SetStore did not update the store")
}
}
// TestDynamicClientRegistrar_CredentialsFromStore tests loading from store
func TestDynamicClientRegistrar_CredentialsFromStore(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
providerURL := "https://auth.example.com"
ctx := context.Background()
// Pre-save credentials
testCreds := &ClientRegistrationResponse{
ClientID: "pre-saved-client",
ClientSecret: "pre-saved-secret",
ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
}
if err := store.Save(ctx, providerURL, testCreds); err != nil {
t.Fatalf("Failed to pre-save credentials: %v", err)
}
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
}
registrar := NewDynamicClientRegistrarWithStore(
nil,
logger,
config,
providerURL,
store,
)
// Test loading via the internal method
loaded, err := registrar.loadCredentialsFromStore(ctx)
if err != nil {
t.Fatalf("Failed to load from store: %v", err)
}
if loaded == nil {
t.Fatal("Expected credentials but got nil")
}
if loaded.ClientID != "pre-saved-client" {
t.Errorf("ClientID mismatch: got %s", loaded.ClientID)
}
}
// TestFileCredentialsStore_CorruptedFile tests handling of corrupted files
func TestFileCredentialsStore_CorruptedFile(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
ctx := context.Background()
providerURL := "https://auth.example.com"
// Write corrupted JSON
filePath := store.getFilePath(providerURL)
if err := os.WriteFile(filePath, []byte("{corrupted json"), 0600); err != nil {
t.Fatalf("Failed to write corrupted file: %v", err)
}
// Should return error for corrupted file
_, err := store.Load(ctx, providerURL)
if err == nil {
t.Error("Expected error for corrupted JSON")
}
}
// TestFileCredentialsStore_DirectoryCreation tests auto directory creation
func TestFileCredentialsStore_DirectoryCreation(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
deepPath := filepath.Join(tempDir, "deep", "nested", "path", "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(deepPath, logger)
ctx := context.Background()
creds := &ClientRegistrationResponse{ClientID: "test"}
err := store.Save(ctx, "https://example.com", creds)
if err != nil {
t.Fatalf("Failed to save with nested directory: %v", err)
}
loaded, err := store.Load(ctx, "https://example.com")
if err != nil {
t.Fatalf("Failed to load after nested directory creation: %v", err)
}
if loaded == nil || loaded.ClientID != "test" {
t.Error("Failed to load credentials from nested directory")
}
}
+7 -4
View File
@@ -25,7 +25,10 @@ The **audience** (`aud`) claim in a JWT identifies the intended recipient of the
### Why Does This Matter?
Proper audience validation prevents **token confusion attacks** where a token intended for one API is used to access another API.
Audience validation rejects access tokens whose `aud` claim does not match the
expected audience, blocking the trivial form of token confusion where a token
issued for API A is presented to API B. (Defence in depth — pair with
short-lived tokens, rotation, and per-API client credentials.)
---
@@ -137,8 +140,8 @@ http:
**Recommended:** `true` for production
**What it does:**
- When `true`: Rejects sessions if access token audience doesn't match (prevents Scenario 2)
- When `false`: Logs warnings but allows fallback to ID token (backward compatible)
- When `true`: On audience mismatch, the middleware does **not** silently fall back to ID-token validation. It tries to refresh the access token first; if no refresh token is present (or refresh fails), the user is re-authenticated.
- When `false`: Logs warnings and falls back to ID-token validation (backward compatible).
**Example:**
```yaml
@@ -349,7 +352,7 @@ When opaque tokens are detected:
**Cache behavior:**
- Cache key: Token hash
- TTL: 5 minutes or token expiry (whichever is shorter)
- TTL: 5 minutes; if the token's `exp` is sooner, the cache entry expires at `exp` instead. Tokens without `exp` use the flat 5-minute TTL.
- Reduces introspection requests for frequently used tokens
---
+489
View File
@@ -0,0 +1,489 @@
# Configuration Reference
Complete reference for all Traefik OIDC middleware configuration options.
## Table of Contents
- [Required Parameters](#required-parameters)
- [Optional Parameters](#optional-parameters)
- [Security Options](#security-options)
- [Session Management](#session-management)
- [Access Control](#access-control)
- [Headers Configuration](#headers-configuration)
- [Security Headers](#security-headers)
- [Scope Configuration](#scope-configuration)
- [Advanced Options](#advanced-options)
---
## Required Parameters
| Parameter | Type | Description | Example |
|-----------|------|-------------|---------|
| `providerURL` | string | Base URL of the OIDC provider | `https://accounts.google.com` |
| `clientID` | string | OAuth 2.0 client identifier | `1234567890.apps.googleusercontent.com` |
| `clientSecret` | string | OAuth 2.0 client secret | `your-client-secret` |
| `sessionEncryptionKey` | string | Key for encrypting session data (min 32 bytes) | `your-32-byte-encryption-key-here` |
| `callbackURL` | string | Path where provider redirects after authentication | `/oauth2/callback` |
### Basic Configuration Example
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-auth
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: your-client-id.apps.googleusercontent.com
clientSecret: your-client-secret
sessionEncryptionKey: your-32-byte-encryption-key-here
callbackURL: /oauth2/callback
```
---
## Optional Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `logoutURL` | string | `callbackURL + "/logout"` | Path for logout requests |
| `postLogoutRedirectURI` | string | `/` | Redirect URL after logout |
| `logLevel` | string | `info` | Logging verbosity (`debug`, `info`, `error`) |
| `forceHTTPS` | bool | `true` | Force HTTPS for redirect URIs (set `false` only for plaintext HTTP local dev) |
| `rateLimit` | int | `100` | Maximum requests per second |
| `excludedURLs` | []string | none | Paths that bypass authentication |
| `revocationURL` | string | auto-discovered | Token revocation endpoint |
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint |
| `enablePKCE` | bool | `false` | Enable PKCE for authorization code flow |
| `minimalHeaders` | bool | `false` | Reduce forwarded headers |
### TLS Termination at Load Balancer
`forceHTTPS` defaults to `true`, so redirect URIs always use `https://`. This is
the correct default behind any TLS-terminating load balancer (AWS ALB, Google
Cloud LB, Azure App Gateway) — `X-Forwarded-Proto` cannot be trusted (ALB may
overwrite it).
Set `forceHTTPS: false` only when you serve OIDC over plaintext HTTP (local
dev). Otherwise leave it at default.
---
## Security Options
### Audience Validation
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `audience` | string | `clientID` | Expected audience for access token validation |
| `strictAudienceValidation` | bool | `false` | Reject sessions with audience mismatch |
| `allowOpaqueTokens` | bool | `false` | Enable opaque token support via RFC 7662 |
| `requireTokenIntrospection` | bool | `false` | Require introspection for opaque tokens |
#### Production Security Configuration
```yaml
audience: "https://my-api.example.com"
strictAudienceValidation: true
```
#### Opaque Token Support
```yaml
allowOpaqueTokens: true
requireTokenIntrospection: true
strictAudienceValidation: true
```
### Other Security Options
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `disableReplayDetection` | bool | `false` | Disable JTI-based replay attack detection |
| `allowPrivateIPAddresses` | bool | `false` | Allow private IPs in provider URLs |
---
## Session Management
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `sessionMaxAge` | int | `86400` (24h) | Maximum session age in seconds |
| `refreshGracePeriodSeconds` | int | `60` | Seconds before expiry to attempt refresh |
| `cookieDomain` | string | auto-detected | Domain for session cookies |
| `cookiePrefix` | string | `_oidc_raczylo_` | Prefix for cookie names |
### Multi-Subdomain Setup
```yaml
cookieDomain: .example.com # Share cookies across subdomains
```
### Multiple Middleware Instances
When running multiple middleware instances with different authorization requirements, use unique prefixes:
```yaml
# User authentication middleware
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-userauth
spec:
plugin:
traefikoidc:
cookiePrefix: "_oidc_userauth_"
sessionEncryptionKey: user-encryption-key-min-32-bytes
# ... other config
---
# Admin authentication middleware
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-adminauth
spec:
plugin:
traefikoidc:
cookiePrefix: "_oidc_adminauth_"
sessionEncryptionKey: admin-encryption-key-min-32-bytes
allowedUsers:
- admin@example.com
# ... other config
```
### Extended Session Duration
```yaml
sessionMaxAge: 604800 # 7 days
# Common values:
# 3600 - 1 hour (high security)
# 86400 - 1 day (default)
# 259200 - 3 days
# 604800 - 7 days
# 2592000 - 30 days
```
---
## Access Control
### User Restrictions
| Parameter | Type | Description |
|-----------|------|-------------|
| `allowedUserDomains` | []string | Restrict to specific email domains |
| `allowedUsers` | []string | Specific email addresses allowed |
| `allowedRolesAndGroups` | []string | Required roles or groups |
| `roleClaimName` | string | JWT claim for roles (default: `roles`) |
| `groupClaimName` | string | JWT claim for groups (default: `groups`) |
| `userIdentifierClaim` | string | Claim for user ID (default: `email`) |
### Domain Restriction
```yaml
allowedUserDomains:
- company.com
- subsidiary.com
```
### Specific User Access
```yaml
allowedUsers:
- user@example.com
- contractor@external.org
```
### Role-Based Access Control
```yaml
allowedRolesAndGroups:
- admin
- developer
roleClaimName: "https://myapp.com/roles" # For namespaced claims (Auth0)
```
### Access Control Logic
- If only `allowedUsers` is set: Only specified emails can access
- If only `allowedUserDomains` is set: Only specified domains can access
- If both are set: Access granted if email is in `allowedUsers` OR domain is in `allowedUserDomains`
- If neither is set: Any authenticated user can access
### Users Without Email (Azure AD)
For Azure AD service accounts or users without email:
```yaml
userIdentifierClaim: sub # Options: sub, oid, upn, preferred_username
allowedUsers:
- "abc12345-6789-0abc-def0-123456789abc" # User object ID
```
---
## Headers Configuration
### Default Headers
The middleware sets these headers for downstream services:
| Header | Description |
|--------|-------------|
| `X-Forwarded-User` | User's email address |
| `X-User-Groups` | Comma-separated user groups |
| `X-User-Roles` | Comma-separated user roles |
| `X-Auth-Request-Redirect` | Original request URI |
| `X-Auth-Request-User` | User's email address |
| `X-Auth-Request-Token` | User's ID token |
### Minimal Headers Mode
For "431 Request Header Fields Too Large" errors:
```yaml
minimalHeaders: true # Only forwards X-Forwarded-User
```
### Custom Templated Headers
```yaml
headers:
- name: "X-User-Email"
value: "{{{{.Claims.email}}}}"
- name: "X-User-ID"
value: "{{{{.Claims.sub}}}}"
- name: "Authorization"
value: "Bearer {{{{.AccessToken}}}}"
- name: "X-User-Roles"
value: "{{{{range $i, $e := .Claims.roles}}}}{{{{if $i}}}},{{{{end}}}}{{{{$e}}}}{{{{end}}}}"
```
**Template Variables:**
- `{{.Claims.field}}` - ID token claims
- `{{.AccessToken}}` - Raw access token
- `{{.IdToken}}` - Raw ID token
- `{{.RefreshToken}}` - Raw refresh token
**Important:** Use double curly braces (`{{{{` and `}}}}`) to escape templates in YAML.
---
## Security Headers
### Security Profiles
| Profile | Use Case | Security Level |
|---------|----------|----------------|
| `default` | Standard web apps | High |
| `strict` | Maximum security | Very High |
| `development` | Local development | Medium |
| `api` | API endpoints | High |
| `custom` | Custom requirements | Configurable |
### Basic Configuration
```yaml
securityHeaders:
enabled: true
profile: "default"
```
### API with CORS
```yaml
securityHeaders:
enabled: true
profile: "api"
corsEnabled: true
corsAllowedOrigins:
- "https://your-frontend.com"
- "https://*.example.com"
corsAllowCredentials: true
```
### Custom Security Configuration
```yaml
securityHeaders:
enabled: true
profile: "custom"
# Content Security Policy
contentSecurityPolicy: "default-src 'self'; script-src 'self'"
# HSTS
strictTransportSecurity: true
strictTransportSecurityMaxAge: 31536000
strictTransportSecuritySubdomains: true
strictTransportSecurityPreload: true
# Frame and Content Protection
frameOptions: "DENY"
contentTypeOptions: "nosniff"
xssProtection: "1; mode=block"
referrerPolicy: "strict-origin-when-cross-origin"
# CORS
corsEnabled: true
corsAllowedOrigins: ["https://app.example.com"]
corsAllowedMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
corsAllowedHeaders: ["Authorization", "Content-Type"]
corsAllowCredentials: true
corsMaxAge: 86400
# Custom Headers
customHeaders:
X-Custom-Header: "value"
# Server Identification
disableServerHeader: true
disablePoweredByHeader: true
```
### CORS Origin Patterns
```yaml
corsAllowedOrigins:
- "https://example.com" # Exact match
- "https://*.example.com" # Subdomain wildcard
- "http://localhost:*" # Port wildcard (development)
```
---
## Scope Configuration
### Default Behavior (Append Mode)
```yaml
scopes:
- roles
- custom_scope
# Result: ["openid", "profile", "email", "roles", "custom_scope"]
```
### Override Mode
```yaml
overrideScopes: true
scopes:
- openid
- profile
- custom_scope
# Result: ["openid", "profile", "custom_scope"]
```
---
## Advanced Options
### Dynamic Client Registration (RFC 7591)
Dynamic Client Registration allows the middleware to automatically register itself with the OIDC provider, eliminating the need to manually create client credentials.
**Basic Configuration (Single Instance):**
```yaml
dynamicClientRegistration:
enabled: true
initialAccessToken: "your-token" # Optional, if provider requires it
persistCredentials: true
credentialsFile: "/tmp/oidc-credentials.json"
clientMetadata:
redirect_uris:
- "https://your-app.com/oauth2/callback"
client_name: "My Application"
application_type: "web"
grant_types:
- "authorization_code"
- "refresh_token"
```
**Multi-Replica Deployment (Kubernetes):**
For Kubernetes deployments with multiple replicas, use Redis storage to share credentials across all instances and prevent registration race conditions:
```yaml
dynamicClientRegistration:
enabled: true
persistCredentials: true
storageBackend: "redis" # Share credentials via Redis
redisKeyPrefix: "myapp:dcr:" # Optional custom prefix
clientMetadata:
redirect_uris:
- "https://your-app.com/oauth2/callback"
client_name: "My Application"
redis:
enabled: true
address: "redis:6379"
cacheMode: "redis"
```
**Storage Backend Options:**
| Backend | Description | Use Case |
|---------|-------------|----------|
| `file` | Store credentials in local file | Single instance deployments |
| `redis` | Store credentials in Redis | Multi-replica Kubernetes deployments |
| `auto` | Use Redis if available, fallback to file | Flexible deployments (default) |
### Multi-Replica Deployment
Without Redis, disable replay detection:
```yaml
disableReplayDetection: true
```
With Redis (recommended):
```yaml
redis:
enabled: true
address: "redis:6379"
cacheMode: "hybrid"
```
See [REDIS.md](REDIS.md) for complete Redis configuration.
---
## Kubernetes Secrets
Reference secrets instead of hardcoding sensitive values:
```yaml
providerURL: urn:k8s:secret:oidc-secret:ISSUER
clientID: urn:k8s:secret:oidc-secret:CLIENT_ID
clientSecret: urn:k8s:secret:oidc-secret:SECRET
```
Create the secret:
```bash
kubectl create secret generic oidc-secret \
--from-literal=ISSUER=https://accounts.google.com \
--from-literal=CLIENT_ID=your-client-id \
--from-literal=SECRET=your-client-secret \
-n traefik
```
---
## Environment Variable Naming
**Important:** Avoid using "API" as a substring in environment variable names when using `${VAR}` syntax in Traefik configuration. Traefik reserves `TRAEFIK_API_*` variables and the substring may cause conflicts.
```yaml
# Bad - may cause issues
sessionEncryptionKey: ${OIDC_SECRET_API}
# Good
sessionEncryptionKey: ${OIDC_SECRET_SVC}
```
+95
View File
@@ -0,0 +1,95 @@
# Dynamic Client Registration (RFC 7591)
The middleware can register itself with an OIDC provider at startup instead of
using a pre-provisioned `clientID` / `clientSecret`. Useful for multi-tenant
deployments, self-service integrations, and ephemeral environments.
## How it works
1. Middleware reads `registration_endpoint` from `.well-known/openid-configuration`.
2. If `clientID` is empty, it `POST`s `clientMetadata` to the registration endpoint.
3. Returned `client_id` / `client_secret` are cached, optionally persisted.
4. Subsequent requests use the registered credentials.
For multi-replica deployments, set `storageBackend: redis` so all replicas
share one client and avoid registration races.
## Configuration
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-dcr
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://your-oidc-provider.com
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
callbackURL: /oauth2/callback
dynamicClientRegistration:
enabled: true
persistCredentials: true
storageBackend: redis # file | redis | auto
initialAccessToken: "" # optional, for protected endpoints
registrationEndpoint: "" # optional, override discovery
credentialsFile: /tmp/oidc-client-credentials.json
redisKeyPrefix: "dcr:creds:"
clientMetadata:
redirect_uris:
- https://app.example.com/oauth2/callback
client_name: My Application
application_type: web
grant_types: [authorization_code, refresh_token]
response_types: [code]
token_endpoint_auth_method: client_secret_basic
contacts: [admin@example.com]
```
## Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `enabled` | `false` | Enable DCR. |
| `persistCredentials` | `false` | Save returned credentials for reuse across restarts. |
| `storageBackend` | `auto` | `file`, `redis`, or `auto` (Redis if available, else file). |
| `credentialsFile` | `/tmp/oidc-client-credentials.json` | Path for file-backed storage. Mode `0600`. |
| `redisKeyPrefix` | (none — set explicitly) | Key prefix for Redis-backed storage. The code does not inject a default; if unset, keys have no prefix. `dcr:creds:` is a sensible convention. |
| `registrationEndpoint` | discovered | Override the discovered endpoint. |
| `initialAccessToken` | none | Bearer token for protected registration endpoints. |
| `clientMetadata.redirect_uris` | required | Callback URIs for the OAuth flow. |
| `clientMetadata.client_name` | none | Human-readable client name. |
| `clientMetadata.application_type` | `web` | `web` or `native`. |
| `clientMetadata.grant_types` | `[authorization_code, refresh_token]` | OAuth grant types. |
| `clientMetadata.response_types` | `[code]` | OAuth response types. |
| `clientMetadata.token_endpoint_auth_method` | `client_secret_basic` | `client_secret_basic`, `client_secret_post`, or `none`. |
| `clientMetadata.scope` | none | Space-separated scopes. |
| `clientMetadata.contacts` | none | Admin email addresses. |
| `clientMetadata.logo_uri` | none | Logo URL for consent screens. |
| `clientMetadata.client_uri` | none | Client homepage URL. |
| `clientMetadata.policy_uri` | none | Privacy policy URL. |
| `clientMetadata.tos_uri` | none | Terms of service URL. |
## Provider support
The middleware does not gate DCR by provider — if the provider exposes a
`registration_endpoint` in its discovery document (or you set
`registrationEndpoint` explicitly), DCR will attempt registration. The table
below is informational guidance based on each provider's published support.
| Provider | DCR | Notes |
|----------|-----|-------|
| Keycloak | Yes | Enable in realm settings. |
| Auth0 | Yes | Requires Management API token. |
| Okta | Yes | Enable Dynamic Client Registration in admin console. |
| Azure AD | Limited | Use App Registration API instead. |
| Google | No | Manual registration required. |
| AWS Cognito | No | Manual registration required. |
## Security notes
- Registration endpoints must be HTTPS (loopback excepted for local dev).
- Use `initialAccessToken` in production to gate registration.
- File-backed credentials use `0600`; protect the mount path.
- The plugin marks credentials invalid when within ~5 min of `client_secret_expires_at` but does **not** automatically re-register. If your provider sets a non-zero expiry, schedule manual rotation (delete the credentials file or Redis entry, restart) before that time.
+376
View File
@@ -0,0 +1,376 @@
# Development Guide
Guide for local development, testing, and contributing to the Traefik OIDC middleware.
## Table of Contents
- [Prerequisites](#prerequisites)
- [Local Development Setup](#local-development-setup)
- [Running Tests](#running-tests)
- [Test Categories](#test-categories)
- [CI/CD Pipeline](#cicd-pipeline)
- [Code Quality](#code-quality)
- [Contributing](#contributing)
---
## Prerequisites
- **Go 1.24+** (matches `go.mod`; CI runs Go 1.24.11)
- **OIDC Provider** credentials (Google, Azure, etc.) for any end-to-end test against a real provider
### Required Development Tools
```bash
# golangci-lint (comprehensive linting)
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# staticcheck (static analysis)
go install honnef.co/go/tools/cmd/staticcheck@latest
# gosec (security scanning)
go install github.com/securego/gosec/v2/cmd/gosec@latest
# govulncheck (vulnerability scanning)
go install golang.org/x/vuln/cmd/govulncheck@latest
```
---
## Local Development Setup
### Build and unit tests
```bash
go mod tidy
go build ./...
go test ./... -short # fast loop, < 30 s
go test -race -timeout=15m ./...
```
### Sample plugin configurations
Working middleware/Traefik configs live in [`examples/`](../examples/):
- `complete-traefik-config.yaml` — full middleware example
- `redis-config.yaml` — Redis cache configuration
To run the plugin against a real Traefik instance, drop the project on disk
and load it via `experimental.localPlugins` in your Traefik static config —
see the [README install section](../README.md#install).
### Integration tests
Integration tests live in `integration/`. Run them explicitly:
```bash
go test ./integration/... -run Integration -v
```
---
## Running Tests
### Quick Start
```bash
# Fast development testing (< 30 seconds)
go test ./... -short
# Standard tests with race detector
go test -race -timeout=15m ./...
# With coverage report
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
```
### Test Modes
| Mode | Command | Duration | Use Case |
|------|---------|----------|----------|
| Quick | `go test ./... -short` | < 30s | During development |
| Extended | `RUN_EXTENDED_TESTS=1 go test ./...` | 2-5 min | Before commits |
| Long | `RUN_LONG_TESTS=1 go test ./...` | 5-15 min | Release validation |
| Stress | `RUN_STRESS_TESTS=1 go test ./...` | 10-30 min | Performance testing |
### Environment Variables
```bash
# Enable specific test types
export RUN_EXTENDED_TESTS=1
export RUN_LONG_TESTS=1
export RUN_STRESS_TESTS=1
# Disable specific features
export DISABLE_LEAK_DETECTION=1
# Customize test parameters
export TEST_MAX_CONCURRENCY=10
export TEST_MAX_ITERATIONS=50
export TEST_MEMORY_THRESHOLD_MB=25.5
```
---
## Test Categories
### Quick Tests (Default)
- Basic functionality verification
- Limited iterations (1-3)
- Small data sets
- Essential memory leak checks
**Configuration:**
- Max Iterations: 3
- Max Concurrency: 5
- Memory Threshold: 2.0 MB
- Timeout: 10 seconds
### Extended Tests
- Comprehensive testing before commits
- More iterations (5-10)
- Enhanced memory leak detection
**Configuration:**
- Max Iterations: 10
- Max Concurrency: 20
- Memory Threshold: 10.0 MB
- Timeout: 30 seconds
### Long Tests
- Performance validation
- High iteration counts (50-100)
- Large data sets
**Configuration:**
- Max Iterations: 100
- Max Concurrency: 50
- Memory Threshold: 50.0 MB
- Timeout: 60 seconds
### Stress Tests
- Maximum load testing
- Edge case validation
- Extreme parameters
**Configuration:**
- Max Iterations: 500
- Max Concurrency: 100
- Memory Threshold: 100.0 MB
- Timeout: 120 seconds
### Running Specific Test Suites
```bash
# Memory leak tests
go test -v -run='.*Leak.*' ./...
# Integration tests
go test -v -run='.*Integration.*' ./...
# Regression tests
go test -v -run='.*Regression.*' ./...
# Provider-specific tests
go test -v -run='.*Azure.*' ./...
go test -v -run='.*Google.*' ./...
```
### Benchmarks
```bash
# Quick benchmarks
go test -bench=. -short
# Extended benchmarks
RUN_EXTENDED_TESTS=1 go test -bench=.
# Memory profiling
go test -bench=. -memprofile=mem.prof
go tool pprof mem.prof
```
---
## CI/CD Pipeline
The repository uses GitHub Actions for comprehensive validation with 20+ parallel checks.
### Triggered On
- Pull requests to `main` branch
- Pushes to `main` branch
### Parallel Jobs
#### Code Quality (3 checks)
- **Format & Basic Checks** - gofmt, go vet, go mod
- **golangci-lint** - 30+ linters
- **Staticcheck** - Advanced static analysis
#### Security (3 checks)
- **Gosec** - Security vulnerability scanning
- **Govulncheck** - Go vulnerability database
- **CodeQL** - GitHub's semantic code analysis
#### Testing (9 suites)
- Race Detector
- Coverage (70% threshold, enforced in `pr.yaml`)
- Memory Leaks
- Integration Tests
- Regression Tests
- Security Edge Cases
- Session Tests
- Token Tests
- CSRF Tests
#### Provider Testing (9 providers)
Tests run in parallel for:
- Google
- Azure AD
- Auth0
- Okta
- Keycloak
- AWS Cognito
- GitLab
- GitHub
- Generic OIDC
#### Performance & Build (3 checks)
- Benchmarks
- Multi-platform Build (linux/darwin x amd64/arm64)
- Go Version Compatibility (currently Go 1.24.11 in CI)
### Quality Gates
All PRs must pass:
- All parallel checks
- 70% test coverage minimum
- Zero security vulnerabilities
- No race conditions
- No memory leaks
- All providers tested
- Builds on all platforms
---
## Code Quality
### Pre-Commit Checklist
```bash
# Run before every commit
gofmt -s -w . && \
go mod tidy && \
golangci-lint run && \
go test -race -short ./... && \
echo "Ready to commit!"
```
### Local Validation
```bash
# Format code
gofmt -s -w .
# Run linter
golangci-lint run
# Static analysis
staticcheck ./...
# Security scan
gosec ./...
# Vulnerability check
govulncheck ./...
# Tests with race detector
go test -race -timeout=15m -count=1 ./...
# Coverage report
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
# View coverage in browser
go tool cover -html=coverage.out
```
### Troubleshooting
**Coverage Below Threshold:**
```bash
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out # See uncovered lines
```
**Race Condition Found:**
```bash
go test -race -v -run=TestName ./...
```
**Linter Errors:**
```bash
golangci-lint run -v
golangci-lint run --fix # Auto-fix some issues
```
**Provider Test Fails:**
```bash
go test -v -run='.*Azure.*' ./...
```
---
## Contributing
### Development Guidelines
1. **Memory Management**: Ensure all goroutines can be cancelled and resources are bounded
2. **Testing**: Add tests for new features, including memory leak tests where appropriate
3. **Race Conditions**: Run tests with `-race` flag to detect race conditions
4. **Documentation**: Update README and configuration files for new options
### Pull Request Template
PRs should include:
- Description of changes
- Type of change (bug fix, feature, breaking change, etc.)
- Related issues
- Provider impact (which providers are affected)
- Testing performed
- Security considerations
- Performance impact
- Breaking changes (if any)
### Checklist
Before submitting:
- [ ] Code follows project style
- [ ] Self-review completed
- [ ] Tests added for new functionality
- [ ] All tests pass locally
- [ ] Documentation updated
- [ ] No new warnings generated
### Code Owners
The repository uses CODEOWNERS for automatic PR reviewer assignment based on file paths.
### Dependabot
Automated dependency updates run weekly (Mondays 9 AM) with security updates prioritized.
---
## Additional Resources
- [golangci-lint Rules](.golangci.yml)
- [PR Template](.github/PULL_REQUEST_TEMPLATE.md)
- [Workflow Documentation](.github/workflows/README.md)
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
+582
View File
@@ -0,0 +1,582 @@
# OIDC Provider Configuration Guide
Configuration reference for each supported OIDC provider.
## Table of Contents
- [Provider Support Matrix](#provider-support-matrix)
- [Google](#google)
- [Microsoft Azure AD](#microsoft-azure-ad)
- [Auth0](#auth0)
- [Okta](#okta)
- [Keycloak](#keycloak)
- [AWS Cognito](#aws-cognito)
- [GitLab](#gitlab)
- [GitHub](#github)
- [Generic OIDC](#generic-oidc)
- [Automatic Scope Filtering](#automatic-scope-filtering)
---
## Provider Support Matrix
| Provider | OIDC Support | Refresh Tokens | Auto-Detection | ID Tokens |
|----------|-------------|----------------|----------------|-----------|
| Google | Full | Yes | `accounts.google.com` | Yes |
| Azure AD | Full | Yes | `login.microsoftonline.com`, `sts.windows.net` | Yes |
| Auth0 | Full | Yes | `*.auth0.com` | Yes |
| Okta | Full | Yes | `*.okta.com`, `*.oktapreview.com`, `*.okta-emea.com` | Yes |
| Keycloak | Full | Yes | host containing `keycloak`, or `/realms/` in path (matches both `/auth/realms/` legacy and `/realms/` modern) | Yes |
| AWS Cognito | Full | Yes | `cognito-idp.*.amazonaws.com` | Yes |
| GitLab | Full | Yes | `gitlab.com` | Yes |
| GitHub | OAuth 2.0 Only | No | `github.com` | No |
| Generic | Full | Yes | Any OIDC endpoint | Yes |
---
## Google
### Provider URL
```yaml
providerURL: "https://accounts.google.com"
```
### Configuration
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-google
spec:
plugin:
traefikoidc:
providerURL: "https://accounts.google.com"
clientID: "your-id.apps.googleusercontent.com"
clientSecret: "your-client-secret"
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-char-encryption-key-here"
scopes:
- openid
- email
- profile
allowedUserDomains:
- "your-gsuite-domain.com" # Optional: Workspace restriction
forceHttps: true
enablePkce: true
```
### Google-Specific Features
- **Automatic offline access**: Middleware adds `access_type=offline` and `prompt=consent`
- **Scope filtering**: Automatically removes unsupported `offline_access` scope
- **Workspace domains**: Restrict to specific Google Workspace domains via `hd` claim
### Google Cloud Console Setup
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create or select a project
3. Navigate to APIs & Services > Credentials
4. Create OAuth 2.0 Client ID (Web application)
5. Add authorized redirect URI: `https://your-domain.com/oauth2/callback`
6. Configure OAuth consent screen (must be "Published" for production)
---
## Microsoft Azure AD
### Provider URL
```yaml
# Single tenant
providerURL: "https://login.microsoftonline.com/{tenant-id}/v2.0"
# Multi-tenant
providerURL: "https://login.microsoftonline.com/common/v2.0"
```
### Basic Configuration
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-azure
spec:
plugin:
traefikoidc:
providerURL: "https://login.microsoftonline.com/common/v2.0"
clientID: "your-azure-client-id"
clientSecret: "your-azure-client-secret"
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-char-encryption-key-here"
scopes:
- openid
- profile
- email
- offline_access
allowedRolesAndGroups:
- "App.Users"
- "Admin.Group"
forceHttps: true
```
### With Application ID URI (API Access)
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-azure-api
spec:
plugin:
traefikoidc:
providerURL: "https://login.microsoftonline.com/common/v2.0"
clientID: "your-azure-client-id"
clientSecret: "your-azure-client-secret"
audience: "api://your-azure-client-id" # Application ID URI
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-char-encryption-key-here"
forceHttps: true
```
### Users Without Email
```yaml
userIdentifierClaim: sub # Options: sub, oid, upn, preferred_username
allowedUsers:
- "user-object-id-1"
- "user-object-id-2"
```
### Azure AD 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/oauth2/callback`
5. Create client secret in Certificates & secrets
6. Configure Token Configuration for group claims
---
## Auth0
### Provider URL
```yaml
providerURL: "https://your-domain.auth0.com"
```
### Basic Configuration
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-auth0
spec:
plugin:
traefikoidc:
providerURL: "https://your-domain.auth0.com"
clientID: "your-auth0-client-id"
clientSecret: "your-auth0-client-secret"
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-char-encryption-key-here"
scopes:
- openid
- profile
- email
- offline_access
postLogoutRedirectUri: "https://your-app.com"
forceHttps: true
enablePkce: true
```
### With Custom API Audience
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-auth0-api
spec:
plugin:
traefikoidc:
providerURL: "https://your-domain.auth0.com"
clientID: "your-auth0-client-id"
clientSecret: "your-auth0-client-secret"
audience: "https://api.your-domain.com" # API identifier
strictAudienceValidation: true
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-char-encryption-key-here"
roleClaimName: "https://your-app.com/roles" # Namespaced claim
groupClaimName: "https://your-app.com/groups"
allowedRolesAndGroups:
- admin
- editor
```
### Auth0 Action for Custom Claims
```javascript
exports.onExecutePostLogin = async (event, api) => {
const namespace = 'https://your-app.com/';
if (event.authorization) {
api.idToken.setCustomClaim(namespace + 'roles', event.authorization.roles);
api.idToken.setCustomClaim('email', event.user.email);
}
};
```
### Auth0 Setup
1. Go to [Auth0 Dashboard](https://manage.auth0.com/)
2. Create Regular Web Application
3. Configure Allowed Callback URLs: `https://your-domain.com/oauth2/callback`
4. Configure Allowed Logout URLs: `https://your-domain.com/oauth2/logout`
5. Enable OIDC Conformant in Advanced Settings
6. Create API in APIs section for custom audiences
See [AUTH0_AUDIENCE_GUIDE.md](AUTH0_AUDIENCE_GUIDE.md) for detailed audience configuration.
---
## Okta
### Provider URL
```yaml
providerURL: "https://your-domain.okta.com"
# Or with custom authorization server:
providerURL: "https://your-domain.okta.com/oauth2/default"
```
### Configuration
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-okta
spec:
plugin:
traefikoidc:
providerURL: "https://your-domain.okta.com"
clientID: "your-okta-client-id"
clientSecret: "your-okta-client-secret"
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-char-encryption-key-here"
scopes:
- openid
- profile
- email
- groups
- offline_access
allowedRolesAndGroups:
- admin
- "Everyone"
forceHttps: true
enablePkce: true
```
### Okta Setup
1. Access Okta Admin Console
2. Go to Applications > Create App Integration
3. Select OIDC - OpenID Connect > Web Application
4. Configure Sign-in redirect URIs: `https://your-domain.com/oauth2/callback`
5. Configure Sign-out redirect URIs: `https://your-domain.com/oauth2/logout`
6. Enable Authorization Code and Refresh Token grant types
7. Configure Groups claim in authorization server
---
## Keycloak
### Provider URL
```yaml
providerURL: "https://keycloak.your-domain.com/realms/{realm-name}"
```
### Configuration
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-keycloak
spec:
plugin:
traefikoidc:
providerURL: "https://keycloak.company.com/realms/your-realm"
clientID: "your-keycloak-client-id"
clientSecret: "your-keycloak-client-secret"
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-char-encryption-key-here"
scopes:
- openid
- profile
- email
- roles
- groups
- offline_access
allowedRolesAndGroups:
- admin
- editor
forceHttps: true
enablePkce: true
```
### Internal Network Deployment
For private IP addresses (Docker networks, Kubernetes):
```yaml
providerURL: "https://192.168.1.100:8443/realms/your-realm"
allowPrivateIPAddresses: true # Required for private IPs
```
### Keycloak Client Setup
1. Access Keycloak Admin Console
2. Select your realm
3. Go to Clients > Create client
4. Set Client Protocol: openid-connect
5. Set Access Type: confidential
6. Add Valid Redirect URIs: `https://your-domain.com/oauth2/callback`
7. Generate client secret in Credentials tab
8. Configure mappers to add claims to ID Token:
- Email: User Property mapper with "Add to ID token" enabled
- Roles: User Client Role mapper with "Add to ID token" enabled
- Groups: Group Membership mapper with "Add to ID token" enabled
See [KEYCLOAK_SETUP_GUIDE.md](KEYCLOAK_SETUP_GUIDE.md) for detailed step-by-step setup instructions, mapper configuration, troubleshooting, and performance optimization.
---
## AWS Cognito
### Provider URL
```yaml
providerURL: "https://cognito-idp.{region}.amazonaws.com/{user-pool-id}"
```
### Configuration
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-cognito
spec:
plugin:
traefikoidc:
providerURL: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABCDEF123"
clientID: "your-cognito-client-id"
clientSecret: "your-cognito-client-secret"
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-char-encryption-key-here"
scopes:
- openid
- profile
- email
- aws.cognito.signin.user.admin
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/oauth2/callback`
- Sign out URLs: `https://your-domain.com/oauth2/logout`
- OAuth flows: Authorization code grant
4. Configure hosted UI domain (optional)
5. Set up groups for role-based access
---
## GitLab
### Provider URL
```yaml
# GitLab.com
providerURL: "https://gitlab.com"
# Self-hosted
providerURL: "https://gitlab.your-company.com"
```
### Configuration
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-gitlab
spec:
plugin:
traefikoidc:
providerURL: "https://gitlab.com"
clientID: "your-gitlab-application-id"
clientSecret: "your-gitlab-application-secret"
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-char-encryption-key-here"
scopes:
- openid
- profile
- email
# Note: GitLab doesn't require offline_access scope
# Refresh tokens are issued automatically with openid
allowedRolesAndGroups:
- developers
- maintainers
forceHttps: true
enablePkce: true
```
### GitLab 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/oauth2/callback`
5. Save and note Application ID and Secret
---
## GitHub
### Provider URL
```yaml
providerURL: "https://github.com"
```
### Configuration
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oauth-github
spec:
plugin:
traefikoidc:
providerURL: "https://github.com/login/oauth"
clientID: "your-github-client-id"
clientSecret: "your-github-client-secret"
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-char-encryption-key-here"
scopes:
- user:email
- read:user
allowedUsers:
- "github-username"
forceHttps: true
```
### Limitations
- **OAuth 2.0 only** - Not OpenID Connect
- **No ID tokens** - Only access tokens for API calls
- **No refresh tokens** - Users must re-authenticate on expiry
- **No standard claims** - User info requires API calls
Use GitHub only for API access, not for user authentication with claims.
### GitHub Setup
1. Go to GitHub Settings > Developer settings > OAuth Apps
2. Create new OAuth App
3. Set Authorization callback URL: `https://your-domain.com/oauth2/callback`
4. Note Client ID and generate Client Secret
---
## Generic OIDC
For any OIDC-compliant provider not listed above.
### Configuration
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-generic
spec:
plugin:
traefikoidc:
providerURL: "https://oidc.your-provider.com"
clientID: "your-client-id"
clientSecret: "your-client-secret"
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-char-encryption-key-here"
scopes:
- openid
- profile
- email
forceHttps: true
enablePkce: true
```
### Requirements
- Provider must expose `.well-known/openid-configuration` endpoint
- Must support authorization code flow
- ID tokens must contain required claims (email, sub, etc.)
---
## Automatic Scope Filtering
The middleware automatically filters OAuth scopes based on the provider's declared capabilities.
### How It Works
1. Fetches provider's `.well-known/openid-configuration`
2. Extracts `scopes_supported` field
3. Filters requested scopes to only include supported ones
4. Falls back to all requested scopes if provider doesn't declare supported scopes
### Example: Self-Hosted GitLab
Self-hosted GitLab may reject `offline_access` scope:
```yaml
scopes:
- openid
- profile
- email
- offline_access # Will be automatically filtered out if unsupported
```
The middleware will:
1. Read GitLab's discovery document
2. Detect `offline_access` is NOT in `scopes_supported`
3. Filter it out automatically
4. Authentication succeeds
### Logging
```
INFO: ScopeFilter: Filtered unsupported scopes: [offline_access]
DEBUG: ScopeFilter: Final filtered scopes: [openid profile email]
```
### Troubleshooting
If a provider rejects scopes even after filtering:
1. Check the provider's discovery document: `curl https://provider/.well-known/openid-configuration`
2. Use `overrideScopes: true` with only supported scopes
3. Review middleware debug logs for filtering decisions
-970
View File
@@ -1,970 +0,0 @@
# 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
- **Application ID URI**: Supports custom audience for protected APIs
### Example Configuration (Basic)
```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 AD API Configuration (Application ID URI)
When exposing your application as an API with a custom Application ID URI, you need to specify the `audience` parameter. Azure AD includes the Application ID URI in the JWT `aud` claim.
```yaml
http:
middlewares:
azure-api-oidc:
plugin:
traefik-oidc:
providerUrl: "https://login.microsoftonline.com/common/v2.0"
clientId: "12345678-1234-1234-1234-123456789abc"
clientSecret: "your-azure-client-secret"
# Specify the Application ID URI as audience
audience: "api://12345678-1234-1234-1234-123456789abc"
callbackUrl: "https://app.example.com/auth/callback"
logoutUrl: "https://app.example.com/auth/logout"
scopes: ["openid", "profile", "email", "offline_access"]
forceHttps: true
```
**Important**:
- The `audience` parameter should match your Application ID URI (typically `api://{app-id}`)
- Find your Application ID URI in Azure Portal → App Registration → Expose an API → Application ID URI
- Without the `audience` parameter, access tokens with custom audiences will be rejected
- For ID token validation only (no API access), you can omit the `audience` parameter
### 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
### Azure AD API Exposure Setup (for custom audiences)
1. In your App Registration, go to "Expose an API"
2. Set the Application ID URI (e.g., `api://12345678-1234-1234-1234-123456789abc`)
3. Add any custom scopes your API exposes
4. Update the middleware configuration to include the `audience` parameter with this URI
---
## 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
- **API audiences**: Supports custom audience for API access tokens
### Example Configuration (Basic)
```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 API Configuration (Custom Audience)
When using Auth0 APIs with custom audience parameters, you need to specify the `audience` field. Auth0 includes the API identifier in the JWT `aud` claim instead of the `clientId`.
```yaml
http:
middlewares:
auth0-api-oidc:
plugin:
traefik-oidc:
providerUrl: "https://company.auth0.com"
clientId: "abcdef123456789"
clientSecret: "your-auth0-client-secret"
# Specify the Auth0 API identifier as audience
audience: "https://api.company.com"
callbackUrl: "https://app.example.com/auth/callback"
logoutUrl: "https://app.example.com/auth/logout"
scopes: ["openid", "profile", "email", "offline_access"]
forceHttps: true
enablePkce: true
```
**Important**:
- The `audience` parameter should match your Auth0 API identifier (not the client ID)
- Find your API identifier in Auth0 Dashboard → APIs → Your API → Settings → Identifier
- Without the `audience` parameter, access tokens with custom audiences will be rejected with "invalid audience" error
- For ID token validation only (no APIs), you can omit the `audience` parameter
### 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
### Auth0 API Setup (for custom audiences)
1. Go to Auth0 Dashboard → APIs
2. Create a new API or select existing API
3. Note the "Identifier" field (e.g., `https://api.company.com`) - this is your `audience` value
4. In API Settings → Machine to Machine Applications, authorize your application
5. Configure API permissions/scopes as needed
6. Use the API identifier as the `audience` parameter in your configuration
---
## 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 without requiring `offline_access` scope
### 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"]
# Note: GitLab doesn't support the offline_access scope.
# Refresh tokens are issued automatically for the openid scope.
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
### Internal Network Deployment
If your Keycloak instance runs on an internal network with private IP addresses (e.g., Docker networks, Kubernetes internal services), set `allowPrivateIPAddresses: true`:
```yaml
traefikoidc:
providerUrl: "https://192.168.1.100:8443/auth/realms/your-realm" # Private IP
allowPrivateIPAddresses: true # Required for private IP addresses
clientId: "your-client-id"
clientSecret: "your-client-secret"
# ... other config
```
> **Security Warning**: Only enable `allowPrivateIPAddresses` in trusted network environments where you control the OIDC provider. This setting reduces SSRF protection.
---
## 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
```
---
## Automatic Scope Filtering
### Overview
The middleware automatically filters OAuth scopes based on the provider's capabilities declared in their OIDC discovery document (`.well-known/openid-configuration`). This prevents authentication failures when providers reject unsupported scopes.
### How It Works
1. **Discovery Document Parsing**: The middleware fetches the provider's discovery document and extracts the `scopes_supported` field
2. **Intelligent Filtering**: Requested scopes are filtered to only include those the provider supports
3. **Fallback Behavior**: If the provider doesn't declare `scopes_supported`, all requested scopes are used (backward compatible)
4. **Provider-Specific Handling**: Special logic for Google and Azure is preserved and applied after filtering
### Example Scenarios
#### Self-Hosted GitLab
**Problem**: Self-hosted GitLab instances reject the `offline_access` scope with error:
```
The requested scope is invalid, unknown, or malformed.
```
**Solution**: The middleware automatically detects this by:
1. Reading GitLab's discovery document at `https://gitlab.example.com/.well-known/openid-configuration`
2. Observing that `offline_access` is NOT in the `scopes_supported` list
3. Filtering out `offline_access` from the request
4. Authentication succeeds
**Configuration**:
```yaml
http:
middlewares:
gitlab-oidc:
plugin:
traefik-oidc:
providerUrl: "https://gitlab.example.com"
clientId: "your-gitlab-application-id"
clientSecret: "your-gitlab-application-secret"
callbackUrl: "https://app.example.com/auth/callback"
scopes: ["openid", "profile", "email", "offline_access"]
# Even though offline_access is listed, it will be automatically
# filtered out if GitLab doesn't support it
```
#### Auth0 or Keycloak
These providers typically support `offline_access` and it will be included:
```yaml
# Auth0 scopes_supported: ["openid", "profile", "email", "offline_access", ...]
# Result: All requested scopes are sent
```
### Benefits
1. **Self-Hosted Support**: Works seamlessly with self-hosted provider instances
2. **No Manual Configuration**: No need to know which scopes each provider supports
3. **Error Prevention**: Eliminates "invalid scope" authentication failures
4. **Standards Compliant**: Uses official OIDC discovery specification (RFC 8414)
5. **Backward Compatible**: Existing configurations continue to work
### Logging
The middleware provides detailed logging for scope filtering:
```
INFO: ScopeFilter: Filtered unsupported scopes for https://gitlab.example.com: [offline_access]
DEBUG: ScopeFilter: Provider https://gitlab.example.com supported scopes: [openid profile email read_user read_api]
DEBUG: ScopeFilter: Final filtered scopes: [openid profile email]
```
### Troubleshooting
**Issue**: Provider rejects scope even after filtering
**Possible Causes**:
1. Provider's discovery document is outdated
2. Provider doesn't properly implement `scopes_supported`
3. Custom authorization server with non-standard behavior
**Solutions**:
1. Use `overrideScopes: true` and explicitly list only supported scopes
2. Check the provider's discovery document manually: `curl https://your-provider/.well-known/openid-configuration`
3. Review middleware debug logs for filtering decisions
---
## Common Configuration Options
### Audience Configuration
The `audience` parameter specifies the expected JWT audience claim value. This is particularly important when using Auth0 APIs, Azure AD Application ID URIs, or other providers with custom audience requirements.
```yaml
# Optional: Custom audience for JWT validation
# If not set, defaults to clientID for backward compatibility
audience: "https://api.example.com" # Auth0 API identifier
# OR
audience: "api://12345-guid" # Azure AD Application ID URI
```
**When to use**:
- **Auth0**: When using Auth0 APIs with custom audience parameters
- **Azure AD**: When exposing your app as an API with Application ID URI
- **Keycloak**: When using audience-restricted tokens
- **Okta**: When using custom authorization servers with API audiences
**When to omit**:
- For standard ID token validation (default behavior)
- When the provider sets `aud` claim to your `clientID`
- For backward compatibility with existing configurations
**Security Note**: The `audience` parameter prevents token confusion attacks by ensuring tokens issued for one service cannot be used at another service.
### 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
```
+554
View File
@@ -0,0 +1,554 @@
# Redis Cache for Distributed Deployments
Redis cache support for multi-replica Traefik deployments with shared state.
## Table of Contents
- [Overview](#overview)
- [Why Use Redis Cache?](#why-use-redis-cache)
- [Configuration](#configuration)
- [Cache Modes](#cache-modes)
- [Deployment Examples](#deployment-examples)
- [Performance Tuning](#performance-tuning)
- [Monitoring](#monitoring)
- [Troubleshooting](#troubleshooting)
- [Migration Guide](#migration-guide)
---
## Overview
The Redis cache feature provides distributed caching for the Traefik OIDC plugin, enabling seamless operation across multiple Traefik instances.
### Key Features
- **Distributed JTI Replay Detection**: Prevents token replay attacks across all instances
- **Shared Session Management**: Consistent user sessions across replicas
- **Circuit Breaker**: Automatic fallback to memory cache during Redis outages
- **Health Checking**: Continuous monitoring of Redis connectivity
- **Flexible Cache Modes**: Memory, Redis, or hybrid caching strategies
- **Pure-Go Implementation**: Yaegi-compatible, works with dynamic plugin loading
### Architecture
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Traefik #1 │ │ Traefik #2 │ │ Traefik #3
│ (Plugin) │ │ (Plugin) │ │ (Plugin) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└────────────────────┼────────────────────┘
┌──────▼──────┐
│ Redis │
│ (Shared │
│ Cache) │
└─────────────┘
```
---
## Why Use Redis Cache?
### The Problem
When running multiple Traefik instances without shared cache:
1. **False Positive Replay Detection**
- User authenticates → Token stored in Instance A's JTI cache
- Next request → Load balancer routes to Instance B
- Instance B doesn't have the JTI → Falsely detects replay attack
2. **Session Inconsistency**
- User session created on Instance A
- Subsequent request routed to Instance B
- Instance B has no knowledge of the session
3. **Token Metadata Fragmentation**
- Token refresh happens on Instance A
- Other instances continue using old tokens
### The Solution
Redis provides centralized cache that all instances share, ensuring:
- **Consistent Authentication**: All instances share authentication state
- **True Replay Detection**: JTI cache shared across all instances
- **Seamless Scaling**: Add/remove instances without affecting sessions
- **High Availability**: Circuit breaker with automatic fallback
---
## Configuration
### Basic Configuration
```yaml
redis:
enabled: true
address: "redis:6379"
password: "your-password" # Optional
db: 0
keyPrefix: "traefikoidc:"
cacheMode: "hybrid"
```
### All Configuration Options
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `enabled` | bool | `false` | Enable Redis caching |
| `address` | string | - | Redis server address (`host:port`) |
| `password` | string | - | Redis password (optional) |
| `db` | int | `0` | Redis database number (0-15) |
| `keyPrefix` | string | `traefikoidc:` | Prefix for all Redis keys |
| `cacheMode` | string | `redis` | Cache mode: `memory`, `redis`, `hybrid` |
| `poolSize` | int | `10` | Connection pool size |
| `connectTimeout` | int | `5` | Connection timeout (seconds) |
| `readTimeout` | int | `3` | Read timeout (seconds) |
| `writeTimeout` | int | `3` | Write timeout (seconds) |
| `enableTLS` | bool | `false` | Enable TLS for connections |
| `tlsSkipVerify` | bool | `false` | Skip TLS certificate verification |
| `enableCircuitBreaker` | bool | `false` | Wrap the Redis backend with a circuit breaker. **Recommended `true` in production.** |
| `circuitBreakerThreshold` | int | `5` | Consecutive failures before the circuit opens (only when `enableCircuitBreaker: true`). |
| `circuitBreakerTimeout` | int | `60` | Seconds the circuit stays open before allowing a probe (only when `enableCircuitBreaker: true`). |
| `enableHealthCheck` | bool | `false` | Wrap the Redis backend with periodic health checks. **Recommended `true` in production.** |
| `healthCheckInterval` | int | `30` | Health check interval in seconds (only when `enableHealthCheck: true`). |
| `hybridL1Size` | int | `500` | Max items in L1 cache (hybrid mode) |
| `hybridL1MemoryMB` | int64 | `10` | Max memory for L1 cache in MB |
### Environment Variables (Fallback)
If not configured through Traefik, these environment variables are used:
```bash
REDIS_ENABLED=true
REDIS_ADDRESS=redis:6379
REDIS_PASSWORD=your-password
REDIS_DB=0
REDIS_KEY_PREFIX=traefikoidc:
REDIS_CACHE_MODE=hybrid
REDIS_POOL_SIZE=10
REDIS_CONNECT_TIMEOUT=5
REDIS_READ_TIMEOUT=3
REDIS_WRITE_TIMEOUT=3
REDIS_ENABLE_TLS=false
REDIS_TLS_SKIP_VERIFY=false
REDIS_HYBRID_L1_SIZE=500
REDIS_HYBRID_L1_MEMORY_MB=10
```
> Resilience fields (`enableCircuitBreaker`, `enableHealthCheck`,
> `circuitBreakerThreshold`, `circuitBreakerTimeout`, `healthCheckInterval`)
> have no environment variable fallback — set them in plugin configuration.
Invalid `cacheMode` values are rejected at plugin startup.
---
## Cache Modes
### Memory Mode (used when Redis is disabled)
```yaml
redis:
cacheMode: "memory"
```
- Uses only in-memory cache
- Suitable for single-instance deployments
- No Redis dependency
- Fastest performance
### Redis Mode
```yaml
redis:
enabled: true
address: "redis:6379"
cacheMode: "redis"
```
- All operations go directly to Redis
- Ensures consistency across replicas
- Slightly higher latency
### Hybrid Mode (Recommended)
```yaml
redis:
enabled: true
address: "redis:6379"
cacheMode: "hybrid"
```
Two-tier caching strategy:
```
┌─────────────────────────────────────────┐
│ Client Request │
└────────────────┬────────────────────────┘
┌────────────────┐
│ Local Cache │ ← L1 Cache (Fast)
│ (Memory) │
└────────┬───────┘
│ Miss
┌────────────────┐
│ Remote Cache │ ← L2 Cache (Shared)
│ (Redis) │
└────────────────┘
```
**Read Path:**
1. Check local memory cache (L1)
2. On miss, check Redis (L2)
3. On hit in Redis, populate L1
4. Return value
**Write Path:**
1. Write to Redis (L2) for durability
2. Write to local cache (L1) for speed
### Performance Comparison
| Operation | Memory Mode | Redis Mode | Hybrid Mode |
|-----------|------------|------------|-------------|
| Read (p50) | 0.1ms | 2ms | 0.2ms |
| Read (p99) | 0.5ms | 10ms | 5ms |
| Write (p50) | 0.2ms | 3ms | 3ms |
| Throughput | 100k/s | 20k/s | 80k/s |
---
## Deployment Examples
### Docker Compose
```yaml
version: '3.8'
services:
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 30s
timeout: 3s
retries: 3
traefik:
image: traefik:v3.2
deploy:
replicas: 3
labels:
- "traefik.http.middlewares.oidc.plugin.traefikoidc.redis.enabled=true"
- "traefik.http.middlewares.oidc.plugin.traefikoidc.redis.address=redis:6379"
- "traefik.http.middlewares.oidc.plugin.traefikoidc.redis.password=${REDIS_PASSWORD}"
- "traefik.http.middlewares.oidc.plugin.traefikoidc.redis.cacheMode=hybrid"
depends_on:
redis:
condition: service_healthy
volumes:
redis-data:
```
### Kubernetes
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-with-redis
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: your-client-id
clientSecret: your-client-secret
sessionEncryptionKey: your-encryption-key
callbackURL: /oauth2/callback
redis:
enabled: true
address: "redis-service.redis-namespace:6379"
password: "urn:k8s:secret:redis-secret:password"
db: 0
keyPrefix: "traefikoidc:"
cacheMode: "hybrid"
poolSize: 20
enableCircuitBreaker: true
circuitBreakerThreshold: 5
```
### AWS ElastiCache
```yaml
redis:
enabled: true
address: "your-cache.abc123.cache.amazonaws.com:6379"
cacheMode: "hybrid"
enableTLS: true
password: "your-elasticache-auth-token"
```
---
## Performance Tuning
### Connection Pool Sizing
```yaml
redis:
poolSize: 20 # Formula: 2 * CPU cores * replicas
# For 4 cores, 3 replicas: poolSize = 24
```
### TTL Strategy
The plugin automatically sets TTLs based on token lifetimes:
- **JTI Cache**: Matches token lifetime (typically 1 hour)
- **Session**: Matches `sessionMaxAge` configuration
- **Token Metadata**: 5 minutes (short-lived)
### Redis Server Configuration
```bash
# Recommended Redis settings for cache
maxmemory 512mb
maxmemory-policy allkeys-lru # Evict least recently used
# For cache data, disable persistence for better performance
save ""
appendonly no
```
### Hybrid Mode Tuning
```yaml
redis:
cacheMode: "hybrid"
hybridL1Size: 500 # Max items in local cache
hybridL1MemoryMB: 10 # Max memory for local cache
```
---
## Monitoring
### Key Metrics
- **Cache hit rate** (target: >90% for hybrid mode)
- **Redis latency** (target: <10ms p99)
- **Circuit breaker state**
- **Connection pool utilization
### Redis Commands for Monitoring
```bash
# Monitor commands in real-time
redis-cli MONITOR
# Check slow queries
redis-cli SLOWLOG GET 10
# Memory usage
redis-cli INFO memory
# Key statistics
redis-cli DBSIZE
# List keys with prefix
redis-cli --scan --pattern "traefikoidc:*"
# Check key TTL
redis-cli TTL "traefikoidc:session:abc123"
```
### Health Check Endpoint
The plugin provides health information including:
```json
{
"status": "healthy",
"cache": {
"mode": "hybrid",
"redis": {
"connected": true,
"latency": "2ms"
},
"circuit_breaker": {
"state": "closed",
"failures": 0
}
}
}
```
---
## Troubleshooting
### Connection Refused
**Symptoms:** `dial tcp: connection refused`
**Solutions:**
1. Verify Redis is running: `redis-cli ping`
2. Check network connectivity: `telnet redis-host 6379`
3. Verify address configuration
### Authentication Failure
**Symptoms:** `NOAUTH Authentication required`
**Solutions:**
1. Set Redis password in configuration
2. Verify password is correct
### Circuit Breaker Open
**Symptoms:** `Circuit breaker is open`, falling back to memory
**Solutions:**
1. Check Redis health: `redis-cli INFO server`
2. Review network latency: `redis-cli --latency`
3. Adjust circuit breaker thresholds if needed
### High Memory Usage
**Symptoms:** Redis memory constantly growing, OOM errors
**Solutions:**
1. Configure eviction policy:
```bash
CONFIG SET maxmemory 512mb
CONFIG SET maxmemory-policy allkeys-lru
```
2. Review key count: `redis-cli DBSIZE`
3. Check for large keys: `redis-cli --bigkeys`
### Inconsistent Cache State
**Symptoms:** Different responses from different replicas
**Solutions:**
1. Verify all instances use the same Redis address
2. Check cache mode consistency across instances
3. Verify time synchronization on all hosts
---
## Migration Guide
### From Memory-Only to Redis
#### Phase 1: Preparation
1. Deploy Redis infrastructure
2. Test Redis connectivity
3. Configure monitoring
#### Phase 2: Gradual Rollout
1. Enable Redis on one instance:
```yaml
redis:
enabled: true
address: "redis:6379"
cacheMode: "hybrid"
```
2. Monitor for errors
3. Gradually enable on more instances
#### Phase 3: Full Migration
1. Enable Redis on all instances
2. Remove `disableReplayDetection: true` if set
3. Monitor for issues
### Rollback Plan
If issues occur:
1. Set `redis.enabled: false`
2. Plugin falls back to memory cache automatically
3. Investigate and resolve issues
### Migration Checklist
- [ ] Redis deployed and accessible
- [ ] Redis password configured
- [ ] Network connectivity verified
- [ ] Monitoring configured
- [ ] Backup plan prepared
- [ ] Test environment validated
- [ ] Gradual rollout planned
---
## Best Practices
### Security
- Always use Redis password authentication
- Enable TLS for production deployments
- Use network segmentation (private subnets)
- Rotate Redis passwords regularly
### High Availability
- Use Redis Sentinel or Cluster for HA
- Configure appropriate circuit breaker thresholds
- Implement proper health checks
- Use connection pooling
### Performance
- Use hybrid cache mode for best performance
- Monitor cache hit rates
- Size Redis memory appropriately
- Disable persistence for cache-only usage
### Operations
- Implement comprehensive monitoring
- Set up alerting for circuit breaker state
- Document Redis configuration
- Test failover scenarios
---
## FAQ
### Is Redis required?
No, Redis is optional. The plugin works with in-memory cache for single-instance deployments.
### What happens if Redis goes down?
The circuit breaker opens after threshold failures, and the plugin falls back to in-memory cache. It periodically attempts to reconnect.
### Which cache mode should I use?
For production multi-replica deployments, use `hybrid` mode for best performance and consistency.
### How much memory does Redis need?
Depends on active sessions and token sizes:
- Small (1-1000 users): 128MB
- Medium (1000-10000 users): 256-512MB
- Large (10000+ users): 1GB+
### Can I use managed Redis services?
Yes, the plugin works with AWS ElastiCache, Azure Cache for Redis, Google Cloud Memorystore, and Redis Enterprise Cloud.
### Is data encrypted in Redis?
Session data is encrypted before storing using `sessionEncryptionKey`. Additionally, you can enable TLS for Redis connections.
-1125
View File
File diff suppressed because it is too large Load Diff
-413
View File
@@ -1,413 +0,0 @@
# Redis Cache Backend Test Suite
## Overview
This document describes the comprehensive test suite created for the Redis cache backend feature in the Traefik OIDC plugin. The test suite ensures reliability, performance, and correctness of the caching infrastructure.
## Test Structure
### Directory Organization
```
internal/cache/
├── backend/
│ ├── interface.go # CacheBackend interface definition
│ ├── interface_test.go # Contract tests for all backends
│ ├── memory.go # In-memory backend implementation
│ ├── memory_test.go # Memory backend unit tests
│ ├── redis.go # Redis backend implementation
│ ├── redis_test.go # Redis backend unit tests
│ ├── errors.go # Error definitions
│ └── test_helpers_test.go # Test infrastructure and helpers
└── resilience/
├── circuit_breaker.go # Circuit breaker implementation
├── circuit_breaker_test.go # Circuit breaker tests
├── health_check.go # Health checker implementation
└── health_check_test.go # Health check tests
redis_integration_test.go # End-to-end integration tests
```
## Test Categories
### 1. Interface Contract Tests (`interface_test.go`)
**Purpose:** Ensure all backend implementations (Memory, Redis, Hybrid) comply with the CacheBackend interface contract.
**Test Cases:**
- `TestCacheBackendContract` - Runs all contract tests against each backend type
- `testBasicSetGet` - Verifies basic set/get operations
- `testGetNonExistent` - Tests behavior for non-existent keys
- `testUpdateExisting` - Validates updating existing keys
- `testDelete` - Tests delete operations
- `testDeleteNonExistent` - Delete non-existent keys
- `testExists` - Key existence checking
- `testTTLExpiration` - TTL and expiration behavior
- `testClear` - Clear all keys operation
- `testPing` - Health check functionality
- `testStats` - Statistics tracking
- `testConcurrentAccess` - Thread safety with 10+ goroutines
- `testLargeValues` - Handling of 1MB+ values
- `testEmptyValues` - Empty byte array handling
- `testSpecialCharactersInKeys` - Special characters in key names
**Coverage:** ~95% of interface methods
### 2. Memory Backend Tests (`memory_test.go`)
**Purpose:** Test the in-memory LRU cache backend with comprehensive edge cases.
**Test Cases:**
#### Basic Operations (6 tests)
- `TestMemoryBackend_BasicOperations` - CRUD operations
- SetAndGet
- GetNonExistent
- Delete
- DeleteNonExistent
- Exists
- Clear
#### TTL and Expiration (3 tests)
- `TestMemoryBackend_TTLExpiration`
- ShortTTL (100ms)
- TTLDecrement over time
- CleanupExpiredItems
#### LRU Eviction (2 tests)
- `TestMemoryBackend_LRUEviction` - Verifies LRU algorithm
- `TestMemoryBackend_MemoryLimit` - Memory-based eviction
#### Concurrency (1 test)
- `TestMemoryBackend_ConcurrentAccess` - 20 goroutines, 50 iterations each
#### Edge Cases (6 tests)
- `TestMemoryBackend_UpdateExisting` - Overwriting values
- `TestMemoryBackend_Stats` - Metrics tracking (hits, misses, hit rate)
- `TestMemoryBackend_EmptyValues` - Zero-length byte arrays
- `TestMemoryBackend_LargeValues` - 1MB values
- `TestMemoryBackend_Close` - Proper cleanup
- `TestMemoryBackend_Ping` - Health checks
- `TestMemoryBackend_ValueIsolation` - Returns copies, not references
**Coverage:** ~92% of memory backend code
### 3. Redis Backend Tests (`redis_test.go`)
**Purpose:** Test Redis backend using miniredis (in-memory Redis mock).
**Test Cases:**
#### Basic Operations (4 tests)
- `TestRedisBackend_BasicOperations`
- SetAndGet
- GetNonExistent
- Delete
- Exists
#### Redis-Specific Features (6 tests)
- `TestRedisBackend_KeyPrefixing` - Namespace isolation
- `TestRedisBackend_TTLExpiration` - Redis TTL handling
- `TestRedisBackend_Clear` - Bulk delete with SCAN
- `TestRedisBackend_NoPrefix` - Operation without prefix
#### Error Handling (2 tests)
- `TestRedisBackend_ConnectionFailure` - Connection errors
- `TestRedisBackend_RedisErrors` - Simulated Redis failures
#### Concurrency (1 test)
- `TestRedisBackend_ConcurrentAccess` - 20 goroutines, 50 operations
#### Advanced Features (3 tests)
- `TestRedisBackend_PipelineOperations`
- SetMany (batch writes)
- GetMany (batch reads)
- GetManyWithNonExistent
#### Edge Cases (5 tests)
- `TestRedisBackend_Stats` - Statistics tracking
- `TestRedisBackend_Ping` - Connection health
- `TestRedisBackend_Close` - Resource cleanup
- `TestRedisBackend_UpdateExisting` - Overwrite handling
- `TestRedisBackend_LargeValues` - 1MB values
- `TestRedisBackend_EmptyValues` - Empty arrays
**Coverage:** ~88% of Redis backend code
**Key Testing Tool:** `miniredis` - In-memory Redis mock that supports:
- All basic Redis commands
- TTL and expiration
- Time manipulation (FastForward)
- Error simulation
- No external Redis server required
### 4. Circuit Breaker Tests (`circuit_breaker_test.go`)
**Purpose:** Verify circuit breaker pattern implementation for fault tolerance.
**Test Cases:**
#### State Transitions (5 tests)
- `TestCircuitBreaker_StateTransitions`
- Initial state (Closed)
- Closed → Open (after max failures)
- Open → HalfOpen (after timeout)
- HalfOpen → Closed (after successful requests)
- HalfOpen → Open (on failure)
#### Behavior Tests (5 tests)
- `TestCircuitBreaker_OpenCircuitBlocks` - Blocks requests when open
- `TestCircuitBreaker_HalfOpenMaxRequests` - Limits requests in half-open
- `TestCircuitBreaker_SuccessResetsFailures` - Failure counter reset
- `TestCircuitBreaker_ConcurrentAccess` - Thread safety
- `TestCircuitBreaker_Stats` - Statistics tracking
#### Advanced Tests (7 tests)
- `TestCircuitBreaker_Reset` - Manual reset
- `TestCircuitBreaker_StateChangeCallback` - Notifications
- `TestCircuitBreaker_IsAvailable` - Availability check
- `TestCircuitBreaker_RapidFailures` - Fast consecutive failures
- `TestCircuitBreaker_TimeoutAccuracy` - Timeout precision
- `TestCircuitBreaker_DefaultConfig` - Default configuration
- `TestCircuitBreaker_StateString` - String representation
**Benchmarks:**
- `BenchmarkCircuitBreaker_Execute` - Successful operations
- `BenchmarkCircuitBreaker_ExecuteWithFailures` - Mixed success/failure
**Coverage:** ~95% of circuit breaker code
### 5. Health Check Tests (`health_check_test.go`)
**Purpose:** Validate periodic health checking and status management.
**Test Cases:**
#### Status Transitions (4 tests)
- `TestHealthChecker_StatusTransitions` - Healthy → Degraded → Unhealthy → Healthy
- `TestHealthChecker_InitialState` - Default healthy state
- `TestHealthChecker_ForceCheck` - Manual health check trigger
- `TestHealthChecker_StatusChangeCallback` - Change notifications
#### Behavior Tests (6 tests)
- `TestHealthChecker_Stats` - Statistics tracking
- `TestHealthChecker_Timeout` - Check timeout handling
- `TestHealthChecker_ConcurrentAccess` - Thread safety
- `TestHealthChecker_StopAndStart` - Lifecycle management
- `TestHealthChecker_DegradedState` - Degraded status detection
- `TestHealthChecker_DefaultConfig` - Default settings
#### Advanced Tests (2 tests)
- `TestHealthChecker_StatusString` - String representation
- `TestHealthChecker_RecoveryPattern` - Typical failure/recovery cycle
**Benchmarks:**
- `BenchmarkHealthChecker_ForceCheck` - Check performance
- `BenchmarkHealthChecker_Status` - Status read performance
**Coverage:** ~90% of health checker code
### 6. Integration Tests (`redis_integration_test.go`)
**Purpose:** End-to-end testing of real-world scenarios.
**Test Cases:**
#### Multi-Instance Tests (3 tests)
- `TestRedisIntegration_MultipleInstances`
- ShareTokenBlacklist - JTI sharing across Traefik replicas
- ShareTokenCache - Token cache sharing
- ShareMetadataCache - Provider metadata sharing
#### Replay Detection (2 tests)
- `TestRedisIntegration_JTIReplayDetection`
- PreventReplayAcrossInstances - Block used JTIs
- ConcurrentJTIChecks - Race condition handling
#### Resilience (1 test)
- `TestRedisIntegration_Failover`
- RedisTemporaryFailure - Recovery from temporary failures
#### Performance (1 test)
- `TestRedisIntegration_HighLoad`
- HighConcurrency - 50 goroutines × 100 operations
#### Consistency (2 tests)
- `TestRedisIntegration_TTLConsistency` - TTL accuracy
- `TestRedisIntegration_MemoryUsage` - 10,000 item dataset
- `TestRedisIntegration_Cleanup` - Bulk cleanup operations
**Coverage:** Integration scenarios covering 80%+ of realistic use cases
## Test Helpers and Infrastructure
### Test Helpers (`test_helpers_test.go`)
**Utilities:**
- `TestLogger` - Logging for tests
- `MiniredisServer` - Miniredis setup/teardown
- `TestConfig` - Default test configurations
- `GenerateTestData` - Test data generation
- `GenerateLargeValue` - Large value creation
- `AssertCacheStats` - Statistics validation
- `WaitForCondition` - Async condition waiting
- `AssertEventuallyExpires` - TTL expiration verification
## Running the Tests
### Run All Tests
```bash
go test ./internal/cache/backend/... -v
go test ./internal/cache/resilience/... -v
go test -run TestRedisIntegration -v
```
### Run Specific Test Suites
```bash
# Memory backend only
go test ./internal/cache/backend -run TestMemoryBackend -v
# Redis backend only
go test ./internal/cache/backend -run TestRedisBackend -v
# Circuit breaker only
go test ./internal/cache/resilience -run TestCircuitBreaker -v
# Integration tests only
go test -run TestRedisIntegration -v
```
### Run with Coverage
```bash
go test ./internal/cache/backend/... -coverprofile=coverage.out
go test ./internal/cache/resilience/... -coverprofile=coverage_resilience.out
go tool cover -html=coverage.out
```
### Run Benchmarks
```bash
go test ./internal/cache/backend -bench=. -benchmem
go test ./internal/cache/resilience -bench=. -benchmem
```
### Run with Race Detector
```bash
go test ./internal/cache/... -race -v
```
## Test Patterns Used
### 1. Table-Driven Tests
Used for testing multiple scenarios with similar structure.
### 2. Subtests (t.Run)
Organized test cases into logical groups with clear names.
### 3. Parallel Tests
Tests marked with `t.Parallel()` for faster execution.
### 4. Test Fixtures
Reusable setup functions for common test data.
### 5. Mocking
- `miniredis` for Redis operations
- Mock functions for callbacks and health checks
### 6. Assertion Helpers
Using `testify/assert` and `testify/require` for clear assertions.
## Test Coverage Summary
| Component | Coverage | Tests | Lines of Code |
|-----------|----------|-------|---------------|
| Interface Contract | 95% | 14 | ~200 |
| Memory Backend | 92% | 18 | ~350 |
| Redis Backend | 88% | 21 | ~400 |
| Circuit Breaker | 95% | 17 | ~250 |
| Health Checker | 90% | 12 | ~200 |
| Integration Tests | 80% | 9 | ~300 |
| **Total** | **90%** | **91** | **~1,700** |
## Edge Cases Tested
1. **Empty values** - Zero-length byte arrays
2. **Large values** - 1MB+ data
3. **Special characters** - Keys with :, /, -, _, ., |
4. **Concurrent access** - 10-50 goroutines
5. **TTL edge cases** - Very short (<100ms) and long (24h+) TTLs
6. **Connection failures** - Network errors, timeouts
7. **Redis errors** - Simulated Redis failures
8. **Memory limits** - Eviction under memory pressure
9. **Race conditions** - Concurrent JTI checks
10. **State transitions** - All circuit breaker and health check states
## Performance Benchmarks
Benchmarks included for:
- Cache operations (Set, Get, Delete)
- Circuit breaker execution
- Health check operations
- Concurrent access patterns
- Large datasets (10,000+ items)
## Dependencies
### Testing Libraries
- `github.com/stretchr/testify` - Assertions and test utilities
- `github.com/alicebob/miniredis/v2` - In-memory Redis mock
- `github.com/redis/go-redis/v9` - Redis client
### Why Miniredis?
- **No external dependencies** - No Redis server required
- **Fast** - In-memory, perfect for unit tests
- **Full Redis API** - Supports all operations we need
- **Time manipulation** - FastForward for TTL testing
- **Error simulation** - Test failure scenarios
## Future Enhancements
### Planned Tests
1. Hybrid backend tests (L1/L2 cache)
2. Network partition scenarios
3. Redis cluster support
4. Persistence and recovery tests
5. Metrics and monitoring integration
### Test Infrastructure Improvements
1. Test containers for real Redis integration
2. Performance regression tracking
3. Chaos engineering tests
4. Load testing framework
## Continuous Integration
### Recommended CI Configuration
```yaml
test:
script:
- go test ./internal/cache/... -race -cover -v
- go test -run TestRedisIntegration -v
- go test ./internal/cache/... -bench=. -benchmem
```
## Maintenance Guidelines
1. **Add tests for new features** - Maintain >85% coverage
2. **Update contract tests** - When interface changes
3. **Test edge cases** - Always test error paths
4. **Document test purpose** - Clear comments explaining what each test validates
5. **Keep tests fast** - Use t.Parallel() where possible
6. **Mock external dependencies** - Use miniredis, not real Redis
## Conclusion
This comprehensive test suite provides:
- **High confidence** in cache backend correctness
- **Fast feedback** - Tests run in seconds
- **Good coverage** - 90% overall
- **Clear documentation** - Each test is well-documented
- **Maintainability** - Clear structure and patterns
The test suite ensures that the Redis cache backend feature is production-ready and reliable for multi-replica Traefik deployments with shared caching requirements.
+390
View File
@@ -0,0 +1,390 @@
# Testing Guide
Comprehensive testing infrastructure for traefikoidc.
## Overview
| Metric | Value |
|--------|-------|
| Test files | 110 |
| Lines of test code | ~72,000 |
| Code coverage | 71.0% |
| Race conditions | None (all pass with `-race`) |
## Running Tests
```bash
# Run all tests
go test ./...
# Run with race detection
go test -race ./...
# Run with coverage
go test -cover ./...
# Run specific test suite
go test -v -run "TokenValidationSuite" .
# Run edge case tests
go test -v -run "ClockSkewEdgeCasesSuite|UnicodeClaimsSuite" .
```
## Test Infrastructure
### Directory Structure
```
internal/testutil/
├── compat.go # Re-exports for main package access
├── mocks/
│ ├── interfaces.go # JWKCache, TokenExchanger, TokenVerifier, etc.
│ ├── session.go # SessionManager, SessionData
│ ├── cache.go # Cache, TokenCache, Blacklist
│ └── interfaces_test.go # Mock verification tests
├── fixtures/
│ └── tokens.go # JWT token generation fixtures
└── servers/
├── oidc.go # Mock OIDC server factory
└── oidc_test.go # Server tests
```
### Test Suites
| Suite | File | Description |
|-------|------|-------------|
| TokenValidationSuite | `token_validation_suite_test.go` | Token validation happy path and error cases |
| JWKCacheTestSuite | `token_validation_suite_test.go` | JWK cache behavior tests |
| TokenExchangerTestSuite | `token_validation_suite_test.go` | Token exchange scenarios |
| ClockSkewEdgeCasesSuite | `edge_cases_suite_test.go` | Expiry boundary testing |
| UnicodeClaimsSuite | `edge_cases_suite_test.go` | Unicode/emoji handling in claims |
| LargeClaimsSuite | `edge_cases_suite_test.go` | Large data handling (100s of claims) |
| URLPathEdgeCasesSuite | `edge_cases_suite_test.go` | URL parsing edge cases |
| ConcurrencyEdgeCasesSuite | `edge_cases_suite_test.go` | Concurrent token validation |
| ExampleTestSuite | `testutil_example_test.go` | Example demonstrating patterns |
| AuthFlowBehaviourSuite | `auth_flow_behaviour_test.go` | Authentication flow behavior tests |
| SessionBehaviourSuite | `session_behaviour_test.go` | Session management behavior tests |
| EnhancedMocksSuite | `enhanced_mocks_suite_test.go` | Enhanced mock usage demonstration |
## Mock Types
The project provides two mocking patterns:
### State-Based Mocks (Basic)
Located in `main_test.go`, `mocks_test.go`. Simple mocks that store data in struct fields.
| Mock | Interface | Description |
|------|-----------|-------------|
| `MockJWKCache` | `JWKCacheInterface` | Simple state-based mock with JWKS/Err fields |
| `MockTokenVerifier` | `TokenVerifier` | Function-based mock for token verification |
| `MockTokenExchanger` | `TokenExchanger` | Function-based mock for token exchange |
| `MockOAuthProvider` | `http.Handler` | Full HTTP handler mock for OAuth provider simulation |
| `MockSessionManager` | `SessionManager` | State-based mock for session management |
| `MockHTTPClient` | N/A | Mock HTTP client with customizable responses |
**Usage:**
```go
mock := &MockJWKCache{
JWKS: &JWKSet{Keys: []JWK{jwk}},
Err: nil,
}
tOidc := &TraefikOidc{
jwkCache: mock,
// ...
}
```
### Enhanced State-Based Mocks (with Call Tracking)
Located in `enhanced_mocks_test.go`. State-based mocks with built-in call tracking and assertion helpers.
| Mock | Interface | Description |
|------|-----------|-------------|
| `EnhancedMockJWKCache` | `JWKCacheInterface` | State-based with call tracking |
| `EnhancedMockTokenVerifier` | `TokenVerifier` | State-based with call tracking |
| `EnhancedMockTokenExchanger` | `TokenExchanger` | State-based with call tracking |
| `EnhancedMockCacheInterface` | `CacheInterface` | Functional cache with call tracking |
**Usage:**
```go
mock := &EnhancedMockJWKCache{
JWKS: &JWKSet{Keys: []JWK{jwk}},
}
// Make calls
result, err := mock.GetJWKS(ctx, "https://example.com/jwks", nil)
// Verify calls were made
mock.AssertGetJWKSCalled(t)
mock.AssertGetJWKSCalledWith(t, "https://example.com/jwks")
mock.AssertGetJWKSCallCount(t, 1)
// Access call details
s.Equal(1, mock.GetJWKSCallCount())
```
**Features:**
- Track all calls with parameters and timestamps
- Built-in assertion helpers using testify
- Thread-safe for concurrent tests
- `Reset()` method to clear state between tests
- `LastCall()` to inspect most recent call
### Testify-Based Mocks
Located in `testify_mocks_test.go`. Mocks using testify's `.On()/.Return()` pattern for behavior verification.
| Mock | Interface | Description |
|------|-----------|-------------|
| `TestifyJWKCache` | `JWKCacheInterface` | Testify mock with `.On()/.Return()` |
| `TestifyTokenVerifier` | `TokenVerifier` | Testify mock for token verification |
| `TestifyTokenExchanger` | `TokenExchanger` | Testify mock for token exchange |
| `TestifyCacheInterface` | `CacheInterface` | Testify mock for cache operations |
| `TestifyHTTPClient` | N/A | Testify mock for HTTP client |
| `TestifyRoundTripper` | `http.RoundTripper` | Testify mock for HTTP transport |
**Usage:**
```go
mock := &TestifyJWKCache{}
mock.On("GetJWKS", mock.Anything, "https://example.com/jwks", mock.Anything).
Return(&JWKSet{Keys: []JWK{jwk}}, nil)
// After test
mock.AssertExpectations(t)
```
### Testutil Package Mocks
Located in `internal/testutil/mocks/`. Generic mocks for testing the test infrastructure itself.
```go
import "github.com/lukaszraczylo/traefikoidc/internal/testutil"
mock := testutil.NewJWKCacheMock()
mock.On("GetJWKS", mock.Anything, mock.Anything, mock.Anything).
Return(&mocks.JWKSet{Keys: []mocks.JWK{{Kty: "RSA"}}}, nil)
```
### Choosing the Right Mock
| Use Case | Recommended Mock |
|----------|-----------------|
| Simple return values only | Basic state-based (`MockJWKCache`) |
| Return values + verify calls made | Enhanced state-based (`EnhancedMockJWKCache`) |
| Complex call expectations | Testify-based (`TestifyJWKCache`) |
| Verify call order/sequence | Testify-based |
| HTTP endpoint simulation | `MockOAuthProvider` |
| New testify suite tests | Enhanced or Testify-based |
**Decision Guide:**
1. **Basic State-Based**: Use when you only need to control return values and don't care about verifying interactions.
2. **Enhanced State-Based**: Use when you want to verify calls were made with specific parameters, but prefer simpler setup than testify's `.On()/.Return()` pattern.
3. **Testify-Based**: Use when you need complex behavior like different returns per call, strict call ordering, or detailed expectation matching.
## Token Fixtures
The `testutil.TokenFixture` generates JWT tokens for testing:
```go
fixture, err := testutil.NewTokenFixture()
// Valid token with default claims
token, _ := fixture.ValidToken(nil)
// Token with custom claims
token, _ := fixture.ValidToken(map[string]interface{}{
"email": "test@example.com",
"roles": []string{"admin"},
})
// Expired token
token, _ := fixture.ExpiredToken()
// Token with specific roles/groups
token, _ := fixture.TokenWithRoles([]string{"admin", "user"})
token, _ := fixture.TokenWithGroups([]string{"developers"})
// Token with clock skew
token, _ := fixture.TokenWithSkew(-2 * time.Minute) // expired 2 min ago
token, _ := fixture.TokenWithSkew(5 * time.Minute) // expires in 5 min
// Token missing specific claims
token, _ := fixture.TokenMissingClaim("email", "sub")
// Malformed token
token := fixture.MalformedToken() // "not.a.valid.jwt"
// Get JWKS for verification
jwks := fixture.GetJWKS()
```
## Mock OIDC Server
The `testutil.OIDCServer` provides a fully functional mock OIDC provider:
```go
// Default configuration
server := testutil.NewOIDCServer(nil)
defer server.Close()
// Custom configuration
config := testutil.DefaultServerConfig()
config.Issuer = "https://custom-issuer.com"
config.TokenError = &testutil.OIDCError{
Error: "invalid_grant",
Description: "Authorization code expired",
}
server := testutil.NewOIDCServer(config)
// Provider-specific configurations
googleConfig := testutil.GoogleServerConfig()
azureConfig := testutil.AzureServerConfig()
auth0Config := testutil.Auth0ServerConfig()
keycloakConfig := testutil.KeycloakServerConfig()
// Behavior configurations
slowConfig := testutil.SlowServerConfig(100 * time.Millisecond)
rateLimitedConfig := testutil.RateLimitedServerConfig(5) // Limit after 5 requests
```
### Server Endpoints
| Endpoint | Description |
|----------|-------------|
| `/.well-known/openid-configuration` | OIDC discovery document |
| `/authorize` | Authorization endpoint |
| `/token` | Token exchange endpoint |
| `/jwks` | JSON Web Key Set |
| `/userinfo` | User information endpoint |
| `/introspect` | Token introspection |
| `/revoke` | Token revocation |
| `/logout` | End session endpoint |
### Request Tracking
```go
server := testutil.NewOIDCServer(nil)
// Make requests...
count := server.GetRequestCount()
requests := server.GetRequests()
server.Reset() // Clear tracking
```
## Writing Test Suites
### Basic Suite Structure
```go
type MyTestSuite struct {
suite.Suite
fixture *testutil.TokenFixture
tOidc *TraefikOidc
}
func (s *MyTestSuite) SetupSuite() {
var err error
s.fixture, err = testutil.NewTokenFixture()
s.Require().NoError(err)
}
func (s *MyTestSuite) SetupTest() {
// Per-test setup
s.tOidc = &TraefikOidc{
issuerURL: s.fixture.Issuer,
// ...
}
}
func (s *MyTestSuite) TearDownTest() {
// Per-test cleanup
}
func (s *MyTestSuite) TestSomething() {
token, err := s.fixture.ValidToken(nil)
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
s.NoError(err)
}
func TestMyTestSuite(t *testing.T) {
suite.Run(t, new(MyTestSuite))
}
```
### Table-Driven Tests
```go
func (s *MyTestSuite) TestClockSkewEdgeCases() {
testCases := []struct {
name string
skew time.Duration
shouldPass bool
}{
{"valid_token", 5 * time.Minute, true},
{"expired_within_tolerance", -1 * time.Minute, true},
{"expired_beyond_tolerance", -10 * time.Minute, false},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
token, err := s.fixture.TokenWithSkew(tc.skew)
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
if tc.shouldPass {
s.NoError(err)
} else {
s.Error(err)
}
})
}
}
```
## Test Categories
### Happy Path Tests
Test the expected successful scenarios:
- Valid token verification
- Successful token exchange
- Session creation and retrieval
- Cache operations
### Error Case Tests
Test failure scenarios:
- Expired tokens
- Invalid signatures
- Wrong issuer/audience
- Network failures
- Rate limiting
### Edge Case Tests
Test boundary conditions:
- Clock skew tolerance boundaries
- Unicode/emoji in claims
- Very large claim values
- Concurrent access
- Special characters in URLs
## Best Practices
1. **Use fixtures for token generation** - Don't manually construct JWTs
2. **Use mock servers for integration tests** - Test against realistic OIDC behavior
3. **Always run with `-race`** - Catch concurrency issues early
4. **Use testify assertions** - Better error messages and cleaner code
5. **Clean up resources** - Use `t.Cleanup()` or `TearDownTest()`
6. **Test edge cases systematically** - Use table-driven tests
-308
View File
@@ -1,308 +0,0 @@
# Test Execution Guide
This guide explains how to run tests efficiently with the new test categorization and optimization system.
## Quick Start
### Fast Development Testing (Default - Target: < 30 seconds)
```bash
# Run quick smoke tests only
go test ./...
# Or explicitly run in short mode
go test ./... -short
```
### Extended Testing (Target: 2-5 minutes)
```bash
# Enable extended tests with more iterations and concurrency
RUN_EXTENDED_TESTS=1 go test ./...
# Or use the flag equivalent (if using test runner that supports it)
go test ./... -extended
```
### Long-Running Performance Tests (Target: 5-15 minutes)
```bash
# Enable comprehensive performance and stress tests
RUN_LONG_TESTS=1 go test ./...
```
### Full Stress Testing (Target: 10-30 minutes)
```bash
# Enable all stress tests with maximum parameters
RUN_STRESS_TESTS=1 go test ./...
```
## Test Categories
### 1. Quick Tests (Default)
- **Purpose**: Fast feedback during development
- **Duration**: < 30 seconds total
- **Features**:
- Basic functionality verification
- Limited iterations (1-3)
- Small data sets
- Minimal concurrency
- Essential memory leak checks
**Configuration**:
- Max Iterations: 3
- Max Concurrency: 5
- Memory Threshold: 2.0 MB
- Cache Size: 50
- Timeout: 10 seconds
### 2. Extended Tests
- **Purpose**: Comprehensive testing before commits
- **Duration**: 2-5 minutes
- **Features**:
- Increased test coverage
- More iterations (5-10)
- Medium concurrency tests
- Enhanced memory leak detection
**Configuration**:
- Max Iterations: 10
- Max Concurrency: 20
- Memory Threshold: 10.0 MB
- Cache Size: 200
- Timeout: 30 seconds
### 3. Long Tests
- **Purpose**: Performance validation and stress testing
- **Duration**: 5-15 minutes
- **Features**:
- High iteration counts (50-100)
- High concurrency scenarios
- Large data sets
- Comprehensive memory testing
**Configuration**:
- Max Iterations: 100
- Max Concurrency: 50
- Memory Threshold: 50.0 MB
- Cache Size: 1000
- Timeout: 60 seconds
### 4. Stress Tests
- **Purpose**: Maximum load testing and edge case validation
- **Duration**: 10-30 minutes
- **Features**:
- Extreme iteration counts (100-500)
- Maximum concurrency (100+)
- Large memory allocations
- Edge case combinations
**Configuration**:
- Max Iterations: 500
- Max Concurrency: 100
- Memory Threshold: 100.0 MB
- Cache Size: 2000
- Timeout: 120 seconds
## Environment Variables
### Test Execution Control
```bash
# Enable specific test types
export RUN_EXTENDED_TESTS=1 # Enable extended tests
export RUN_LONG_TESTS=1 # Enable long-running tests
export RUN_STRESS_TESTS=1 # Enable stress tests
# Disable specific features
export DISABLE_LEAK_DETECTION=1 # Skip memory leak detection
```
### Parameter Customization
```bash
# Customize concurrency limits
export TEST_MAX_CONCURRENCY=10 # Override max concurrent operations
# Customize iteration limits
export TEST_MAX_ITERATIONS=50 # Override max test iterations
# Customize memory thresholds
export TEST_MEMORY_THRESHOLD_MB=25.5 # Override memory growth limit (in MB)
```
## Test-Specific Behavior
### Memory Leak Tests
- **Quick Mode**: 1-3 iterations, small data sets, strict memory limits
- **Extended Mode**: 5-10 iterations, medium data sets, relaxed limits
- **Long Mode**: 50-100 iterations, large data sets, performance focus
- **Stress Mode**: 100-500 iterations, maximum data sets, stress focus
### Concurrency Tests
- **Quick Mode**: 2-5 concurrent operations, basic race detection
- **Extended Mode**: 10-20 concurrent operations, moderate stress
- **Long Mode**: 20-50 concurrent operations, high contention
- **Stress Mode**: 50-100+ concurrent operations, maximum stress
### Cache Tests
- **Quick Mode**: Small caches (50 items), basic operations
- **Extended Mode**: Medium caches (200 items), varied operations
- **Long Mode**: Large caches (1000 items), performance testing
- **Stress Mode**: Very large caches (2000+ items), stress testing
## Integration with CI/CD
### GitHub Actions Example
```yaml
# Quick tests for every push/PR
- name: Quick Tests
run: go test ./... -short
# Extended tests for main branch
- name: Extended Tests
if: github.ref == 'refs/heads/main'
run: RUN_EXTENDED_TESTS=1 go test ./...
# Nightly comprehensive testing
- name: Nightly Stress Tests
if: github.event_name == 'schedule'
run: RUN_STRESS_TESTS=1 go test ./...
```
### Local Development Workflow
```bash
# During active development
go test ./... -short
# Before committing
RUN_EXTENDED_TESTS=1 go test ./...
# Before major releases
RUN_LONG_TESTS=1 go test ./...
# Performance validation
RUN_STRESS_TESTS=1 go test ./...
```
## Performance Optimization Features
### Dynamic Test Scaling
The test system automatically adjusts parameters based on:
- Test mode (quick/extended/long/stress)
- Available resources
- Environment variables
- Previous test performance
### Memory Management
- **Garbage Collection**: Forced GC between test iterations
- **Memory Monitoring**: Real-time memory growth tracking
- **Leak Detection**: Goroutine and memory leak prevention
- **Resource Cleanup**: Automatic cleanup of test resources
### Timeout Management
- **Adaptive Timeouts**: Timeouts scale with test complexity
- **Graceful Degradation**: Tests adapt to slower environments
- **Early Termination**: Failed tests terminate quickly
## Troubleshooting
### Tests Taking Too Long
```bash
# Check if running in extended mode accidentally
echo $RUN_EXTENDED_TESTS $RUN_LONG_TESTS
# Force quick mode
unset RUN_EXTENDED_TESTS RUN_LONG_TESTS RUN_STRESS_TESTS
go test ./... -short
```
### Memory Issues
```bash
# Reduce memory limits for constrained environments
export TEST_MEMORY_THRESHOLD_MB=5.0
export TEST_MAX_CONCURRENCY=2
go test ./...
```
### Concurrency Issues
```bash
# Reduce concurrency for slower systems
export TEST_MAX_CONCURRENCY=5
export TEST_MAX_ITERATIONS=10
go test ./...
```
### Skip Specific Test Types
```bash
# Skip memory leak detection if problematic
export DISABLE_LEAK_DETECTION=1
go test ./...
```
## Benchmarking
### Running Benchmarks
```bash
# Quick benchmarks
go test -bench=. -short
# Extended benchmarks
RUN_EXTENDED_TESTS=1 go test -bench=.
# Memory profiling
go test -bench=. -memprofile=mem.prof
go tool pprof mem.prof
```
### Benchmark Categories
- **Basic Operations**: Set/Get performance
- **Concurrency**: Multi-threaded performance
- **Memory**: Allocation and cleanup performance
- **Cache**: Eviction and cleanup performance
## Best Practices
### For Developers
1. Always run quick tests during development (`go test ./... -short`)
2. Run extended tests before committing (`RUN_EXTENDED_TESTS=1 go test ./...`)
3. Use appropriate test categories for your use case
4. Monitor test execution time and adjust if needed
### For CI/CD
1. Use quick tests for fast feedback on PRs
2. Use extended tests for main branch validation
3. Use long tests for release validation
4. Use stress tests for nightly/weekly validation
### For Performance Testing
1. Use consistent environment variables
2. Run tests multiple times for statistical significance
3. Monitor both execution time and resource usage
4. Use profiling tools for detailed analysis
## Examples
### Daily Development
```bash
# Fast tests while coding
go test ./... -short
# Before git commit
RUN_EXTENDED_TESTS=1 go test ./...
```
### Release Testing
```bash
# Comprehensive validation
RUN_LONG_TESTS=1 go test ./...
# Stress testing
RUN_STRESS_TESTS=1 go test ./...
```
### Custom Configuration
```bash
# Custom limits for specific environment
export TEST_MAX_CONCURRENCY=8
export TEST_MAX_ITERATIONS=25
export TEST_MEMORY_THRESHOLD_MB=15.0
RUN_EXTENDED_TESTS=1 go test ./...
```
This test system provides flexible, scalable test execution that adapts to your development workflow and infrastructure constraints while maintaining comprehensive test coverage.
-163
View File
@@ -1,163 +0,0 @@
# Google OAuth Integration Fix
## Problem Overview
The Traefik OIDC plugin encountered an authentication issue when using Google as an OAuth provider. Authentication would fail with the following error:
```
Some requested scopes were invalid. {valid=[openid, https://www.googleapis.com/auth/userinfo.email, https://www.googleapis.com/auth/userinfo.profile], invalid=[offline_access]}
```
This occurred because Google's OAuth implementation differs from the standard OIDC specification in how it handles refresh tokens and offline access.
## Technical Details of the Issue
### Standard OIDC Provider Behavior
Most OpenID Connect (OIDC) providers follow the standard specification, where:
- To obtain a refresh token, clients include the `offline_access` scope in their authorization request
- This allows authenticated sessions to persist beyond the initial access token expiration
### Google's Non-Standard Approach
Google's OAuth implementation deviates from the standard by:
1. Not supporting the `offline_access` scope, instead rejecting it as an invalid scope
2. Requiring the `access_type=offline` query parameter for requesting refresh tokens
3. Needing the `prompt=consent` parameter to consistently issue refresh tokens (especially for repeat authentications)
This difference caused the plugin to fail when configured for Google OAuth, as it was using a standard approach that didn't work with Google's implementation.
## Solution Implementation
The fix involved modifying the authentication flow to specifically handle Google providers:
1. **Google Provider Detection**: Added code to detect if the OIDC provider is Google based on the issuer URL:
```go
// Check if we're dealing with a Google OIDC provider
isGoogleProvider := strings.Contains(t.issuerURL, "google") ||
strings.Contains(t.issuerURL, "accounts.google.com")
```
2. **Provider-Specific Auth URL Building**: Modified the `buildAuthURL` function to handle Google and non-Google providers differently:
```go
// Handle offline access differently for Google vs other providers
if isGoogleProvider {
// For Google, use access_type=offline parameter instead of offline_access scope
params.Set("access_type", "offline")
t.logger.Debug("Google OIDC provider detected, added access_type=offline for refresh tokens")
// Add prompt=consent for Google to ensure refresh token is issued
params.Set("prompt", "consent")
t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
} else {
// For non-Google providers, use the offline_access scope
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
}
}
```
3. **Token Refresh Enhancement**: Improved the token refresh logic to better handle Google's behavior, particularly when refresh tokens aren't returned in refresh responses (as Google often uses the same refresh token for multiple requests).
## Why This Approach Works
This solution aligns with Google's OAuth 2.0 documentation which specifies:
1. **Access Type Parameter**: Google's [OAuth 2.0 documentation](https://developers.google.com/identity/protocols/oauth2/web-server#offline) states that to request a refresh token, applications must include `access_type=offline` in the authorization request.
2. **Prompt Parameter**: The [`prompt=consent`](https://developers.google.com/identity/protocols/oauth2/web-server#forceapprovalprompt) parameter forces the consent screen to appear, ensuring a refresh token is issued even if the user has previously granted access.
3. **Scope Validation**: Google strictly validates scopes and rejects non-standard ones like `offline_access`, instead relying on the `access_type` parameter to indicate whether a refresh token should be issued.
By adapting to these Google-specific requirements, the OIDC plugin can now seamlessly work with both standard OIDC providers and Google's OAuth implementation.
## Testing and Verification
Comprehensive tests were implemented to verify the solution:
1. **Provider Detection Test**: Ensures the code correctly identifies Google providers and applies the appropriate parameters.
2. **Auth URL Parameter Tests**: Verifies that:
- For Google providers: `access_type=offline` and `prompt=consent` are included; `offline_access` scope is NOT included
- For non-Google providers: `offline_access` scope IS included; `access_type` parameter is NOT added
3. **Token Refresh Tests**: Validates that Google's token refresh process works correctly, including the preservation of refresh tokens when Google doesn't return a new one.
4. **Integration Test**: Tests the complete authentication flow with a mocked Google provider to ensure all components work together seamlessly.
Sample test case (simplified):
```go
t.Run("Google provider detection adds required parameters", func(t *testing.T) {
// Test buildAuthURL to ensure it adds access_type=offline and prompt=consent for Google
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
// Check that access_type=offline was added (not offline_access scope for Google)
if !strings.Contains(authURL, "access_type=offline") {
t.Errorf("access_type=offline not added to Google auth URL: %s", authURL)
}
// Verify offline_access scope is NOT included for Google providers
if strings.Contains(authURL, "offline_access") {
t.Errorf("offline_access scope incorrectly added to Google auth URL: %s", authURL)
}
// Check that prompt=consent was added
if !strings.Contains(authURL, "prompt=consent") {
t.Errorf("prompt=consent not added to Google auth URL: %s", authURL)
}
})
```
## Usage Guidance for Developers
When configuring the Traefik OIDC middleware for Google:
1. **Provider URL**: Use `https://accounts.google.com` as the `providerURL` value
2. **Client Configuration**: Create OAuth 2.0 credentials in the Google Cloud Console:
- Configure the authorized redirect URI to match your `callbackURL` setting
- Ensure your OAuth consent screen is properly configured (especially if you want long-lived refresh tokens)
3. **Configuration Example**:
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-google
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://accounts.google.com
clientID: your-google-client-id.apps.googleusercontent.com
clientSecret: your-google-client-secret
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
callbackURL: /oauth2/callback
scopes:
- openid
- email
- profile
# Note: DO NOT manually add offline_access scope for Google
# The middleware handles this automatically and correctly
```
4. **Troubleshooting**: If sessions still expire prematurely with Google (typically after 1 hour):
- Ensure your Google Cloud OAuth consent screen is set to "External" and "Production" mode (not "Testing" mode, which limits refresh token validity)
- Review your application logs with `logLevel: debug` to check for refresh token errors
- Verify you're using a version of the middleware that includes this fix
## Conclusion
This fix ensures that the Traefik OIDC plugin works seamlessly with Google's OAuth implementation without requiring users to make provider-specific configuration changes. The middleware now intelligently adapts to the provider's requirements, making it more robust and user-friendly while maintaining compatibility with the standard OIDC specification for other providers.
+110 -1
View File
@@ -90,6 +90,7 @@
<a href="#configuration" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Configuration</a>
<a href="#deployment" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Deployment</a>
<a href="#security" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Security</a>
<a href="#logout" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Logout</a>
</div>
<div class="flex items-center space-x-4">
<button id="theme-toggle" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle theme">
@@ -114,6 +115,7 @@
<a href="#configuration" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Configuration</a>
<a href="#deployment" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Deployment</a>
<a href="#security" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Security</a>
<a href="#logout" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Logout</a>
</div>
</div>
</nav>
@@ -193,7 +195,7 @@
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Dynamic Registration</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">RFC 7591 Dynamic Client Registration for automatic client setup without manual configuration</p>
<p class="text-sm text-gray-600 dark:text-gray-400">RFC 7591 Dynamic Client Registration with Redis storage support for multi-replica deployments</p>
</div>
</div>
</div>
@@ -862,6 +864,48 @@ spec:
</table>
</div>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Dynamic Client Registration (RFC 7591)</h3>
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm">Automatically register your application with the OIDC provider. Supports Redis storage for multi-replica deployments:</p>
<div class="overflow-x-auto mb-4">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Parameter</th>
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Default</th>
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Description</th>
</tr>
</thead>
<tbody class="text-gray-600 dark:text-gray-400">
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.enabled</code></td>
<td class="py-2 px-3">false</td>
<td class="py-2 px-3">Enable dynamic client registration</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.persistCredentials</code></td>
<td class="py-2 px-3">true</td>
<td class="py-2 px-3">Persist registered credentials across restarts</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.storageBackend</code></td>
<td class="py-2 px-3">auto</td>
<td class="py-2 px-3">Storage backend: <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">file</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">redis</code>, or <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">auto</code> (uses Redis if available)</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.redisKeyPrefix</code></td>
<td class="py-2 px-3">dcr:creds:</td>
<td class="py-2 px-3">Redis key prefix for DCR credentials</td>
</tr>
<tr>
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.clientMetadata.redirect_uris</code></td>
<td class="py-2 px-3">-</td>
<td class="py-2 px-3">Redirect URIs for the registered client (required)</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Example: Security Headers with CORS</h3>
@@ -1177,6 +1221,71 @@ spec:
</div>
</section>
<!-- IdP-Initiated Logout Section -->
<section id="logout" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">IdP-Initiated Logout</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Support for OIDC Back-Channel and Front-Channel Logout specifications</p>
</div>
<div class="max-w-4xl mx-auto">
<div class="grid md:grid-cols-2 gap-6 mb-8">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
<i class="fas fa-server mr-2 text-blue-500"></i>
Back-Channel Logout
</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
Server-to-server logout notification. The IdP sends a signed JWT (logout_token) directly to your application when a user logs out.
</p>
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
<li>&#8226; Signed JWT logout tokens</li>
<li>&#8226; Session ID (sid) based invalidation</li>
<li>&#8226; Subject (sub) based invalidation</li>
<li>&#8226; Works behind firewalls</li>
</ul>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
<i class="fas fa-browser mr-2 text-purple-500"></i>
Front-Channel Logout
</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
Browser-based logout via iframe. The IdP embeds an iframe pointing to your logout endpoint during user logout.
</p>
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
<li>&#8226; Iframe-based session termination</li>
<li>&#8226; Immediate cookie invalidation</li>
<li>&#8226; Simple GET request handling</li>
<li>&#8226; Issuer validation</li>
</ul>
</div>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Configuration Example</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code>http:
middlewares:
oidc-auth:
plugin:
traefikoidc:
# ... other OIDC configuration ...
# Back-Channel Logout (server-to-server)
enableBackchannelLogout: true
backchannelLogoutURL: "/backchannel-logout"
# Front-Channel Logout (browser-based)
enableFrontchannelLogout: true
frontchannelLogoutURL: "/frontchannel-logout"</code></pre>
<p class="text-gray-600 dark:text-gray-400 text-sm mt-4">
Configure your IdP with the full URLs (e.g., <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">https://your-app.example.com/backchannel-logout</code>).
When a user logs out from the IdP, all their sessions across your applications will be invalidated.
</p>
</div>
</div>
</div>
</section>
<!-- Why Choose Section -->
<section class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
+103 -45
View File
@@ -16,35 +16,26 @@ import (
// ClientRegistrationResponse represents the response from a successful client registration (RFC 7591)
type ClientRegistrationResponse struct {
// Required fields
ClientID string `json:"client_id"`
// Conditional - only for confidential clients
ClientSecret string `json:"client_secret,omitempty"`
// Optional - for managing registration
RegistrationAccessToken string `json:"registration_access_token,omitempty"`
RegistrationClientURI string `json:"registration_client_uri,omitempty"`
// Expiration
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
// Echo back of registered metadata
RedirectURIs []string `json:"redirect_uris,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ApplicationType string `json:"application_type,omitempty"`
Contacts []string `json:"contacts,omitempty"`
ClientName string `json:"client_name,omitempty"`
LogoURI string `json:"logo_uri,omitempty"`
ClientURI string `json:"client_uri,omitempty"`
PolicyURI string `json:"policy_uri,omitempty"`
TOSURI string `json:"tos_uri,omitempty"`
JWKSURI string `json:"jwks_uri,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
LogoURI string `json:"logo_uri,omitempty"`
RegistrationAccessToken string `json:"registration_access_token,omitempty"`
RegistrationClientURI string `json:"registration_client_uri,omitempty"`
Scope string `json:"scope,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
TOSURI string `json:"tos_uri,omitempty"`
PolicyURI string `json:"policy_uri,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
ApplicationType string `json:"application_type,omitempty"`
ClientID string `json:"client_id"`
ClientName string `json:"client_name,omitempty"`
JWKSURI string `json:"jwks_uri,omitempty"`
ClientURI string `json:"client_uri,omitempty"`
Contacts []string `json:"contacts,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
RedirectURIs []string `json:"redirect_uris,omitempty"`
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
}
// ClientRegistrationError represents an error response from client registration (RFC 7591)
@@ -55,14 +46,13 @@ type ClientRegistrationError struct {
// DynamicClientRegistrar handles OIDC Dynamic Client Registration (RFC 7591)
type DynamicClientRegistrar struct {
httpClient *http.Client
logger *Logger
config *DynamicClientRegistrationConfig
providerURL string
// Cached registration response
mu sync.RWMutex
httpClient *http.Client
logger *Logger
config *DynamicClientRegistrationConfig
registrationResponse *ClientRegistrationResponse
store DCRCredentialsStore // Storage backend for credentials
providerURL string
mu sync.RWMutex
}
// NewDynamicClientRegistrar creates a new dynamic client registrar
@@ -84,8 +74,37 @@ func NewDynamicClientRegistrar(
}
}
// NewDynamicClientRegistrarWithStore creates a new dynamic client registrar with a specific storage backend
func NewDynamicClientRegistrarWithStore(
httpClient *http.Client,
logger *Logger,
dcrConfig *DynamicClientRegistrationConfig,
providerURL string,
store DCRCredentialsStore,
) *DynamicClientRegistrar {
if logger == nil {
logger = GetSingletonNoOpLogger()
}
return &DynamicClientRegistrar{
httpClient: httpClient,
logger: logger,
config: dcrConfig,
providerURL: providerURL,
store: store,
}
}
// SetStore sets the credentials store for the registrar
// This allows setting the store after creation when the cache manager is available
func (r *DynamicClientRegistrar) SetStore(store DCRCredentialsStore) {
r.mu.Lock()
defer r.mu.Unlock()
r.store = store
}
// RegisterClient performs dynamic client registration with the OIDC provider
// It first attempts to load existing credentials from a file if persistence is enabled,
// It first attempts to load existing credentials from storage if persistence is enabled,
// then registers a new client if no valid credentials exist.
func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registrationEndpoint string) (*ClientRegistrationResponse, error) {
if r.config == nil || !r.config.Enabled {
@@ -94,10 +113,13 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
// Try to load existing credentials if persistence is enabled
if r.config.PersistCredentials {
if resp, err := r.loadCredentials(); err == nil && resp != nil {
resp, err := r.loadCredentialsFromStore(ctx)
if err != nil {
r.logger.Debugf("Failed to load credentials from store: %v", err)
} else if resp != nil {
// Check if credentials are still valid (not expired)
if r.areCredentialsValid(resp) {
r.logger.Info("Loaded existing client credentials from file")
r.logger.Info("Loaded existing client credentials from storage")
r.mu.Lock()
r.registrationResponse = resp
r.mu.Unlock()
@@ -190,7 +212,7 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
// Persist credentials if enabled
if r.config.PersistCredentials {
if err := r.saveCredentials(&regResp); err != nil {
if err := r.saveCredentialsToStore(ctx, &regResp); err != nil {
r.logger.Errorf("Failed to persist client credentials: %v", err)
// Don't fail registration if persistence fails
}
@@ -326,7 +348,44 @@ func (r *DynamicClientRegistrar) credentialsFilePath() string {
return "/tmp/oidc-client-credentials.json"
}
// saveCredentials persists client credentials to a file
// loadCredentialsFromStore loads client credentials from the configured storage backend
// Falls back to legacy file-based loading if no store is configured
func (r *DynamicClientRegistrar) loadCredentialsFromStore(ctx context.Context) (*ClientRegistrationResponse, error) {
// Use store if available
if r.store != nil {
return r.store.Load(ctx, r.providerURL)
}
// Fallback to legacy file-based loading
return r.loadCredentials()
}
// saveCredentialsToStore persists client credentials to the configured storage backend
// Falls back to legacy file-based saving if no store is configured
func (r *DynamicClientRegistrar) saveCredentialsToStore(ctx context.Context, resp *ClientRegistrationResponse) error {
// Use store if available
if r.store != nil {
return r.store.Save(ctx, r.providerURL, resp)
}
// Fallback to legacy file-based saving
return r.saveCredentials(resp)
}
// deleteCredentialsFromStore removes credentials from the configured storage backend
// Falls back to legacy file-based deletion if no store is configured
func (r *DynamicClientRegistrar) deleteCredentialsFromStore(ctx context.Context) error {
// Use store if available
if r.store != nil {
return r.store.Delete(ctx, r.providerURL)
}
// Fallback to legacy file-based deletion
filePath := r.credentialsFilePath()
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// saveCredentials persists client credentials to a file (legacy method)
func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationResponse) error {
filePath := r.credentialsFilePath()
@@ -344,7 +403,7 @@ func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationRespons
return nil
}
// loadCredentials loads client credentials from a file
// loadCredentials loads client credentials from a file (legacy method)
func (r *DynamicClientRegistrar) loadCredentials() (*ClientRegistrationResponse, error) {
filePath := r.credentialsFilePath()
@@ -431,7 +490,7 @@ func (r *DynamicClientRegistrar) UpdateClientRegistration(ctx context.Context) (
// Persist updated credentials if enabled
if r.config.PersistCredentials {
if err := r.saveCredentials(&regResp); err != nil {
if err := r.saveCredentialsToStore(ctx, &regResp); err != nil {
r.logger.Errorf("Failed to persist updated credentials: %v", err)
}
}
@@ -538,11 +597,10 @@ func (r *DynamicClientRegistrar) DeleteClientRegistration(ctx context.Context) e
r.registrationResponse = nil
r.mu.Unlock()
// Remove credentials file if persistence is enabled
// Remove credentials from storage if persistence is enabled
if r.config.PersistCredentials {
filePath := r.credentialsFilePath()
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
r.logger.Errorf("Failed to remove credentials file: %v", err)
if err := r.deleteCredentialsFromStore(ctx); err != nil {
r.logger.Errorf("Failed to remove credentials from storage: %v", err)
}
}
+5 -5
View File
@@ -223,10 +223,10 @@ func TestRegisterClientWithInitialAccessToken(t *testing.T) {
// TestRegisterClientError tests error handling during registration
func TestRegisterClientError(t *testing.T) {
tests := []struct {
name string
serverResponse func(w http.ResponseWriter, r *http.Request)
expectError bool
name string
errorContains string
expectError bool
}{
{
name: "invalid_redirect_uri error",
@@ -321,8 +321,8 @@ func TestRegisterClientError(t *testing.T) {
// TestRegisterClientDisabled tests that registration fails when not enabled
func TestRegisterClientDisabled(t *testing.T) {
tests := []struct {
name string
dcrConfig *DynamicClientRegistrationConfig
name string
}{
{
name: "nil config",
@@ -521,8 +521,8 @@ func TestCredentialsValidation(t *testing.T) {
registrar := NewDynamicClientRegistrar(&http.Client{}, NewLogger("DEBUG"), dcrConfig, "https://example.com")
tests := []struct {
name string
response *ClientRegistrationResponse
name string
expected bool
}{
{
@@ -584,9 +584,9 @@ func TestCredentialsValidation(t *testing.T) {
// TestBuildRegistrationRequest tests the request body construction
func TestBuildRegistrationRequest(t *testing.T) {
tests := []struct {
name string
metadata *ClientRegistrationMetadata
expectedFields map[string]interface{}
name string
expectError bool
}{
{
+620
View File
@@ -0,0 +1,620 @@
package traefikoidc
import (
"context"
"encoding/base64"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/testutil"
"github.com/stretchr/testify/suite"
"golang.org/x/time/rate"
)
// ClockSkewEdgeCasesSuite tests clock skew tolerance scenarios
type ClockSkewEdgeCasesSuite struct {
suite.Suite
fixture *testutil.TokenFixture
tOidc *TraefikOidc
}
func (s *ClockSkewEdgeCasesSuite) SetupSuite() {
var err error
s.fixture, err = testutil.NewTokenFixture()
s.Require().NoError(err)
}
func (s *ClockSkewEdgeCasesSuite) SetupTest() {
// Create JWK for the test key
jwk := JWK{
Kty: "RSA",
Kid: s.fixture.KeyID,
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(s.fixture.RSAPublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(bigIntToBytes(big.NewInt(int64(s.fixture.RSAPublicKey.E)))),
}
jwkCache := &MockJWKCache{
JWKS: &JWKSet{Keys: []JWK{jwk}},
Err: nil,
}
tokenBlacklist := NewCache()
tokenCacheInternal := NewCache()
tokenCache := &TokenCache{}
if tokenCache.cache == nil {
if wrapper, ok := tokenCacheInternal.(*CacheInterfaceWrapper); ok {
tokenCache.cache = wrapper.cache
}
}
logger := NewLogger("error") // Reduce noise
s.tOidc = &TraefikOidc{
issuerURL: s.fixture.Issuer,
clientID: s.fixture.Audience,
audience: s.fixture.Audience,
clientSecret: "test-client-secret",
roleClaimName: "roles",
groupClaimName: "groups",
userIdentifierClaim: "email",
jwkCache: jwkCache,
jwksURL: "https://test-jwks-url.com",
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
tokenBlacklist: tokenBlacklist,
tokenCache: tokenCache,
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
extractClaimsFunc: extractClaims,
initComplete: make(chan struct{}),
goroutineWG: &sync.WaitGroup{},
ctx: context.Background(),
}
close(s.tOidc.initComplete)
s.tOidc.tokenVerifier = s.tOidc
s.tOidc.jwtVerifier = s.tOidc
s.T().Cleanup(func() {
if s.tOidc.tokenBlacklist != nil {
s.tOidc.tokenBlacklist.Close()
}
if s.tOidc.tokenCache != nil && s.tOidc.tokenCache.cache != nil {
s.tOidc.tokenCache.cache.Close()
}
})
}
func (s *ClockSkewEdgeCasesSuite) TestExactlyAtExpiry() {
token, err := s.fixture.TokenWithSkew(0)
s.Require().NoError(err)
// Token at exact expiry - behavior is implementation-defined
err = s.tOidc.VerifyToken(token)
s.T().Logf("Exact expiry result: %v", err)
}
func (s *ClockSkewEdgeCasesSuite) TestOneSecondBeforeExpiry() {
token, err := s.fixture.TokenWithSkew(1 * time.Second)
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
s.NoError(err, "Token should be valid 1 second before expiry")
}
func (s *ClockSkewEdgeCasesSuite) TestOneSecondAfterExpiry() {
token, err := s.fixture.TokenWithSkew(-1 * time.Second)
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
// With default 2-minute clock skew tolerance, 1 second past expiry should still be valid
s.NoError(err, "Token 1 second past expiry should be valid within clock skew tolerance")
}
func (s *ClockSkewEdgeCasesSuite) TestWithinSkewTolerance() {
// Most implementations allow 5-minute clock skew
token, err := s.fixture.TokenWithSkew(-4 * time.Minute)
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
// May pass or fail depending on implementation
s.T().Logf("4-minute expired token result: %v", err)
}
func (s *ClockSkewEdgeCasesSuite) TestBeyondSkewTolerance() {
token, err := s.fixture.TokenWithSkew(-10 * time.Minute)
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
s.Error(err, "Token should be invalid 10 minutes after expiry")
}
func TestClockSkewEdgeCasesSuite(t *testing.T) {
suite.Run(t, new(ClockSkewEdgeCasesSuite))
}
// UnicodeClaimsSuite tests Unicode handling in JWT claims
type UnicodeClaimsSuite struct {
suite.Suite
fixture *testutil.TokenFixture
tOidc *TraefikOidc
}
func (s *UnicodeClaimsSuite) SetupSuite() {
var err error
s.fixture, err = testutil.NewTokenFixture()
s.Require().NoError(err)
}
func (s *UnicodeClaimsSuite) SetupTest() {
jwk := JWK{
Kty: "RSA",
Kid: s.fixture.KeyID,
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(s.fixture.RSAPublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(bigIntToBytes(big.NewInt(int64(s.fixture.RSAPublicKey.E)))),
}
jwkCache := &MockJWKCache{
JWKS: &JWKSet{Keys: []JWK{jwk}},
Err: nil,
}
tokenBlacklist := NewCache()
tokenCacheInternal := NewCache()
tokenCache := &TokenCache{}
if tokenCache.cache == nil {
if wrapper, ok := tokenCacheInternal.(*CacheInterfaceWrapper); ok {
tokenCache.cache = wrapper.cache
}
}
logger := NewLogger("error")
s.tOidc = &TraefikOidc{
issuerURL: s.fixture.Issuer,
clientID: s.fixture.Audience,
audience: s.fixture.Audience,
clientSecret: "test-client-secret",
roleClaimName: "roles",
groupClaimName: "groups",
userIdentifierClaim: "email",
jwkCache: jwkCache,
jwksURL: "https://test-jwks-url.com",
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
tokenBlacklist: tokenBlacklist,
tokenCache: tokenCache,
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
extractClaimsFunc: extractClaims,
initComplete: make(chan struct{}),
goroutineWG: &sync.WaitGroup{},
ctx: context.Background(),
}
close(s.tOidc.initComplete)
s.tOidc.tokenVerifier = s.tOidc
s.tOidc.jwtVerifier = s.tOidc
s.T().Cleanup(func() {
if s.tOidc.tokenBlacklist != nil {
s.tOidc.tokenBlacklist.Close()
}
if s.tOidc.tokenCache != nil && s.tOidc.tokenCache.cache != nil {
s.tOidc.tokenCache.cache.Close()
}
})
}
func (s *UnicodeClaimsSuite) TestUnicodeEmail() {
token, err := s.fixture.TokenWithEmail("用户@example.com")
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
s.NoError(err, "Unicode email should be handled correctly")
}
func (s *UnicodeClaimsSuite) TestUnicodeName() {
token, err := s.fixture.TokenWithCustomClaims(map[string]interface{}{
"name": "田中太郎",
})
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
s.NoError(err, "Unicode name should be handled correctly")
}
func (s *UnicodeClaimsSuite) TestEmojiInClaims() {
token, err := s.fixture.TokenWithCustomClaims(map[string]interface{}{
"name": "Test User 😀",
})
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
s.NoError(err, "Emoji in claims should be handled correctly")
}
func (s *UnicodeClaimsSuite) TestRTLText() {
token, err := s.fixture.TokenWithCustomClaims(map[string]interface{}{
"name": "مستخدم اختبار",
})
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
s.NoError(err, "RTL text should be handled correctly")
}
func (s *UnicodeClaimsSuite) TestMixedScripts() {
token, err := s.fixture.TokenWithCustomClaims(map[string]interface{}{
"name": "Test 测试 テスト",
"roles": []string{"admin", "管理者", "管理员"},
})
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
s.NoError(err, "Mixed scripts should be handled correctly")
}
func TestUnicodeClaimsSuite(t *testing.T) {
suite.Run(t, new(UnicodeClaimsSuite))
}
// LargeClaimsSuite tests large claim values
type LargeClaimsSuite struct {
suite.Suite
fixture *testutil.TokenFixture
tOidc *TraefikOidc
}
func (s *LargeClaimsSuite) SetupSuite() {
var err error
s.fixture, err = testutil.NewTokenFixture()
s.Require().NoError(err)
}
func (s *LargeClaimsSuite) SetupTest() {
jwk := JWK{
Kty: "RSA",
Kid: s.fixture.KeyID,
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(s.fixture.RSAPublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(bigIntToBytes(big.NewInt(int64(s.fixture.RSAPublicKey.E)))),
}
jwkCache := &MockJWKCache{
JWKS: &JWKSet{Keys: []JWK{jwk}},
Err: nil,
}
tokenBlacklist := NewCache()
tokenCacheInternal := NewCache()
tokenCache := &TokenCache{}
if tokenCache.cache == nil {
if wrapper, ok := tokenCacheInternal.(*CacheInterfaceWrapper); ok {
tokenCache.cache = wrapper.cache
}
}
logger := NewLogger("error")
s.tOidc = &TraefikOidc{
issuerURL: s.fixture.Issuer,
clientID: s.fixture.Audience,
audience: s.fixture.Audience,
clientSecret: "test-client-secret",
roleClaimName: "roles",
groupClaimName: "groups",
userIdentifierClaim: "email",
jwkCache: jwkCache,
jwksURL: "https://test-jwks-url.com",
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
tokenBlacklist: tokenBlacklist,
tokenCache: tokenCache,
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
extractClaimsFunc: extractClaims,
initComplete: make(chan struct{}),
goroutineWG: &sync.WaitGroup{},
ctx: context.Background(),
}
close(s.tOidc.initComplete)
s.tOidc.tokenVerifier = s.tOidc
s.tOidc.jwtVerifier = s.tOidc
s.T().Cleanup(func() {
if s.tOidc.tokenBlacklist != nil {
s.tOidc.tokenBlacklist.Close()
}
if s.tOidc.tokenCache != nil && s.tOidc.tokenCache.cache != nil {
s.tOidc.tokenCache.cache.Close()
}
})
}
func (s *LargeClaimsSuite) TestManyRoles() {
roles := make([]string, 100)
for i := 0; i < 100; i++ {
roles[i] = strings.Repeat("role", 10) + string(rune('A'+i%26))
}
token, err := s.fixture.TokenWithRoles(roles)
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
s.NoError(err, "Token with 100 roles should be handled")
}
func (s *LargeClaimsSuite) TestManyGroups() {
groups := make([]string, 50)
for i := 0; i < 50; i++ {
groups[i] = strings.Repeat("group", 5) + string(rune('A'+i%26))
}
token, err := s.fixture.TokenWithGroups(groups)
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
s.NoError(err, "Token with 50 groups should be handled")
}
func (s *LargeClaimsSuite) TestLongEmail() {
// RFC 5321 allows up to 254 characters
localPart := strings.Repeat("a", 64)
domain := strings.Repeat("b", 63) + ".com"
email := localPart + "@" + domain
token, err := s.fixture.TokenWithEmail(email)
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
s.NoError(err, "Token with long email should be handled")
}
func (s *LargeClaimsSuite) TestLongSubject() {
longSub := strings.Repeat("subject", 100)
token, err := s.fixture.TokenWithCustomClaims(map[string]interface{}{
"sub": longSub,
})
s.Require().NoError(err)
err = s.tOidc.VerifyToken(token)
s.NoError(err, "Token with long subject should be handled")
}
func TestLargeClaimsSuite(t *testing.T) {
suite.Run(t, new(LargeClaimsSuite))
}
// URLPathEdgeCasesSuite tests URL handling edge cases
type URLPathEdgeCasesSuite struct {
suite.Suite
}
func (s *URLPathEdgeCasesSuite) TestVeryLongPath() {
longPath := "/" + strings.Repeat("segment/", 100)
req := httptest.NewRequest("GET", longPath, nil)
s.NotNil(req)
s.Contains(req.URL.Path, "segment")
}
func (s *URLPathEdgeCasesSuite) TestSpecialCharactersInPath() {
paths := []string{
"/path%20with%20spaces",
"/path/with/日本語",
"/path?query=value&another=test",
"/path#fragment",
"/path/../traversal",
"/path/./current",
}
for _, path := range paths {
s.Run(path, func() {
req := httptest.NewRequest("GET", path, nil)
s.NotNil(req)
})
}
}
func (s *URLPathEdgeCasesSuite) TestEmptyPath() {
req := httptest.NewRequest("GET", "/", nil)
s.Equal("/", req.URL.Path)
}
func (s *URLPathEdgeCasesSuite) TestDoubleSlashes() {
req := httptest.NewRequest("GET", "//double//slashes//", nil)
s.NotNil(req)
}
func TestURLPathEdgeCasesSuite(t *testing.T) {
suite.Run(t, new(URLPathEdgeCasesSuite))
}
// ConcurrencyEdgeCasesSuite tests concurrency scenarios
type ConcurrencyEdgeCasesSuite struct {
suite.Suite
fixture *testutil.TokenFixture
tOidc *TraefikOidc
}
func (s *ConcurrencyEdgeCasesSuite) SetupSuite() {
var err error
s.fixture, err = testutil.NewTokenFixture()
s.Require().NoError(err)
}
func (s *ConcurrencyEdgeCasesSuite) SetupTest() {
jwk := JWK{
Kty: "RSA",
Kid: s.fixture.KeyID,
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(s.fixture.RSAPublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(bigIntToBytes(big.NewInt(int64(s.fixture.RSAPublicKey.E)))),
}
jwkCache := &MockJWKCache{
JWKS: &JWKSet{Keys: []JWK{jwk}},
Err: nil,
}
tokenBlacklist := NewCache()
tokenCacheInternal := NewCache()
tokenCache := &TokenCache{}
if tokenCache.cache == nil {
if wrapper, ok := tokenCacheInternal.(*CacheInterfaceWrapper); ok {
tokenCache.cache = wrapper.cache
}
}
logger := NewLogger("error")
s.tOidc = &TraefikOidc{
issuerURL: s.fixture.Issuer,
clientID: s.fixture.Audience,
audience: s.fixture.Audience,
clientSecret: "test-client-secret",
roleClaimName: "roles",
groupClaimName: "groups",
userIdentifierClaim: "email",
jwkCache: jwkCache,
jwksURL: "https://test-jwks-url.com",
limiter: rate.NewLimiter(rate.Every(time.Second), 100), // Higher limit for concurrency tests
tokenBlacklist: tokenBlacklist,
tokenCache: tokenCache,
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
extractClaimsFunc: extractClaims,
initComplete: make(chan struct{}),
goroutineWG: &sync.WaitGroup{},
ctx: context.Background(),
}
close(s.tOidc.initComplete)
s.tOidc.tokenVerifier = s.tOidc
s.tOidc.jwtVerifier = s.tOidc
s.T().Cleanup(func() {
if s.tOidc.tokenBlacklist != nil {
s.tOidc.tokenBlacklist.Close()
}
if s.tOidc.tokenCache != nil && s.tOidc.tokenCache.cache != nil {
s.tOidc.tokenCache.cache.Close()
}
})
}
func (s *ConcurrencyEdgeCasesSuite) TestConcurrentTokenValidation() {
token, err := s.fixture.ValidToken(nil)
s.Require().NoError(err)
const goroutines = 50
var wg sync.WaitGroup
errors := make(chan error, goroutines)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := s.tOidc.VerifyToken(token); err != nil {
errors <- err
}
}()
}
wg.Wait()
close(errors)
var errCount int
for err := range errors {
s.T().Logf("Concurrent error: %v", err)
errCount++
}
s.Equal(0, errCount, "All concurrent validations should succeed")
}
func (s *ConcurrencyEdgeCasesSuite) TestConcurrentDifferentTokens() {
const goroutines = 20
var wg sync.WaitGroup
errors := make(chan error, goroutines)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
token, err := s.fixture.TokenWithCustomClaims(map[string]interface{}{
"custom": idx,
})
if err != nil {
errors <- err
return
}
if err := s.tOidc.VerifyToken(token); err != nil {
errors <- err
}
}(i)
}
wg.Wait()
close(errors)
var errCount int
for err := range errors {
s.T().Logf("Concurrent different token error: %v", err)
errCount++
}
s.Equal(0, errCount, "All concurrent different token validations should succeed")
}
func (s *ConcurrencyEdgeCasesSuite) TestConcurrentMixedValidInvalid() {
validToken, err := s.fixture.ValidToken(nil)
s.Require().NoError(err)
expiredToken, err := s.fixture.ExpiredToken()
s.Require().NoError(err)
const goroutines = 40
var wg sync.WaitGroup
validCount := int32(0)
expiredCount := int32(0)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
var token string
if idx%2 == 0 {
token = validToken
} else {
token = expiredToken
}
err := s.tOidc.VerifyToken(token)
if idx%2 == 0 {
if err == nil {
atomic.AddInt32(&validCount, 1)
}
} else {
if err != nil {
atomic.AddInt32(&expiredCount, 1)
}
}
}(i)
}
wg.Wait()
s.T().Logf("Valid passed: %d, Expired rejected: %d", validCount, expiredCount)
}
func TestConcurrencyEdgeCasesSuite(t *testing.T) {
suite.Run(t, new(ConcurrencyEdgeCasesSuite))
}
+258
View File
@@ -0,0 +1,258 @@
package traefikoidc
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/suite"
)
// EnhancedMocksSuite demonstrates improved state-based mocks with call tracking
type EnhancedMocksSuite struct {
suite.Suite
}
func (s *EnhancedMocksSuite) TestEnhancedJWKCacheCallTracking() {
mock := &EnhancedMockJWKCache{
JWKS: &JWKSet{Keys: []JWK{{Kid: "test-key"}}},
}
// Make some calls
result, err := mock.GetJWKS(context.Background(), "https://example.com/jwks", nil)
s.NoError(err)
s.NotNil(result)
// Another call with different URL
_, _ = mock.GetJWKS(context.Background(), "https://other.com/jwks", nil)
// Verify calls were tracked
s.Equal(2, mock.GetJWKSCallCount())
mock.AssertGetJWKSCalled(s.T())
mock.AssertGetJWKSCalledWith(s.T(), "https://example.com/jwks")
mock.AssertGetJWKSCallCount(s.T(), 2)
}
func (s *EnhancedMocksSuite) TestEnhancedJWKCacheWithError() {
expectedErr := errors.New("network error")
mock := &EnhancedMockJWKCache{
Err: expectedErr,
}
result, err := mock.GetJWKS(context.Background(), "https://example.com/jwks", nil)
s.Nil(result)
s.Equal(expectedErr, err)
mock.AssertGetJWKSCalled(s.T())
}
func (s *EnhancedMocksSuite) TestEnhancedJWKCacheReset() {
mock := &EnhancedMockJWKCache{
JWKS: &JWKSet{Keys: []JWK{{Kid: "test-key"}}},
}
_, _ = mock.GetJWKS(context.Background(), "https://example.com/jwks", nil)
s.Equal(1, mock.GetJWKSCallCount())
mock.Reset()
s.Equal(0, mock.GetJWKSCallCount())
s.Nil(mock.JWKS)
}
func (s *EnhancedMocksSuite) TestEnhancedTokenVerifierCallTracking() {
mock := &EnhancedMockTokenVerifier{
Err: nil, // Valid tokens
}
// Verify a token
err := mock.VerifyToken("test-token-1")
s.NoError(err)
// Verify another token
err = mock.VerifyToken("test-token-2")
s.NoError(err)
// Check tracking
s.Equal(2, mock.GetVerifyTokenCallCount())
mock.AssertVerifyTokenCalled(s.T())
mock.AssertVerifyTokenCalledWith(s.T(), "test-token-1")
// Check last call
lastCall := mock.LastCall()
s.NotNil(lastCall)
s.Equal("test-token-2", lastCall.Token)
}
func (s *EnhancedMocksSuite) TestEnhancedTokenVerifierWithDynamicFunc() {
callCount := 0
mock := &EnhancedMockTokenVerifier{
VerifyFunc: func(token string) error {
callCount++
if token == "invalid" {
return errors.New("invalid token")
}
return nil
},
}
// Valid token
err := mock.VerifyToken("valid-token")
s.NoError(err)
// Invalid token
err = mock.VerifyToken("invalid")
s.Error(err)
s.Equal(2, callCount)
s.Equal(2, mock.GetVerifyTokenCallCount())
}
func (s *EnhancedMocksSuite) TestEnhancedTokenExchangerCallTracking() {
mock := &EnhancedMockTokenExchanger{
ExchangeResponse: &TokenResponse{
AccessToken: "access-token",
RefreshToken: "refresh-token",
ExpiresIn: 3600,
},
RefreshResponse: &TokenResponse{
AccessToken: "new-access-token",
ExpiresIn: 3600,
},
}
// Exchange code
resp, err := mock.ExchangeCodeForToken(context.Background(), "authorization_code", "auth-code", "https://redirect.com", "verifier")
s.NoError(err)
s.Equal("access-token", resp.AccessToken)
// Refresh token
resp, err = mock.GetNewTokenWithRefreshToken("refresh-token")
s.NoError(err)
s.Equal("new-access-token", resp.AccessToken)
// Revoke token
err = mock.RevokeTokenWithProvider("access-token", "access_token")
s.NoError(err)
// Check tracking
mock.AssertExchangeCalled(s.T())
mock.AssertExchangeCalledWith(s.T(), "authorization_code")
mock.AssertRefreshCalled(s.T())
mock.AssertRevokeCalled(s.T())
s.Equal(1, mock.GetExchangeCallCount())
s.Equal(1, mock.GetRefreshCallCount())
s.Equal(1, mock.GetRevokeCallCount())
// Check last exchange call details
lastExchange := mock.LastExchangeCall()
s.NotNil(lastExchange)
s.Equal("authorization_code", lastExchange.GrantType)
s.Equal("auth-code", lastExchange.CodeOrToken)
s.Equal("https://redirect.com", lastExchange.RedirectURL)
}
func (s *EnhancedMocksSuite) TestEnhancedTokenExchangerWithErrors() {
mock := &EnhancedMockTokenExchanger{
ExchangeErr: errors.New("invalid_grant"),
RefreshErr: errors.New("refresh_expired"),
RevokeErr: errors.New("revoke_failed"),
}
_, err := mock.ExchangeCodeForToken(context.Background(), "authorization_code", "code", "", "")
s.Error(err)
s.Contains(err.Error(), "invalid_grant")
_, err = mock.GetNewTokenWithRefreshToken("token")
s.Error(err)
s.Contains(err.Error(), "refresh_expired")
err = mock.RevokeTokenWithProvider("token", "access_token")
s.Error(err)
s.Contains(err.Error(), "revoke_failed")
}
func (s *EnhancedMocksSuite) TestEnhancedCacheCallTracking() {
mock := NewEnhancedMockCache()
// Set some values
mock.Set("key1", "value1", 5*time.Minute)
mock.Set("key2", "value2", 10*time.Minute)
// Get values
val, found := mock.Get("key1")
s.True(found)
s.Equal("value1", val)
_, found = mock.Get("nonexistent")
s.False(found)
// Delete
mock.Delete("key1")
// Verify tracking
mock.AssertSetCalled(s.T(), "key1")
mock.AssertSetCalled(s.T(), "key2")
mock.AssertGetCalled(s.T(), "key1")
mock.AssertGetCalled(s.T(), "nonexistent")
mock.AssertDeleteCalled(s.T(), "key1")
s.Equal(2, mock.SetCallCount())
s.Equal(2, mock.GetCallCount())
}
func (s *EnhancedMocksSuite) TestEnhancedCacheActualStorage() {
mock := NewEnhancedMockCache()
// The enhanced mock actually stores data
mock.Set("key", "value", time.Hour)
s.Equal(1, mock.Size())
val, found := mock.Get("key")
s.True(found)
s.Equal("value", val)
mock.Delete("key")
s.Equal(0, mock.Size())
_, found = mock.Get("key")
s.False(found)
}
func (s *EnhancedMocksSuite) TestEnhancedCacheClear() {
mock := NewEnhancedMockCache()
mock.Set("key1", "value1", time.Hour)
mock.Set("key2", "value2", time.Hour)
s.Equal(2, mock.Size())
mock.Clear()
s.Equal(0, mock.Size())
}
func (s *EnhancedMocksSuite) TestConcurrentAccess() {
mock := &EnhancedMockJWKCache{
JWKS: &JWKSet{Keys: []JWK{{Kid: "test-key"}}},
}
// Concurrent calls should be safe
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
_, _ = mock.GetJWKS(context.Background(), "https://example.com/jwks", nil)
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
s.Equal(10, mock.GetJWKSCallCount())
}
func TestEnhancedMocksSuite(t *testing.T) {
suite.Run(t, new(EnhancedMocksSuite))
}
+604
View File
@@ -0,0 +1,604 @@
package traefikoidc
import (
"context"
"crypto"
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/stretchr/testify/assert"
)
// EnhancedMockJWKCache is an improved state-based mock with call tracking
type EnhancedMockJWKCache struct {
Err error
JWKS *JWKSet
GetJWKSCalls []JWKSCall
mu sync.RWMutex
getJWKSCallsMu sync.Mutex
CleanupCalls int32
CloseCalls int32
}
// JWKSCall records parameters from a GetJWKS call
type JWKSCall struct {
Timestamp time.Time
URL string
}
func (m *EnhancedMockJWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
m.getJWKSCallsMu.Lock()
m.GetJWKSCalls = append(m.GetJWKSCalls, JWKSCall{
URL: jwksURL,
Timestamp: time.Now(),
})
m.getJWKSCallsMu.Unlock()
m.mu.RLock()
defer m.mu.RUnlock()
return m.JWKS, m.Err
}
func (m *EnhancedMockJWKCache) GetPublicKey(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
jwks, err := m.GetJWKS(ctx, jwksURL, httpClient)
if err != nil {
return nil, err
}
if jwks == nil {
return nil, fmt.Errorf("JWKS is nil")
}
for i := range jwks.Keys {
k := &jwks.Keys[i]
if k.Kid != kid {
continue
}
switch k.Kty {
case "RSA":
return k.ToRSAPublicKey()
case "EC":
return k.ToECDSAPublicKey()
default:
return nil, fmt.Errorf("unsupported key type: %s", k.Kty)
}
}
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
}
func (m *EnhancedMockJWKCache) Cleanup() {
atomic.AddInt32(&m.CleanupCalls, 1)
m.mu.Lock()
defer m.mu.Unlock()
m.JWKS = nil
m.Err = nil
}
func (m *EnhancedMockJWKCache) Close() {
atomic.AddInt32(&m.CloseCalls, 1)
}
// Assertion helpers
// AssertGetJWKSCalled verifies GetJWKS was called
func (m *EnhancedMockJWKCache) AssertGetJWKSCalled(t assert.TestingT) bool {
m.getJWKSCallsMu.Lock()
defer m.getJWKSCallsMu.Unlock()
return assert.NotEmpty(t, m.GetJWKSCalls, "GetJWKS should have been called")
}
// AssertGetJWKSCalledWith verifies GetJWKS was called with specific URL
func (m *EnhancedMockJWKCache) AssertGetJWKSCalledWith(t assert.TestingT, expectedURL string) bool {
m.getJWKSCallsMu.Lock()
defer m.getJWKSCallsMu.Unlock()
for _, call := range m.GetJWKSCalls {
if call.URL == expectedURL {
return true
}
}
return assert.Fail(t, "GetJWKS was not called with URL: "+expectedURL)
}
// AssertGetJWKSCallCount verifies the number of GetJWKS calls
func (m *EnhancedMockJWKCache) AssertGetJWKSCallCount(t assert.TestingT, expected int) bool {
m.getJWKSCallsMu.Lock()
defer m.getJWKSCallsMu.Unlock()
return assert.Equal(t, expected, len(m.GetJWKSCalls), "GetJWKS call count mismatch")
}
// GetJWKSCallCount returns the number of GetJWKS calls
func (m *EnhancedMockJWKCache) GetJWKSCallCount() int {
m.getJWKSCallsMu.Lock()
defer m.getJWKSCallsMu.Unlock()
return len(m.GetJWKSCalls)
}
// Reset clears all state and call tracking
func (m *EnhancedMockJWKCache) Reset() {
m.mu.Lock()
m.JWKS = nil
m.Err = nil
m.mu.Unlock()
m.getJWKSCallsMu.Lock()
m.GetJWKSCalls = nil
m.getJWKSCallsMu.Unlock()
atomic.StoreInt32(&m.CleanupCalls, 0)
atomic.StoreInt32(&m.CloseCalls, 0)
}
// EnhancedMockTokenVerifier is an improved state-based mock with call tracking
type EnhancedMockTokenVerifier struct {
Err error
VerifyFunc func(token string) error
VerifyCalls []TokenVerifyCall
mu sync.RWMutex
verifyCallsMu sync.Mutex
}
// TokenVerifyCall records parameters from a VerifyToken call
type TokenVerifyCall struct {
Timestamp time.Time
Result error
Token string
}
func (m *EnhancedMockTokenVerifier) VerifyToken(token string) error {
var result error
m.mu.RLock()
if m.VerifyFunc != nil {
result = m.VerifyFunc(token)
} else {
result = m.Err
}
m.mu.RUnlock()
m.verifyCallsMu.Lock()
m.VerifyCalls = append(m.VerifyCalls, TokenVerifyCall{
Token: token,
Timestamp: time.Now(),
Result: result,
})
m.verifyCallsMu.Unlock()
return result
}
// Assertion helpers
// AssertVerifyTokenCalled verifies VerifyToken was called
func (m *EnhancedMockTokenVerifier) AssertVerifyTokenCalled(t assert.TestingT) bool {
m.verifyCallsMu.Lock()
defer m.verifyCallsMu.Unlock()
return assert.NotEmpty(t, m.VerifyCalls, "VerifyToken should have been called")
}
// AssertVerifyTokenCalledWith verifies VerifyToken was called with specific token
func (m *EnhancedMockTokenVerifier) AssertVerifyTokenCalledWith(t assert.TestingT, expectedToken string) bool {
m.verifyCallsMu.Lock()
defer m.verifyCallsMu.Unlock()
for _, call := range m.VerifyCalls {
if call.Token == expectedToken {
return true
}
}
return assert.Fail(t, "VerifyToken was not called with expected token")
}
// AssertVerifyTokenCallCount verifies the number of VerifyToken calls
func (m *EnhancedMockTokenVerifier) AssertVerifyTokenCallCount(t assert.TestingT, expected int) bool {
m.verifyCallsMu.Lock()
defer m.verifyCallsMu.Unlock()
return assert.Equal(t, expected, len(m.VerifyCalls), "VerifyToken call count mismatch")
}
// GetVerifyTokenCallCount returns the number of VerifyToken calls
func (m *EnhancedMockTokenVerifier) GetVerifyTokenCallCount() int {
m.verifyCallsMu.Lock()
defer m.verifyCallsMu.Unlock()
return len(m.VerifyCalls)
}
// LastCall returns the most recent VerifyToken call
func (m *EnhancedMockTokenVerifier) LastCall() *TokenVerifyCall {
m.verifyCallsMu.Lock()
defer m.verifyCallsMu.Unlock()
if len(m.VerifyCalls) == 0 {
return nil
}
return &m.VerifyCalls[len(m.VerifyCalls)-1]
}
// Reset clears all state and call tracking
func (m *EnhancedMockTokenVerifier) Reset() {
m.mu.Lock()
m.Err = nil
m.VerifyFunc = nil
m.mu.Unlock()
m.verifyCallsMu.Lock()
m.VerifyCalls = nil
m.verifyCallsMu.Unlock()
}
// EnhancedMockTokenExchanger is an improved state-based mock with call tracking
type EnhancedMockTokenExchanger struct {
RefreshErr error
RevokeErr error
ExchangeErr error
ExchangeCodeFunc func(ctx context.Context, grantType, codeOrToken, redirectURL, codeVerifier string) (*TokenResponse, error)
RefreshResponse *TokenResponse
ExchangeResponse *TokenResponse
RefreshTokenFunc func(refreshToken string) (*TokenResponse, error)
RevokeTokenFunc func(token, tokenType string) error
ExchangeCalls []ExchangeCall
RefreshCalls []RefreshCall
RevokeCalls []RevokeCall
mu sync.RWMutex
exchangeCallsMu sync.Mutex
refreshCallsMu sync.Mutex
revokeCallsMu sync.Mutex
}
// ExchangeCall records parameters from an ExchangeCodeForToken call
type ExchangeCall struct {
Timestamp time.Time
GrantType string
CodeOrToken string
RedirectURL string
CodeVerifier string
}
// RefreshCall records parameters from a GetNewTokenWithRefreshToken call
type RefreshCall struct {
Timestamp time.Time
RefreshToken string
}
// RevokeCall records parameters from a RevokeTokenWithProvider call
type RevokeCall struct {
Timestamp time.Time
Token string
TokenType string
}
func (m *EnhancedMockTokenExchanger) ExchangeCodeForToken(ctx context.Context, grantType, codeOrToken, redirectURL, codeVerifier string) (*TokenResponse, error) {
m.exchangeCallsMu.Lock()
m.ExchangeCalls = append(m.ExchangeCalls, ExchangeCall{
GrantType: grantType,
CodeOrToken: codeOrToken,
RedirectURL: redirectURL,
CodeVerifier: codeVerifier,
Timestamp: time.Now(),
})
m.exchangeCallsMu.Unlock()
m.mu.RLock()
defer m.mu.RUnlock()
if m.ExchangeCodeFunc != nil {
return m.ExchangeCodeFunc(ctx, grantType, codeOrToken, redirectURL, codeVerifier)
}
return m.ExchangeResponse, m.ExchangeErr
}
func (m *EnhancedMockTokenExchanger) GetNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) {
m.refreshCallsMu.Lock()
m.RefreshCalls = append(m.RefreshCalls, RefreshCall{
RefreshToken: refreshToken,
Timestamp: time.Now(),
})
m.refreshCallsMu.Unlock()
m.mu.RLock()
defer m.mu.RUnlock()
if m.RefreshTokenFunc != nil {
return m.RefreshTokenFunc(refreshToken)
}
return m.RefreshResponse, m.RefreshErr
}
func (m *EnhancedMockTokenExchanger) RevokeTokenWithProvider(token, tokenType string) error {
m.revokeCallsMu.Lock()
m.RevokeCalls = append(m.RevokeCalls, RevokeCall{
Token: token,
TokenType: tokenType,
Timestamp: time.Now(),
})
m.revokeCallsMu.Unlock()
m.mu.RLock()
defer m.mu.RUnlock()
if m.RevokeTokenFunc != nil {
return m.RevokeTokenFunc(token, tokenType)
}
return m.RevokeErr
}
// Assertion helpers
// AssertExchangeCalled verifies ExchangeCodeForToken was called
func (m *EnhancedMockTokenExchanger) AssertExchangeCalled(t assert.TestingT) bool {
m.exchangeCallsMu.Lock()
defer m.exchangeCallsMu.Unlock()
return assert.NotEmpty(t, m.ExchangeCalls, "ExchangeCodeForToken should have been called")
}
// AssertExchangeCalledWith verifies ExchangeCodeForToken was called with specific grant type
func (m *EnhancedMockTokenExchanger) AssertExchangeCalledWith(t assert.TestingT, grantType string) bool {
m.exchangeCallsMu.Lock()
defer m.exchangeCallsMu.Unlock()
for _, call := range m.ExchangeCalls {
if call.GrantType == grantType {
return true
}
}
return assert.Fail(t, "ExchangeCodeForToken was not called with grant type: "+grantType)
}
// AssertRefreshCalled verifies GetNewTokenWithRefreshToken was called
func (m *EnhancedMockTokenExchanger) AssertRefreshCalled(t assert.TestingT) bool {
m.refreshCallsMu.Lock()
defer m.refreshCallsMu.Unlock()
return assert.NotEmpty(t, m.RefreshCalls, "GetNewTokenWithRefreshToken should have been called")
}
// AssertRevokeCalled verifies RevokeTokenWithProvider was called
func (m *EnhancedMockTokenExchanger) AssertRevokeCalled(t assert.TestingT) bool {
m.revokeCallsMu.Lock()
defer m.revokeCallsMu.Unlock()
return assert.NotEmpty(t, m.RevokeCalls, "RevokeTokenWithProvider should have been called")
}
// GetExchangeCallCount returns the number of ExchangeCodeForToken calls
func (m *EnhancedMockTokenExchanger) GetExchangeCallCount() int {
m.exchangeCallsMu.Lock()
defer m.exchangeCallsMu.Unlock()
return len(m.ExchangeCalls)
}
// GetRefreshCallCount returns the number of GetNewTokenWithRefreshToken calls
func (m *EnhancedMockTokenExchanger) GetRefreshCallCount() int {
m.refreshCallsMu.Lock()
defer m.refreshCallsMu.Unlock()
return len(m.RefreshCalls)
}
// GetRevokeCallCount returns the number of RevokeTokenWithProvider calls
func (m *EnhancedMockTokenExchanger) GetRevokeCallCount() int {
m.revokeCallsMu.Lock()
defer m.revokeCallsMu.Unlock()
return len(m.RevokeCalls)
}
// LastExchangeCall returns the most recent ExchangeCodeForToken call
func (m *EnhancedMockTokenExchanger) LastExchangeCall() *ExchangeCall {
m.exchangeCallsMu.Lock()
defer m.exchangeCallsMu.Unlock()
if len(m.ExchangeCalls) == 0 {
return nil
}
return &m.ExchangeCalls[len(m.ExchangeCalls)-1]
}
// Reset clears all state and call tracking
func (m *EnhancedMockTokenExchanger) Reset() {
m.mu.Lock()
m.ExchangeResponse = nil
m.ExchangeErr = nil
m.RefreshResponse = nil
m.RefreshErr = nil
m.RevokeErr = nil
m.ExchangeCodeFunc = nil
m.RefreshTokenFunc = nil
m.RevokeTokenFunc = nil
m.mu.Unlock()
m.exchangeCallsMu.Lock()
m.ExchangeCalls = nil
m.exchangeCallsMu.Unlock()
m.refreshCallsMu.Lock()
m.RefreshCalls = nil
m.refreshCallsMu.Unlock()
m.revokeCallsMu.Lock()
m.RevokeCalls = nil
m.revokeCallsMu.Unlock()
}
// EnhancedMockCacheInterface is an improved state-based mock for CacheInterface
type EnhancedMockCacheInterface struct {
data map[string]cacheEntry
GetCalls []CacheGetCall
SetCalls []CacheSetCall
DeleteCalls []string
maxSize int
mu sync.RWMutex
getCalls sync.Mutex
setCalls sync.Mutex
deleteCalls sync.Mutex
}
type cacheEntry struct {
value any
ttl time.Duration
}
// CacheGetCall records parameters from a Get call
type CacheGetCall struct {
Timestamp time.Time
Key string
Found bool
}
// CacheSetCall records parameters from a Set call
type CacheSetCall struct {
Timestamp time.Time
Value any
Key string
TTL time.Duration
}
// NewEnhancedMockCache creates a new enhanced cache mock
func NewEnhancedMockCache() *EnhancedMockCacheInterface {
return &EnhancedMockCacheInterface{
data: make(map[string]cacheEntry),
maxSize: 1000,
}
}
func (m *EnhancedMockCacheInterface) Set(key string, value any, ttl time.Duration) {
m.setCalls.Lock()
m.SetCalls = append(m.SetCalls, CacheSetCall{
Key: key,
Value: value,
TTL: ttl,
Timestamp: time.Now(),
})
m.setCalls.Unlock()
m.mu.Lock()
m.data[key] = cacheEntry{value: value, ttl: ttl}
m.mu.Unlock()
}
func (m *EnhancedMockCacheInterface) Get(key string) (any, bool) {
m.mu.RLock()
entry, found := m.data[key]
m.mu.RUnlock()
m.getCalls.Lock()
m.GetCalls = append(m.GetCalls, CacheGetCall{
Key: key,
Found: found,
Timestamp: time.Now(),
})
m.getCalls.Unlock()
if found {
return entry.value, true
}
return nil, false
}
func (m *EnhancedMockCacheInterface) Delete(key string) {
m.deleteCalls.Lock()
m.DeleteCalls = append(m.DeleteCalls, key)
m.deleteCalls.Unlock()
m.mu.Lock()
delete(m.data, key)
m.mu.Unlock()
}
func (m *EnhancedMockCacheInterface) SetMaxSize(size int) {
m.mu.Lock()
m.maxSize = size
m.mu.Unlock()
}
func (m *EnhancedMockCacheInterface) Size() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.data)
}
func (m *EnhancedMockCacheInterface) Clear() {
m.mu.Lock()
m.data = make(map[string]cacheEntry)
m.mu.Unlock()
}
func (m *EnhancedMockCacheInterface) Cleanup() {
// No-op for mock
}
func (m *EnhancedMockCacheInterface) Close() {
// No-op for mock
}
func (m *EnhancedMockCacheInterface) GetStats() map[string]any {
m.mu.RLock()
defer m.mu.RUnlock()
return map[string]any{
"size": len(m.data),
"max_size": m.maxSize,
}
}
// Assertion helpers
// AssertGetCalled verifies Get was called with specific key
func (m *EnhancedMockCacheInterface) AssertGetCalled(t assert.TestingT, key string) bool {
m.getCalls.Lock()
defer m.getCalls.Unlock()
for _, call := range m.GetCalls {
if call.Key == key {
return true
}
}
return assert.Fail(t, "Get was not called with key: "+key)
}
// AssertSetCalled verifies Set was called with specific key
func (m *EnhancedMockCacheInterface) AssertSetCalled(t assert.TestingT, key string) bool {
m.setCalls.Lock()
defer m.setCalls.Unlock()
for _, call := range m.SetCalls {
if call.Key == key {
return true
}
}
return assert.Fail(t, "Set was not called with key: "+key)
}
// AssertDeleteCalled verifies Delete was called with specific key
func (m *EnhancedMockCacheInterface) AssertDeleteCalled(t assert.TestingT, key string) bool {
m.deleteCalls.Lock()
defer m.deleteCalls.Unlock()
for _, k := range m.DeleteCalls {
if k == key {
return true
}
}
return assert.Fail(t, "Delete was not called with key: "+key)
}
// GetCallCount returns the number of Get calls
func (m *EnhancedMockCacheInterface) GetCallCount() int {
m.getCalls.Lock()
defer m.getCalls.Unlock()
return len(m.GetCalls)
}
// SetCallCount returns the number of Set calls
func (m *EnhancedMockCacheInterface) SetCallCount() int {
m.setCalls.Lock()
defer m.setCalls.Unlock()
return len(m.SetCalls)
}
// Reset clears all state and call tracking
func (m *EnhancedMockCacheInterface) Reset() {
m.mu.Lock()
m.data = make(map[string]cacheEntry)
m.mu.Unlock()
m.getCalls.Lock()
m.GetCalls = nil
m.getCalls.Unlock()
m.setCalls.Lock()
m.SetCalls = nil
m.setCalls.Unlock()
m.deleteCalls.Lock()
m.DeleteCalls = nil
m.deleteCalls.Unlock()
}
+16 -37
View File
@@ -642,14 +642,10 @@ func (e *HTTPError) Error() string {
// OIDCError represents OIDC-specific errors with context information.
// It provides structured error reporting for authentication and authorization failures.
type OIDCError struct {
// Code identifies the specific error type
Code string
// Message provides a human-readable description
Message string
// Context contains additional error context (e.g., provider, session details)
Cause error
Context map[string]interface{}
// Cause is the underlying error that caused this error
Cause error
Code string
Message string
}
// Error returns the string representation of the OIDC error.
@@ -669,14 +665,10 @@ func (e *OIDCError) Unwrap() error {
// SessionError represents session-related errors with context.
// Used for session management, validation, and storage errors.
type SessionError struct {
// Operation describes what session operation failed
Cause error
Operation string
// Message provides a human-readable description
Message string
// SessionID identifies the session (if available)
Message string
SessionID string
// Cause is the underlying error that caused this error
Cause error
}
// Error returns the string representation of the session error.
@@ -696,14 +688,10 @@ func (e *SessionError) Unwrap() error {
// TokenError represents token-related errors with validation context.
// Used for JWT validation, token refresh, and token format errors.
type TokenError struct {
// TokenType identifies the type of token (id_token, access_token, refresh_token)
Cause error
TokenType string
// Reason describes why the token is invalid
Reason string
// Message provides a human-readable description
Message string
// Cause is the underlying error that caused this error
Cause error
Reason string
Message string
}
// Error returns the string representation of the token error.
@@ -765,24 +753,15 @@ func NewTokenError(tokenType, reason, message string, cause error) *TokenError {
// It provides fallback mechanisms when primary services are unavailable and monitors
// service health to automatically recover when services become available again.
type GracefulDegradation struct {
// BaseRecoveryMechanism provides common functionality
*BaseRecoveryMechanism
// fallbacks stores service-specific fallback implementations
fallbacks map[string]func() (interface{}, error)
// healthChecks stores service health check functions
healthChecks map[string]func() bool
// degradedServices tracks which services are currently degraded
fallbacks map[string]func() (interface{}, error)
healthChecks map[string]func() bool
degradedServices map[string]time.Time
// config contains graceful degradation configuration
config GracefulDegradationConfig
// mutex protects shared state
mutex sync.RWMutex
// healthCheckTask manages background health checking
healthCheckTask *BackgroundTask
// stopChan signals shutdown
stopChan chan struct{}
// shutdownOnce ensures shutdown happens only once
shutdownOnce sync.Once
healthCheckTask *BackgroundTask
stopChan chan struct{}
config GracefulDegradationConfig
mutex sync.RWMutex
shutdownOnce sync.Once
}
// GracefulDegradationConfig holds configuration for graceful degradation behavior.
@@ -975,7 +954,7 @@ func (gd *GracefulDegradation) GetDegradedServices() []string {
gd.mutex.RLock()
defer gd.mutex.RUnlock()
var degraded []string
degraded := make([]string, 0, len(gd.degradedServices))
for serviceName := range gd.degradedServices {
degraded = append(degraded, serviceName)
}
-242
View File
@@ -1,242 +0,0 @@
package traefikoidc
import (
"testing"
"time"
)
// TestDefaultCircuitBreakerConfig tests the default configuration function
func TestDefaultCircuitBreakerConfig(t *testing.T) {
config := DefaultCircuitBreakerConfig()
// Test default values
if config.MaxFailures != 2 {
t.Errorf("Expected MaxFailures 2, got %d", config.MaxFailures)
}
if config.Timeout != 60*time.Second {
t.Errorf("Expected Timeout 60s, got %v", config.Timeout)
}
if config.ResetTimeout != 30*time.Second {
t.Errorf("Expected ResetTimeout 30s, got %v", config.ResetTimeout)
}
}
// TestBaseRecoveryMechanism_GetBaseMetrics tests getting base metrics
func TestBaseRecoveryMechanism_GetBaseMetrics(t *testing.T) {
logger := GetSingletonNoOpLogger()
base := NewBaseRecoveryMechanism("test-mechanism", logger)
metrics := base.GetBaseMetrics()
if metrics == nil {
t.Fatal("Expected non-nil metrics")
}
// Check expected metric fields
expectedFields := []string{
"total_requests",
"total_failures",
"total_successes",
"uptime_seconds",
"name",
}
for _, field := range expectedFields {
if _, exists := metrics[field]; !exists {
t.Errorf("Expected metric field %s to exist", field)
}
}
}
// TestBaseRecoveryMechanism_RecordRequest tests request recording
func TestBaseRecoveryMechanism_RecordRequest(t *testing.T) {
logger := GetSingletonNoOpLogger()
base := NewBaseRecoveryMechanism("test-mechanism", logger)
// Record some requests
base.RecordRequest()
base.RecordRequest()
base.RecordRequest()
// Get metrics to verify
metrics := base.GetBaseMetrics()
totalRequests := metrics["total_requests"].(int64)
if totalRequests != 3 {
t.Errorf("Expected 3 total requests, got %d", totalRequests)
}
}
// TestBaseRecoveryMechanism_RecordSuccess tests success recording
func TestBaseRecoveryMechanism_RecordSuccess(t *testing.T) {
logger := GetSingletonNoOpLogger()
base := NewBaseRecoveryMechanism("test-mechanism", logger)
// Record some successes
base.RecordSuccess()
base.RecordSuccess()
// Get metrics to verify
metrics := base.GetBaseMetrics()
totalSuccesses := metrics["total_successes"].(int64)
if totalSuccesses != 2 {
t.Errorf("Expected 2 successful requests, got %d", totalSuccesses)
}
}
// TestBaseRecoveryMechanism_RecordFailure tests failure recording
func TestBaseRecoveryMechanism_RecordFailure(t *testing.T) {
logger := GetSingletonNoOpLogger()
base := NewBaseRecoveryMechanism("test-mechanism", logger)
// Record some failures
base.RecordFailure()
base.RecordFailure()
base.RecordFailure()
// Get metrics to verify
metrics := base.GetBaseMetrics()
totalFailures := metrics["total_failures"].(int64)
if totalFailures != 3 {
t.Errorf("Expected 3 failed requests, got %d", totalFailures)
}
}
// TestBaseRecoveryMechanism_LogInfo tests info logging
func TestBaseRecoveryMechanism_LogInfo(t *testing.T) {
logger := GetSingletonNoOpLogger()
base := NewBaseRecoveryMechanism("test-mechanism", logger)
// Test logging doesn't panic
base.LogInfo("test message")
base.LogInfo("test message with args: %s %d", "arg1", 42)
// Test with nil logger
baseNoLogger := NewBaseRecoveryMechanism("test", nil)
baseNoLogger.LogInfo("test message") // Should not panic
}
// TestBaseRecoveryMechanism_LogError tests error logging
func TestBaseRecoveryMechanism_LogError(t *testing.T) {
logger := GetSingletonNoOpLogger()
base := NewBaseRecoveryMechanism("test-mechanism", logger)
// Test logging doesn't panic
base.LogError("error message")
base.LogError("error message with args: %s %d", "error", 500)
// Test with nil logger
baseNoLogger := NewBaseRecoveryMechanism("test", nil)
baseNoLogger.LogError("error message") // Should not panic
}
// TestBaseRecoveryMechanism_LogDebug tests debug logging
func TestBaseRecoveryMechanism_LogDebug(t *testing.T) {
logger := GetSingletonNoOpLogger()
base := NewBaseRecoveryMechanism("test-mechanism", logger)
// Test logging doesn't panic
base.LogDebug("debug message")
base.LogDebug("debug message with args: %s %d", "debug", 123)
// Test with nil logger
baseNoLogger := NewBaseRecoveryMechanism("test", nil)
baseNoLogger.LogDebug("debug message") // Should not panic
}
// TestCircuitBreaker_GetState tests getting circuit breaker state
func TestCircuitBreaker_GetState(t *testing.T) {
config := DefaultCircuitBreakerConfig()
logger := GetSingletonNoOpLogger()
cb := NewCircuitBreaker(config, logger)
// Initial state should be closed
state := cb.GetState()
if state != CircuitBreakerClosed {
t.Errorf("Expected initial state to be closed, got %d", state)
}
}
// TestCircuitBreaker_Reset tests resetting circuit breaker
func TestCircuitBreaker_Reset(t *testing.T) {
config := DefaultCircuitBreakerConfig()
logger := GetSingletonNoOpLogger()
cb := NewCircuitBreaker(config, logger)
// Reset should not panic
cb.Reset()
// State should be closed after reset
state := cb.GetState()
if state != CircuitBreakerClosed {
t.Errorf("Expected state to be closed after reset, got %d", state)
}
}
// TestCircuitBreaker_IsAvailable tests availability check
func TestCircuitBreaker_IsAvailable(t *testing.T) {
config := DefaultCircuitBreakerConfig()
logger := GetSingletonNoOpLogger()
cb := NewCircuitBreaker(config, logger)
// Initially should be available
available := cb.IsAvailable()
if !available {
t.Error("Expected circuit breaker to be available initially")
}
}
// TestCircuitBreaker_GetMetrics tests getting circuit breaker metrics
func TestCircuitBreaker_GetMetrics(t *testing.T) {
config := DefaultCircuitBreakerConfig()
logger := GetSingletonNoOpLogger()
cb := NewCircuitBreaker(config, logger)
metrics := cb.GetMetrics()
if metrics == nil {
t.Fatal("Expected non-nil metrics")
}
// Should include base metrics
if _, exists := metrics["total_requests"]; !exists {
t.Error("Expected total_requests in metrics")
}
// Should include circuit breaker specific metrics
if _, exists := metrics["state"]; !exists {
t.Error("Expected state in metrics")
}
}
// Retry mechanism tests removed due to complex dependencies
// Benchmark tests
func BenchmarkDefaultCircuitBreakerConfig(b *testing.B) {
for i := 0; i < b.N; i++ {
DefaultCircuitBreakerConfig()
}
}
func BenchmarkBaseRecoveryMechanism_GetBaseMetrics(b *testing.B) {
logger := GetSingletonNoOpLogger()
base := NewBaseRecoveryMechanism("test-mechanism", logger)
b.ResetTimer()
for i := 0; i < b.N; i++ {
base.GetBaseMetrics()
}
}
func BenchmarkBaseRecoveryMechanism_RecordRequest(b *testing.B) {
logger := GetSingletonNoOpLogger()
base := NewBaseRecoveryMechanism("test-mechanism", logger)
b.ResetTimer()
for i := 0; i < b.N; i++ {
base.RecordRequest()
}
}
-560
View File
@@ -1,560 +0,0 @@
package traefikoidc
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestRetryExecutorReset tests the Reset method
func TestRetryExecutorReset(t *testing.T) {
logger := GetSingletonNoOpLogger()
executor := NewRetryExecutor(DefaultRetryConfig(), logger)
require.NotNil(t, executor)
// Should not panic
assert.NotPanics(t, func() {
executor.Reset()
})
// Multiple resets should be safe
executor.Reset()
executor.Reset()
}
// TestRetryExecutorIsAvailable tests the IsAvailable method
func TestRetryExecutorIsAvailable(t *testing.T) {
logger := GetSingletonNoOpLogger()
executor := NewRetryExecutor(DefaultRetryConfig(), logger)
// Retry executor should always be available
assert.True(t, executor.IsAvailable())
// Should remain available after operations
ctx := context.Background()
executor.ExecuteWithContext(ctx, func() error {
return nil
})
assert.True(t, executor.IsAvailable())
}
// TestSessionErrorUnwrap tests SessionError.Unwrap
func TestSessionErrorUnwrap(t *testing.T) {
t.Run("unwrap with cause", func(t *testing.T) {
rootErr := errors.New("root cause")
sessionErr := NewSessionError("save", "failed to save session", rootErr)
unwrapped := sessionErr.Unwrap()
assert.Equal(t, rootErr, unwrapped)
})
t.Run("unwrap without cause", func(t *testing.T) {
sessionErr := NewSessionError("load", "failed to load session", nil)
unwrapped := sessionErr.Unwrap()
assert.Nil(t, unwrapped)
})
t.Run("error chain", func(t *testing.T) {
rootErr := errors.New("database error")
sessionErr := NewSessionError("delete", "failed to delete session", rootErr)
// Verify error chain works
assert.True(t, errors.Is(sessionErr, rootErr))
})
}
// TestTokenErrorUnwrap tests TokenError.Unwrap
func TestTokenErrorUnwrap(t *testing.T) {
t.Run("unwrap with cause", func(t *testing.T) {
rootErr := errors.New("signature verification failed")
tokenErr := NewTokenError("id_token", "invalid", "token is invalid", rootErr)
unwrapped := tokenErr.Unwrap()
assert.Equal(t, rootErr, unwrapped)
})
t.Run("unwrap without cause", func(t *testing.T) {
tokenErr := NewTokenError("access_token", "expired", "token has expired", nil)
unwrapped := tokenErr.Unwrap()
assert.Nil(t, unwrapped)
})
t.Run("error chain", func(t *testing.T) {
rootErr := errors.New("crypto error")
tokenErr := NewTokenError("refresh_token", "malformed", "token is malformed", rootErr)
// Verify error chain works
assert.True(t, errors.Is(tokenErr, rootErr))
})
}
// TestGracefulDegradationRegisterFallback tests fallback registration
func TestGracefulDegradationRegisterFallback(t *testing.T) {
logger := GetSingletonNoOpLogger()
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
t.Run("register single fallback", func(t *testing.T) {
fallback := func() (interface{}, error) {
return "fallback result", nil
}
gd.RegisterFallback("service1", fallback)
// Verify fallback was registered (indirectly)
result, err := gd.ExecuteWithFallback("service1", func() (interface{}, error) {
return nil, errors.New("service failed")
})
assert.NoError(t, err)
assert.Equal(t, "fallback result", result)
})
t.Run("register multiple fallbacks", func(t *testing.T) {
gd.RegisterFallback("service2", func() (interface{}, error) {
return "fallback2", nil
})
gd.RegisterFallback("service3", func() (interface{}, error) {
return "fallback3", nil
})
result2, _ := gd.ExecuteWithFallback("service2", func() (interface{}, error) {
return nil, errors.New("fail")
})
result3, _ := gd.ExecuteWithFallback("service3", func() (interface{}, error) {
return nil, errors.New("fail")
})
assert.Equal(t, "fallback2", result2)
assert.Equal(t, "fallback3", result3)
})
t.Run("override existing fallback", func(t *testing.T) {
gd.RegisterFallback("service4", func() (interface{}, error) {
return "old fallback", nil
})
gd.RegisterFallback("service4", func() (interface{}, error) {
return "new fallback", nil
})
result, _ := gd.ExecuteWithFallback("service4", func() (interface{}, error) {
return nil, errors.New("fail")
})
assert.Equal(t, "new fallback", result)
})
}
// TestGracefulDegradationRegisterHealthCheck tests health check registration
func TestGracefulDegradationRegisterHealthCheck(t *testing.T) {
logger := GetSingletonNoOpLogger()
config := DefaultGracefulDegradationConfig()
config.HealthCheckInterval = 50 * time.Millisecond
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
t.Run("register health check", func(t *testing.T) {
healthy := true
healthCheck := func() bool {
return healthy
}
gd.RegisterHealthCheck("service1", healthCheck)
// Mark service as degraded
gd.markServiceDegraded("service1")
assert.True(t, gd.isServiceDegraded("service1"))
// Set healthy and wait for health check to run
healthy = true
time.Sleep(100 * time.Millisecond)
// Service should be recovered
// (may still be degraded due to timing, but health check was registered)
})
t.Run("multiple health checks", func(t *testing.T) {
gd.RegisterHealthCheck("service2", func() bool { return true })
gd.RegisterHealthCheck("service3", func() bool { return false })
// Health checks are registered and will be called periodically
})
}
// TestGracefulDegradationExecuteWithContext tests ExecuteWithContext
func TestGracefulDegradationExecuteWithContext(t *testing.T) {
logger := GetSingletonNoOpLogger()
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
t.Run("successful execution", func(t *testing.T) {
ctx := context.Background()
err := gd.ExecuteWithContext(ctx, func() error {
return nil
})
assert.NoError(t, err)
})
t.Run("failed execution", func(t *testing.T) {
ctx := context.Background()
testErr := errors.New("operation failed")
err := gd.ExecuteWithContext(ctx, func() error {
return testErr
})
assert.Error(t, err)
})
t.Run("uses fallback on failure", func(t *testing.T) {
gd.RegisterFallback("default", func() (interface{}, error) {
return nil, nil // Success fallback
})
ctx := context.Background()
err := gd.ExecuteWithContext(ctx, func() error {
return errors.New("primary failed")
})
// With fallback succeeding, overall operation succeeds
assert.NoError(t, err)
})
}
// TestGracefulDegradationExecuteWithFallback tests ExecuteWithFallback
func TestGracefulDegradationExecuteWithFallback(t *testing.T) {
logger := GetSingletonNoOpLogger()
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
t.Run("primary succeeds", func(t *testing.T) {
result, err := gd.ExecuteWithFallback("service1", func() (interface{}, error) {
return "primary result", nil
})
assert.NoError(t, err)
assert.Equal(t, "primary result", result)
})
t.Run("fallback succeeds when primary fails", func(t *testing.T) {
gd.RegisterFallback("service2", func() (interface{}, error) {
return "fallback result", nil
})
result, err := gd.ExecuteWithFallback("service2", func() (interface{}, error) {
return nil, errors.New("primary failed")
})
assert.NoError(t, err)
assert.Equal(t, "fallback result", result)
})
t.Run("error when no fallback available", func(t *testing.T) {
config.EnableFallbacks = false
gdNoFallback := NewGracefulDegradation(config, logger)
defer gdNoFallback.Close()
result, err := gdNoFallback.ExecuteWithFallback("service3", func() (interface{}, error) {
return nil, errors.New("primary failed")
})
assert.Error(t, err)
assert.Nil(t, result)
})
t.Run("fallback also fails", func(t *testing.T) {
gd.RegisterFallback("service4", func() (interface{}, error) {
return nil, errors.New("fallback also failed")
})
result, err := gd.ExecuteWithFallback("service4", func() (interface{}, error) {
return nil, errors.New("primary failed")
})
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "fallback also failed")
})
}
// TestGracefulDegradationIsServiceDegraded tests service degradation status
func TestGracefulDegradationIsServiceDegraded(t *testing.T) {
logger := GetSingletonNoOpLogger()
config := DefaultGracefulDegradationConfig()
config.RecoveryTimeout = 100 * time.Millisecond
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
t.Run("service not degraded initially", func(t *testing.T) {
assert.False(t, gd.isServiceDegraded("new-service"))
})
t.Run("service degraded after marking", func(t *testing.T) {
gd.markServiceDegraded("service1")
assert.True(t, gd.isServiceDegraded("service1"))
})
t.Run("service recovers after timeout", func(t *testing.T) {
gd.markServiceDegraded("service2")
assert.True(t, gd.isServiceDegraded("service2"))
// Wait for recovery timeout
time.Sleep(150 * time.Millisecond)
// Should be recovered
assert.False(t, gd.isServiceDegraded("service2"))
})
}
// TestGracefulDegradationMarkServiceDegraded tests marking services as degraded
func TestGracefulDegradationMarkServiceDegraded(t *testing.T) {
logger := GetSingletonNoOpLogger()
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
t.Run("mark single service", func(t *testing.T) {
gd.markServiceDegraded("service1")
degraded := gd.GetDegradedServices()
assert.Contains(t, degraded, "service1")
})
t.Run("mark multiple services", func(t *testing.T) {
gd.markServiceDegraded("service2")
gd.markServiceDegraded("service3")
degraded := gd.GetDegradedServices()
assert.Contains(t, degraded, "service2")
assert.Contains(t, degraded, "service3")
})
t.Run("marking same service multiple times updates timestamp", func(t *testing.T) {
gd.markServiceDegraded("service4")
time.Sleep(50 * time.Millisecond)
gd.markServiceDegraded("service4")
// Service should still be marked as degraded
assert.True(t, gd.isServiceDegraded("service4"))
})
}
// TestGracefulDegradationExecuteFallback tests fallback execution
func TestGracefulDegradationExecuteFallback(t *testing.T) {
logger := GetSingletonNoOpLogger()
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
t.Run("execute registered fallback", func(t *testing.T) {
gd.RegisterFallback("service1", func() (interface{}, error) {
return "fallback value", nil
})
result, err := gd.executeFallback("service1")
assert.NoError(t, err)
assert.Equal(t, "fallback value", result)
})
t.Run("error when fallback not registered", func(t *testing.T) {
result, err := gd.executeFallback("non-existent-service")
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "no fallback available")
})
t.Run("propagate fallback errors", func(t *testing.T) {
gd.RegisterFallback("service2", func() (interface{}, error) {
return nil, errors.New("fallback error")
})
result, err := gd.executeFallback("service2")
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "fallback error")
})
}
// TestGracefulDegradationReset tests Reset method
func TestGracefulDegradationReset(t *testing.T) {
logger := GetSingletonNoOpLogger()
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
t.Run("reset clears degraded services", func(t *testing.T) {
// Mark several services as degraded
gd.markServiceDegraded("service1")
gd.markServiceDegraded("service2")
gd.markServiceDegraded("service3")
assert.Len(t, gd.GetDegradedServices(), 3)
// Reset
gd.Reset()
// All should be cleared
assert.Len(t, gd.GetDegradedServices(), 0)
})
t.Run("can mark services degraded after reset", func(t *testing.T) {
gd.Reset()
gd.markServiceDegraded("service4")
assert.Len(t, gd.GetDegradedServices(), 1)
assert.Contains(t, gd.GetDegradedServices(), "service4")
})
t.Run("multiple resets are safe", func(t *testing.T) {
assert.NotPanics(t, func() {
gd.Reset()
gd.Reset()
gd.Reset()
})
})
}
// TestGracefulDegradationIsAvailable tests IsAvailable method
func TestGracefulDegradationIsAvailable(t *testing.T) {
logger := GetSingletonNoOpLogger()
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
// Should always return true
assert.True(t, gd.IsAvailable())
// Even with degraded services
gd.markServiceDegraded("service1")
assert.True(t, gd.IsAvailable())
// Even after reset
gd.Reset()
assert.True(t, gd.IsAvailable())
}
// TestGracefulDegradationGetMetrics tests GetMetrics method
func TestGracefulDegradationGetMetrics(t *testing.T) {
logger := GetSingletonNoOpLogger()
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
t.Run("basic metrics", func(t *testing.T) {
metrics := gd.GetMetrics()
require.NotNil(t, metrics)
assert.Contains(t, metrics, "degraded_services_count")
assert.Contains(t, metrics, "degraded_services")
assert.Contains(t, metrics, "registered_fallbacks_count")
assert.Contains(t, metrics, "registered_health_checks_count")
assert.Contains(t, metrics, "health_check_interval_seconds")
assert.Contains(t, metrics, "recovery_timeout_seconds")
assert.Contains(t, metrics, "fallbacks_enabled")
})
t.Run("metrics reflect degraded services", func(t *testing.T) {
gd.Reset()
gd.markServiceDegraded("service1")
gd.markServiceDegraded("service2")
metrics := gd.GetMetrics()
assert.Equal(t, 2, metrics["degraded_services_count"])
degradedList := metrics["degraded_services"].([]string)
assert.Len(t, degradedList, 2)
})
t.Run("metrics reflect registered fallbacks", func(t *testing.T) {
gd.RegisterFallback("service1", func() (interface{}, error) { return nil, nil })
gd.RegisterFallback("service2", func() (interface{}, error) { return nil, nil })
metrics := gd.GetMetrics()
assert.GreaterOrEqual(t, metrics["registered_fallbacks_count"], 2)
})
t.Run("metrics include base metrics", func(t *testing.T) {
metrics := gd.GetMetrics()
// Should include base recovery mechanism metrics
assert.Contains(t, metrics, "name")
assert.Contains(t, metrics, "uptime_seconds")
assert.Contains(t, metrics, "total_requests")
})
}
// TestGracefulDegradationFullScenario tests a complete degradation scenario
func TestGracefulDegradationFullScenario(t *testing.T) {
if testing.Short() {
t.Skip("Skipping full scenario test in short mode")
}
logger := GetSingletonNoOpLogger()
config := DefaultGracefulDegradationConfig()
config.RecoveryTimeout = 200 * time.Millisecond
config.HealthCheckInterval = 50 * time.Millisecond
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
// Register fallback
gd.RegisterFallback("critical-service", func() (interface{}, error) {
return "fallback data", nil
})
// Register health check
serviceHealthy := false
gd.RegisterHealthCheck("critical-service", func() bool {
return serviceHealthy
})
// First call - primary succeeds
result1, err1 := gd.ExecuteWithFallback("critical-service", func() (interface{}, error) {
return "primary data", nil
})
assert.NoError(t, err1)
assert.Equal(t, "primary data", result1)
// Second call - primary fails, fallback succeeds
result2, err2 := gd.ExecuteWithFallback("critical-service", func() (interface{}, error) {
return nil, errors.New("service down")
})
assert.NoError(t, err2)
assert.Equal(t, "fallback data", result2)
// Service is now degraded
assert.True(t, gd.isServiceDegraded("critical-service"))
// Third call - should use fallback immediately
result3, err3 := gd.ExecuteWithFallback("critical-service", func() (interface{}, error) {
return "should not be called", nil
})
assert.NoError(t, err3)
assert.Equal(t, "fallback data", result3)
// Mark service as healthy and wait for health check
serviceHealthy = true
time.Sleep(250 * time.Millisecond)
// Service should be recovered
// (timing-dependent, so we don't assert)
// Get metrics
metrics := gd.GetMetrics()
assert.NotNil(t, metrics)
}
+29
View File
@@ -0,0 +1,29 @@
package traefikoidc
import "testing"
func BenchmarkDefaultCircuitBreakerConfig(b *testing.B) {
for i := 0; i < b.N; i++ {
DefaultCircuitBreakerConfig()
}
}
func BenchmarkBaseRecoveryMechanism_GetBaseMetrics(b *testing.B) {
logger := GetSingletonNoOpLogger()
base := NewBaseRecoveryMechanism("test-mechanism", logger)
b.ResetTimer()
for i := 0; i < b.N; i++ {
base.GetBaseMetrics()
}
}
func BenchmarkBaseRecoveryMechanism_RecordRequest(b *testing.B) {
logger := GetSingletonNoOpLogger()
base := NewBaseRecoveryMechanism("test-mechanism", logger)
b.ResetTimer()
for i := 0; i < b.N; i++ {
base.RecordRequest()
}
}
-663
View File
@@ -1,663 +0,0 @@
package traefikoidc
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// TestCircuitBreakerAllowRequestEdgeCases tests edge cases in circuit breaker request allowing
func TestCircuitBreakerAllowRequestEdgeCases(t *testing.T) {
logger := GetSingletonNoOpLogger()
t.Run("invalid state returns false", func(t *testing.T) {
config := DefaultCircuitBreakerConfig()
cb := NewCircuitBreaker(config, logger)
// Force invalid state
cb.mutex.Lock()
cb.state = CircuitBreakerState(999) // Invalid state
cb.mutex.Unlock()
// Should return false for invalid state
allowed := cb.allowRequest()
assert.False(t, allowed, "invalid state should not allow requests")
})
t.Run("open to half-open transition on timeout", func(t *testing.T) {
baseTimeout := GetTestDuration(50 * time.Millisecond)
config := CircuitBreakerConfig{
MaxFailures: 1,
Timeout: baseTimeout,
ResetTimeout: 30 * time.Second,
}
cb := NewCircuitBreaker(config, logger)
// Trip the circuit
cb.Execute(func() error { return errors.New("fail") })
// Verify circuit is open
assert.Equal(t, CircuitBreakerOpen, cb.GetState())
assert.False(t, cb.allowRequest())
// Wait for timeout (longer than timeout to ensure transition)
time.Sleep(baseTimeout + GetTestDuration(20*time.Millisecond))
// Should transition to half-open
allowed := cb.allowRequest()
assert.True(t, allowed, "should allow request after timeout")
assert.Equal(t, CircuitBreakerHalfOpen, cb.GetState())
})
t.Run("half-open allows requests", func(t *testing.T) {
config := DefaultCircuitBreakerConfig()
cb := NewCircuitBreaker(config, logger)
// Manually set to half-open
cb.mutex.Lock()
cb.state = CircuitBreakerHalfOpen
cb.mutex.Unlock()
allowed := cb.allowRequest()
assert.True(t, allowed, "half-open should allow requests")
})
t.Run("open blocks requests before timeout", func(t *testing.T) {
config := CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 1 * time.Hour, // Long timeout
ResetTimeout: 30 * time.Second,
}
cb := NewCircuitBreaker(config, logger)
// Trip the circuit
cb.Execute(func() error { return errors.New("fail") })
// Should be blocked
allowed := cb.allowRequest()
assert.False(t, allowed, "open circuit should block requests")
})
}
// TestRetryExecutorIsRetryableErrorEdgeCases tests edge cases for retry decision
func TestRetryExecutorIsRetryableErrorEdgeCases(t *testing.T) {
logger := GetSingletonNoOpLogger()
config := DefaultRetryConfig()
re := NewRetryExecutor(config, logger)
t.Run("nil error is not retryable", func(t *testing.T) {
retryable := re.isRetryableError(nil)
assert.False(t, retryable)
})
t.Run("HTTPError with 429 is retryable", func(t *testing.T) {
httpErr := &HTTPError{
StatusCode: 429,
Message: "Too Many Requests",
}
retryable := re.isRetryableError(httpErr)
assert.True(t, retryable, "429 Too Many Requests should be retryable")
})
t.Run("HTTPError with 500 is retryable", func(t *testing.T) {
httpErr := &HTTPError{
StatusCode: 500,
Message: "Internal Server Error",
}
retryable := re.isRetryableError(httpErr)
assert.True(t, retryable, "500 errors should be retryable")
})
t.Run("HTTPError with 503 is retryable", func(t *testing.T) {
httpErr := &HTTPError{
StatusCode: 503,
Message: "Service Unavailable",
}
retryable := re.isRetryableError(httpErr)
assert.True(t, retryable, "503 errors should be retryable")
})
t.Run("HTTPError with 400 is not retryable", func(t *testing.T) {
httpErr := &HTTPError{
StatusCode: 400,
Message: "Bad Request",
}
retryable := re.isRetryableError(httpErr)
assert.False(t, retryable, "400 errors should not be retryable")
})
t.Run("net.Error with timeout is retryable", func(t *testing.T) {
netErr := &mockNetError{
timeout: true,
temporary: false,
msg: "timeout error",
}
retryable := re.isRetryableError(netErr)
assert.True(t, retryable, "timeout errors should be retryable")
})
t.Run("net.Error with connection refused is retryable", func(t *testing.T) {
netErr := &mockNetError{
timeout: false,
temporary: false,
msg: "connection refused",
}
retryable := re.isRetryableError(netErr)
assert.True(t, retryable, "connection refused should be retryable")
})
t.Run("net.Error with connection reset is retryable", func(t *testing.T) {
netErr := &mockNetError{
timeout: false,
temporary: false,
msg: "connection reset by peer",
}
retryable := re.isRetryableError(netErr)
assert.True(t, retryable, "connection reset should be retryable")
})
t.Run("net.Error with network unreachable is retryable", func(t *testing.T) {
netErr := &mockNetError{
timeout: false,
temporary: false,
msg: "network is unreachable",
}
retryable := re.isRetryableError(netErr)
assert.True(t, retryable, "network unreachable should be retryable")
})
t.Run("net.Error with no route to host is retryable", func(t *testing.T) {
netErr := &mockNetError{
timeout: false,
temporary: false,
msg: "no route to host",
}
retryable := re.isRetryableError(netErr)
assert.True(t, retryable, "no route to host should be retryable")
})
t.Run("net.Error with temporary failure is retryable", func(t *testing.T) {
netErr := &mockNetError{
timeout: false,
temporary: false,
msg: "temporary failure in name resolution",
}
retryable := re.isRetryableError(netErr)
assert.True(t, retryable, "temporary failure should be retryable")
})
t.Run("net.Error with try again is retryable", func(t *testing.T) {
netErr := &mockNetError{
timeout: false,
temporary: false,
msg: "try again later",
}
retryable := re.isRetryableError(netErr)
assert.True(t, retryable, "try again should be retryable")
})
t.Run("net.Error with resource temporarily unavailable is retryable", func(t *testing.T) {
netErr := &mockNetError{
timeout: false,
temporary: false,
msg: "resource temporarily unavailable",
}
retryable := re.isRetryableError(netErr)
assert.True(t, retryable, "resource temporarily unavailable should be retryable")
})
t.Run("configured retryable error patterns", func(t *testing.T) {
err := errors.New("connection refused by server")
retryable := re.isRetryableError(err)
assert.True(t, retryable, "configured pattern should be retryable")
})
t.Run("non-retryable error", func(t *testing.T) {
err := errors.New("invalid input data")
retryable := re.isRetryableError(err)
assert.False(t, retryable, "non-configured error should not be retryable")
})
}
// TestRetryExecutorCalculateDelayEdgeCases tests delay calculation edge cases
func TestRetryExecutorCalculateDelayEdgeCases(t *testing.T) {
logger := GetSingletonNoOpLogger()
t.Run("delay calculation without jitter", func(t *testing.T) {
config := RetryConfig{
MaxAttempts: 3,
InitialDelay: 100 * time.Millisecond,
MaxDelay: 5 * time.Second,
BackoffFactor: 2.0,
EnableJitter: false, // Jitter disabled
}
re := NewRetryExecutor(config, logger)
// Attempt 1: 100ms * 2^0 = 100ms
delay1 := re.calculateDelay(1)
assert.Equal(t, 100*time.Millisecond, delay1)
// Attempt 2: 100ms * 2^1 = 200ms
delay2 := re.calculateDelay(2)
assert.Equal(t, 200*time.Millisecond, delay2)
// Attempt 3: 100ms * 2^2 = 400ms
delay3 := re.calculateDelay(3)
assert.Equal(t, 400*time.Millisecond, delay3)
})
t.Run("delay calculation with jitter", func(t *testing.T) {
config := RetryConfig{
MaxAttempts: 3,
InitialDelay: 100 * time.Millisecond,
MaxDelay: 5 * time.Second,
BackoffFactor: 2.0,
EnableJitter: true, // Jitter enabled
}
re := NewRetryExecutor(config, logger)
// With jitter, delay should be within 10% of expected
delay := re.calculateDelay(2)
expectedBase := 200 * time.Millisecond
minDelay := time.Duration(float64(expectedBase) * 0.9)
maxDelay := time.Duration(float64(expectedBase) * 1.1)
assert.GreaterOrEqual(t, delay, minDelay, "delay should be >= 90% of base")
assert.LessOrEqual(t, delay, maxDelay, "delay should be <= 110% of base")
})
t.Run("delay capped at max delay", func(t *testing.T) {
config := RetryConfig{
MaxAttempts: 10,
InitialDelay: 100 * time.Millisecond,
MaxDelay: 500 * time.Millisecond, // Low max delay
BackoffFactor: 2.0,
EnableJitter: false,
}
re := NewRetryExecutor(config, logger)
// Attempt 10: would be 100ms * 2^9 = 51200ms, but capped at 500ms
delay := re.calculateDelay(10)
assert.Equal(t, 500*time.Millisecond, delay, "delay should be capped at max")
})
t.Run("delay with large backoff factor", func(t *testing.T) {
config := RetryConfig{
MaxAttempts: 5,
InitialDelay: 50 * time.Millisecond,
MaxDelay: 10 * time.Second,
BackoffFactor: 3.0, // Larger backoff
EnableJitter: false,
}
re := NewRetryExecutor(config, logger)
// Attempt 3: 50ms * 3^2 = 450ms
delay := re.calculateDelay(3)
assert.Equal(t, 450*time.Millisecond, delay)
})
}
// TestErrorTypesErrorMethodsWithoutCause tests error type Error() methods without cause
func TestErrorTypesErrorMethodsWithoutCause(t *testing.T) {
t.Run("HTTPError.Error without cause", func(t *testing.T) {
httpErr := &HTTPError{
StatusCode: 404,
Message: "Not Found",
}
errStr := httpErr.Error()
assert.Equal(t, "HTTP 404: Not Found", errStr)
})
t.Run("HTTPError.Error with different status codes", func(t *testing.T) {
testCases := []struct {
code int
message string
expected string
}{
{200, "OK", "HTTP 200: OK"},
{301, "Moved", "HTTP 301: Moved"},
{401, "Unauthorized", "HTTP 401: Unauthorized"},
{500, "Server Error", "HTTP 500: Server Error"},
}
for _, tc := range testCases {
httpErr := &HTTPError{
StatusCode: tc.code,
Message: tc.message,
}
assert.Equal(t, tc.expected, httpErr.Error())
}
})
t.Run("OIDCError.Error without cause", func(t *testing.T) {
oidcErr := &OIDCError{
Code: "invalid_token",
Message: "Token validation failed",
Context: make(map[string]interface{}),
}
errStr := oidcErr.Error()
assert.Equal(t, "OIDC error [invalid_token]: Token validation failed", errStr)
})
t.Run("OIDCError.Error with cause", func(t *testing.T) {
rootErr := errors.New("signature mismatch")
oidcErr := &OIDCError{
Code: "invalid_signature",
Message: "JWT signature invalid",
Context: make(map[string]interface{}),
Cause: rootErr,
}
errStr := oidcErr.Error()
assert.Contains(t, errStr, "OIDC error [invalid_signature]: JWT signature invalid")
assert.Contains(t, errStr, "caused by: signature mismatch")
})
t.Run("SessionError.Error without cause", func(t *testing.T) {
sessErr := &SessionError{
Operation: "load",
Message: "Session not found",
SessionID: "sess123",
}
errStr := sessErr.Error()
assert.Equal(t, "Session error in load: Session not found", errStr)
})
t.Run("SessionError.Error with cause", func(t *testing.T) {
rootErr := errors.New("database connection failed")
sessErr := &SessionError{
Operation: "save",
Message: "Failed to persist session",
SessionID: "sess456",
Cause: rootErr,
}
errStr := sessErr.Error()
assert.Contains(t, errStr, "Session error in save: Failed to persist session")
assert.Contains(t, errStr, "caused by: database connection failed")
})
t.Run("TokenError.Error without cause", func(t *testing.T) {
tokenErr := &TokenError{
TokenType: "access_token",
Reason: "expired",
Message: "Token has expired",
}
errStr := tokenErr.Error()
assert.Equal(t, "Token error (access_token) - expired: Token has expired", errStr)
})
t.Run("TokenError.Error with cause", func(t *testing.T) {
rootErr := errors.New("time check failed")
tokenErr := &TokenError{
TokenType: "id_token",
Reason: "expired",
Message: "Token validity period exceeded",
Cause: rootErr,
}
errStr := tokenErr.Error()
assert.Contains(t, errStr, "Token error (id_token) - expired: Token validity period exceeded")
assert.Contains(t, errStr, "caused by: time check failed")
})
}
// TestGracefulDegradationHealthChecks tests health check functionality
func TestGracefulDegradationHealthChecks(t *testing.T) {
logger := GetSingletonNoOpLogger()
t.Run("performHealthChecks recovers degraded service", func(t *testing.T) {
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
// Register health check that returns true
healthCheckCalled := false
gd.RegisterHealthCheck("test-service", func() bool {
healthCheckCalled = true
return true // Service is healthy
})
// Mark service as degraded
gd.markServiceDegraded("test-service")
// Verify service is degraded
assert.True(t, gd.isServiceDegraded("test-service"))
// Manually trigger health check
gd.performHealthChecks()
// Health check should have been called
assert.True(t, healthCheckCalled, "health check should be called")
// Service should be recovered
assert.False(t, gd.isServiceDegraded("test-service"), "service should be recovered")
})
t.Run("performHealthChecks marks service degraded on failure", func(t *testing.T) {
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
// Register health check that returns false
gd.RegisterHealthCheck("failing-service", func() bool {
return false // Service is unhealthy
})
// Initially not degraded
assert.False(t, gd.isServiceDegraded("failing-service"))
// Manually trigger health check
gd.performHealthChecks()
// Service should be marked degraded
assert.True(t, gd.isServiceDegraded("failing-service"), "service should be degraded")
})
t.Run("performHealthChecks runs multiple health checks independently", func(t *testing.T) {
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
service1Checked := false
service2Checked := false
gd.RegisterHealthCheck("service1", func() bool {
service1Checked = true
return true
})
gd.RegisterHealthCheck("service2", func() bool {
service2Checked = true
return true
})
// Manually trigger health checks
gd.performHealthChecks()
assert.True(t, service1Checked, "service1 health check should run")
assert.True(t, service2Checked, "service2 health check should run")
})
t.Run("performHealthChecks handles empty health checks", func(t *testing.T) {
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
// Call performHealthChecks with no registered health checks
// Should not panic
assert.NotPanics(t, func() {
gd.performHealthChecks()
})
})
}
// TestGracefulDegradationServiceRecoveryTimeout tests recovery timeout behavior
func TestGracefulDegradationServiceRecoveryTimeout(t *testing.T) {
logger := GetSingletonNoOpLogger()
t.Run("service auto-recovers after timeout", func(t *testing.T) {
baseTimeout := GetTestDuration(50 * time.Millisecond)
config := GracefulDegradationConfig{
HealthCheckInterval: 1 * time.Hour, // Long interval, won't run during test
RecoveryTimeout: baseTimeout,
EnableFallbacks: true,
}
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
// Mark service degraded
gd.markServiceDegraded("auto-recover-service")
// Verify degraded
assert.True(t, gd.isServiceDegraded("auto-recover-service"))
// Wait for recovery timeout (longer than timeout to ensure recovery)
time.Sleep(baseTimeout + GetTestDuration(20*time.Millisecond))
// Should auto-recover
assert.False(t, gd.isServiceDegraded("auto-recover-service"), "service should auto-recover after timeout")
})
t.Run("service remains degraded before timeout", func(t *testing.T) {
config := GracefulDegradationConfig{
HealthCheckInterval: 1 * time.Hour,
RecoveryTimeout: 1 * time.Hour, // Very long timeout
EnableFallbacks: true,
}
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
// Mark service degraded
gd.markServiceDegraded("long-timeout-service")
// Verify degraded
assert.True(t, gd.isServiceDegraded("long-timeout-service"))
// Wait a bit
time.Sleep(GetTestDuration(10 * time.Millisecond))
// Should still be degraded
assert.True(t, gd.isServiceDegraded("long-timeout-service"), "service should remain degraded before timeout")
})
}
// TestErrorRecoveryManagerIntegration tests full integration of error recovery mechanisms
func TestErrorRecoveryManagerIntegration(t *testing.T) {
logger := GetSingletonNoOpLogger()
erm := NewErrorRecoveryManager(logger)
t.Run("circuit breaker and retry integration", func(t *testing.T) {
// Create a circuit breaker with higher max failures to allow retries
cb := NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: 10, // High threshold
Timeout: 60 * time.Second,
ResetTimeout: 30 * time.Second,
}, logger)
erm.mutex.Lock()
erm.circuitBreakers["test-service-integration"] = cb
erm.mutex.Unlock()
attempts := 0
fn := func() error {
attempts++
if attempts < 3 {
return errors.New("temporary failure")
}
return nil
}
err := erm.ExecuteWithRecovery(context.Background(), "test-service-integration", fn)
assert.NoError(t, err)
assert.GreaterOrEqual(t, attempts, 3, "should retry until success")
})
t.Run("circuit breaker opens on repeated failures", func(t *testing.T) {
fn := func() error {
return errors.New("persistent failure")
}
// First call - should fail after retries
err1 := erm.ExecuteWithRecovery(context.Background(), "failing-service", fn)
assert.Error(t, err1)
// Second call - should fail after retries
err2 := erm.ExecuteWithRecovery(context.Background(), "failing-service", fn)
assert.Error(t, err2)
// Check circuit breaker state
cb := erm.GetCircuitBreaker("failing-service")
state := cb.GetState()
assert.Equal(t, CircuitBreakerOpen, state, "circuit should be open after repeated failures")
})
t.Run("recovery metrics include all mechanisms", func(t *testing.T) {
metrics := erm.GetRecoveryMetrics()
assert.NotNil(t, metrics)
assert.Contains(t, metrics, "circuit_breakers")
assert.Contains(t, metrics, "degraded_services")
})
}
// TestContainsHelperFunction tests the contains helper function edge cases
func TestContainsHelperFunction(t *testing.T) {
t.Run("exact match", func(t *testing.T) {
assert.True(t, contains("timeout", "timeout"))
})
t.Run("prefix match", func(t *testing.T) {
assert.True(t, contains("timeout error occurred", "timeout"))
})
t.Run("suffix match", func(t *testing.T) {
assert.True(t, contains("connection timeout", "timeout"))
})
t.Run("middle match", func(t *testing.T) {
assert.True(t, contains("a connection timeout error", "timeout"))
})
t.Run("no match", func(t *testing.T) {
assert.False(t, contains("connection refused", "timeout"))
})
t.Run("substring longer than string", func(t *testing.T) {
assert.False(t, contains("abc", "abcdef"))
})
t.Run("empty substring", func(t *testing.T) {
assert.True(t, contains("test", ""))
})
t.Run("empty string", func(t *testing.T) {
assert.False(t, contains("", "test"))
})
t.Run("both empty", func(t *testing.T) {
assert.True(t, contains("", ""))
})
}
+931 -83
View File
File diff suppressed because it is too large Load Diff
-797
View File
@@ -1,797 +0,0 @@
package features
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"text/template"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Mock types for testing
type TemplatedHeader struct {
Name string `json:"name"`
Value string `json:"value"`
}
type MockConfig struct {
ProviderURL string `json:"providerURL"`
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
CallbackURL string `json:"callbackURL"`
SessionEncryptionKey string `json:"sessionEncryptionKey"`
Headers []TemplatedHeader `json:"headers"`
}
// TestTemplateHeaderFeatures consolidates all template header-related tests
func TestTemplateHeaderFeatures(t *testing.T) {
t.Run("Issue55_TemplateExecutionWithWrongTypes", testIssue55TemplateExecutionWithWrongTypes)
t.Run("Template_Parsing_Validation", testTemplateParsingValidation)
t.Run("Middleware_Header_Templating", testMiddlewareHeaderTemplating)
t.Run("JSON_Config_Parsing", testJSONConfigParsing)
t.Run("Template_Double_Processing", testTemplateDoubleProcessing)
t.Run("Template_Execution_Context", testTemplateExecutionContext)
t.Run("Template_Integration_With_Plugin", testTemplateIntegrationWithPlugin)
t.Run("Template_Syntax_Validation", testTemplateSyntaxValidation)
t.Run("Missing_Field_Handling", testMissingFieldHandling)
t.Run("Complex_Template_Expressions", testComplexTemplateExpressions)
t.Run("Traefik_Configuration_Parsing", testTraefikConfigurationParsing)
}
// testIssue55TemplateExecutionWithWrongTypes tests what happens when templates
// receive wrong data types during execution - reproduces GitHub issue #55
func testIssue55TemplateExecutionWithWrongTypes(t *testing.T) {
testCases := []struct {
name string
templateText string
templateData interface{}
errorContains string
expectError bool
}{
{
name: "correct map data",
templateText: "Bearer {{.AccessToken}}",
templateData: map[string]interface{}{
"AccessToken": "valid-token",
},
expectError: false,
},
{
name: "boolean as root context - reproduces issue #55",
templateText: "Bearer {{.AccessToken}}",
templateData: true,
expectError: true,
errorContains: "can't evaluate field AccessToken in type bool",
},
{
name: "string as root context",
templateText: "Bearer {{.AccessToken}}",
templateData: "just a string",
expectError: true,
errorContains: "can't evaluate field AccessToken in type string",
},
{
name: "nested claims access with correct data",
templateText: "User: {{.Claims.email}}",
templateData: map[string]interface{}{
"Claims": map[string]interface{}{
"email": "user@example.com",
},
},
expectError: false,
},
{
name: "nested claims with wrong structure",
templateText: "User: {{.Claims.email}}",
templateData: map[string]interface{}{
"Claims": "not a map",
},
expectError: true,
errorContains: "can't evaluate field email in type",
},
{
name: "complex nested structure",
templateText: "{{.Claims.sub}} - {{.Claims.groups}} - {{.AccessToken}}",
templateData: map[string]interface{}{
"AccessToken": "token123",
"Claims": map[string]interface{}{
"sub": "user-id",
"groups": "admin,users",
},
},
expectError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
require.NoError(t, err)
var buf bytes.Buffer
err = tmpl.Execute(&buf, tc.templateData)
if tc.expectError {
require.Error(t, err)
if tc.errorContains != "" {
assert.Contains(t, err.Error(), tc.errorContains)
}
} else {
require.NoError(t, err)
}
})
}
}
// testTemplateParsingValidation ensures templates are parsed correctly
func testTemplateParsingValidation(t *testing.T) {
testCases := []struct {
name string
headerTemplates []TemplatedHeader
shouldError bool
}{
{
name: "valid bearer token template",
headerTemplates: []TemplatedHeader{
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
},
shouldError: false,
},
{
name: "multiple valid templates",
headerTemplates: []TemplatedHeader{
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-ID", Value: "{{.Claims.sub}}"},
},
shouldError: false,
},
{
name: "template with conditional logic",
headerTemplates: []TemplatedHeader{
{Name: "X-Auth-Info", Value: "{{if .AccessToken}}Bearer {{.AccessToken}}{{else}}No Token{{end}}"},
},
shouldError: false,
},
{
name: "invalid template syntax",
headerTemplates: []TemplatedHeader{
{Name: "Bad-Template", Value: "{{.AccessToken"},
},
shouldError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
for _, header := range tc.headerTemplates {
_, err := template.New(header.Name).Parse(header.Value)
if tc.shouldError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
}
})
}
}
// testMiddlewareHeaderTemplating simulates the actual middleware flow
func testMiddlewareHeaderTemplating(t *testing.T) {
testCases := []struct {
name string
headers []TemplatedHeader
accessToken string
idToken string
claims map[string]interface{}
expectedValues map[string]string
}{
{
name: "authorization header with access token",
headers: []TemplatedHeader{
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
},
accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
expectedValues: map[string]string{
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
},
},
{
name: "multiple headers with claims",
headers: []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-Groups", Value: "{{.Claims.groups}}"},
{Name: "X-Auth-Token", Value: "{{.AccessToken}}"},
},
accessToken: "token123",
claims: map[string]interface{}{
"email": "user@example.com",
"groups": "admin,developers",
},
expectedValues: map[string]string{
"X-User-Email": "user@example.com",
"X-User-Groups": "admin,developers",
"X-Auth-Token": "token123",
},
},
{
name: "complex template expressions",
headers: []TemplatedHeader{
{Name: "X-User-Info", Value: "{{.Claims.sub}} ({{.Claims.email}})"},
{Name: "X-Auth-Header", Value: "Bearer {{.AccessToken}} | ID: {{.IDToken}}"},
},
accessToken: "access-token",
idToken: "id-token",
claims: map[string]interface{}{
"sub": "user-12345",
"email": "john@example.com",
},
expectedValues: map[string]string{
"X-User-Info": "user-12345 (john@example.com)",
"X-Auth-Header": "Bearer access-token | ID: id-token",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Parse all templates
headerTemplates := make(map[string]*template.Template)
for _, header := range tc.headers {
tmpl, err := template.New(header.Name).Parse(header.Value)
require.NoError(t, err)
headerTemplates[header.Name] = tmpl
}
// Create template data
templateData := map[string]interface{}{
"AccessToken": tc.accessToken,
"IDToken": tc.idToken,
"Claims": tc.claims,
}
// Create a test request
req := httptest.NewRequest("GET", "/test", nil)
// Execute templates and set headers
for headerName, tmpl := range headerTemplates {
var buf bytes.Buffer
err := tmpl.Execute(&buf, templateData)
require.NoError(t, err)
req.Header.Set(headerName, buf.String())
}
// Verify all expected headers are set correctly
for headerName, expectedValue := range tc.expectedValues {
actualValue := req.Header.Get(headerName)
assert.Equal(t, expectedValue, actualValue)
}
})
}
}
// testJSONConfigParsing tests that JSON configuration is properly parsed
func testJSONConfigParsing(t *testing.T) {
testCases := []struct {
name string
jsonConfig string
expectedError bool
description string
}{
{
name: "valid JSON configuration",
jsonConfig: `{
"headers": [
{
"name": "Authorization",
"value": "Bearer {{.AccessToken}}"
}
]
}`,
expectedError: false,
description: "Properly formatted JSON with string values",
},
{
name: "JSON with boolean value",
jsonConfig: `{
"headers": [
{
"name": "Authorization",
"value": true
}
]
}`,
expectedError: true,
description: "Boolean value instead of string template",
},
{
name: "JSON with number value",
jsonConfig: `{
"headers": [
{
"name": "Authorization",
"value": 123
}
]
}`,
expectedError: true,
description: "Number value instead of string template",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var config struct {
Headers []TemplatedHeader `json:"headers"`
}
err := json.Unmarshal([]byte(tc.jsonConfig), &config)
if tc.expectedError {
require.Error(t, err, tc.description)
} else {
require.NoError(t, err, tc.description)
}
})
}
}
// testTemplateDoubleProcessing tests if template strings are being double-processed
func testTemplateDoubleProcessing(t *testing.T) {
// Simulate how Traefik passes config to the plugin
config := &MockConfig{
Headers: []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-Role", Value: "{{.Claims.internal_role}}"},
},
}
// Verify that template strings are still raw (not processed)
assert.Equal(t, "{{.Claims.email}}", config.Headers[0].Value)
assert.Equal(t, "{{.Claims.internal_role}}", config.Headers[1].Value)
// Simulate template parsing during initialization
headerTemplates := make(map[string]*template.Template)
funcMap := template.FuncMap{
"default": func(defaultVal interface{}, val interface{}) interface{} {
if val == nil || val == "" || val == "<no value>" {
return defaultVal
}
return val
},
"get": func(m interface{}, key string) interface{} {
if mapVal, ok := m.(map[string]interface{}); ok {
if val, exists := mapVal[key]; exists {
return val
}
}
return ""
},
}
for _, header := range config.Headers {
tmpl := template.New(header.Name).Funcs(funcMap).Option("missingkey=zero")
parsedTmpl, err := tmpl.Parse(header.Value)
require.NoError(t, err)
headerTemplates[header.Name] = parsedTmpl
}
// Test execution with actual claims
claims := map[string]interface{}{
"email": "user@example.com",
// Note: internal_role is missing
}
templateData := map[string]interface{}{
"Claims": claims,
}
// Execute templates
for headerName, tmpl := range headerTemplates {
var buf bytes.Buffer
err := tmpl.Execute(&buf, templateData)
require.NoError(t, err)
result := buf.String()
if headerName == "X-User-Email" {
assert.Equal(t, "user@example.com", result)
} else if headerName == "X-User-Role" {
// With missingkey=zero, missing fields return "<no value>"
assert.Equal(t, "<no value>", result)
}
}
}
// testTemplateExecutionContext tests the specific template data context
func testTemplateExecutionContext(t *testing.T) {
testCases := []struct {
name string
templateText string
data map[string]interface{}
expectedValue string
}{
{
name: "Access and ID token distinction",
templateText: "Access: {{.AccessToken}} ID: {{.IDToken}}",
data: map[string]interface{}{
"AccessToken": "access-token-value",
"IDToken": "id-token-value",
"Claims": map[string]interface{}{},
},
expectedValue: "Access: access-token-value ID: id-token-value",
},
{
name: "Combining tokens and claims",
templateText: "User: {{.Claims.sub}} Token: {{.AccessToken}}",
data: map[string]interface{}{
"AccessToken": "access-token",
"IDToken": "id-token",
"Claims": map[string]interface{}{
"sub": "user123",
},
},
expectedValue: "User: user123 Token: access-token",
},
{
name: "Custom non-standard claims",
templateText: "X-User-Role: {{.Claims.role}}, X-User-Permissions: {{.Claims.permissions}}",
data: map[string]interface{}{
"AccessToken": "access-token-value",
"Claims": map[string]interface{}{
"role": "admin",
"permissions": "read:all,write:own",
},
},
expectedValue: "X-User-Role: admin, X-User-Permissions: read:all,write:own",
},
{
name: "Deeply nested custom claims",
templateText: "X-Organization: {{.Claims.app_metadata.organization.name}}, X-Team: {{.Claims.app_metadata.team}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"app_metadata": map[string]interface{}{
"organization": map[string]interface{}{
"name": "acme-corp",
},
"team": "platform",
},
},
},
expectedValue: "X-Organization: acme-corp, X-Team: platform",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
require.NoError(t, err)
var buf bytes.Buffer
err = tmpl.Execute(&buf, tc.data)
require.NoError(t, err)
assert.Equal(t, tc.expectedValue, buf.String())
})
}
}
// testTemplateIntegrationWithPlugin tests template processing in the actual plugin
func testTemplateIntegrationWithPlugin(t *testing.T) {
// Test template integration using mock plugin components
// Set up test OIDC server
var testServerURL string
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
json.NewEncoder(w).Encode(map[string]interface{}{
"issuer": testServerURL,
"authorization_endpoint": testServerURL + "/auth",
"token_endpoint": testServerURL + "/token",
"jwks_uri": testServerURL + "/jwks",
"userinfo_endpoint": testServerURL + "/userinfo",
})
case "/jwks":
json.NewEncoder(w).Encode(map[string]interface{}{
"keys": []interface{}{},
})
default:
http.NotFound(w, r)
}
}))
defer testServer.Close()
testServerURL = testServer.URL
// Create config with templates that reference potentially missing fields
config := &MockConfig{
ProviderURL: testServer.URL,
ClientID: "test-client",
ClientSecret: "test-secret",
CallbackURL: "/callback",
SessionEncryptionKey: "test-encryption-key-32-characters",
Headers: []TemplatedHeader{
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-Role", Value: "{{.Claims.internal_role}}"},
},
}
// Initialize plugin would be done here
ctx := context.Background()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Test would create plugin handler here
_ = ctx
_ = next
_ = config
}
// testTemplateSyntaxValidation tests that template syntax is properly validated
func testTemplateSyntaxValidation(t *testing.T) {
validTemplates := []string{
"{{.Claims.email}}",
"{{.Claims.internal_role}}",
"{{.AccessToken}}",
"{{.IdToken}}",
"{{.RefreshToken}}",
}
for _, tmplStr := range validTemplates {
err := validateTemplateSecure(tmplStr)
assert.NoError(t, err, "Template should be valid: %s", tmplStr)
}
// Test invalid templates
invalidTemplates := []struct {
template string
reason string
}{
{"{{call .SomeFunc}}", "function calls not allowed"},
{"{{range .Items}}{{.}}{{end}}", "range not allowed"},
{"{{with .Data}}{{.Field}}{{end}}", "with statements blocked"},
{"{{index .Array 0}}", "index access blocked"},
{"{{printf \"%s\" .Data}}", "printf blocked"},
}
for _, tc := range invalidTemplates {
err := validateTemplateSecure(tc.template)
assert.Error(t, err, "Template should be invalid: %s (%s)", tc.template, tc.reason)
assert.Contains(t, strings.ToLower(err.Error()), "dangerous")
}
// Test safe custom functions
safeTemplates := []string{
"{{get .Claims \"internal_role\"}}",
"{{default \"guest\" .Claims.role}}",
}
for _, tmplStr := range safeTemplates {
err := validateTemplateSecure(tmplStr)
assert.NoError(t, err, "Safe custom functions should be allowed: %s", tmplStr)
}
}
// Mock validation function for template security
func validateTemplateSecure(templateStr string) error {
// List of potentially dangerous template actions
dangerousFunctions := []string{
"call", "range", "with", "index", "printf", "println", "print",
"js", "html", "urlquery", "base64", "exec",
}
for _, dangerous := range dangerousFunctions {
if strings.Contains(templateStr, dangerous) {
return fmt.Errorf("dangerous template function detected: %s", dangerous)
}
}
// Define safe custom functions
funcMap := template.FuncMap{
"get": func(data map[string]interface{}, key string) interface{} {
return data[key]
},
"default": func(defaultVal interface{}, val interface{}) interface{} {
if val == nil || val == "" {
return defaultVal
}
return val
},
}
// Try to parse the template with custom functions to check for syntax errors
_, err := template.New("test").Funcs(funcMap).Parse(templateStr)
return err
}
// testMissingFieldHandling tests handling of missing fields in templates
func testMissingFieldHandling(t *testing.T) {
testCases := []struct {
name string
templateText string
data map[string]interface{}
expected string
}{
{
name: "missing claim field",
templateText: "{{.Claims.missing}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{},
},
expected: "<no value>",
},
{
name: "missing nested field",
templateText: "{{.Claims.user.missing}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"user": map[string]interface{}{},
},
},
expected: "<no value>",
},
{
name: "missing entire path",
templateText: "{{.Missing.Path.Field}}",
data: map[string]interface{}{},
expected: "<no value>",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
require.NoError(t, err)
var buf bytes.Buffer
err = tmpl.Execute(&buf, tc.data)
require.NoError(t, err)
assert.Equal(t, tc.expected, buf.String())
})
}
}
// testComplexTemplateExpressions tests complex template expressions
func testComplexTemplateExpressions(t *testing.T) {
testCases := []struct {
name string
templateText string
data map[string]interface{}
expected string
}{
{
name: "conditional template",
templateText: "{{if .Claims.admin}}Admin User{{else}}Regular User{{end}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"admin": true,
},
},
expected: "Admin User",
},
{
name: "multiple claims concatenation",
templateText: "{{.Claims.firstName}} {{.Claims.lastName}} <{{.Claims.email}}>",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com",
},
},
expected: "John Doe <john.doe@example.com>",
},
{
name: "array access",
templateText: "{{index .Claims.roles 0}}",
data: map[string]interface{}{
"Claims": map[string]interface{}{
"roles": []string{"admin", "user"},
},
},
expected: "admin",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
require.NoError(t, err)
var buf bytes.Buffer
err = tmpl.Execute(&buf, tc.data)
require.NoError(t, err)
assert.Equal(t, tc.expected, buf.String())
})
}
}
// testTraefikConfigurationParsing tests various ways Traefik might pass configuration
func testTraefikConfigurationParsing(t *testing.T) {
testCases := []struct {
name string
config *MockConfig
expectError bool
description string
}{
{
name: "valid configuration with templated headers",
config: &MockConfig{
ProviderURL: "https://accounts.google.com",
ClientID: "test-client",
ClientSecret: "test-secret",
SessionEncryptionKey: "test-encryption-key-32-bytes-long",
CallbackURL: "/oauth2/callback",
Headers: []TemplatedHeader{
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
},
},
expectError: false,
description: "Standard configuration should work",
},
{
name: "configuration with multiple headers",
config: &MockConfig{
ProviderURL: "https://accounts.google.com",
ClientID: "test-client",
ClientSecret: "test-secret",
SessionEncryptionKey: "test-encryption-key-32-bytes-long",
CallbackURL: "/oauth2/callback",
Headers: []TemplatedHeader{
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
{Name: "X-User-Email", Value: "{{.Claims.email}}"},
{Name: "X-User-ID", Value: "{{.Claims.sub}}"},
},
},
expectError: false,
description: "Multiple headers should work",
},
{
name: "empty headers configuration",
config: &MockConfig{
ProviderURL: "https://accounts.google.com",
ClientID: "test-client",
ClientSecret: "test-secret",
SessionEncryptionKey: "test-encryption-key-32-bytes-long",
CallbackURL: "/oauth2/callback",
Headers: []TemplatedHeader{},
},
expectError: false,
description: "Empty headers should not cause issues",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create a simple next handler
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Try to create the middleware would be done here
ctx := context.Background()
// Test would create middleware handler here
_ = ctx
_ = next
_ = tc.config
// For now, we just validate the configuration is well-formed
if !tc.expectError {
require.NotNil(t, tc.config, tc.description)
require.NotEmpty(t, tc.config.ClientID, tc.description)
}
})
}
}
+1 -1
View File
@@ -4,7 +4,6 @@ go 1.24.0
require (
github.com/alicebob/miniredis/v2 v2.35.0
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.3.0
github.com/redis/go-redis/v9 v9.17.2
github.com/stretchr/testify v1.10.0
@@ -18,5 +17,6 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
)
+2 -2
View File
@@ -12,8 +12,6 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
@@ -22,6 +20,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
+6 -6
View File
@@ -10,16 +10,16 @@ import (
type GoroutineManager struct {
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
mu sync.RWMutex
goroutines map[string]*managedGoroutine
logger *Logger
wg sync.WaitGroup
mu sync.RWMutex
}
type managedGoroutine struct {
name string
cancel context.CancelFunc
startTime time.Time
cancel context.CancelFunc
name string
running bool
}
@@ -149,10 +149,10 @@ func (m *GoroutineManager) GetStatus() map[string]GoroutineStatus {
// GoroutineStatus represents the status of a managed goroutine
type GoroutineStatus struct {
Name string
Running bool
StartTime time.Time
Name string
Runtime time.Duration
Running bool
}
// ErrShutdownTimeout is returned when shutdown times out
-764
View File
@@ -1,764 +0,0 @@
package handlers
import (
"errors"
"net/http"
"sync"
"testing"
"time"
)
// ============================================================================
// OAuth Handler Tests
// ============================================================================
func TestOAuthHandler(t *testing.T) {
t.Run("HandleAuthorizationRequest", func(t *testing.T) {
// Test authorization request handling logic
logger := &MockLogger{}
tests := []struct {
name string
requestURL string
expectedStatus int
checkLocation bool
}{
{
name: "Valid authorization request",
requestURL: "/auth/login",
expectedStatus: http.StatusFound,
checkLocation: true,
},
{
name: "With return URL",
requestURL: "/auth/login?return=/dashboard",
expectedStatus: http.StatusFound,
checkLocation: true,
},
}
// Test the test case structure
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Verify test case parameters
if test.requestURL == "" {
t.Error("Request URL should not be empty")
}
if test.expectedStatus == 0 {
t.Error("Expected status should be set")
}
// In a real implementation, this would test the actual handler
t.Logf("Testing %s with URL %s expecting status %d", test.name, test.requestURL, test.expectedStatus)
})
}
// Verify logger doesn't cause issues
logger.Debugf("Authorization request test completed")
})
t.Run("HandleCallbackRequest", func(t *testing.T) {
// Test callback request handling with existing mocks
sessionManager := NewMockSessionManager()
logger := &MockLogger{}
tests := []struct {
name string
queryParams string
expectedStatus int
expectError bool
}{
{
name: "Valid callback with code",
queryParams: "code=test-code&state=test-state",
expectedStatus: http.StatusFound,
expectError: false,
},
{
name: "Callback with error",
queryParams: "error=access_denied&error_description=User denied access",
expectedStatus: http.StatusBadRequest,
expectError: true,
},
{
name: "Missing code",
queryParams: "state=test-state",
expectedStatus: http.StatusBadRequest,
expectError: true,
},
{
name: "Missing state",
queryParams: "code=test-code",
expectedStatus: http.StatusBadRequest,
expectError: true,
},
}
// Test the callback scenarios
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Verify test case parameters
if test.queryParams == "" && !test.expectError {
t.Error("Query params should not be empty for successful cases")
}
if test.expectedStatus == 0 {
t.Error("Expected status should be set")
}
// Test session manager functionality
if sessionManager != nil {
t.Logf("Session manager available for test %s", test.name)
}
t.Logf("Testing %s with params %s expecting status %d", test.name, test.queryParams, test.expectedStatus)
})
}
// Verify logger doesn't cause issues
logger.Debugf("Callback request test completed")
})
t.Run("HandleLogout", func(t *testing.T) {
// Test logout functionality with mock implementations
sessionManager := NewMockSessionManager()
logger := &MockLogger{}
// Test session clearing
mockReq := &http.Request{}
session, err := sessionManager.GetSession(mockReq)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
// Set up authenticated session
err = session.SetAuthenticated(true)
if err != nil {
t.Fatalf("Failed to set authentication: %v", err)
}
session.SetIDToken("test-token")
// Verify session is authenticated
if !session.GetAuthenticated() {
t.Error("Session should be authenticated before logout")
}
// Test logout by clearing session
// session.Clear() // Method not implemented in SessionData
// Additional logout verification would go here
// Verify logger doesn't cause issues
logger.Debugf("Logout test completed")
t.Log("Logout test completed successfully")
})
}
// ============================================================================
// Auth Handler Tests
// ============================================================================
func TestAuthHandler(t *testing.T) {
t.Run("HandleAuthentication", func(t *testing.T) {
// Test authentication handling with mock types
// validator := &MockTokenValidator{valid: true} // Currently unused
/*
handler := &MockAuthHandler{
logger: &MockLogger{},
sessionManager: NewMockSessionManager(),
}
*/
tests := []struct {
name string
setupSession func(*MockSession)
expectedStatus int
expectNext bool
}{
{
name: "Authenticated user",
setupSession: func(s *MockSession) {
s.SetAuthenticated(true)
s.SetIDToken("valid-token")
},
expectedStatus: http.StatusOK,
expectNext: true,
},
{
name: "Unauthenticated user",
setupSession: func(s *MockSession) {
s.SetAuthenticated(false)
},
expectedStatus: http.StatusUnauthorized,
expectNext: false,
},
{
name: "Expired token",
setupSession: func(s *MockSession) {
s.SetAuthenticated(true)
s.SetIDToken("expired-token")
},
expectedStatus: http.StatusUnauthorized,
expectNext: false,
},
}
// Test the authentication test cases
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Test with mock session
mockSession := &MockSession{values: make(map[string]interface{})}
// Use mock session to avoid unused variable error
_ = mockSession
t.Logf("Testing %s", test.name)
})
}
})
t.Run("HandleRefreshToken", func(t *testing.T) {
// Test authentication handling with mock types
// validator := &MockTokenValidator{valid: true} // Currently unused
tests := []struct {
name string
refreshToken string
mockResponse *MockTokenResponse
mockError error
expectSuccess bool
}{
{
name: "Successful refresh",
refreshToken: "valid-refresh-token",
mockResponse: &MockTokenResponse{
AccessToken: "new-access-token",
IDToken: "new-id-token",
RefreshToken: "new-refresh-token",
},
expectSuccess: true,
},
{
name: "Failed refresh",
refreshToken: "invalid-refresh-token",
mockError: errors.New("invalid_grant"),
expectSuccess: false,
},
{
name: "Empty refresh token",
refreshToken: "",
expectSuccess: false,
},
}
// Test the authentication test cases
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Test with mock session
mockSession := &MockSession{values: make(map[string]interface{})}
// Use mock session to avoid unused variable error
_ = mockSession
t.Logf("Testing %s", test.name)
})
}
})
}
// ============================================================================
// Error Handler Tests
// ============================================================================
func TestErrorHandler(t *testing.T) {
t.Run("HandleHTTPErrors", func(t *testing.T) {
// Test with mock implementations
/*
handler := &MockErrorHandler{
logger: &MockLogger{},
}
*/
tests := []struct {
name string
errorCode int
errorMessage string
isAjax bool
expectedStatus int
expectedBody string
}{
{
name: "401 Unauthorized",
errorCode: http.StatusUnauthorized,
errorMessage: "Authentication required",
isAjax: false,
expectedStatus: http.StatusUnauthorized,
expectedBody: "Authentication required",
},
{
name: "403 Forbidden",
errorCode: http.StatusForbidden,
errorMessage: "Access denied",
isAjax: false,
expectedStatus: http.StatusForbidden,
expectedBody: "Access denied",
},
{
name: "500 Internal Server Error",
errorCode: http.StatusInternalServerError,
errorMessage: "Internal server error",
isAjax: false,
expectedStatus: http.StatusInternalServerError,
expectedBody: "Internal server error",
},
{
name: "Ajax 401",
errorCode: http.StatusUnauthorized,
errorMessage: "Token expired",
isAjax: true,
expectedStatus: http.StatusUnauthorized,
expectedBody: `{"error":"unauthorized","message":"Token expired"}`,
},
}
// Test the authentication test cases
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Test with mock session
mockSession := &MockSession{values: make(map[string]interface{})}
// Use mock session to avoid unused variable error
_ = mockSession
t.Logf("Testing %s", test.name)
})
}
})
t.Run("RecoverFromPanic", func(t *testing.T) {
// Test with mock implementations
/*
handler := &MockErrorHandler{
logger: &MockLogger{},
}
*/
tests := []struct {
name string
panicValue interface{}
expectError bool
}{
{
name: "String panic",
panicValue: "something went wrong",
expectError: true,
},
{
name: "Error panic",
panicValue: errors.New("critical error"),
expectError: true,
},
{
name: "Nil panic",
panicValue: nil,
expectError: false,
},
}
// Test the authentication test cases
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Test with mock session
mockSession := &MockSession{values: make(map[string]interface{})}
// Use mock session to avoid unused variable error
_ = mockSession
t.Logf("Testing %s", test.name)
})
}
})
}
// ============================================================================
// Azure OAuth Callback Tests
// ============================================================================
func TestAzureOAuthCallback(t *testing.T) {
t.Run("AzureSpecificClaims", func(t *testing.T) {
// Test with mock configuration
/*
handler := &OAuthHandler{
logger: &MockLogger{},
sessionManager: NewMockSessionManager(),
}
*/
azureClaims := map[string]interface{}{
"oid": "object-id",
"tid": "tenant-id",
"preferred_username": "user@example.com",
"name": "Test User",
"email": "user@example.com",
"groups": []string{"group1", "group2"},
}
// Test would go here when properly implemented
_ = azureClaims
})
t.Run("AzureTokenValidation", func(t *testing.T) {
// Test with mock validator types
/*
validator := &MockAzureTokenValidator{
tenantID: "test-tenant",
clientID: "test-client",
}
*/
tests := []struct {
name string
token string
claims map[string]interface{}
expectValid bool
}{
{
name: "Valid Azure token",
token: "valid-azure-token",
claims: map[string]interface{}{
"aud": "test-client",
"tid": "test-tenant",
"exp": float64(time.Now().Add(time.Hour).Unix()),
},
expectValid: true,
},
{
name: "Wrong tenant",
token: "wrong-tenant-token",
claims: map[string]interface{}{
"aud": "test-client",
"tid": "wrong-tenant",
"exp": float64(time.Now().Add(time.Hour).Unix()),
},
expectValid: false,
},
{
name: "Wrong audience",
token: "wrong-audience-token",
claims: map[string]interface{}{
"aud": "wrong-client",
"tid": "test-tenant",
"exp": float64(time.Now().Add(time.Hour).Unix()),
},
expectValid: false,
},
}
// Test the authentication test cases
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Test with mock session
mockSession := &MockSession{values: make(map[string]interface{})}
// Use mock session to avoid unused variable error
_ = mockSession
t.Logf("Testing %s", test.name)
})
}
})
}
// ============================================================================
// Concurrent Handler Tests
// ============================================================================
func TestConcurrentHandlers(t *testing.T) {
t.Run("ConcurrentCallbacks", func(t *testing.T) {
// Test with mock configuration
/*
handler := &OAuthHandler{
logger: &MockLogger{},
sessionManager: NewMockSessionManager(),
}
*/
var wg sync.WaitGroup
successCount := int32(0)
errorCount := int32(0)
// Test would go here when properly implemented
wg.Wait() // Proper usage instead of assignment
_ = successCount
_ = errorCount
})
t.Run("ConcurrentLogouts", func(t *testing.T) {
// Test with mock configuration
/*
handler := &OAuthHandler{
logger: &MockLogger{},
sessionManager: NewMockSessionManager(),
}
*/
var wg sync.WaitGroup
logoutCount := int32(0)
// Test would go here when properly implemented
wg.Wait() // Proper usage instead of assignment
_ = logoutCount
})
}
// ============================================================================
// Mock Implementations
// ============================================================================
type MockSessionManager struct {
sessions map[string]*MockSession
mu sync.RWMutex
}
func NewMockSessionManager() *MockSessionManager {
return &MockSessionManager{
sessions: make(map[string]*MockSession),
}
}
func (m *MockSessionManager) GetSession(r *http.Request) (SessionData, error) {
m.mu.Lock()
defer m.mu.Unlock()
sessionID := "test-session"
if session, exists := m.sessions[sessionID]; exists {
return session, nil
}
session := &MockSession{
values: make(map[string]interface{}),
}
m.sessions[sessionID] = session
return session, nil
}
type MockSession struct {
values map[string]interface{}
mu sync.RWMutex
}
func (s *MockSession) SetAuthenticated(auth bool) error {
s.mu.Lock()
defer s.mu.Unlock()
s.values["authenticated"] = auth
return nil
}
func (s *MockSession) GetAuthenticated() bool {
s.mu.RLock()
defer s.mu.RUnlock()
auth, ok := s.values["authenticated"].(bool)
return ok && auth
}
func (s *MockSession) SetIDToken(token string) {
s.mu.Lock()
defer s.mu.Unlock()
s.values["id_token"] = token
}
func (s *MockSession) GetIDToken() string {
s.mu.RLock()
defer s.mu.RUnlock()
token, _ := s.values["id_token"].(string)
return token
}
func (s *MockSession) SetAccessToken(token string) {
s.mu.Lock()
defer s.mu.Unlock()
s.values["access_token"] = token
}
func (s *MockSession) GetAccessToken() string {
s.mu.RLock()
defer s.mu.RUnlock()
token, _ := s.values["access_token"].(string)
return token
}
func (s *MockSession) SetRefreshToken(token string) {
s.mu.Lock()
defer s.mu.Unlock()
s.values["refresh_token"] = token
}
func (s *MockSession) GetRefreshToken() string {
s.mu.RLock()
defer s.mu.RUnlock()
token, _ := s.values["refresh_token"].(string)
return token
}
func (s *MockSession) SetState(state string) {
s.mu.Lock()
defer s.mu.Unlock()
s.values["state"] = state
}
func (s *MockSession) GetState() string {
s.mu.RLock()
defer s.mu.RUnlock()
state, _ := s.values["state"].(string)
return state
}
func (s *MockSession) SetClaims(claims map[string]interface{}) {
s.mu.Lock()
defer s.mu.Unlock()
s.values["claims"] = claims
}
func (s *MockSession) GetClaims() map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
claims, _ := s.values["claims"].(map[string]interface{})
return claims
}
// Additional SessionData interface methods to match real interface
func (s *MockSession) GetCSRF() string {
s.mu.RLock()
defer s.mu.RUnlock()
csrf, _ := s.values["csrf"].(string)
return csrf
}
func (s *MockSession) GetNonce() string {
s.mu.RLock()
defer s.mu.RUnlock()
nonce, _ := s.values["nonce"].(string)
return nonce
}
func (s *MockSession) GetCodeVerifier() string {
s.mu.RLock()
defer s.mu.RUnlock()
verifier, _ := s.values["code_verifier"].(string)
return verifier
}
func (s *MockSession) GetIncomingPath() string {
s.mu.RLock()
defer s.mu.RUnlock()
path, _ := s.values["incoming_path"].(string)
return path
}
func (s *MockSession) SetEmail(email string) {
s.mu.Lock()
defer s.mu.Unlock()
s.values["email"] = email
}
func (s *MockSession) GetEmail() string {
s.mu.RLock()
defer s.mu.RUnlock()
email, _ := s.values["email"].(string)
return email
}
func (s *MockSession) SetCSRF(csrf string) {
s.mu.Lock()
defer s.mu.Unlock()
s.values["csrf"] = csrf
}
func (s *MockSession) SetNonce(nonce string) {
s.mu.Lock()
defer s.mu.Unlock()
s.values["nonce"] = nonce
}
func (s *MockSession) SetCodeVerifier(verifier string) {
s.mu.Lock()
defer s.mu.Unlock()
s.values["code_verifier"] = verifier
}
func (s *MockSession) SetIncomingPath(path string) {
s.mu.Lock()
defer s.mu.Unlock()
s.values["incoming_path"] = path
}
func (s *MockSession) ResetRedirectCount() {
s.mu.Lock()
defer s.mu.Unlock()
s.values["redirect_count"] = 0
}
func (s *MockSession) Save(r *http.Request, w http.ResponseWriter) error {
return nil
}
func (s *MockSession) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.values = make(map[string]interface{})
}
func (s *MockSession) returnToPoolSafely() {
// No-op for mock
}
type MockTokenValidator struct {
valid bool
}
func (v *MockTokenValidator) Validate(token string) bool {
if token == "expired-token" {
return false
}
return v.valid
}
// ============================================================================
// Mock Handler Type Definitions (for testing)
// ============================================================================
// These mock handlers are simplified versions for testing purposes
// They don't match the actual handler implementations
type MockAuthHandler struct{}
type MockErrorHandler struct{}
type MockAzureTokenValidator struct {
tenantID string
clientID string
}
func (v *MockAzureTokenValidator) ValidateAzureToken(token string, claims map[string]interface{}) bool {
// Validate tenant ID
if tid, ok := claims["tid"].(string); !ok || tid != v.tenantID {
return false
}
// Validate audience
if aud, ok := claims["aud"].(string); !ok || aud != v.clientID {
return false
}
// Validate expiration
if exp, ok := claims["exp"].(float64); ok {
if time.Now().Unix() > int64(exp) {
return false
}
}
return true
}
// ============================================================================
// Helper Types and Mock Logger
// ============================================================================
type MockLogger struct{}
func (l *MockLogger) Debugf(format string, args ...interface{}) {}
func (l *MockLogger) Errorf(format string, args ...interface{}) {}
func (l *MockLogger) Error(msg string) {}
type MockTokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
}
-330
View File
@@ -1,330 +0,0 @@
// Package handlers provides HTTP request handlers for the OIDC middleware.
package handlers
import (
"context"
"fmt"
"net/http"
"strings"
)
// OAuthHandler handles OAuth callback requests
type OAuthHandler struct {
logger Logger
sessionManager SessionManager
tokenExchanger TokenExchanger
tokenVerifier TokenVerifier
extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
isAllowedUserFunc func(userIdentifier string) bool // validates user authorization
userIdentifierClaim string // JWT claim to use for user identification
redirURLPath string
sendErrorResponseFunc func(rw http.ResponseWriter, req *http.Request, message string, code int)
}
// Logger interface for dependency injection
type Logger interface {
Debugf(format string, args ...interface{})
Errorf(format string, args ...interface{})
Error(msg string)
}
// SessionManager interface for session operations
type SessionManager interface {
GetSession(req *http.Request) (SessionData, error)
}
// SessionData interface for session data operations
type SessionData interface {
GetCSRF() string
GetNonce() string
GetCodeVerifier() string
GetIncomingPath() string
GetAuthenticated() bool
GetAccessToken() string
GetRefreshToken() string
GetIDToken() string
GetEmail() string
SetAuthenticated(bool) error
SetEmail(string)
SetIDToken(string)
SetAccessToken(string)
SetRefreshToken(string)
SetCSRF(string)
SetNonce(string)
SetCodeVerifier(string)
SetIncomingPath(string)
ResetRedirectCount()
Save(req *http.Request, rw http.ResponseWriter) error
returnToPoolSafely()
}
// TokenExchanger interface for token operations
type TokenExchanger interface {
ExchangeCodeForToken(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error)
}
// TokenVerifier interface for token verification
type TokenVerifier interface {
VerifyToken(token string) error
}
// TokenResponse represents the response from token exchange
type TokenResponse struct {
IDToken string
AccessToken string
RefreshToken string
}
// NewOAuthHandler creates a new OAuth handler
func NewOAuthHandler(logger Logger, sessionManager SessionManager, tokenExchanger TokenExchanger,
tokenVerifier TokenVerifier, extractClaimsFunc func(string) (map[string]interface{}, error),
isAllowedUserFunc func(string) bool, userIdentifierClaim string, redirURLPath string,
sendErrorResponseFunc func(http.ResponseWriter, *http.Request, string, int)) *OAuthHandler {
// Default to "email" for backward compatibility
if userIdentifierClaim == "" {
userIdentifierClaim = "email"
}
return &OAuthHandler{
logger: logger,
sessionManager: sessionManager,
tokenExchanger: tokenExchanger,
tokenVerifier: tokenVerifier,
extractClaimsFunc: extractClaimsFunc,
isAllowedUserFunc: isAllowedUserFunc,
userIdentifierClaim: userIdentifierClaim,
redirURLPath: redirURLPath,
sendErrorResponseFunc: sendErrorResponseFunc,
}
}
// HandleCallback handles OAuth callback requests
func (h *OAuthHandler) HandleCallback(rw http.ResponseWriter, req *http.Request, redirectURL string) {
session, err := h.sessionManager.GetSession(req)
if err != nil {
h.logger.Errorf("Session error during callback: %v", err)
h.sendErrorResponseFunc(rw, req, "Session error during callback", http.StatusInternalServerError)
return
}
defer session.returnToPoolSafely()
h.logger.Debugf("Handling callback, URL: %s", req.URL.String())
// Debug logging for cookie configuration
h.logger.Debugf("Callback request headers - Host: %s, X-Forwarded-Host: %s, X-Forwarded-Proto: %s",
req.Host, req.Header.Get("X-Forwarded-Host"), req.Header.Get("X-Forwarded-Proto"))
// Log all cookies in the request for debugging
cookies := req.Cookies()
h.logger.Debugf("Total cookies in callback request: %d", len(cookies))
for _, cookie := range cookies {
if strings.HasPrefix(cookie.Name, "_oidc_") {
h.logger.Debugf("Cookie found - Name: %s, Domain: %s, Path: %s, SameSite: %v, Secure: %v, HttpOnly: %v, Value length: %d",
cookie.Name, cookie.Domain, cookie.Path, cookie.SameSite, cookie.Secure, cookie.HttpOnly, len(cookie.Value))
}
}
if req.URL.Query().Get("error") != "" {
errorDescription := req.URL.Query().Get("error_description")
if errorDescription == "" {
errorDescription = req.URL.Query().Get("error")
}
h.logger.Errorf("Authentication error from provider during callback: %s - %s", req.URL.Query().Get("error"), errorDescription)
h.sendErrorResponseFunc(rw, req, fmt.Sprintf("Authentication error from provider: %s", errorDescription), http.StatusBadRequest)
return
}
state := req.URL.Query().Get("state")
if state == "" {
h.logger.Error("No state in callback")
h.sendErrorResponseFunc(rw, req, "State parameter missing in callback", http.StatusBadRequest)
return
}
// Debug log the state parameter received
h.logger.Debugf("State parameter received in callback: %s (length: %d)", state, len(state))
csrfToken := session.GetCSRF()
if csrfToken == "" {
h.logger.Errorf("CSRF token missing in session during callback. Authenticated: %v, Request URL: %s",
session.GetAuthenticated(), req.URL.String())
// Enhanced debugging for missing CSRF token
cookie, err := req.Cookie("_oidc_raczylo_m")
if err != nil {
h.logger.Errorf("Main session cookie not found in request: %v", err)
// Log cookie names only, not values (avoid logging sensitive session data)
cookieNames := make([]string, 0, len(req.Cookies()))
for _, c := range req.Cookies() {
cookieNames = append(cookieNames, c.Name)
}
h.logger.Debugf("Available cookies (names only): %v", cookieNames)
} else {
h.logger.Errorf("Main session cookie exists but CSRF token is empty. Cookie value length: %d", len(cookie.Value))
h.logger.Debugf("Cookie details - Domain: %s, Path: %s, Secure: %v, HttpOnly: %v, SameSite: %v",
cookie.Domain, cookie.Path, cookie.Secure, cookie.HttpOnly, cookie.SameSite)
}
// Log session state for debugging
h.logger.Debugf("Session state during CSRF check - Authenticated: %v, Has AccessToken: %v",
session.GetAuthenticated(), session.GetAccessToken() != "")
h.sendErrorResponseFunc(rw, req, "CSRF token missing in session", http.StatusBadRequest)
return
}
// Debug log successful CSRF token retrieval
h.logger.Debugf("CSRF token retrieved from session: %s (length: %d)", csrfToken, len(csrfToken))
if state != csrfToken {
h.logger.Error("State parameter does not match CSRF token in session during callback")
h.sendErrorResponseFunc(rw, req, "Invalid state parameter (CSRF mismatch)", http.StatusBadRequest)
return
}
code := req.URL.Query().Get("code")
if code == "" {
h.logger.Error("No code in callback")
h.sendErrorResponseFunc(rw, req, "No authorization code received in callback", http.StatusBadRequest)
return
}
codeVerifier := session.GetCodeVerifier()
tokenResponse, err := h.tokenExchanger.ExchangeCodeForToken(req.Context(), "authorization_code", code, redirectURL, codeVerifier)
if err != nil {
h.logger.Errorf("Failed to exchange code for token during callback: %v", err)
h.sendErrorResponseFunc(rw, req, "Authentication failed: Could not exchange code for token", http.StatusInternalServerError)
return
}
if err = h.tokenVerifier.VerifyToken(tokenResponse.IDToken); err != nil {
h.logger.Errorf("Failed to verify id_token during callback: %v", err)
h.sendErrorResponseFunc(rw, req, "Authentication failed: Could not verify ID token", http.StatusInternalServerError)
return
}
claims, err := h.extractClaimsFunc(tokenResponse.IDToken)
if err != nil {
h.logger.Errorf("Failed to extract claims during callback: %v", err)
h.sendErrorResponseFunc(rw, req, "Authentication failed: Could not extract claims from token", http.StatusInternalServerError)
return
}
nonceClaim, ok := claims["nonce"].(string)
if !ok || nonceClaim == "" {
h.logger.Error("Nonce claim missing in id_token during callback")
h.sendErrorResponseFunc(rw, req, "Authentication failed: Nonce missing in token", http.StatusInternalServerError)
return
}
sessionNonce := session.GetNonce()
if sessionNonce == "" {
h.logger.Error("Nonce not found in session during callback")
h.sendErrorResponseFunc(rw, req, "Authentication failed: Nonce missing in session", http.StatusInternalServerError)
return
}
if nonceClaim != sessionNonce {
h.logger.Error("Nonce claim does not match session nonce during callback")
h.sendErrorResponseFunc(rw, req, "Authentication failed: Nonce mismatch", http.StatusInternalServerError)
return
}
// Extract user identifier from the configured claim (defaults to "email" for backward compatibility)
userIdentifier, _ := claims[h.userIdentifierClaim].(string)
if userIdentifier == "" {
// Try "sub" as fallback since it's required by OIDC spec
if h.userIdentifierClaim != "sub" {
userIdentifier, _ = claims["sub"].(string)
}
if userIdentifier == "" {
h.logger.Errorf("User identifier claim '%s' missing or empty in token during callback", h.userIdentifierClaim)
h.sendErrorResponseFunc(rw, req, "Authentication failed: User identifier missing in token", http.StatusInternalServerError)
return
}
h.logger.Debugf("Configured claim '%s' not found, using 'sub' claim as fallback", h.userIdentifierClaim)
}
// Validate user authorization
if !h.isAllowedUserFunc(userIdentifier) {
h.logger.Errorf("User not authorized during callback: %s", userIdentifier)
h.sendErrorResponseFunc(rw, req, "Authentication failed: User not authorized", http.StatusForbidden)
return
}
if err := session.SetAuthenticated(true); err != nil {
h.logger.Errorf("Failed to set authenticated state and regenerate session ID: %v", err)
h.sendErrorResponseFunc(rw, req, "Failed to update session", http.StatusInternalServerError)
return
}
session.SetEmail(userIdentifier) // SetEmail stores the user identifier (email or other claim)
session.SetIDToken(tokenResponse.IDToken)
session.SetAccessToken(tokenResponse.AccessToken)
session.SetRefreshToken(tokenResponse.RefreshToken)
session.SetCSRF("")
session.SetNonce("")
session.SetCodeVerifier("")
session.ResetRedirectCount()
redirectPath := "/"
if incomingPath := session.GetIncomingPath(); incomingPath != "" && incomingPath != h.redirURLPath {
redirectPath = incomingPath
}
session.SetIncomingPath("")
if err := session.Save(req, rw); err != nil {
h.logger.Errorf("Failed to save session after callback: %v", err)
h.sendErrorResponseFunc(rw, req, "Failed to save session after callback", http.StatusInternalServerError)
return
}
h.logger.Debugf("Callback successful, redirecting to %s", redirectPath)
http.Redirect(rw, req, redirectPath, http.StatusFound)
}
// URLHelper provides utility methods for URL operations
type URLHelper struct {
logger Logger
}
// NewURLHelper creates a new URL helper
func NewURLHelper(logger Logger) *URLHelper {
return &URLHelper{logger: logger}
}
// DetermineExcludedURL checks if a URL path should bypass OIDC authentication.
// It compares the request path against configured excluded URL prefixes.
func (h *URLHelper) DetermineExcludedURL(currentRequest string, excludedURLs map[string]struct{}) bool {
for excludedURL := range excludedURLs {
if strings.HasPrefix(currentRequest, excludedURL) {
h.logger.Debugf("URL is excluded - got %s / excluded hit: %s", currentRequest, excludedURL)
return true
}
}
return false
}
// DetermineScheme determines the URL scheme for building redirect URLs.
// It checks X-Forwarded-Proto header first, then TLS presence.
func (h *URLHelper) DetermineScheme(req *http.Request) string {
if scheme := req.Header.Get("X-Forwarded-Proto"); scheme != "" {
return scheme
}
if req.TLS != nil {
return "https"
}
return "http"
}
// DetermineHost determines the host for building redirect URLs.
// It checks X-Forwarded-Host header first, then falls back to req.Host.
func (h *URLHelper) DetermineHost(req *http.Request) string {
if host := req.Header.Get("X-Forwarded-Host"); host != "" {
return host
}
return req.Host
}
-899
View File
@@ -1,899 +0,0 @@
package handlers
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Test mocks - implementing interfaces defined in oauth_handler.go
type mockLogger struct {
debugMessages []string
errorMessages []string
}
func (l *mockLogger) Debugf(format string, args ...interface{}) {
l.debugMessages = append(l.debugMessages, format)
}
func (l *mockLogger) Errorf(format string, args ...interface{}) {
l.errorMessages = append(l.errorMessages, format)
}
func (l *mockLogger) Error(msg string) {
l.errorMessages = append(l.errorMessages, msg)
}
type mockSessionManager struct {
sessionToReturn SessionData
errorToReturn error
}
func (m *mockSessionManager) GetSession(req *http.Request) (SessionData, error) {
return m.sessionToReturn, m.errorToReturn
}
type mockSessionData struct {
authenticated bool
email string
csrf string
nonce string
codeVerifier string
incomingPath string
accessToken string
refreshToken string
idToken string
saveError error
setAuthError error
}
func (s *mockSessionData) GetCSRF() string { return s.csrf }
func (s *mockSessionData) GetNonce() string { return s.nonce }
func (s *mockSessionData) GetCodeVerifier() string { return s.codeVerifier }
func (s *mockSessionData) GetIncomingPath() string { return s.incomingPath }
func (s *mockSessionData) GetAuthenticated() bool { return s.authenticated }
func (s *mockSessionData) GetAccessToken() string { return s.accessToken }
func (s *mockSessionData) GetRefreshToken() string { return s.refreshToken }
func (s *mockSessionData) GetIDToken() string { return s.idToken }
func (s *mockSessionData) GetEmail() string { return s.email }
func (s *mockSessionData) SetAuthenticated(auth bool) error {
s.authenticated = auth
return s.setAuthError
}
func (s *mockSessionData) SetEmail(email string) { s.email = email }
func (s *mockSessionData) SetIDToken(token string) { s.idToken = token }
func (s *mockSessionData) SetAccessToken(token string) { s.accessToken = token }
func (s *mockSessionData) SetRefreshToken(token string) { s.refreshToken = token }
func (s *mockSessionData) SetCSRF(csrf string) { s.csrf = csrf }
func (s *mockSessionData) SetNonce(nonce string) { s.nonce = nonce }
func (s *mockSessionData) SetCodeVerifier(verif string) { s.codeVerifier = verif }
func (s *mockSessionData) SetIncomingPath(path string) { s.incomingPath = path }
func (s *mockSessionData) ResetRedirectCount() {}
func (s *mockSessionData) returnToPoolSafely() {}
func (s *mockSessionData) Save(req *http.Request, rw http.ResponseWriter) error {
return s.saveError
}
type mockTokenExchanger struct {
response *TokenResponse
err error
}
func (e *mockTokenExchanger) ExchangeCodeForToken(ctx context.Context, grantType, codeOrToken, redirectURL, codeVerifier string) (*TokenResponse, error) {
return e.response, e.err
}
type mockTokenVerifier struct {
err error
}
func (v *mockTokenVerifier) VerifyToken(token string) error {
return v.err
}
// TestOAuthHandler_NewOAuthHandler tests the constructor
func TestOAuthHandler_NewOAuthHandler(t *testing.T) {
logger := &mockLogger{}
sessionManager := &mockSessionManager{}
tokenExchanger := &mockTokenExchanger{}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "test@example.com", "nonce": "test-nonce"}, nil
}
isAllowedUser := func(userIdentifier string) bool { return true }
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowedUser, "email", "/callback", sendError)
if handler == nil {
t.Fatal("Expected handler to be created, got nil")
}
if handler.logger != logger {
t.Error("Logger not set correctly")
}
if handler.redirURLPath != "/callback" {
t.Errorf("Expected redirURLPath '/callback', got '%s'", handler.redirURLPath)
}
}
// TestOAuthHandler_HandleCallback_SessionError tests session retrieval errors
func TestOAuthHandler_HandleCallback_SessionError(t *testing.T) {
logger := &mockLogger{}
sessionManager := &mockSessionManager{errorToReturn: errors.New("session error")}
tokenExchanger := &mockTokenExchanger{}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) {
return nil, nil
}
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, code)
}
if !strings.Contains(msg, "Session error") {
t.Errorf("Expected error message to contain 'Session error', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test&state=test", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
if len(logger.errorMessages) == 0 {
t.Error("Expected error to be logged")
}
}
// TestOAuthHandler_HandleCallback_ProviderError tests OAuth provider errors
func TestOAuthHandler_HandleCallback_ProviderError(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{csrf: "test-state", nonce: "test-nonce"}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenExchanger := &mockTokenExchanger{}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) { return nil, nil }
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, code)
}
if !strings.Contains(msg, "Authentication error from provider") {
t.Errorf("Expected error message to contain 'Authentication error from provider', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
// Test with error parameter
req := httptest.NewRequest("GET", "/callback?error=access_denied&error_description=User%20denied%20access", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
if len(logger.errorMessages) == 0 {
t.Error("Expected error to be logged")
}
}
// TestOAuthHandler_HandleCallback_MissingState tests missing state parameter
func TestOAuthHandler_HandleCallback_MissingState(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenExchanger := &mockTokenExchanger{}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) { return nil, nil }
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, code)
}
if !strings.Contains(msg, "State parameter missing") {
t.Errorf("Expected error message to contain 'State parameter missing', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_MissingCSRF tests missing CSRF token in session
func TestOAuthHandler_HandleCallback_MissingCSRF(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{csrf: ""} // Empty CSRF
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenExchanger := &mockTokenExchanger{}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) { return nil, nil }
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, code)
}
if !strings.Contains(msg, "CSRF token missing") {
t.Errorf("Expected error message to contain 'CSRF token missing', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_CSRFMismatch tests CSRF token mismatch
func TestOAuthHandler_HandleCallback_CSRFMismatch(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{csrf: "different-token"}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenExchanger := &mockTokenExchanger{}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) { return nil, nil }
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, code)
}
if !strings.Contains(msg, "CSRF mismatch") {
t.Errorf("Expected error message to contain 'CSRF mismatch', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_MissingCode tests missing authorization code
func TestOAuthHandler_HandleCallback_MissingCode(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{csrf: "test-state"}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenExchanger := &mockTokenExchanger{}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) { return nil, nil }
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, code)
}
if !strings.Contains(msg, "No authorization code received") {
t.Errorf("Expected error message to contain 'No authorization code received', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_TokenExchangeError tests token exchange failure
func TestOAuthHandler_HandleCallback_TokenExchangeError(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{csrf: "test-state", nonce: "test-nonce", codeVerifier: "test-verifier"}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenExchanger := &mockTokenExchanger{err: errors.New("token exchange failed")}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) { return nil, nil }
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, code)
}
if !strings.Contains(msg, "Could not exchange code for token") {
t.Errorf("Expected error message to contain 'Could not exchange code for token', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_TokenVerificationError tests token verification failure
func TestOAuthHandler_HandleCallback_TokenVerificationError(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{csrf: "test-state", nonce: "test-nonce"}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenResponse := &TokenResponse{IDToken: "invalid-token", AccessToken: "access-token"}
tokenExchanger := &mockTokenExchanger{response: tokenResponse}
tokenVerifier := &mockTokenVerifier{err: errors.New("token verification failed")}
extractClaims := func(token string) (map[string]interface{}, error) { return nil, nil }
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, code)
}
if !strings.Contains(msg, "Could not verify ID token") {
t.Errorf("Expected error message to contain 'Could not verify ID token', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_ClaimsExtractionError tests claims extraction failure
func TestOAuthHandler_HandleCallback_ClaimsExtractionError(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{csrf: "test-state", nonce: "test-nonce"}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenResponse := &TokenResponse{IDToken: "valid-token", AccessToken: "access-token"}
tokenExchanger := &mockTokenExchanger{response: tokenResponse}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) {
return nil, errors.New("claims extraction failed")
}
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, code)
}
if !strings.Contains(msg, "Could not extract claims") {
t.Errorf("Expected error message to contain 'Could not extract claims', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_MissingNonceInToken tests missing nonce in token
func TestOAuthHandler_HandleCallback_MissingNonceInToken(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{csrf: "test-state", nonce: "test-nonce"}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenResponse := &TokenResponse{IDToken: "valid-token", AccessToken: "access-token"}
tokenExchanger := &mockTokenExchanger{response: tokenResponse}
tokenVerifier := &mockTokenVerifier{}
// Claims without nonce
extractClaims := func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "test@example.com"}, nil
}
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, code)
}
if !strings.Contains(msg, "Nonce missing in token") {
t.Errorf("Expected error message to contain 'Nonce missing in token', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_MissingNonceInSession tests missing nonce in session
func TestOAuthHandler_HandleCallback_MissingNonceInSession(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{csrf: "test-state", nonce: ""} // Empty nonce
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenResponse := &TokenResponse{IDToken: "valid-token", AccessToken: "access-token"}
tokenExchanger := &mockTokenExchanger{response: tokenResponse}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "test@example.com", "nonce": "test-nonce"}, nil
}
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, code)
}
if !strings.Contains(msg, "Nonce missing in session") {
t.Errorf("Expected error message to contain 'Nonce missing in session', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_NonceMismatch tests nonce mismatch
func TestOAuthHandler_HandleCallback_NonceMismatch(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{csrf: "test-state", nonce: "session-nonce"}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenResponse := &TokenResponse{IDToken: "valid-token", AccessToken: "access-token"}
tokenExchanger := &mockTokenExchanger{response: tokenResponse}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "test@example.com", "nonce": "token-nonce"}, nil
}
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, code)
}
if !strings.Contains(msg, "Nonce mismatch") {
t.Errorf("Expected error message to contain 'Nonce mismatch', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_MissingEmail tests missing email in claims
func TestOAuthHandler_HandleCallback_MissingEmail(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{csrf: "test-state", nonce: "test-nonce"}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenResponse := &TokenResponse{IDToken: "valid-token", AccessToken: "access-token"}
tokenExchanger := &mockTokenExchanger{response: tokenResponse}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"nonce": "test-nonce"}, nil // No email
}
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, code)
}
if !strings.Contains(msg, "User identifier missing in token") {
t.Errorf("Expected error message to contain 'User identifier missing in token', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_DisallowedDomain tests disallowed email domain
func TestOAuthHandler_HandleCallback_DisallowedDomain(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{csrf: "test-state", nonce: "test-nonce"}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenResponse := &TokenResponse{IDToken: "valid-token", AccessToken: "access-token"}
tokenExchanger := &mockTokenExchanger{response: tokenResponse}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "test@disallowed.com", "nonce": "test-nonce"}, nil
}
isAllowed := func(email string) bool { return false } // Disallow all domains
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusForbidden {
t.Errorf("Expected status %d, got %d", http.StatusForbidden, code)
}
if !strings.Contains(msg, "User not authorized") {
t.Errorf("Expected error message to contain 'User not authorized', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_SessionSaveError tests session save failure
func TestOAuthHandler_HandleCallback_SessionSaveError(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{
csrf: "test-state",
nonce: "test-nonce",
saveError: errors.New("save failed"),
}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenResponse := &TokenResponse{IDToken: "valid-token", AccessToken: "access-token", RefreshToken: "refresh-token"}
tokenExchanger := &mockTokenExchanger{response: tokenResponse}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "test@example.com", "nonce": "test-nonce"}, nil
}
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, code)
}
if !strings.Contains(msg, "Failed to save session") {
t.Errorf("Expected error message to contain 'Failed to save session', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_SetAuthenticatedError tests SetAuthenticated failure
func TestOAuthHandler_HandleCallback_SetAuthenticatedError(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{
csrf: "test-state",
nonce: "test-nonce",
setAuthError: errors.New("set auth failed"),
}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenResponse := &TokenResponse{IDToken: "valid-token", AccessToken: "access-token"}
tokenExchanger := &mockTokenExchanger{response: tokenResponse}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "test@example.com", "nonce": "test-nonce"}, nil
}
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
if code != http.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, code)
}
if !strings.Contains(msg, "Failed to update session") {
t.Errorf("Expected error message to contain 'Failed to update session', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if !errorSent {
t.Error("Expected error response to be sent")
}
}
// TestOAuthHandler_HandleCallback_Success tests successful callback handling
func TestOAuthHandler_HandleCallback_Success(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{
csrf: "test-state",
nonce: "test-nonce",
incomingPath: "/dashboard",
}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenResponse := &TokenResponse{
IDToken: "valid-id-token",
AccessToken: "valid-access-token",
RefreshToken: "valid-refresh-token",
}
tokenExchanger := &mockTokenExchanger{response: tokenResponse}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "test@example.com", "nonce": "test-nonce"}, nil
}
isAllowed := func(email string) bool { return true }
errorSent := false
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
errorSent = true
t.Errorf("Unexpected error sent: %s (code: %d)", msg, code)
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
if errorSent {
t.Error("Unexpected error response sent")
}
// Check redirect
if rw.Code != http.StatusFound {
t.Errorf("Expected status %d, got %d", http.StatusFound, rw.Code)
}
location := rw.Header().Get("Location")
if location != "/dashboard" {
t.Errorf("Expected redirect to '/dashboard', got '%s'", location)
}
// Verify session data was set correctly
if session.email != "test@example.com" {
t.Errorf("Expected email 'test@example.com', got '%s'", session.email)
}
if session.idToken != "valid-id-token" {
t.Errorf("Expected ID token 'valid-id-token', got '%s'", session.idToken)
}
if session.accessToken != "valid-access-token" {
t.Errorf("Expected access token 'valid-access-token', got '%s'", session.accessToken)
}
if session.refreshToken != "valid-refresh-token" {
t.Errorf("Expected refresh token 'valid-refresh-token', got '%s'", session.refreshToken)
}
if !session.authenticated {
t.Error("Expected session to be authenticated")
}
// Check that temporary fields are cleared
if session.csrf != "" {
t.Errorf("Expected CSRF to be cleared, got '%s'", session.csrf)
}
if session.nonce != "" {
t.Errorf("Expected nonce to be cleared, got '%s'", session.nonce)
}
if session.codeVerifier != "" {
t.Errorf("Expected code verifier to be cleared, got '%s'", session.codeVerifier)
}
if session.incomingPath != "" {
t.Errorf("Expected incoming path to be cleared, got '%s'", session.incomingPath)
}
}
// TestOAuthHandler_HandleCallback_SuccessDefaultRedirect tests successful callback with default redirect
func TestOAuthHandler_HandleCallback_SuccessDefaultRedirect(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{
csrf: "test-state",
nonce: "test-nonce",
incomingPath: "", // No incoming path, should default to "/"
}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenResponse := &TokenResponse{IDToken: "valid-token", AccessToken: "access-token"}
tokenExchanger := &mockTokenExchanger{response: tokenResponse}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "test@example.com", "nonce": "test-nonce"}, nil
}
isAllowed := func(email string) bool { return true }
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
t.Errorf("Unexpected error sent: %s (code: %d)", msg, code)
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
// Check redirect to default path
if rw.Code != http.StatusFound {
t.Errorf("Expected status %d, got %d", http.StatusFound, rw.Code)
}
location := rw.Header().Get("Location")
if location != "/" {
t.Errorf("Expected redirect to '/', got '%s'", location)
}
}
// TestOAuthHandler_HandleCallback_RedirectURLPathExcluded tests incoming path same as redirect URL
func TestOAuthHandler_HandleCallback_RedirectURLPathExcluded(t *testing.T) {
logger := &mockLogger{}
session := &mockSessionData{
csrf: "test-state",
nonce: "test-nonce",
incomingPath: "/callback", // Same as redirect URL path
}
sessionManager := &mockSessionManager{sessionToReturn: session}
tokenResponse := &TokenResponse{IDToken: "valid-token", AccessToken: "access-token"}
tokenExchanger := &mockTokenExchanger{response: tokenResponse}
tokenVerifier := &mockTokenVerifier{}
extractClaims := func(token string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "test@example.com", "nonce": "test-nonce"}, nil
}
isAllowed := func(email string) bool { return true }
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {
t.Errorf("Unexpected error sent: %s (code: %d)", msg, code)
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "email", "/callback", sendError)
req := httptest.NewRequest("GET", "/callback?code=test-code&state=test-state", nil)
rw := httptest.NewRecorder()
handler.HandleCallback(rw, req, "http://example.com/callback")
// Should redirect to default path when incoming path is same as callback path
location := rw.Header().Get("Location")
if location != "/" {
t.Errorf("Expected redirect to '/', got '%s'", location)
}
}
-454
View File
@@ -1,454 +0,0 @@
package handlers
import (
"crypto/tls"
"net/http"
"testing"
)
// TestURLHelper_NewURLHelper tests the URLHelper constructor
func TestURLHelper_NewURLHelper(t *testing.T) {
logger := &mockLogger{}
helper := NewURLHelper(logger)
if helper == nil {
t.Fatal("Expected URLHelper to be created, got nil")
}
if helper.logger != logger {
t.Error("Logger not set correctly")
}
}
// TestURLHelper_DetermineExcludedURL tests URL exclusion checking
func TestURLHelper_DetermineExcludedURL(t *testing.T) {
logger := &mockLogger{}
helper := NewURLHelper(logger)
tests := []struct {
name string
currentURL string
excludedURLs map[string]struct{}
expected bool
}{
{
name: "Exact match",
currentURL: "/health",
excludedURLs: map[string]struct{}{
"/health": {},
},
expected: true,
},
{
name: "Prefix match",
currentURL: "/health/status",
excludedURLs: map[string]struct{}{
"/health": {},
},
expected: true,
},
{
name: "No match",
currentURL: "/api/users",
excludedURLs: map[string]struct{}{
"/health": {},
},
expected: false,
},
{
name: "Multiple exclusions - first match",
currentURL: "/api/health",
excludedURLs: map[string]struct{}{
"/api": {},
"/health": {},
},
expected: true,
},
{
name: "Multiple exclusions - second match",
currentURL: "/health/check",
excludedURLs: map[string]struct{}{
"/api": {},
"/health": {},
},
expected: true,
},
{
name: "Empty excluded URLs",
currentURL: "/api/users",
excludedURLs: map[string]struct{}{},
expected: false,
},
{
name: "Root path exclusion",
currentURL: "/anything",
excludedURLs: map[string]struct{}{
"/": {},
},
expected: true,
},
{
name: "Case sensitive matching",
currentURL: "/API/users",
excludedURLs: map[string]struct{}{
"/api": {},
},
expected: false,
},
{
name: "Partial substring but not prefix",
currentURL: "/user/api/test",
excludedURLs: map[string]struct{}{
"/api": {},
},
expected: false,
},
{
name: "Empty current URL",
currentURL: "",
excludedURLs: map[string]struct{}{
"/health": {},
},
expected: false,
},
{
name: "URL with query parameters",
currentURL: "/health?status=ok",
excludedURLs: map[string]struct{}{
"/health": {},
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := helper.DetermineExcludedURL(tt.currentURL, tt.excludedURLs)
if result != tt.expected {
t.Errorf("DetermineExcludedURL() = %v, expected %v", result, tt.expected)
}
// Verify debug logging for excluded URLs
if result && len(logger.debugMessages) > 0 {
// Should have logged a debug message for excluded URL
found := false
for _, msg := range logger.debugMessages {
if msg == "URL is excluded - got %s / excluded hit: %s" {
found = true
break
}
}
if !found {
t.Error("Expected debug message for excluded URL")
}
}
// Reset logger messages for next test
logger.debugMessages = nil
})
}
}
// TestURLHelper_DetermineScheme tests scheme determination
func TestURLHelper_DetermineScheme(t *testing.T) {
logger := &mockLogger{}
helper := NewURLHelper(logger)
tests := []struct {
name string
setupRequest func() *http.Request
expectedScheme string
}{
{
name: "X-Forwarded-Proto header present - https",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "http://example.com", nil)
req.Header.Set("X-Forwarded-Proto", "https")
return req
},
expectedScheme: "https",
},
{
name: "X-Forwarded-Proto header present - http",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "http://example.com", nil)
req.Header.Set("X-Forwarded-Proto", "http")
return req
},
expectedScheme: "http",
},
{
name: "TLS connection without X-Forwarded-Proto",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "https://example.com", nil)
req.TLS = &tls.ConnectionState{} // Simulate TLS connection
return req
},
expectedScheme: "https",
},
{
name: "No TLS and no X-Forwarded-Proto",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "http://example.com", nil)
return req
},
expectedScheme: "http",
},
{
name: "X-Forwarded-Proto takes precedence over TLS",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "https://example.com", nil)
req.TLS = &tls.ConnectionState{} // Simulate TLS connection
req.Header.Set("X-Forwarded-Proto", "http")
return req
},
expectedScheme: "http",
},
{
name: "Empty X-Forwarded-Proto falls back to TLS",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "https://example.com", nil)
req.TLS = &tls.ConnectionState{} // Simulate TLS connection
req.Header.Set("X-Forwarded-Proto", "")
return req
},
expectedScheme: "https",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := tt.setupRequest()
result := helper.DetermineScheme(req)
if result != tt.expectedScheme {
t.Errorf("DetermineScheme() = %v, expected %v", result, tt.expectedScheme)
}
})
}
}
// TestURLHelper_DetermineHost tests host determination
func TestURLHelper_DetermineHost(t *testing.T) {
logger := &mockLogger{}
helper := NewURLHelper(logger)
tests := []struct {
name string
setupRequest func() *http.Request
expectedHost string
}{
{
name: "X-Forwarded-Host header present",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "http://example.com", nil)
req.Host = "internal.example.com"
req.Header.Set("X-Forwarded-Host", "public.example.com")
return req
},
expectedHost: "public.example.com",
},
{
name: "No X-Forwarded-Host, use req.Host",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "http://example.com", nil)
req.Host = "direct.example.com"
return req
},
expectedHost: "direct.example.com",
},
{
name: "Empty X-Forwarded-Host falls back to req.Host",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "http://example.com", nil)
req.Host = "fallback.example.com"
req.Header.Set("X-Forwarded-Host", "")
return req
},
expectedHost: "fallback.example.com",
},
{
name: "X-Forwarded-Host with port",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "http://example.com", nil)
req.Host = "internal.example.com:8080"
req.Header.Set("X-Forwarded-Host", "public.example.com:443")
return req
},
expectedHost: "public.example.com:443",
},
{
name: "req.Host with port",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "http://example.com:8080", nil)
req.Host = "example.com:8080"
return req
},
expectedHost: "example.com:8080",
},
{
name: "Multiple X-Forwarded-Host values (first one used)",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "http://example.com", nil)
req.Host = "internal.example.com"
req.Header.Set("X-Forwarded-Host", "first.example.com, second.example.com")
return req
},
expectedHost: "first.example.com, second.example.com", // Header value as-is
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := tt.setupRequest()
result := helper.DetermineHost(req)
if result != tt.expectedHost {
t.Errorf("DetermineHost() = %v, expected %v", result, tt.expectedHost)
}
})
}
}
// TestURLHelper_DetermineSchemeAndHost_Integration tests scheme and host working together
func TestURLHelper_DetermineSchemeAndHost_Integration(t *testing.T) {
logger := &mockLogger{}
helper := NewURLHelper(logger)
tests := []struct {
name string
setupRequest func() *http.Request
expectedScheme string
expectedHost string
}{
{
name: "Both headers present",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "http://internal.example.com", nil)
req.Host = "internal.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "public.example.com")
return req
},
expectedScheme: "https",
expectedHost: "public.example.com",
},
{
name: "Neither header present, TLS connection",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "https://secure.example.com", nil)
req.Host = "secure.example.com"
req.TLS = &tls.ConnectionState{} // Simulate TLS connection
return req
},
expectedScheme: "https",
expectedHost: "secure.example.com",
},
{
name: "Neither header present, no TLS",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "http://plain.example.com", nil)
req.Host = "plain.example.com"
return req
},
expectedScheme: "http",
expectedHost: "plain.example.com",
},
{
name: "Mixed - only scheme header",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "http://mixed.example.com", nil)
req.Host = "mixed.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
return req
},
expectedScheme: "https",
expectedHost: "mixed.example.com",
},
{
name: "Mixed - only host header",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("GET", "http://mixed.example.com", nil)
req.Host = "internal.example.com"
req.Header.Set("X-Forwarded-Host", "external.example.com")
return req
},
expectedScheme: "http",
expectedHost: "external.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := tt.setupRequest()
scheme := helper.DetermineScheme(req)
host := helper.DetermineHost(req)
if scheme != tt.expectedScheme {
t.Errorf("DetermineScheme() = %v, expected %v", scheme, tt.expectedScheme)
}
if host != tt.expectedHost {
t.Errorf("DetermineHost() = %v, expected %v", host, tt.expectedHost)
}
// Test that we can build a complete URL
fullURL := scheme + "://" + host + "/callback"
expectedURL := tt.expectedScheme + "://" + tt.expectedHost + "/callback"
if fullURL != expectedURL {
t.Errorf("Combined URL = %v, expected %v", fullURL, expectedURL)
}
})
}
}
// Benchmark tests to ensure the helper methods are performant
func BenchmarkURLHelper_DetermineExcludedURL(b *testing.B) {
logger := &mockLogger{}
helper := NewURLHelper(logger)
excludedURLs := map[string]struct{}{
"/health": {},
"/metrics": {},
"/status": {},
"/api/v1": {},
"/api/v2": {},
"/static": {},
"/assets": {},
"/favicon": {},
"/robots": {},
"/sitemap": {},
}
testURL := "/api/users"
b.ResetTimer()
for i := 0; i < b.N; i++ {
helper.DetermineExcludedURL(testURL, excludedURLs)
}
}
func BenchmarkURLHelper_DetermineScheme(b *testing.B) {
logger := &mockLogger{}
helper := NewURLHelper(logger)
req, _ := http.NewRequest("GET", "http://example.com", nil)
req.Header.Set("X-Forwarded-Proto", "https")
b.ResetTimer()
for i := 0; i < b.N; i++ {
helper.DetermineScheme(req)
}
}
func BenchmarkURLHelper_DetermineHost(b *testing.B) {
logger := &mockLogger{}
helper := NewURLHelper(logger)
req, _ := http.NewRequest("GET", "http://example.com", nil)
req.Host = "internal.example.com"
req.Header.Set("X-Forwarded-Host", "external.example.com")
b.ResetTimer()
for i := 0; i < b.N; i++ {
helper.DetermineHost(req)
}
}
+20 -2
View File
@@ -13,8 +13,25 @@ import (
"net/url"
"strings"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/utils"
)
// newUUIDv4 returns an RFC 4122 v4 UUID string (e.g.
// "f47ac10b-58cc-4372-a567-0e02b2c3d479") backed by crypto/rand. Used for CSRF
// tokens and other opaque random identifiers — replaces github.com/google/uuid
// to keep the plugin stdlib-only on the production path.
func newUUIDv4() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("could not generate UUID: %w", err)
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
// generateNonce creates a cryptographically secure random nonce for OIDC flows.
// The nonce is used to prevent replay attacks and associate client sessions with ID tokens.
// Returns:
@@ -334,6 +351,7 @@ func createStringMap(keys []string) map[string]struct{} {
// and redirects to the provider's logout endpoint or configured post-logout URI.
// It handles potential errors during session retrieval or clearing.
func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
t.logger.Debug("Processing logout request")
session, err := t.sessionManager.GetSession(req)
if err != nil {
t.logger.Errorf("Error getting session: %v", err)
@@ -349,8 +367,8 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
return
}
host := t.determineHost(req)
scheme := t.determineScheme(req)
host := utils.DetermineHost(req)
scheme := utils.DetermineScheme(req, t.forceHTTPS)
baseURL := fmt.Sprintf("%s://%s", scheme, host)
postLogoutRedirectURI := t.postLogoutRedirectURI
+29
View File
@@ -0,0 +1,29 @@
package traefikoidc
import (
"regexp"
"testing"
)
// TestNewUUIDv4 verifies the in-house UUID v4 generator produces RFC 4122
// compliant identifiers. Locks in the replacement for github.com/google/uuid
// — a regression here would weaken the CSRF token used in the OIDC flow.
func TestNewUUIDv4(t *testing.T) {
rfc4122v4 := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
const samples = 1000
seen := make(map[string]struct{}, samples)
for i := 0; i < samples; i++ {
got, err := newUUIDv4()
if err != nil {
t.Fatalf("newUUIDv4 failed: %v", err)
}
if !rfc4122v4.MatchString(got) {
t.Fatalf("UUID %q does not match RFC 4122 v4 format", got)
}
if _, dup := seen[got]; dup {
t.Fatalf("duplicate UUID emitted within %d samples: %q", samples, got)
}
seen[got] = struct{}{}
}
}
+18 -17
View File
@@ -3,6 +3,7 @@ package traefikoidc
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"net/http"
@@ -12,27 +13,26 @@ import (
// HTTPClientConfig provides configuration for creating HTTP clients
type HTTPClientConfig struct {
// Timeout for the entire request
Timeout time.Duration
// MaxRedirects allowed (0 means follow Go's default of 10)
MaxRedirects int
// UseCookieJar enables cookie jar for the client
UseCookieJar bool
// Connection settings
IdleConnTimeout time.Duration
MaxIdleConns int
ReadBufferSize int
DialTimeout time.Duration
KeepAlive time.Duration
TLSHandshakeTimeout time.Duration
ResponseHeaderTimeout time.Duration
ExpectContinueTimeout time.Duration
IdleConnTimeout time.Duration
// Connection pool settings
MaxIdleConns int
MaxIdleConnsPerHost int
MaxConnsPerHost int
// Buffer settings
WriteBufferSize int
ReadBufferSize int
// Feature flags
MaxRedirects int
MaxIdleConnsPerHost int
Timeout time.Duration
MaxConnsPerHost int
WriteBufferSize int
// RootCAs is an optional certificate pool used for TLS verification.
// A nil pool means "use the system trust store" (default behavior).
RootCAs *x509.CertPool
// InsecureSkipVerify disables TLS certificate verification.
// ONLY set this for local development against self-signed certificates.
InsecureSkipVerify bool
UseCookieJar bool
ForceHTTP2 bool
DisableKeepAlives bool
DisableCompression bool
@@ -210,7 +210,8 @@ func (f *HTTPClientFactory) CreateHTTPClient(config HTTPClientConfig) *http.Clie
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
},
PreferServerCipherSuites: true,
InsecureSkipVerify: false, // Always verify certificates
RootCAs: config.RootCAs,
InsecureSkipVerify: config.InsecureSkipVerify, //nolint:gosec // opt-in, loud warning emitted at plugin startup
},
ForceAttemptHTTP2: config.ForceHTTP2,
TLSHandshakeTimeout: config.TLSHandshakeTimeout,
+1 -1
View File
@@ -110,9 +110,9 @@ func TestHTTPClientFactoryValidateHTTPClientConfig(t *testing.T) {
tests := []struct {
name string
errorMsg string
config HTTPClientConfig
wantError bool
errorMsg string
}{
{
name: "valid config",
+24 -9
View File
@@ -3,6 +3,7 @@ package traefikoidc
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"sync"
@@ -12,19 +13,19 @@ import (
// SharedTransportPool manages a pool of shared HTTP transports to prevent connection exhaustion
type SharedTransportPool struct {
mu sync.RWMutex
transports map[string]*sharedTransport
maxConns int
ctx context.Context
transports map[string]*sharedTransport
cancel context.CancelFunc
clientCount int32 // SECURITY FIX: Track total HTTP clients
maxClients int32 // SECURITY FIX: Limit total clients to 5
maxConns int
mu sync.RWMutex
clientCount int32
maxClients int32
}
type sharedTransport struct {
lastUsed time.Time
transport *http.Transport
refCount int
lastUsed time.Time
}
var (
@@ -103,7 +104,8 @@ func (p *SharedTransportPool) GetOrCreateTransport(config HTTPClientConfig) *htt
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
},
PreferServerCipherSuites: true,
InsecureSkipVerify: false,
RootCAs: config.RootCAs,
InsecureSkipVerify: config.InsecureSkipVerify, //nolint:gosec // opt-in, loud warning emitted at plugin startup
},
ForceAttemptHTTP2: config.ForceHTTP2,
TLSHandshakeTimeout: config.TLSHandshakeTimeout,
@@ -205,8 +207,21 @@ func (p *SharedTransportPool) performCleanup() {
// configKey generates a unique key for a config
func (p *SharedTransportPool) configKey(config HTTPClientConfig) string {
// Simple key based on main parameters
return string(rune(config.MaxConnsPerHost)) + string(rune(config.MaxIdleConnsPerHost))
// Pool transports by the parameters that change TLS or connection
// behavior. RootCAs and InsecureSkipVerify MUST be part of the key:
// otherwise a middleware configured with a custom CA would share a
// transport with one using the system store, silently bypassing its
// CA configuration.
skip := "0"
if config.InsecureSkipVerify {
skip = "1"
}
return fmt.Sprintf("%d|%d|%p|%s",
config.MaxConnsPerHost,
config.MaxIdleConnsPerHost,
config.RootCAs,
skip,
)
}
// Cleanup closes all transports and stops the cleanup goroutine
+14 -27
View File
@@ -10,6 +10,14 @@ import (
"unicode/utf8"
)
// Pre-compiled regex patterns for validation (const patterns should use MustCompile)
var (
emailRegexPattern = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
urlRegexPattern = regexp.MustCompile(`^https?://[a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,})?(?::[0-9]+)?(?:/[^\s]*)?$`)
tokenRegexPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
usernameRegexPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
)
// InputValidator provides comprehensive input validation and sanitization
// to protect against common security vulnerabilities including SQL injection,
// XSS, path traversal, and other injection attacks. It validates and sanitizes
@@ -73,7 +81,7 @@ func DefaultInputValidationConfig() InputValidationConfig {
}
// NewInputValidator creates a new input validator with the specified configuration.
// It compiles all necessary regex patterns and initializes security pattern lists.
// It uses pre-compiled regex patterns and initializes security pattern lists.
//
// Parameters:
// - config: Validation configuration with size limits and mode settings.
@@ -81,29 +89,8 @@ func DefaultInputValidationConfig() InputValidationConfig {
//
// Returns:
// - A configured InputValidator instance.
// - An error if regex compilation fails.
// - An error (always nil, kept for API compatibility).
func NewInputValidator(config InputValidationConfig, logger *Logger) (*InputValidator, error) {
// Compile regex patterns
emailRegex, err := regexp.Compile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
if err != nil {
return nil, fmt.Errorf("failed to compile email regex: %w", err)
}
urlRegex, err := regexp.Compile(`^https?://[a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,})?(?::[0-9]+)?(?:/[^\s]*)?$`)
if err != nil {
return nil, fmt.Errorf("failed to compile URL regex: %w", err)
}
tokenRegex, err := regexp.Compile(`^[A-Za-z0-9._-]+$`)
if err != nil {
return nil, fmt.Errorf("failed to compile token regex: %w", err)
}
usernameRegex, err := regexp.Compile(`^[a-zA-Z0-9._-]+$`)
if err != nil {
return nil, fmt.Errorf("failed to compile username regex: %w", err)
}
return &InputValidator{
maxTokenLength: config.MaxTokenLength,
maxURLLength: config.MaxURLLength,
@@ -112,10 +99,10 @@ func NewInputValidator(config InputValidationConfig, logger *Logger) (*InputVali
maxEmailLength: config.MaxEmailLength,
maxUsernameLength: config.MaxUsernameLength,
allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
emailRegex: emailRegex,
urlRegex: urlRegex,
tokenRegex: tokenRegex,
usernameRegex: usernameRegex,
emailRegex: emailRegexPattern,
urlRegex: urlRegexPattern,
tokenRegex: tokenRegexPattern,
usernameRegex: usernameRegexPattern,
sqlInjectionPatterns: []string{
"'", "\"", ";", "--", "/*", "*/", "xp_", "sp_",
"union", "select", "insert", "update", "delete", "drop",
+9 -9
View File
@@ -14,7 +14,7 @@ func TestInputValidator(t *testing.T) {
}
t.Run("Valid token validation", func(t *testing.T) {
validToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8jO19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHs3UjpMC6M6FNqI2J-I2NxrragtnDxGxdJUvDERDQVHzeNlVQiuqWDEeO_O-0KptafbfyuGqfQxH_6dp2_MeFpAc"
validToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8jO19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHs3UjpMC6M6FNqI2J-I2NxrragtnDxGxdJUvDERDQVHzeNlVQiuqWDEeO_O-0KptafbfyuGqfQxH_6dp2_MeFpAc" // trufflehog:ignore
result := validator.ValidateToken(validToken)
if !result.IsValid {
@@ -428,12 +428,12 @@ func TestInputValidatorValidateToken(t *testing.T) {
tests := []struct {
name string
token string
expectValid bool
description string
expectValid bool
}{
{
name: "ValidJWTToken",
token: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyLCJpYXQiOjE1MTYyMzkwMjJ9.signature",
token: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyLCJpYXQiOjE1MTYyMzkwMjJ9.signature", // trufflehog:ignore
expectValid: true,
description: "Valid JWT token should pass validation",
},
@@ -475,7 +475,7 @@ func TestInputValidatorValidateToken(t *testing.T) {
},
{
name: "MaliciousJWTWithExtraData",
token: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig.malicious_extra",
token: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig.malicious_extra", // trufflehog:ignore
expectValid: false,
description: "JWT with extra malicious data should fail validation",
},
@@ -500,8 +500,8 @@ func TestInputValidatorValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
expectValid bool
description string
expectValid bool
}{
{
name: "ValidEmail",
@@ -578,8 +578,8 @@ func TestInputValidatorValidateURL(t *testing.T) {
tests := []struct {
name string
url string
expectValid bool
description string
expectValid bool
}{
{
name: "ValidHTTPSURL",
@@ -669,8 +669,8 @@ func TestInputValidatorValidateClaim(t *testing.T) {
name string
claimName string
claimValue string
expectValid bool
description string
expectValid bool
}{
{
name: "ValidStringClaim",
@@ -750,8 +750,8 @@ func TestInputValidatorValidateHeader(t *testing.T) {
name string
headerName string
headerValue string
expectValid bool
description string
expectValid bool
}{
{
name: "ValidHeader",
@@ -830,8 +830,8 @@ func TestInputValidatorValidateUsername(t *testing.T) {
tests := []struct {
name string
username string
expectValid bool
description string
expectValid bool
}{
{
name: "ValidUsername",
+4 -4
View File
@@ -726,20 +726,20 @@ type MockConfig struct {
}
type MockSession struct {
id string
userID string
created time.Time
lastUsed time.Time
data map[string]interface{}
id string
userID string
}
type TestResult struct {
UserID int
StartTime time.Time
EndTime time.Time
Error error
UserID int
Duration time.Duration
Success bool
Error error
}
// ============================================================================
+14 -25
View File
@@ -18,33 +18,22 @@ const (
// Config provides common configuration for cache backends
type Config struct {
// Type specifies the backend type
Type BackendType
// Memory backend settings
MaxSize int
MaxMemoryBytes int64
CleanupInterval time.Duration
// Redis backend settings
RedisAddr string
RedisPassword string
RedisDB int
RedisPrefix string
PoolSize int
// Hybrid backend settings
L1Config *Config // Memory cache (L1)
L2Config *Config // Redis cache (L2)
AsyncWrites bool // Write to L2 asynchronously
// Resilience settings
L2Config *Config
L1Config *Config
RedisPrefix string
Type BackendType
RedisAddr string
RedisPassword string
PoolSize int
RedisDB int
CleanupInterval time.Duration
MaxMemoryBytes int64
MaxSize int
HealthCheckInterval time.Duration
AsyncWrites bool
EnableCircuitBreaker bool
EnableHealthCheck bool
HealthCheckInterval time.Duration
// Metrics
EnableMetrics bool
EnableMetrics bool
}
// DefaultConfig returns a default configuration for in-memory caching
+18 -28
View File
@@ -13,40 +13,30 @@ import (
// HybridBackend implements a two-tier cache with L1 (memory) and L2 (Redis) backends
// It provides automatic failover, async writes for non-critical data, and optimized read paths
type HybridBackend struct {
primary CacheBackend // L1: Memory cache for fast access
secondary CacheBackend // L2: Redis cache for distributed access
// Configuration
syncWriteCacheTypes map[string]bool // Which cache types require synchronous writes
lastL2Error atomic.Value
secondary CacheBackend
primary CacheBackend
logger Logger
ctx context.Context
syncWriteCacheTypes map[string]bool
asyncWriteBuffer chan *asyncWriteItem
// Metrics
l1Hits atomic.Int64
l2Hits atomic.Int64
misses atomic.Int64
l1Writes atomic.Int64
l2Writes atomic.Int64
errors atomic.Int64
// Fallback tracking
fallbackMode atomic.Bool // True when operating in degraded mode (L1 only)
lastL2Error atomic.Value // Stores last L2 error timestamp
// Lifecycle
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
// Logging
logger Logger
cancel context.CancelFunc
wg sync.WaitGroup
l1Hits atomic.Int64
errors atomic.Int64
l2Writes atomic.Int64
l1Writes atomic.Int64
misses atomic.Int64
l2Hits atomic.Int64
fallbackMode atomic.Bool
}
// asyncWriteItem represents an async write operation
type asyncWriteItem struct {
ctx context.Context
key string
value []byte
ttl time.Duration
ctx context.Context
}
// Logger interface for structured logging
@@ -82,9 +72,9 @@ func (l *defaultLogger) Errorf(format string, args ...interface{}) {
type HybridConfig struct {
Primary CacheBackend
Secondary CacheBackend
SyncWriteCacheTypes map[string]bool // Cache types requiring synchronous L2 writes
AsyncBufferSize int
Logger Logger
SyncWriteCacheTypes map[string]bool
AsyncBufferSize int
}
// NewHybridBackend creates a new hybrid cache backend with L1 (memory) and L2 (Redis) tiers
+6 -6
View File
@@ -17,23 +17,23 @@ import (
// mockBackend is a simple mock implementation of CacheBackend for testing
type mockBackend struct {
pingError error
data map[string]mockEntry
stats map[string]interface{}
mu sync.RWMutex
getCalls atomic.Int32
setCalls atomic.Int32
deleteCalls atomic.Int32
failSet bool
failGet bool
failDelete bool
failClear bool
failPing bool
pingError error
stats map[string]interface{}
getCalls atomic.Int32
setCalls atomic.Int32
deleteCalls atomic.Int32
}
type mockEntry struct {
value []byte
expiresAt time.Time
value []byte
}
// mockBatchBackend extends mockBackend with batch operations
+14 -45
View File
@@ -41,53 +41,22 @@ type CacheBackend interface {
// BackendStats represents statistics for a cache backend
type BackendStats struct {
// Type is the backend type
Type BackendType
// Hits is the number of cache hits
Hits int64
// Misses is the number of cache misses
Misses int64
// Sets is the number of set operations
Sets int64
// Deletes is the number of delete operations
Deletes int64
// Errors is the number of errors
Errors int64
// Evictions is the number of evicted items
Evictions int64
// CurrentSize is the current number of items in cache
CurrentSize int64
// MaxSize is the maximum number of items (0 means unlimited)
MaxSize int64
// MemoryUsage is the approximate memory usage in bytes
MemoryUsage int64
// AverageGetLatency is the average latency for get operations
StartTime time.Time
LastErrorTime time.Time
Type BackendType
LastError string
Deletes int64
Errors int64
Evictions int64
CurrentSize int64
MaxSize int64
MemoryUsage int64
AverageGetLatency time.Duration
// AverageSetLatency is the average latency for set operations
AverageSetLatency time.Duration
// LastError is the last error encountered
LastError string
// LastErrorTime is when the last error occurred
LastErrorTime time.Time
// Uptime is how long the backend has been running
Uptime time.Duration
// StartTime is when the backend was started
StartTime time.Time
Sets int64
Misses int64
Uptime time.Duration
Hits int64
}
// BackendCapabilities describes the capabilities of a cache backend
+219 -200
View File
@@ -2,23 +2,30 @@
package backends
import (
"container/list"
"context"
"sync"
"sync/atomic"
"time"
)
// Default configuration values
const (
defaultShardCount = 256
defaultMaxSize = int64(10000)
defaultMaxMemory = int64(100 * 1024 * 1024) // 100MB
defaultCleanupInterval = 5 * time.Minute
)
// memoryCacheItem represents an item in the memory cache
type memoryCacheItem struct {
key string
value interface{}
expiresAt time.Time
createdAt time.Time
accessedAt time.Time
value interface{}
element interface{} // *list.Element, using interface{} to avoid import cycle
key string
accessCount int64
size int64
element *list.Element // for LRU tracking
}
// isExpired checks if the item is expired
@@ -29,17 +36,23 @@ func (item *memoryCacheItem) isExpired() bool {
return time.Now().After(item.expiresAt)
}
// MemoryCacheBackend implements the CacheBackend interface using in-memory storage
// MemoryCacheBackend implements the CacheBackend interface using sharded in-memory storage
// The sharded design reduces lock contention by partitioning keys across multiple shards,
// each with its own lock.
type MemoryCacheBackend struct {
mu sync.RWMutex
items map[string]*memoryCacheItem
lruList *list.List
maxSize int64
maxMemory int64
currentSize int64
currentMemory int64
shards []*cacheShard
startTime time.Time
lastErrorTime time.Time
cleanupDone chan struct{}
cleanupTicker *time.Ticker
lastError string
shardCount uint32
shardMask uint32
maxSize int64
maxMemory int64
cleanupInterval time.Duration
// Statistics
// Global stats (aggregated from shards)
hits atomic.Int64
misses atomic.Int64
sets atomic.Int64
@@ -53,40 +66,59 @@ type MemoryCacheBackend struct {
getCount atomic.Int64
setCount atomic.Int64
// Status
startTime time.Time
lastError string
lastErrorTime time.Time
cleanupTicker *time.Ticker
cleanupDone chan bool
closed atomic.Bool
// Configuration
cleanupInterval time.Duration
evictionPolicy string // "lru", "lfu", "fifo"
// State
closed atomic.Bool
mu sync.RWMutex // For global operations like stats and error tracking
}
// NewMemoryCacheBackend creates a new memory cache backend
// NewMemoryCacheBackend creates a new sharded memory cache backend
func NewMemoryCacheBackend(maxSize int64, maxMemory int64, cleanupInterval time.Duration) *MemoryCacheBackend {
if maxSize <= 0 {
maxSize = 10000 // Default to 10k items
maxSize = defaultMaxSize
}
if maxMemory <= 0 {
maxMemory = 100 * 1024 * 1024 // Default to 100MB
maxMemory = defaultMaxMemory
}
if cleanupInterval <= 0 {
cleanupInterval = 5 * time.Minute
cleanupInterval = defaultCleanupInterval
}
shardCount := uint32(defaultShardCount)
// For very small caches, reduce shard count to maintain sensible per-shard limits
// Ensure each shard can hold at least 2 items for proper LRU behavior
for shardCount > 1 && maxSize/int64(shardCount) < 2 {
shardCount /= 2
}
if shardCount < 1 {
shardCount = 1
}
// Per-shard limits are soft hints; global limits are enforced
// Give shards 2x the average to allow for uneven distribution
shardMaxSize := (maxSize * 2) / int64(shardCount)
if shardMaxSize < 4 {
shardMaxSize = 4
}
shardMaxMemory := (maxMemory * 2) / int64(shardCount)
if shardMaxMemory < 4096 {
shardMaxMemory = 4096 // Minimum 4KB per shard
}
m := &MemoryCacheBackend{
items: make(map[string]*memoryCacheItem),
lruList: list.New(),
shards: make([]*cacheShard, shardCount),
shardCount: shardCount,
shardMask: shardCount - 1, // For fast modulo with power-of-2
maxSize: maxSize,
maxMemory: maxMemory,
startTime: time.Now(),
cleanupInterval: cleanupInterval,
evictionPolicy: "lru",
cleanupDone: make(chan bool),
cleanupDone: make(chan struct{}),
}
// Initialize shards
for i := uint32(0); i < shardCount; i++ {
m.shards[i] = newCacheShard(shardMaxSize, shardMaxMemory)
}
// Start cleanup goroutine
@@ -96,6 +128,12 @@ func NewMemoryCacheBackend(maxSize int64, maxMemory int64, cleanupInterval time.
return m
}
// getShard returns the shard for a given key
func (m *MemoryCacheBackend) getShard(key string) *cacheShard {
hash := fnv32(key)
return m.shards[hash&m.shardMask]
}
// cleanupLoop runs periodic cleanup of expired items
func (m *MemoryCacheBackend) cleanupLoop() {
for {
@@ -108,20 +146,19 @@ func (m *MemoryCacheBackend) cleanupLoop() {
}
}
// cleanupExpired removes all expired items from the cache
// cleanupExpired removes all expired items from all shards
func (m *MemoryCacheBackend) cleanupExpired() {
m.mu.Lock()
defer m.mu.Unlock()
var keysToDelete []string
for key, item := range m.items {
if item.isExpired() {
keysToDelete = append(keysToDelete, key)
}
if m.closed.Load() {
return
}
for _, key := range keysToDelete {
m.deleteItemLocked(key)
totalRemoved := 0
for _, shard := range m.shards {
totalRemoved += shard.cleanup()
}
if totalRemoved > 0 {
m.evictions.Add(int64(totalRemoved))
}
}
@@ -138,35 +175,23 @@ func (m *MemoryCacheBackend) Get(ctx context.Context, key string) (interface{},
m.getCount.Add(1)
}()
m.mu.RLock()
item, exists := m.items[key]
m.mu.RUnlock()
shard := m.getShard(key)
value, exists, expired := shard.get(key)
if expired {
// Clean up expired item
shard.delete(key)
m.misses.Add(1)
return nil, ErrCacheMiss
}
if !exists {
m.misses.Add(1)
return nil, ErrCacheMiss
}
if item.isExpired() {
m.mu.Lock()
m.deleteItemLocked(key)
m.mu.Unlock()
m.misses.Add(1)
return nil, ErrCacheMiss
}
// Update access time and count
m.mu.Lock()
item.accessedAt = time.Now()
item.accessCount++
// Move to front of LRU list
if m.evictionPolicy == "lru" && item.element != nil {
m.lruList.MoveToFront(item.element)
}
m.mu.Unlock()
m.hits.Add(1)
return item.value, nil
return value, nil
}
// Set stores a value in the cache with optional TTL
@@ -182,113 +207,105 @@ func (m *MemoryCacheBackend) Set(ctx context.Context, key string, value interfac
m.setCount.Add(1)
}()
// Calculate item size (simplified estimation)
// Calculate item size
itemSize := int64(len(key)) + estimateValueSize(value)
m.mu.Lock()
defer m.mu.Unlock()
// Enforce global limits before adding new item
m.enforceGlobalLimits(itemSize)
// Check if we need to evict items
if m.currentSize >= m.maxSize || m.currentMemory+itemSize > m.maxMemory {
m.evictLocked()
}
// Check if key exists
if oldItem, exists := m.items[key]; exists {
m.currentMemory -= oldItem.size
if oldItem.element != nil {
m.lruList.Remove(oldItem.element)
}
} else {
m.currentSize++
}
now := time.Now()
var expiresAt time.Time
if ttl > 0 {
expiresAt = now.Add(ttl)
expiresAt = time.Now().Add(ttl)
}
item := &memoryCacheItem{
key: key,
value: value,
expiresAt: expiresAt,
createdAt: now,
accessedAt: now,
accessCount: 0,
size: itemSize,
}
shard := m.getShard(key)
shard.set(key, value, expiresAt, itemSize)
// Add to LRU list
if m.evictionPolicy == "lru" {
item.element = m.lruList.PushFront(item)
}
m.items[key] = item
m.currentMemory += itemSize
m.sets.Add(1)
return nil
}
// enforceGlobalLimits ensures global size and memory limits are respected
// by evicting from shards when necessary
func (m *MemoryCacheBackend) enforceGlobalLimits(newItemSize int64) {
// Check and enforce size limit
for {
totalSize, totalMemory := m.getGlobalStats()
needsSizeEviction := m.maxSize > 0 && totalSize >= m.maxSize
needsMemoryEviction := m.maxMemory > 0 && totalMemory+newItemSize > m.maxMemory
if !needsSizeEviction && !needsMemoryEviction {
break
}
// Find the shard with the most items and evict from it
evicted := m.evictFromLargestShard()
if !evicted {
break // No more items to evict
}
m.evictions.Add(1)
}
}
// getGlobalStats returns the total size and memory usage across all shards
func (m *MemoryCacheBackend) getGlobalStats() (totalSize, totalMemory int64) {
for _, shard := range m.shards {
size, memory := shard.stats()
totalSize += size
totalMemory += memory
}
return
}
// evictFromLargestShard evicts the globally oldest item across all shards
// This provides true LRU behavior even with sharding
func (m *MemoryCacheBackend) evictFromLargestShard() bool {
var oldestShard *cacheShard
var oldestTime time.Time
for _, shard := range m.shards {
accessTime := shard.getOldestAccessTime()
// Skip empty shards
if accessTime.IsZero() {
continue
}
// Find the shard with the oldest (earliest) access time
if oldestShard == nil || accessTime.Before(oldestTime) {
oldestTime = accessTime
oldestShard = shard
}
}
if oldestShard == nil {
return false
}
return oldestShard.evictOne()
}
// Delete removes a key from the cache
func (m *MemoryCacheBackend) Delete(ctx context.Context, key string) error {
if m.closed.Load() {
return ErrBackendUnavailable
}
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.items[key]; !exists {
return nil
shard := m.getShard(key)
if shard.delete(key) {
m.deletes.Add(1)
}
m.deleteItemLocked(key)
m.deletes.Add(1)
return nil
}
// deleteItemLocked deletes an item without acquiring the lock (must be called with lock held)
func (m *MemoryCacheBackend) deleteItemLocked(key string) {
if item, exists := m.items[key]; exists {
m.currentMemory -= item.size
m.currentSize--
if item.element != nil {
m.lruList.Remove(item.element)
}
delete(m.items, key)
}
}
// evictLocked evicts items based on the eviction policy (must be called with lock held)
func (m *MemoryCacheBackend) evictLocked() {
if m.evictionPolicy == "lru" && m.lruList.Len() > 0 {
// Evict least recently used item
element := m.lruList.Back()
if element != nil {
item := element.Value.(*memoryCacheItem)
m.deleteItemLocked(item.key)
m.evictions.Add(1)
}
}
}
// Exists checks if a key exists in the cache
func (m *MemoryCacheBackend) Exists(ctx context.Context, key string) (bool, error) {
if m.closed.Load() {
return false, ErrBackendUnavailable
}
m.mu.RLock()
item, exists := m.items[key]
m.mu.RUnlock()
if !exists {
return false, nil
}
return !item.isExpired(), nil
shard := m.getShard(key)
return shard.exists(key), nil
}
// Clear removes all items from the cache
@@ -297,13 +314,9 @@ func (m *MemoryCacheBackend) Clear(ctx context.Context) error {
return ErrBackendUnavailable
}
m.mu.Lock()
defer m.mu.Unlock()
m.items = make(map[string]*memoryCacheItem)
m.lruList = list.New()
m.currentSize = 0
m.currentMemory = 0
for _, shard := range m.shards {
shard.clear()
}
return nil
}
@@ -314,29 +327,28 @@ func (m *MemoryCacheBackend) Keys(ctx context.Context, pattern string) ([]string
return nil, ErrBackendUnavailable
}
m.mu.RLock()
defer m.mu.RUnlock()
var keys []string
for key, item := range m.items {
if !item.isExpired() && matchPattern(pattern, key) {
keys = append(keys, key)
}
var allKeys []string
for _, shard := range m.shards {
keys := shard.keys(pattern)
allKeys = append(allKeys, keys...)
}
return keys, nil
return allKeys, nil
}
// Size returns the number of items in the cache
// Size returns the total number of items in the cache
func (m *MemoryCacheBackend) Size(ctx context.Context) (int64, error) {
if m.closed.Load() {
return 0, ErrBackendUnavailable
}
m.mu.RLock()
defer m.mu.RUnlock()
var total int64
for _, shard := range m.shards {
size, _ := shard.stats()
total += size
}
return m.currentSize, nil
return total, nil
}
// TTL returns the remaining time-to-live for a key
@@ -345,24 +357,13 @@ func (m *MemoryCacheBackend) TTL(ctx context.Context, key string) (time.Duration
return 0, ErrBackendUnavailable
}
m.mu.RLock()
item, exists := m.items[key]
m.mu.RUnlock()
if !exists || item.isExpired() {
shard := m.getShard(key)
ttl, exists := shard.ttl(key)
if !exists {
return 0, ErrCacheMiss
}
if item.expiresAt.IsZero() {
return 0, nil // No expiration
}
remaining := time.Until(item.expiresAt)
if remaining < 0 {
return 0, nil
}
return remaining, nil
return ttl, nil
}
// Expire updates the TTL for an existing key
@@ -371,20 +372,11 @@ func (m *MemoryCacheBackend) Expire(ctx context.Context, key string, ttl time.Du
return ErrBackendUnavailable
}
m.mu.Lock()
defer m.mu.Unlock()
item, exists := m.items[key]
if !exists || item.isExpired() {
shard := m.getShard(key)
if !shard.expire(key, ttl) {
return ErrCacheMiss
}
if ttl > 0 {
item.expiresAt = time.Now().Add(ttl)
} else {
item.expiresAt = time.Time{} // Remove expiration
}
return nil
}
@@ -394,6 +386,14 @@ func (m *MemoryCacheBackend) GetStats(ctx context.Context) (*BackendStats, error
return nil, ErrBackendUnavailable
}
// Aggregate stats from all shards
var totalSize, totalMemory int64
for _, shard := range m.shards {
size, memory := shard.stats()
totalSize += size
totalMemory += memory
}
m.mu.RLock()
lastError := m.lastError
lastErrorTime := m.lastErrorTime
@@ -417,9 +417,9 @@ func (m *MemoryCacheBackend) GetStats(ctx context.Context) (*BackendStats, error
Deletes: m.deletes.Load(),
Errors: m.errors.Load(),
Evictions: m.evictions.Load(),
CurrentSize: m.currentSize,
CurrentSize: totalSize,
MaxSize: m.maxSize,
MemoryUsage: m.currentMemory,
MemoryUsage: totalMemory,
AverageGetLatency: avgGetLatency,
AverageSetLatency: avgSetLatency,
LastError: lastError,
@@ -446,10 +446,10 @@ func (m *MemoryCacheBackend) Close() error {
m.cleanupTicker.Stop()
close(m.cleanupDone)
m.mu.Lock()
m.items = nil
m.lruList = nil
m.mu.Unlock()
// Clear all shards
for _, shard := range m.shards {
shard.clear()
}
return nil
}
@@ -482,12 +482,28 @@ func (m *MemoryCacheBackend) Capabilities() *BackendCapabilities {
}
}
// GetShardCount returns the number of shards (for testing/monitoring)
func (m *MemoryCacheBackend) GetShardCount() uint32 {
return m.shardCount
}
// GetShardStats returns per-shard statistics (for monitoring)
func (m *MemoryCacheBackend) GetShardStats() []map[string]int64 {
stats := make([]map[string]int64, m.shardCount)
for i, shard := range m.shards {
size, memory := shard.stats()
stats[i] = map[string]int64{
"size": size,
"memory": memory,
}
}
return stats
}
// Helper functions
// estimateValueSize estimates the size of a value in bytes
func estimateValueSize(value interface{}) int64 {
// This is a simplified estimation
// In production, you might want to use a more accurate method
switch v := value.(type) {
case string:
return int64(len(v))
@@ -510,7 +526,10 @@ func matchPattern(pattern, key string) bool {
if pattern == "*" {
return true
}
// Simplified pattern matching - in production, use a proper glob library
return key == pattern || (len(pattern) > 0 && pattern[0] == '*' &&
len(key) >= len(pattern)-1 && key[len(key)-len(pattern)+1:] == pattern[1:])
// Simplified pattern matching
if len(pattern) > 0 && pattern[0] == '*' {
suffix := pattern[1:]
return len(key) >= len(suffix) && key[len(key)-len(suffix):] == suffix
}
return key == pattern
}
+294
View File
@@ -0,0 +1,294 @@
package backends
import (
"container/list"
"sync"
"time"
)
// cacheShard represents a single shard of the sharded cache
// Each shard has its own lock for reduced contention
type cacheShard struct {
items map[string]*memoryCacheItem
lruList *list.List
mu sync.RWMutex
maxSize int64
maxMemory int64
size int64
memoryUsed int64
}
// newCacheShard creates a new cache shard
func newCacheShard(maxSize, maxMemory int64) *cacheShard {
return &cacheShard{
items: make(map[string]*memoryCacheItem),
lruList: list.New(),
maxSize: maxSize,
maxMemory: maxMemory,
}
}
// get retrieves a value from this shard
// Returns: value, exists, expired
func (s *cacheShard) get(key string) (interface{}, bool, bool) {
s.mu.RLock()
item, exists := s.items[key]
s.mu.RUnlock()
if !exists {
return nil, false, false
}
if item.isExpired() {
return nil, true, true // exists but expired
}
// Update access time and LRU position under write lock
s.mu.Lock()
// Re-check item exists (could have been deleted)
item, exists = s.items[key]
if exists && !item.isExpired() {
item.accessedAt = time.Now()
item.accessCount++
if elem, ok := item.element.(*list.Element); ok && elem != nil {
s.lruList.MoveToFront(elem)
}
}
s.mu.Unlock()
if !exists || item.isExpired() {
return nil, false, false
}
return item.value, true, false
}
// set stores a value in this shard
func (s *cacheShard) set(key string, value interface{}, expiresAt time.Time, size int64) {
s.mu.Lock()
defer s.mu.Unlock()
// Check if we need to evict items
if s.maxSize > 0 && s.size >= s.maxSize {
s.evictLRULocked()
}
if s.maxMemory > 0 && s.memoryUsed+size > s.maxMemory {
s.evictLRULocked()
}
// Remove old item if exists
if oldItem, exists := s.items[key]; exists {
s.memoryUsed -= oldItem.size
if elem, ok := oldItem.element.(*list.Element); ok && elem != nil {
s.lruList.Remove(elem)
}
s.size--
}
now := time.Now()
item := &memoryCacheItem{
key: key,
value: value,
expiresAt: expiresAt,
createdAt: now,
accessedAt: now,
accessCount: 0,
size: size,
}
item.element = s.lruList.PushFront(item)
s.items[key] = item
s.size++
s.memoryUsed += size
}
// delete removes a key from this shard
// Returns true if the key was deleted
func (s *cacheShard) delete(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
item, exists := s.items[key]
if !exists {
return false
}
s.deleteItemLocked(item)
return true
}
// exists checks if a key exists (and is not expired)
func (s *cacheShard) exists(key string) bool {
s.mu.RLock()
item, exists := s.items[key]
s.mu.RUnlock()
if !exists {
return false
}
return !item.isExpired()
}
// ttl returns the remaining TTL for a key
func (s *cacheShard) ttl(key string) (time.Duration, bool) {
s.mu.RLock()
item, exists := s.items[key]
s.mu.RUnlock()
if !exists || item.isExpired() {
return 0, false
}
if item.expiresAt.IsZero() {
return 0, true // No expiration
}
remaining := time.Until(item.expiresAt)
if remaining < 0 {
return 0, false
}
return remaining, true
}
// expire updates the TTL for an existing key
func (s *cacheShard) expire(key string, ttl time.Duration) bool {
s.mu.Lock()
defer s.mu.Unlock()
item, exists := s.items[key]
if !exists || item.isExpired() {
return false
}
if ttl > 0 {
item.expiresAt = time.Now().Add(ttl)
} else {
item.expiresAt = time.Time{} // Remove expiration
}
return true
}
// keys returns all non-expired keys matching the pattern
func (s *cacheShard) keys(pattern string) []string {
s.mu.RLock()
defer s.mu.RUnlock()
var keys []string
for key, item := range s.items {
if !item.isExpired() && matchPattern(pattern, key) {
keys = append(keys, key)
}
}
return keys
}
// clear removes all items from this shard
func (s *cacheShard) clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.items = make(map[string]*memoryCacheItem)
s.lruList.Init()
s.size = 0
s.memoryUsed = 0
}
// cleanup removes expired items
// Returns the number of items removed
func (s *cacheShard) cleanup() int {
s.mu.Lock()
defer s.mu.Unlock()
var toRemove []*memoryCacheItem
for _, item := range s.items {
if item.isExpired() {
toRemove = append(toRemove, item)
}
}
for _, item := range toRemove {
s.deleteItemLocked(item)
}
return len(toRemove)
}
// stats returns statistics for this shard
func (s *cacheShard) stats() (size, memory int64) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.size, s.memoryUsed
}
// deleteItemLocked removes an item (must be called with lock held)
func (s *cacheShard) deleteItemLocked(item *memoryCacheItem) {
if elem, ok := item.element.(*list.Element); ok && elem != nil {
s.lruList.Remove(elem)
}
delete(s.items, item.key)
s.size--
s.memoryUsed -= item.size
}
// evictLRULocked evicts the least recently used item (must be called with lock held)
func (s *cacheShard) evictLRULocked() bool {
if s.lruList.Len() == 0 {
return false
}
element := s.lruList.Back()
if element != nil {
item, ok := element.Value.(*memoryCacheItem)
if ok {
s.deleteItemLocked(item)
return true
}
}
return false
}
// evictOne evicts one item from this shard (for global limit enforcement)
func (s *cacheShard) evictOne() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.evictLRULocked()
}
// getOldestAccessTime returns the access time of the LRU item (oldest) in this shard
// Returns zero time if shard is empty
func (s *cacheShard) getOldestAccessTime() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
if s.lruList.Len() == 0 {
return time.Time{}
}
element := s.lruList.Back()
if element != nil {
item, ok := element.Value.(*memoryCacheItem)
if ok {
return item.accessedAt
}
}
return time.Time{}
}
// fnv32 computes FNV-1a hash of a string
// This is a fast, well-distributed hash function
func fnv32(key string) uint32 {
const (
offset32 = uint32(2166136261)
prime32 = uint32(16777619)
)
hash := offset32
for i := 0; i < len(key); i++ {
hash ^= uint32(key[i])
hash *= prime32
}
return hash
}
+283
View File
@@ -0,0 +1,283 @@
package backends
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestShardedCache_ShardDistribution tests that keys are distributed across shards
func TestShardedCache_ShardDistribution(t *testing.T) {
t.Parallel()
// Create a cache with large enough size to have multiple shards
config := DefaultConfig()
config.MaxSize = 10000
config.MaxMemoryBytes = 100 * 1024 * 1024 // 100MB
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Add many items to see distribution
numItems := 1000
for i := 0; i < numItems; i++ {
key := fmt.Sprintf("dist-key-%d", i)
value := []byte(fmt.Sprintf("dist-value-%d", i))
err := backend.Set(ctx, key, value, time.Minute)
require.NoError(t, err)
}
// Check that items are distributed across multiple shards
shardStats := backend.MemoryCacheBackend.GetShardStats()
nonEmptyShards := 0
for _, stat := range shardStats {
if stat["size"] > 0 {
nonEmptyShards++
}
}
// With good hash distribution, we should have items in multiple shards
assert.Greater(t, nonEmptyShards, 1, "Items should be distributed across multiple shards")
}
// TestShardedCache_ShardCount tests that shard count adapts to cache size
func TestShardedCache_ShardCount(t *testing.T) {
t.Parallel()
tests := []struct {
maxSize int
expectLowShards bool
}{
{5, true}, // Very small cache should have fewer shards
{100, true}, // Small cache should have fewer shards
{10000, false}, // Large cache should have default shards
}
for _, tt := range tests {
t.Run(fmt.Sprintf("MaxSize_%d", tt.maxSize), func(t *testing.T) {
config := DefaultConfig()
config.MaxSize = tt.maxSize
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
shardCount := backend.MemoryCacheBackend.GetShardCount()
if tt.expectLowShards {
assert.Less(t, shardCount, uint32(256), "Small cache should have fewer shards")
} else {
assert.Equal(t, uint32(256), shardCount, "Large cache should have default shard count")
}
})
}
}
// TestShardedCache_ConcurrentSameKey tests concurrent access to the same key
func TestShardedCache_ConcurrentSameKey(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "concurrent-same-key"
initialValue := []byte("initial-value")
err = backend.Set(ctx, key, initialValue, time.Minute)
require.NoError(t, err)
var wg sync.WaitGroup
goroutines := 50
iterations := 100
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
// Mix of reads and writes
if j%3 == 0 {
newValue := []byte(fmt.Sprintf("value-%d-%d", id, j))
err := backend.Set(ctx, key, newValue, time.Minute)
assert.NoError(t, err)
} else {
_, _, _, err := backend.Get(ctx, key)
assert.NoError(t, err)
}
}
}(i)
}
wg.Wait()
// Key should still exist
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
}
// TestShardedCache_GlobalLRUEviction tests that global LRU is maintained
func TestShardedCache_GlobalLRUEviction(t *testing.T) {
t.Parallel()
// Create a small cache to force eviction
config := DefaultConfig()
config.MaxSize = 10
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Add items
for i := 0; i < 10; i++ {
key := fmt.Sprintf("global-lru-%d", i)
value := []byte(fmt.Sprintf("value-%d", i))
err := backend.Set(ctx, key, value, time.Minute)
require.NoError(t, err)
// Small delay to ensure different access times
time.Sleep(time.Millisecond)
}
// Access some items to make them recently used
for i := 5; i < 10; i++ {
key := fmt.Sprintf("global-lru-%d", i)
_, _, _, err := backend.Get(ctx, key)
require.NoError(t, err)
}
// Add more items to trigger eviction
for i := 10; i < 15; i++ {
key := fmt.Sprintf("global-lru-%d", i)
value := []byte(fmt.Sprintf("value-%d", i))
err := backend.Set(ctx, key, value, time.Minute)
require.NoError(t, err)
}
// Recently accessed items (5-9) should still exist
for i := 5; i < 10; i++ {
key := fmt.Sprintf("global-lru-%d", i)
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists, "Recently accessed item %d should exist", i)
}
// Check eviction stats
stats := backend.GetStats()
evictions := stats["evictions"].(int64)
assert.Greater(t, evictions, int64(0), "Should have evictions")
}
// TestShardedCache_StatsAggregation tests that stats are aggregated correctly
func TestShardedCache_StatsAggregation(t *testing.T) {
t.Parallel()
config := DefaultConfig()
config.MaxSize = 10000
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Add items to multiple shards
numItems := 100
for i := 0; i < numItems; i++ {
key := fmt.Sprintf("stats-key-%d", i)
value := []byte(fmt.Sprintf("stats-value-%d", i))
err := backend.Set(ctx, key, value, time.Minute)
require.NoError(t, err)
}
// Read some items
for i := 0; i < numItems/2; i++ {
key := fmt.Sprintf("stats-key-%d", i)
backend.Get(ctx, key)
}
// Read non-existent items
for i := 0; i < 10; i++ {
backend.Get(ctx, fmt.Sprintf("nonexistent-%d", i))
}
stats := backend.GetStats()
// Verify stats
assert.Equal(t, int64(numItems), stats["sets"].(int64), "Sets should match")
assert.Equal(t, int64(numItems/2), stats["hits"].(int64), "Hits should match")
assert.Equal(t, int64(10), stats["misses"].(int64), "Misses should match")
assert.Equal(t, int64(numItems), stats["size"].(int64), "Size should match")
// Verify hit rate
hitRate := stats["hit_rate"].(float64)
expectedHitRate := float64(numItems/2) / float64(numItems/2+10)
assert.InDelta(t, expectedHitRate, hitRate, 0.01, "Hit rate should match")
}
// BenchmarkShardedCache_Parallel benchmarks parallel access
func BenchmarkShardedCache_Parallel(b *testing.B) {
config := DefaultConfig()
config.MaxSize = 100000
config.MaxMemoryBytes = 100 * 1024 * 1024
backend, _ := NewMemoryBackend(config)
defer backend.Close()
ctx := context.Background()
// Pre-populate cache
for i := 0; i < 10000; i++ {
key := fmt.Sprintf("bench-key-%d", i)
value := []byte(fmt.Sprintf("bench-value-%d", i))
backend.Set(ctx, key, value, time.Hour)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("bench-key-%d", i%10000)
backend.Get(ctx, key)
i++
}
})
}
// BenchmarkShardedCache_MixedOps benchmarks mixed operations
func BenchmarkShardedCache_MixedOps(b *testing.B) {
config := DefaultConfig()
config.MaxSize = 100000
config.MaxMemoryBytes = 100 * 1024 * 1024
backend, _ := NewMemoryBackend(config)
defer backend.Close()
ctx := context.Background()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("mixed-key-%d", i%1000)
if i%3 == 0 {
value := []byte(fmt.Sprintf("mixed-value-%d", i))
backend.Set(ctx, key, value, time.Hour)
} else {
backend.Get(ctx, key)
}
i++
}
})
}
+20 -30
View File
@@ -45,21 +45,11 @@ func (m *MemoryBackend) Get(ctx context.Context, key string) ([]byte, time.Durat
return nil, 0, false, err
}
// Get the item directly to check TTL
m.MemoryCacheBackend.mu.RLock()
item, exists := m.MemoryCacheBackend.items[key]
m.MemoryCacheBackend.mu.RUnlock()
if !exists {
return nil, 0, false, nil
}
var ttl time.Duration
if !item.expiresAt.IsZero() {
ttl = time.Until(item.expiresAt)
if ttl < 0 {
ttl = 0
}
// Get TTL using the TTL method
ttl, ttlErr := m.MemoryCacheBackend.TTL(ctx, key)
if ttlErr != nil {
// If we can't get TTL, still return the value with 0 TTL
ttl = 0
}
// Convert interface{} to []byte
@@ -68,8 +58,7 @@ func (m *MemoryBackend) Get(ctx context.Context, key string) ([]byte, time.Durat
if bytes, ok := val.([]byte); ok {
valueBytes = bytes
} else {
// If it's not already []byte, we might need to handle other types
// For now, we'll just return an error
// If it's not already []byte, return an error
return nil, 0, false, ErrInvalidValue
}
}
@@ -123,19 +112,20 @@ func (m *MemoryBackend) GetStats() map[string]interface{} {
}
return map[string]interface{}{
"type": stats.Type,
"hits": stats.Hits,
"misses": stats.Misses,
"sets": stats.Sets,
"deletes": stats.Deletes,
"errors": stats.Errors,
"evictions": stats.Evictions,
"size": stats.CurrentSize,
"max_size": stats.MaxSize,
"memory": stats.MemoryUsage,
"hit_rate": hitRate,
"uptime": stats.Uptime,
"start_time": stats.StartTime,
"type": stats.Type,
"hits": stats.Hits,
"misses": stats.Misses,
"sets": stats.Sets,
"deletes": stats.Deletes,
"errors": stats.Errors,
"evictions": stats.Evictions,
"size": stats.CurrentSize,
"max_size": stats.MaxSize,
"memory": stats.MemoryUsage,
"hit_rate": hitRate,
"uptime": stats.Uptime,
"start_time": stats.StartTime,
"shard_count": m.MemoryCacheBackend.GetShardCount(),
}
}
+109 -13
View File
@@ -345,7 +345,7 @@ func (r *RedisBackend) prefixKey(key string) string {
// executeWithRetry executes a Redis operation with exponential backoff retry logic.
// It checks context cancellation at multiple points to ensure fast abort when the
// caller's context is cancelled (e.g., due to request timeout).
// caller's context is canceled (e.g., due to request timeout).
func (r *RedisBackend) executeWithRetry(ctx context.Context, operation func(*RedisConn) error) error {
maxRetries := 3
baseDelay := 50 * time.Millisecond // Reduced from 100ms to fail faster
@@ -377,7 +377,7 @@ func (r *RedisBackend) executeWithRetry(ctx context.Context, operation func(*Red
err = operation(conn)
r.pool.Put(conn)
// Check context after operation - if cancelled, don't bother retrying
// Check context after operation - if canceled, don't bother retrying
if ctx.Err() != nil {
return ctx.Err()
}
@@ -431,39 +431,135 @@ func isRetryableError(err error) bool {
return false
}
// SetMany stores multiple values in Redis (batch operation)
// SetMany stores multiple values in Redis using pipelining for efficiency
// This reduces N round-trips to a single round-trip
func (r *RedisBackend) SetMany(ctx context.Context, items map[string][]byte, ttl time.Duration) error {
if r.closed.Load() {
return ErrBackendClosed
}
// For simplicity, execute sequentially (can be optimized with pipelining later)
for key, value := range items {
if err := r.Set(ctx, key, value, ttl); err != nil {
return err
if len(items) == 0 {
return nil
}
// For single items, use regular Set
if len(items) == 1 {
for key, value := range items {
return r.Set(ctx, key, value, ttl)
}
}
conn, err := r.pool.Get(ctx)
if err != nil {
return err
}
defer r.pool.Put(conn)
pipeline := conn.NewPipeline()
// Queue all SET commands
ttlSeconds := int(ttl.Seconds())
ttlMillis := ttl.Milliseconds()
for key, value := range items {
prefixedKey := r.prefixKey(key)
if ttl > 0 {
if ttlMillis < 1000 {
// Use PSETEX for sub-second TTLs
pipeline.Queue("PSETEX", prefixedKey, fmt.Sprintf("%d", ttlMillis), string(value))
} else {
// Use SETEX for larger TTLs
pipeline.Queue("SETEX", prefixedKey, fmt.Sprintf("%d", ttlSeconds), string(value))
}
} else {
pipeline.Queue("SET", prefixedKey, string(value))
}
}
// Execute pipeline
responses, err := pipeline.Execute()
if err != nil {
return fmt.Errorf("pipeline SetMany failed: %w", err)
}
// Check responses for errors (each should be "OK")
for i, resp := range responses {
if resp == nil {
continue
}
if str, ok := resp.(string); ok && str == "OK" {
continue
}
return fmt.Errorf("SetMany: unexpected response at index %d: %v", i, resp)
}
return nil
}
// GetMany retrieves multiple values from Redis
// GetMany retrieves multiple values from Redis using pipelining for efficiency
// This reduces N round-trips to a single round-trip
func (r *RedisBackend) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) {
if r.closed.Load() {
return nil, ErrBackendClosed
}
result := make(map[string][]byte)
if len(keys) == 0 {
return make(map[string][]byte), nil
}
// For simplicity, execute sequentially
for _, key := range keys {
value, _, exists, err := r.Get(ctx, key)
// For single key, use regular Get
if len(keys) == 1 {
result := make(map[string][]byte)
value, _, exists, err := r.Get(ctx, keys[0])
if err != nil {
return nil, err
}
if exists {
result[key] = value
result[keys[0]] = value
}
return result, nil
}
conn, err := r.pool.Get(ctx)
if err != nil {
return nil, err
}
defer r.pool.Put(conn)
pipeline := conn.NewPipeline()
// Queue all GET commands
prefixedKeys := make([]string, len(keys))
for i, key := range keys {
prefixedKeys[i] = r.prefixKey(key)
pipeline.Queue("GET", prefixedKeys[i])
}
// Execute pipeline
responses, err := pipeline.Execute()
if err != nil {
return nil, fmt.Errorf("pipeline GetMany failed: %w", err)
}
// Process responses
result := make(map[string][]byte)
for i, resp := range responses {
if resp == nil {
// Key doesn't exist
r.misses.Add(1)
continue
}
value, err := RESPString(resp)
if err != nil {
// Invalid response, skip this key
r.misses.Add(1)
continue
}
r.hits.Add(1)
result[keys[i]] = []byte(value)
}
return result, nil
+10 -16
View File
@@ -9,30 +9,24 @@ import (
// HealthMonitor continuously monitors Redis connection health and triggers reconnections
type HealthMonitor struct {
pool *ConnectionPool
config *HealthMonitorConfig
// State
healthy atomic.Bool
running atomic.Bool
lastCheckTime atomic.Int64 // Unix timestamp
// Metrics
pool *ConnectionPool
config *HealthMonitorConfig
stopChan chan struct{}
wg sync.WaitGroup
lastCheckTime atomic.Int64
consecutiveFailures atomic.Int64
totalChecks atomic.Int64
totalFailures atomic.Int64
// Lifecycle
stopChan chan struct{}
wg sync.WaitGroup
healthy atomic.Bool
running atomic.Bool
}
// HealthMonitorConfig configures the health monitor
type HealthMonitorConfig struct {
CheckInterval time.Duration // How often to check health
Timeout time.Duration // Timeout for health check
UnhealthyThreshold int // Consecutive failures before marking unhealthy
OnHealthChange func(healthy bool)
CheckInterval time.Duration
Timeout time.Duration
UnhealthyThreshold int
}
// DefaultHealthMonitorConfig returns default health monitor configuration
+461
View File
@@ -0,0 +1,461 @@
package backends
import (
"context"
"fmt"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupTestRedis creates a miniredis instance for testing
func setupTestRedis(t *testing.T) (*miniredis.Miniredis, *RedisBackend) {
t.Helper()
mr, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(func() {
mr.Close()
})
backend, err := NewRedisBackend(&Config{
RedisAddr: mr.Addr(),
RedisPrefix: "test:",
PoolSize: 5,
})
require.NoError(t, err)
t.Cleanup(func() {
backend.Close()
})
return mr, backend
}
// TestPipeline_Basic tests basic pipeline functionality
func TestPipeline_Basic(t *testing.T) {
t.Parallel()
mr, err := miniredis.Run()
require.NoError(t, err)
defer mr.Close()
config := &PoolConfig{
Address: mr.Addr(),
MaxConnections: 5,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
}
pool, err := NewConnectionPool(config)
require.NoError(t, err)
defer pool.Close()
ctx := context.Background()
conn, err := pool.Get(ctx)
require.NoError(t, err)
defer pool.Put(conn)
t.Run("SingleCommand", func(t *testing.T) {
pipeline := conn.NewPipeline()
pipeline.Queue("SET", "single-key", "single-value")
responses, err := pipeline.Execute()
require.NoError(t, err)
require.Len(t, responses, 1)
assert.Equal(t, "OK", responses[0])
})
t.Run("MultipleCommands", func(t *testing.T) {
pipeline := conn.NewPipeline()
pipeline.Queue("SET", "key1", "value1")
pipeline.Queue("SET", "key2", "value2")
pipeline.Queue("SET", "key3", "value3")
pipeline.Queue("GET", "key1")
pipeline.Queue("GET", "key2")
pipeline.Queue("GET", "key3")
responses, err := pipeline.Execute()
require.NoError(t, err)
require.Len(t, responses, 6)
// First 3 are SET responses
assert.Equal(t, "OK", responses[0])
assert.Equal(t, "OK", responses[1])
assert.Equal(t, "OK", responses[2])
// Last 3 are GET responses
assert.Equal(t, "value1", responses[3])
assert.Equal(t, "value2", responses[4])
assert.Equal(t, "value3", responses[5])
})
t.Run("EmptyPipeline", func(t *testing.T) {
pipeline := conn.NewPipeline()
responses, err := pipeline.Execute()
require.NoError(t, err)
assert.Nil(t, responses)
})
t.Run("NilResponses", func(t *testing.T) {
pipeline := conn.NewPipeline()
pipeline.Queue("GET", "nonexistent-key")
responses, err := pipeline.Execute()
require.NoError(t, err)
require.Len(t, responses, 1)
assert.Nil(t, responses[0])
})
}
// TestPipeline_SetMany tests pipelined SetMany
func TestPipeline_SetMany(t *testing.T) {
t.Parallel()
_, backend := setupTestRedis(t)
ctx := context.Background()
t.Run("SetManyItems", func(t *testing.T) {
items := make(map[string][]byte)
for i := 0; i < 10; i++ {
items[fmt.Sprintf("setmany-key-%d", i)] = []byte(fmt.Sprintf("value-%d", i))
}
err := backend.SetMany(ctx, items, time.Minute)
require.NoError(t, err)
// Verify all items were set
for key, expectedValue := range items {
value, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists, "Key %s should exist", key)
assert.Equal(t, expectedValue, value)
}
})
t.Run("SetManyEmpty", func(t *testing.T) {
err := backend.SetMany(ctx, map[string][]byte{}, time.Minute)
require.NoError(t, err)
})
t.Run("SetManySingleItem", func(t *testing.T) {
items := map[string][]byte{
"single-setmany": []byte("single-value"),
}
err := backend.SetMany(ctx, items, time.Minute)
require.NoError(t, err)
value, _, exists, err := backend.Get(ctx, "single-setmany")
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, []byte("single-value"), value)
})
t.Run("SetManyNoTTL", func(t *testing.T) {
items := map[string][]byte{
"nottl-key1": []byte("value1"),
"nottl-key2": []byte("value2"),
}
err := backend.SetMany(ctx, items, 0)
require.NoError(t, err)
// Keys should exist
for key := range items {
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
}
})
}
// TestPipeline_GetMany tests pipelined GetMany
func TestPipeline_GetMany(t *testing.T) {
t.Parallel()
_, backend := setupTestRedis(t)
ctx := context.Background()
// Pre-populate cache
for i := 0; i < 10; i++ {
key := fmt.Sprintf("getmany-key-%d", i)
value := []byte(fmt.Sprintf("value-%d", i))
err := backend.Set(ctx, key, value, time.Minute)
require.NoError(t, err)
}
t.Run("GetManyExisting", func(t *testing.T) {
keys := make([]string, 10)
for i := 0; i < 10; i++ {
keys[i] = fmt.Sprintf("getmany-key-%d", i)
}
results, err := backend.GetMany(ctx, keys)
require.NoError(t, err)
assert.Len(t, results, 10)
for i, key := range keys {
assert.Equal(t, []byte(fmt.Sprintf("value-%d", i)), results[key])
}
})
t.Run("GetManyMixed", func(t *testing.T) {
keys := []string{
"getmany-key-0", // exists
"nonexistent-key-1", // doesn't exist
"getmany-key-2", // exists
"nonexistent-key-2", // doesn't exist
}
results, err := backend.GetMany(ctx, keys)
require.NoError(t, err)
assert.Len(t, results, 2) // Only existing keys
assert.Equal(t, []byte("value-0"), results["getmany-key-0"])
assert.Equal(t, []byte("value-2"), results["getmany-key-2"])
assert.NotContains(t, results, "nonexistent-key-1")
assert.NotContains(t, results, "nonexistent-key-2")
})
t.Run("GetManyEmpty", func(t *testing.T) {
results, err := backend.GetMany(ctx, []string{})
require.NoError(t, err)
assert.NotNil(t, results)
assert.Len(t, results, 0)
})
t.Run("GetManySingleKey", func(t *testing.T) {
results, err := backend.GetMany(ctx, []string{"getmany-key-5"})
require.NoError(t, err)
assert.Len(t, results, 1)
assert.Equal(t, []byte("value-5"), results["getmany-key-5"])
})
t.Run("GetManyAllNonexistent", func(t *testing.T) {
keys := []string{
"nonexistent-1",
"nonexistent-2",
"nonexistent-3",
}
results, err := backend.GetMany(ctx, keys)
require.NoError(t, err)
assert.Len(t, results, 0)
})
}
// TestPipeline_LargeBatch tests pipelining with large batches
func TestPipeline_LargeBatch(t *testing.T) {
t.Parallel()
_, backend := setupTestRedis(t)
ctx := context.Background()
t.Run("SetMany100Items", func(t *testing.T) {
items := make(map[string][]byte)
for i := 0; i < 100; i++ {
items[fmt.Sprintf("large-batch-%d", i)] = []byte(fmt.Sprintf("value-%d", i))
}
err := backend.SetMany(ctx, items, time.Minute)
require.NoError(t, err)
// Verify random samples
for _, i := range []int{0, 25, 50, 75, 99} {
key := fmt.Sprintf("large-batch-%d", i)
value, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, []byte(fmt.Sprintf("value-%d", i)), value)
}
})
t.Run("GetMany100Items", func(t *testing.T) {
keys := make([]string, 100)
for i := 0; i < 100; i++ {
keys[i] = fmt.Sprintf("large-batch-%d", i)
}
results, err := backend.GetMany(ctx, keys)
require.NoError(t, err)
assert.Len(t, results, 100)
})
}
// TestPipeline_Stats tests that stats are tracked correctly with pipelining
func TestPipeline_Stats(t *testing.T) {
t.Parallel()
_, backend := setupTestRedis(t)
ctx := context.Background()
// Set some items
items := map[string][]byte{
"stats-key-1": []byte("value1"),
"stats-key-2": []byte("value2"),
}
err := backend.SetMany(ctx, items, time.Minute)
require.NoError(t, err)
// Get items (some exist, some don't)
keys := []string{
"stats-key-1",
"stats-key-2",
"stats-key-nonexistent",
}
results, err := backend.GetMany(ctx, keys)
require.NoError(t, err)
assert.Len(t, results, 2)
// Check stats
stats := backend.GetStats()
hits := stats["hits"].(int64)
misses := stats["misses"].(int64)
assert.Equal(t, int64(2), hits, "Should have 2 hits")
assert.Equal(t, int64(1), misses, "Should have 1 miss")
}
// BenchmarkPipeline_SetMany benchmarks SetMany with pipelining
func BenchmarkPipeline_SetMany(b *testing.B) {
mr, err := miniredis.Run()
if err != nil {
b.Fatal(err)
}
defer mr.Close()
backend, err := NewRedisBackend(&Config{
RedisAddr: mr.Addr(),
RedisPrefix: "bench:",
PoolSize: 10,
})
if err != nil {
b.Fatal(err)
}
defer backend.Close()
ctx := context.Background()
// Prepare items
items := make(map[string][]byte)
for i := 0; i < 100; i++ {
items[fmt.Sprintf("bench-key-%d", i)] = []byte(fmt.Sprintf("bench-value-%d", i))
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = backend.SetMany(ctx, items, time.Minute)
}
}
// BenchmarkPipeline_GetMany benchmarks GetMany with pipelining
func BenchmarkPipeline_GetMany(b *testing.B) {
mr, err := miniredis.Run()
if err != nil {
b.Fatal(err)
}
defer mr.Close()
backend, err := NewRedisBackend(&Config{
RedisAddr: mr.Addr(),
RedisPrefix: "bench:",
PoolSize: 10,
})
if err != nil {
b.Fatal(err)
}
defer backend.Close()
ctx := context.Background()
// Pre-populate cache
for i := 0; i < 100; i++ {
key := fmt.Sprintf("bench-key-%d", i)
value := []byte(fmt.Sprintf("bench-value-%d", i))
backend.Set(ctx, key, value, time.Hour)
}
// Prepare keys
keys := make([]string, 100)
for i := 0; i < 100; i++ {
keys[i] = fmt.Sprintf("bench-key-%d", i)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = backend.GetMany(ctx, keys)
}
}
// BenchmarkPipeline_VsSequential benchmarks pipeline vs sequential operations
func BenchmarkPipeline_VsSequential(b *testing.B) {
mr, err := miniredis.Run()
if err != nil {
b.Fatal(err)
}
defer mr.Close()
backend, err := NewRedisBackend(&Config{
RedisAddr: mr.Addr(),
RedisPrefix: "bench:",
PoolSize: 10,
})
if err != nil {
b.Fatal(err)
}
defer backend.Close()
ctx := context.Background()
// Prepare items
items := make(map[string][]byte)
keys := make([]string, 50)
for i := 0; i < 50; i++ {
key := fmt.Sprintf("compare-key-%d", i)
keys[i] = key
items[key] = []byte(fmt.Sprintf("compare-value-%d", i))
}
b.Run("Pipelined-Set", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = backend.SetMany(ctx, items, time.Minute)
}
})
b.Run("Sequential-Set", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
for key, value := range items {
_ = backend.Set(ctx, key, value, time.Minute)
}
}
})
// Pre-populate for get benchmarks
_ = backend.SetMany(ctx, items, time.Hour)
b.Run("Pipelined-Get", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = backend.GetMany(ctx, keys)
}
})
b.Run("Sequential-Get", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, key := range keys {
_, _, _, _ = backend.Get(ctx, key)
}
}
})
}
+117
View File
@@ -336,3 +336,120 @@ func (p *ConnectionPool) isConnectionHealthy(conn *RedisConn) bool {
_, err := conn.Do("PING")
return err == nil
}
// Pipeline represents a Redis pipeline for batch operations
// It queues multiple commands and executes them in a single round-trip
type Pipeline struct {
conn *RedisConn
commands []pipelineCommand
mu sync.Mutex
}
// pipelineCommand represents a single command in the pipeline
type pipelineCommand struct {
command string
args []string
}
// NewPipeline creates a new pipeline for the connection
func (c *RedisConn) NewPipeline() *Pipeline {
return &Pipeline{
conn: c,
commands: make([]pipelineCommand, 0, 16), // Pre-allocate for typical batch size
}
}
// Queue adds a command to the pipeline
func (p *Pipeline) Queue(command string, args ...string) {
p.mu.Lock()
defer p.mu.Unlock()
p.commands = append(p.commands, pipelineCommand{
command: command,
args: args,
})
}
// Execute sends all queued commands and returns all responses
// Returns a slice of responses in the same order as commands were queued
func (p *Pipeline) Execute() ([]interface{}, error) {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.commands) == 0 {
return nil, nil
}
if p.conn.closed.Load() {
return nil, ErrBackendClosed
}
p.conn.mu.Lock()
defer p.conn.mu.Unlock()
// Set write timeout for all commands
if p.conn.writeTimeout > 0 {
// Use longer timeout for batch operations
timeout := p.conn.writeTimeout * time.Duration(len(p.commands))
if timeout > 30*time.Second {
timeout = 30 * time.Second // Cap at 30 seconds
}
_ = p.conn.conn.SetWriteDeadline(time.Now().Add(timeout))
}
// Write all commands (pipelining - send all before reading any responses)
writer := NewRESPWriter(p.conn.conn)
for _, cmd := range p.commands {
cmdArgs := append([]string{cmd.command}, cmd.args...)
if err := writer.WriteCommand(cmdArgs...); err != nil {
writer.Release()
p.conn.closed.Store(true)
return nil, fmt.Errorf("pipeline write error: %w", err)
}
}
writer.Release()
// Set read timeout for all responses
if p.conn.readTimeout > 0 {
timeout := p.conn.readTimeout * time.Duration(len(p.commands))
if timeout > 30*time.Second {
timeout = 30 * time.Second
}
_ = p.conn.conn.SetReadDeadline(time.Now().Add(timeout))
}
// Read all responses
responses := make([]interface{}, len(p.commands))
reader := NewRESPReader(p.conn.conn)
defer reader.Release()
for i := range p.commands {
resp, err := reader.ReadResponse()
if err != nil {
// For nil responses, store nil instead of erroring
if errors.Is(err, ErrNilResponse) {
responses[i] = nil
continue
}
p.conn.closed.Store(true)
return responses[:i], fmt.Errorf("pipeline read error at command %d: %w", i, err)
}
responses[i] = resp
}
return responses, nil
}
// Clear resets the pipeline for reuse
func (p *Pipeline) Clear() {
p.mu.Lock()
defer p.mu.Unlock()
p.commands = p.commands[:0]
}
// Len returns the number of queued commands
func (p *Pipeline) Len() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.commands)
}

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