Compare commits

..
Author SHA1 Message Date
lukaszraczylo fbe9b71ada fixup! fixup! fixup! Enhance test coverage. 2025-09-18 10:44:52 +01:00
lukaszraczylo 78d9f555a3 fixup! fixup! Enhance test coverage. 2025-09-17 13:55:24 +01:00
lukaszraczylo 83684ce51d fixup! Enhance test coverage. 2025-09-17 13:54:11 +01:00
lukaszraczylo 397efdae0f Enhance test coverage. 2025-09-17 10:31:41 +01:00
lukaszraczylo ff2562f9fe Pre-release cleanup. 2025-09-16 23:04:20 +01:00
lukaszraczylo 99c30c0e29 fixup! fixup! fixup! fixup! fixup! Weekend fun with further optimisations. 2025-09-15 00:38:45 +01:00
lukaszraczylo cc6d6693e0 fixup! fixup! fixup! fixup! Weekend fun with further optimisations. 2025-09-14 23:47:16 +01:00
lukaszraczylo f411273fa2 fixup! fixup! fixup! Weekend fun with further optimisations. 2025-09-14 18:28:32 +01:00
lukaszraczylo 9af8939f72 fixup! fixup! Weekend fun with further optimisations. 2025-09-14 01:54:06 +01:00
lukaszraczylo 0d88612d8c fixup! Weekend fun with further optimisations. 2025-09-14 01:40:03 +01:00
lukaszraczylo 7457a901a9 Weekend fun with further optimisations. 2025-09-13 23:42:02 +01:00
lukaszraczylo a31fd64112 fixup! fixup! Splitting code into multiple files with reasonable testing coverage. 2025-09-09 09:20:10 +01:00
lukaszraczylo c04f949656 fixup! Splitting code into multiple files with reasonable testing coverage. 2025-09-07 13:01:08 +01:00
lukaszraczylo 4371f9fffd Splitting code into multiple files with reasonable testing coverage.
```
ok      github.com/lukaszraczylo/traefikoidc    117.017s        coverage: 72.6% of statements
ok      github.com/lukaszraczylo/traefikoidc/auth       0.505s  coverage: 87.1% of statements
ok      github.com/lukaszraczylo/traefikoidc/circuit_breaker    0.283s  coverage: 99.0% of statements
        github.com/lukaszraczylo/traefikoidc/config             coverage: 0.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/handlers   0.349s  coverage: 98.2% of statements
ok      github.com/lukaszraczylo/traefikoidc/internal/providers (cached)        coverage: 94.3% of statements
ok      github.com/lukaszraczylo/traefikoidc/middleware 0.808s  coverage: 78.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/recovery   0.653s  coverage: 100.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/chunking   (cached)        coverage: 87.8% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/core       (cached)        coverage: 85.6% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/crypto     (cached)        coverage: 81.8% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/storage    (cached)        coverage: 93.5% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/validators (cached)        coverage: 98.8% of statements
````
2025-09-07 12:57:18 +01:00
lukaszraczylo c9a23dc6c0 Weekend fun with memory leaks 2025-09-07 03:02:34 +01:00
lukaszraczylo e17b820368 fixup! Clear race conditions 2025-09-06 13:17:54 +01:00
lukaszraczylo bf6ab31c50 Clear race conditions 2025-09-06 10:38:32 +01:00
lukaszraczylo a34488f533 fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. 2025-09-06 03:23:03 +01:00
lukaszraczylo 05c316002d fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. 2025-09-06 03:12:59 +01:00
lukaszraczylo f96940921b fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. 2025-09-06 01:02:04 +01:00
lukaszraczylo a1c0bce4be fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. 2025-09-06 00:36:50 +01:00
lukaszraczylo eab8e04573 fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. 2025-09-06 00:24:46 +01:00
lukaszraczylo b182951f22 fixup! fixup! fixup! fixup! Further pursue of perfection. 2025-09-05 16:17:02 +01:00
lukaszraczylo e267cb3a6f fixup! fixup! fixup! Further pursue of perfection. 2025-09-05 13:57:54 +01:00
lukaszraczylo 2c902eaafb fixup! fixup! Further pursue of perfection. 2025-09-05 00:22:13 +01:00
lukaszraczylo f291dcca26 fixup! Further pursue of perfection. 2025-09-04 22:43:39 +01:00
lukaszraczylo 29ec26cbaa Further pursue of perfection. 2025-09-03 23:26:06 +01:00
lukaszraczylo dc16b4fc94 Memory leaks hunting, part 1337. 2025-09-03 13:20:39 +01:00
lukaszraczylo 7db2f8d66c fixup! Metadata cache leak fix + profiling 2025-09-02 17:03:50 +01:00
lukaszraczylo 024e349aa9 Metadata cache leak fix + profiling 2025-09-02 16:42:22 +01:00
lukaszraczylo 873105108e fixup! CSRF and Cookie Domain fixes 2025-08-29 11:07:02 +01:00
lukaszraczylo 5241cfe792 CSRF and Cookie Domain fixes 2025-08-28 10:45:19 +01:00
lukaszraczylo e2e2be53c1 Memory leak fixes and optimisations. 2025-08-28 09:59:47 +01:00
lukaszraczylo c878784f1e Next set of enhancements together with memory usage improvements. 2025-08-27 10:35:46 +01:00
lukaszraczylo 1fd762848a Issue #53: Fix CSRF token handling in reverse proxy
1. ✅ HTTPS Detection Fixed (session.go:723)
- Now uses X-Forwarded-Proto header instead of r.URL.Scheme
- Properly detects HTTPS in reverse proxy environments
2. ✅ SameSite Cookie Attribute Fixed
- Removed automatic SameSiteStrictMode for HTTPS (would break OAuth)
- Keeps SameSiteLaxMode to allow OAuth callbacks from external domains
- Only uses Strict for AJAX requests which don't involve OAuth redirects
3. ✅ Cookie Domain Handling Fixed
- Now respects X-Forwarded-Host header for cookie domain
- Ensures cookies are set for the public domain, not internal proxy domain
4. ✅ EnhanceSessionSecurity Properly Integrated
- Function is now actually called during session save
- Applies security enhancements without breaking OAuth flow

Why Issue #53 Failed Before:

1. Cookies were not marked Secure in HTTPS environments (browser wouldn't send them back)
2. If they had been Secure with SameSite=Strict, Azure callbacks would still fail
3. Cookie domain might have been wrong (internal vs public domain)

Why It Works Now:

1. Cookies are properly marked Secure for HTTPS
2. Uses SameSite=Lax to allow OAuth provider callbacks
3. Cookie domain uses public domain from X-Forwarded-Host
4. CSRF token persists through the entire OAuth flow
2025-08-14 12:10:51 +01:00
lukaszraczylo a4b2bfd70f fixup! fixup! fixup! fixup! fixup! Cleanup tests. 2025-07-24 10:29:08 +01:00
lukaszraczylo 00ed2ea61b fixup! fixup! fixup! fixup! Cleanup tests. 2025-07-24 10:05:50 +01:00
lukaszraczylo 2c2f92cd49 fixup! fixup! fixup! Cleanup tests. 2025-07-24 09:22:49 +01:00
lukaszraczylo 5fc719b162 fixup! fixup! Cleanup tests. 2025-07-24 09:17:28 +01:00
lukaszraczylo 242641c587 fixup! Cleanup tests. 2025-07-23 18:07:48 +01:00
lukaszraczylo f6b3f9ecab Cleanup tests. 2025-07-23 17:56:52 +01:00
lukaszraczylo af22256c96 ... 2025-07-23 17:56:33 +01:00
lukaszraczylo b7b0c60f03 fixup! fixup! fixup! fixup! Simplify all the things. 2025-07-22 12:53:19 +01:00
lukaszraczylo ce9614bb64 fixup! fixup! fixup! Simplify all the things. 2025-07-22 10:47:28 +01:00
lukaszraczylo 46745f5b54 fixup! fixup! Simplify all the things. 2025-06-24 23:55:21 +01:00
lukaszraczylo a54ae71279 fixup! Simplify all the things. 2025-06-24 22:30:21 +01:00
lukaszraczylo ae2a2877e9 Simplify all the things. 2025-06-24 19:54:05 +01:00
lukaszraczylo c2a81bc2df Additional micro fixes and cleanups. 2025-06-24 18:48:02 +01:00
lukaszraczylo dbe3455f49 Abstract the provider logic into a separate package. 2025-06-24 17:56:19 +01:00
lukaszraczylo 0dfc252c95 fixup! fixup! Bugfix #51: Ensures that user provided scopes overrides work. 2025-06-21 22:50:32 +01:00
lukaszraczylo 71574090bf fixup! Bugfix #51: Ensures that user provided scopes overrides work. 2025-06-21 22:39:45 +01:00
lukaszraczylo de91edb514 Bugfix #51: Ensures that user provided scopes overrides work. 2025-06-20 23:58:42 +01:00
lukaszraczylo 667b4213fe Well.. that escalated quickly.
Completely forgot that Traefik uses outdated Yaegi and requires compatibility with 1.20 ( pre-generic Go code ).
2025-06-20 19:23:50 +01:00
lukaszraczylo 70443f0855 Add ability to overwrite the default scopes in the settings file 2025-06-20 11:31:29 +01:00
lukaszraczylo 7a443c626c Fix claims issue. 2025-06-20 09:47:22 +01:00
lukaszraczylo 48de8265c5 Multiple changes to improve performance and reduce complexity.
- Optimise the errors and recovery.
- Deduplicate code in metadata cache.
- Remove unused performance monitoring code.
- Simplify session management and settings handling.
2025-06-20 09:00:02 +01:00
lukaszraczylo d8d1b74175 Fieldalignment 2025-06-20 08:07:03 +01:00
lukaszraczylo c233aa92ef Modernize run 2025-06-20 08:02:26 +01:00
lukaszraczylo c400251625 Refactoring code to fix the issues identified by the users. 2025-06-19 10:10:54 +01:00
lukaszraczylo 48faf7fadf Additional fixes and cleanup 2025-06-18 01:09:14 +01:00
lukaszraczylo 84d7cd3d76 Improvements targetting possible memory usage spikes. 2025-06-18 00:50:12 +01:00
lukaszraczylo 488264028b Ensure that appended roles are unique. Update the documentation. 2025-06-18 00:19:26 +01:00
lukaszraczylo e23135ded0 Fixes issue #51 2025-06-18 00:04:10 +01:00
lukaszraczylo cd307f88a1 Fix bug affecting Azure OIDC authentication ( and most likely others ) 2025-06-17 20:21:36 +01:00
169 changed files with 4612 additions and 49440 deletions
-38
View File
@@ -1,38 +0,0 @@
# Code Owners for traefik-oidc
# These owners will be automatically requested for review when someone opens a PR
# Default owner for everything in the repo
* @lukaszraczylo
# Core authentication and middleware
/middleware/ @lukaszraczylo
/auth/ @lukaszraczylo
/handlers/ @lukaszraczylo
# OIDC providers
/internal/providers/ @lukaszraczylo
# Session management and security
/session/ @lukaszraczylo
/internal/security/ @lukaszraczylo
/security/ @lukaszraczylo
# Token management
/internal/token/ @lukaszraczylo
# Configuration
/config/ @lukaszraczylo
/.traefik.yml @lukaszraczylo
# GitHub Actions and CI/CD
/.github/ @lukaszraczylo
/.github/workflows/ @lukaszraczylo
/.golangci.yml @lukaszraczylo
# Documentation
/docs/ @lukaszraczylo
README.md @lukaszraczylo
# Dependencies
go.mod @lukaszraczylo
go.sum @lukaszraczylo
-123
View File
@@ -1,123 +0,0 @@
## Description
<!-- Provide a brief description of the changes in this PR -->
## Type of Change
<!-- Mark the relevant option with an "x" -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring
- [ ] Security fix
- [ ] Provider-specific fix/enhancement
## Related Issues
<!-- Link to related issues using #issue_number -->
Fixes #
Related to #
## Changes Made
<!-- List the main changes made in this PR -->
-
-
-
## Provider Impact
<!-- If this affects specific OIDC providers, list them here -->
- [ ] Google
- [ ] Azure AD
- [ ] Auth0
- [ ] Okta
- [ ] Keycloak
- [ ] AWS Cognito
- [ ] GitLab
- [ ] GitHub
- [ ] Generic OIDC
- [ ] All providers
## Testing Performed
<!-- Describe the tests you ran to verify your changes -->
- [ ] Unit tests pass locally
- [ ] Integration tests pass locally
- [ ] Race detector shows no issues
- [ ] Memory leak tests pass
- [ ] Manual testing performed
### Test Configuration
<!-- Provide details about your test configuration if applicable -->
**Provider tested:**
**Go version:**
**Traefik version:**
## Security Considerations
<!-- Describe any security implications of these changes -->
- [ ] This PR does not introduce security vulnerabilities
- [ ] Security scanning has been performed
- [ ] Credentials/secrets are properly handled
- [ ] Input validation is implemented
## Performance Impact
<!-- Describe any performance implications -->
- [ ] No performance impact expected
- [ ] Performance improved (describe how)
- [ ] Performance may be affected (describe why and mitigation)
## Breaking Changes
<!-- If this is a breaking change, describe the impact and migration path -->
**Breaking changes:**
**Migration guide:**
## Checklist
<!-- Ensure all items are checked before requesting review -->
- [ ] My code follows the project's code style
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published
## Additional Context
<!-- Add any other context, screenshots, or information about the PR here -->
## Screenshots (if applicable)
<!-- Add screenshots to help explain your changes -->
---
**For Reviewers:**
Please verify:
- [ ] Code quality and style
- [ ] Test coverage is adequate
- [ ] Security implications reviewed
- [ ] Documentation is updated
- [ ] No performance regressions
-52
View File
@@ -1,52 +0,0 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
open-pull-requests-limit: 5
commit-message:
prefix: "chore(deps)"
include: "scope"
labels:
- "dependencies"
- "github-actions"
reviewers:
- "lukaszraczylo"
# Maintain Go module dependencies
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
open-pull-requests-limit: 10
commit-message:
prefix: "chore(deps)"
include: "scope"
labels:
- "dependencies"
- "go"
reviewers:
- "lukaszraczylo"
# Group patch updates together
groups:
patch-updates:
patterns:
- "*"
update-types:
- "patch"
minor-updates:
patterns:
- "*"
update-types:
- "minor"
# Ignore certain dependencies if needed
ignore:
# Example: ignore specific versions
# - dependency-name: "github.com/example/package"
# versions: ["1.x", "2.x"]
-9
View File
@@ -1,9 +0,0 @@
# Ensure consistent line endings
* text=auto eol=lf
# GitHub Actions files should use LF
*.yml text eol=lf
*.yaml text eol=lf
# Shell scripts should use LF
*.sh text eol=lf
-225
View File
@@ -1,225 +0,0 @@
# GitHub Actions Workflows
This directory contains CI/CD workflows for the Traefik OIDC middleware.
## Workflows
### PR Validation (`pr-validation.yml`)
A comprehensive validation workflow that runs **all checks in parallel** for maximum speed and thorough testing.
**Triggered on:**
- Pull requests to `main` branch
- Pushes to `main` branch
**Parallel Jobs (20+ concurrent checks):**
#### Code Quality
- **Quick Checks** - Format, go vet, go mod verify
- **golangci-lint** - Comprehensive linting
- **Staticcheck** - Static analysis
#### Security
- **Gosec** - Security vulnerability scanning
- **Govulncheck** - Go vulnerability database check
- **CodeQL** - GitHub's code analysis
#### Testing
- **Race Detector** - Concurrent access bug detection
- **Coverage** - Test coverage with 75% threshold
- **Memory Leaks** - Goroutine and memory leak detection
- **Integration Tests** - Full integration test suite
- **Regression Tests** - Prevent previously fixed bugs
- **Security Edge Cases** - Security-specific scenarios
- **Session Tests** - Session management validation
- **Token Tests** - Token validation scenarios
- **CSRF Tests** - CSRF protection validation
#### Provider Testing (Matrix)
Tests run in parallel for each OIDC provider:
- Google
- Azure AD
- Auth0
- Okta
- Keycloak
- AWS Cognito
- GitLab
- GitHub
- Generic OIDC
#### Performance & Compatibility
- **Benchmarks** - Performance regression detection
- **Build Matrix** - linux/darwin × amd64/arm64
- **Go Versions** - Go 1.23 and 1.24 compatibility
#### Final Validation
- **All Checks Passed** - Ensures all jobs succeeded
## Workflow Features
### 🚀 Parallel Execution
All independent checks run simultaneously for fastest feedback (~5-10 minutes for full suite).
### 📊 Coverage Reporting
- Automatic PR comments with coverage statistics
- Per-package coverage breakdown
- 75% coverage threshold enforcement
### 🔒 Security First
- Multiple security scanners (gosec, govulncheck, CodeQL)
- SARIF report uploads for GitHub Security tab
- Security edge case testing
### 🎯 Comprehensive Testing
- Race condition detection
- Memory leak detection
- Provider-specific testing
- Integration and regression tests
### 📈 Performance Tracking
- Benchmark results stored as artifacts
- Performance regression detection
### ✅ Quality Gates
All checks must pass before PR can be merged:
- Code formatting and style
- Security vulnerabilities
- Test coverage threshold
- Race conditions
- Memory leaks
- Build success on all platforms
## Local Development
### Run checks locally before pushing:
```bash
# Format code
gofmt -s -w .
# Run linter
golangci-lint run
# Run tests with race detector
go test -race -timeout=15m -count=1 ./...
# Check coverage
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
# Run specific test suites
go test -v -run='.*Leak.*' ./... # Memory leak tests
go test -v -run='.*Integration.*' ./... # Integration tests
go test -v -run='.*Regression.*' ./... # Regression tests
# Run benchmarks
go test -bench=. -benchmem ./...
# Security scan
gosec ./...
govulncheck ./...
```
### Required Tools
Install these tools for local development:
```bash
# golangci-lint
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# staticcheck
go install honnef.co/go/tools/cmd/staticcheck@latest
# gosec
go install github.com/securego/gosec/v2/cmd/gosec@latest
# govulncheck
go install golang.org/x/vuln/cmd/govulncheck@latest
```
## Troubleshooting
### Workflow Fails
1. **Check job status** - Click on failed job for details
2. **Review logs** - Expand failed steps to see error messages
3. **Run locally** - Reproduce issue with local commands above
4. **Check coverage** - Ensure test coverage meets 75% threshold
### Coverage Below Threshold
Add tests to increase coverage:
```bash
# See which lines aren't covered
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
```
### Race Condition Detected
Run with race detector locally:
```bash
go test -race -v ./...
```
### Provider Test Failure
Test specific provider:
```bash
go test -v -run='.*Azure.*' ./internal/providers/...
```
## Performance Optimization
The workflow is optimized for speed:
- **Parallel execution** - All independent jobs run simultaneously
- **Go caching** - Dependencies cached between runs
- **Strategic ordering** - Quick checks run first for fast feedback
- **Fail-fast disabled** - Continue running all tests even if some fail
## Workflow Monitoring
### GitHub Actions Dashboard
Monitor workflow runs at: `https://github.com/{owner}/{repo}/actions`
### Status Badges
Add to README.md:
```markdown
![PR Validation](https://github.com/{owner}/{repo}/actions/workflows/pr-validation.yml/badge.svg)
```
### Notifications
Configure in repository settings:
- Settings → Notifications
- Choose email or Slack notifications for workflow failures
## Maintenance
### Update Go Version
Edit in workflow file:
```yaml
go-version: '1.24' # Update this
```
### Adjust Coverage Threshold
Edit in workflow file:
```yaml
THRESHOLD=75 # Adjust this value
```
### Add New Provider
Add to provider matrix:
```yaml
matrix:
provider:
- new_provider # Add here
```
## Additional Resources
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
- [golangci-lint Configuration](../.golangci.yml)
- [Dependabot Configuration](../dependabot.yml)
- [PR Template](../PULL_REQUEST_TEMPLATE.md)
-622
View File
@@ -1,622 +0,0 @@
name: PR Validation
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]
permissions:
contents: read
pull-requests: write
checks: write
security-events: write
jobs:
# Fast feedback - format and basic checks
quick-checks:
name: Quick Checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Format check
run: |
# Exclude vendor directory from format checks
UNFORMATTED=$(gofmt -s -l . | grep -v "^vendor/" || true)
if [ -n "$UNFORMATTED" ]; then
echo "Code is not formatted. Run: gofmt -s -w ."
echo "Unformatted files:"
echo "$UNFORMATTED"
gofmt -s -d $(echo "$UNFORMATTED")
exit 1
fi
- name: Go vet
run: go vet ./...
- name: Go mod verify
run: go mod verify
- name: Go mod tidy check
run: |
go mod tidy
git diff --exit-code go.mod go.sum
# Static analysis with golangci-lint (advisory - will not fail the build)
golangci-lint:
name: golangci-lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: latest
args: --timeout=10m
continue-on-error: true # Allow pipeline to continue even with linting warnings
# Staticcheck analysis
staticcheck:
name: Staticcheck
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Install staticcheck
run: go install honnef.co/go/tools/cmd/staticcheck@latest
- name: Run staticcheck
run: staticcheck ./...
# Security scanning with gosec
gosec:
name: Gosec Security Scanner
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Run Gosec Security Scanner
run: |
go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec -no-fail -fmt sarif -out results.sarif ./... || echo "Gosec completed with warnings"
continue-on-error: true
- name: Upload SARIF file
if: always() && hashFiles('results.sarif') != ''
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
continue-on-error: true
# Vulnerability scanning
govulncheck:
name: Vulnerability Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
# CodeQL analysis
codeql:
name: CodeQL Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: go
continue-on-error: true
- name: Autobuild
uses: github/codeql-action/autobuild@v3
continue-on-error: true
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
continue-on-error: true
# Unit tests with race detection
test-race:
name: Unit Tests (Race Detector)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Run tests with race detector
run: go test -race -timeout=15m -count=1 -v ./...
env:
GOMAXPROCS: 4
# Coverage analysis with threshold check
test-coverage:
name: Test Coverage
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Run tests with coverage
run: |
go test -coverprofile=coverage.out -covermode=atomic -timeout=15m ./...
go tool cover -func=coverage.out -o=coverage.txt
- name: Calculate coverage
id: coverage
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "coverage=$COVERAGE" >> $GITHUB_OUTPUT
echo "Total Coverage: $COVERAGE%"
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage.out
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
continue-on-error: true
- name: Comment coverage on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v8
with:
script: |
const coverage = '${{ steps.coverage.outputs.coverage }}';
const threshold = 70;
const coverageNum = parseFloat(coverage);
const emoji = coverageNum >= threshold ? '✅' : '⚠️';
const status = coverageNum >= threshold ? 'meets' : 'below';
const body = `## ${emoji} Test Coverage Report
| Metric | Value |
|--------|-------|
| **Total Coverage** | ${coverage}% |
| **Threshold** | ${threshold}% |
| **Status** | ${emoji} Coverage ${status} threshold |`;
// Find existing comment
const { data: comments } = await github.rest.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('Test Coverage Report')
);
if (botComment) {
await github.rest.issues.updateComment({
comment_id: botComment.id,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});
} else {
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});
}
- name: Check coverage threshold
run: |
COVERAGE=${{ steps.coverage.outputs.coverage }}
THRESHOLD=70
echo "Coverage: $COVERAGE%"
echo "Threshold: $THRESHOLD%"
if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then
echo "⚠️ Coverage $COVERAGE% is below threshold $THRESHOLD%"
exit 1
fi
echo "✅ Coverage $COVERAGE% meets threshold $THRESHOLD%"
# Memory leak detection
test-memory-leaks:
name: Memory Leak Detection
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Run goroutine leak tests
run: |
echo "Running goroutine leak detection tests..."
go test -v -timeout=20m -run='.*[Gg]oroutine.*[Ll]eak.*' ./... || echo "No goroutine leak tests found"
- name: Run memory leak tests
run: |
echo "Running memory leak detection tests..."
go test -v -timeout=20m -run='.*[Mm]emory.*[Ll]eak.*' ./... || echo "No memory leak tests found"
- name: Run cleanup tests
run: |
echo "Running cleanup and resource management tests..."
go test -v -timeout=20m -run='.*[Cc]leanup.*' ./... || echo "No cleanup tests found"
# Integration tests
test-integration:
name: Integration Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Run integration tests
run: |
if [ -d "./integration" ]; then
go test -v -timeout=20m ./integration/...
else
echo "Running integration tests from all packages..."
go test -v -timeout=20m -run='.*[Ii]ntegration.*' ./...
fi
# Regression tests
test-regression:
name: Regression Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Run regression tests
run: |
echo "Running regression tests..."
go test -v -timeout=20m -run='.*[Rr]egression.*' ./...
# Provider-specific tests (parallel matrix)
test-providers:
name: Provider Tests (${{ matrix.provider }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
provider:
- google
- azure
- auth0
- okta
- keycloak
- cognito
- gitlab
- github
- generic
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Run ${{ matrix.provider }} provider tests
run: |
PROVIDER_CAP=$(echo "${{ matrix.provider }}" | sed 's/.*/\u&/')
echo "Testing $PROVIDER_CAP provider..."
go test -v -timeout=10m -run=".*$PROVIDER_CAP.*" ./internal/providers/... || true
go test -v -timeout=10m -run=".*${{ matrix.provider }}.*" ./... || true
# Benchmark tests with performance tracking
benchmark:
name: Benchmark Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Run benchmarks
run: |
echo "Running benchmark tests..."
go test -bench=. -benchmem -benchtime=1s -run=^$ ./... | tee benchmark.txt
- name: Upload benchmark results
uses: actions/upload-artifact@v4
with:
name: benchmark-results
path: benchmark.txt
retention-days: 30
- name: Compare benchmarks
if: github.event_name == 'pull_request'
continue-on-error: true
run: |
echo "Benchmark results available in artifacts"
echo "To compare with main branch, download previous benchmark results"
# Build validation across platforms
build:
name: Build (${{ matrix.os }}/${{ matrix.arch }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [linux, darwin]
arch: [amd64, arm64]
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Build for ${{ matrix.os }}/${{ matrix.arch }}
env:
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
run: |
echo "Building for $GOOS/$GOARCH..."
go build -v -ldflags="-s -w" ./...
# Security-specific edge case tests
test-security-edge-cases:
name: Security Edge Cases
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Run security edge case tests
run: |
echo "Running security edge case tests..."
go test -v -timeout=15m -run='.*[Ss]ecurity.*' ./...
# Session management tests
test-session:
name: Session Management Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Run session tests
run: |
echo "Running session management tests..."
go test -v -timeout=15m -run='.*[Ss]ession.*' ./...
# Token validation tests
test-token:
name: Token Validation Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Run token validation tests
run: |
echo "Running token validation tests..."
go test -v -timeout=15m -run='.*[Tt]oken.*' ./...
# CSRF and security tests
test-csrf:
name: CSRF and Security Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: true
- name: Run CSRF tests
run: |
echo "Running CSRF and security tests..."
go test -v -timeout=15m -run='.*[Cc][Ss][Rr][Ff].*' ./...
# Multi-Go version compatibility
test-go-versions:
name: Go ${{ matrix.go-version }} Compatibility
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: ['1.24']
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go ${{ matrix.go-version }}
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
cache: true
- name: Run tests on Go ${{ matrix.go-version }}
run: go test -short -timeout=10m ./...
# Final validation - all checks must pass (golangci-lint is advisory)
all-checks-passed:
name: ✅ All Checks Passed
runs-on: ubuntu-latest
needs:
- quick-checks
- golangci-lint
- staticcheck
- gosec
- govulncheck
- codeql
- test-race
- test-coverage
- test-memory-leaks
- test-integration
- test-regression
- test-providers
- benchmark
- build
- test-security-edge-cases
- test-session
- test-token
- test-csrf
- test-go-versions
if: always()
steps:
- name: Check all jobs status
run: |
echo "Checking status of all jobs..."
# Check critical jobs (excluding golangci-lint which is advisory)
CRITICAL_FAILURES=false
if [ "${{ needs.quick-checks.result }}" == "failure" ] || \
[ "${{ needs.staticcheck.result }}" == "failure" ] || \
[ "${{ needs.test-race.result }}" == "failure" ] || \
[ "${{ needs.test-coverage.result }}" == "failure" ] || \
[ "${{ needs.build.result }}" == "failure" ]; then
CRITICAL_FAILURES=true
fi
if [ "$CRITICAL_FAILURES" == "true" ]; then
echo "❌ Critical checks failed"
exit 1
elif [ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]; then
echo "⚠️ Some checks were cancelled"
exit 1
else
echo "✅ All critical checks passed successfully!"
if [ "${{ needs.golangci-lint.result }}" != "success" ]; then
echo "ℹ️ Note: golangci-lint reported issues (advisory only)"
fi
fi
- name: Post summary
if: always()
run: |
echo "# PR Validation Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Job Status" >> $GITHUB_STEP_SUMMARY
echo "- Quick Checks: ${{ needs.quick-checks.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Linting (advisory): ${{ needs.golangci-lint.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Static Analysis: ${{ needs.staticcheck.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Security Scan (gosec): ${{ needs.gosec.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Vulnerability Scan: ${{ needs.govulncheck.result }}" >> $GITHUB_STEP_SUMMARY
echo "- CodeQL: ${{ needs.codeql.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Race Detection: ${{ needs.test-race.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Coverage: ${{ needs.test-coverage.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Memory Leaks: ${{ needs.test-memory-leaks.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Integration Tests: ${{ needs.test-integration.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Regression Tests: ${{ needs.test-regression.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Provider Tests: ${{ needs.test-providers.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Benchmarks: ${{ needs.benchmark.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Build: ${{ needs.build.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Security Edge Cases: ${{ needs.test-security-edge-cases.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Session Tests: ${{ needs.test-session.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Token Tests: ${{ needs.test-token.result }}" >> $GITHUB_STEP_SUMMARY
echo "- CSRF Tests: ${{ needs.test-csrf.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Go Version Compatibility: ${{ needs.test-go-versions.result }}" >> $GITHUB_STEP_SUMMARY
-192
View File
@@ -1,192 +0,0 @@
version: "2"
run:
go: "1.24"
modules-download-mode: readonly
tests: true
linters:
enable:
- bodyclose
- dupl
- goconst
- gocritic
- gocyclo
- goprintffuncname
- gosec
- misspell
- noctx
- nolintlint
- prealloc
- revive
- rowserrcheck
- sqlclosecheck
- unconvert
- unparam
- whitespace
disable:
- exhaustive
- funlen
- gocognit
- lll
- mnd
- testpackage
- wsl
settings:
dupl:
threshold: 200 # Allow intentional duplication in provider patterns and token management
errcheck:
check-type-assertions: true
check-blank: false # Allow explicit blank assignments (_ = ...) to ignore errors
exclude-functions:
- (io.Closer).Close
- (*database/sql.Rows).Close
- (*database/sql.Stmt).Close
- (io.Writer).Write
- (*net/http.ResponseWriter).Write
- fmt.Fprintf
- fmt.Fprint
- fmt.Fprintln
goconst:
min-len: 3
min-occurrences: 10 # Increased to reduce noise for standard OAuth2/OIDC strings
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
gocyclo:
min-complexity: 30 # OAuth/OIDC flows are inherently complex; set higher for Yaegi compatibility
gosec:
excludes:
- G104
- G404
severity: medium
confidence: medium
govet:
disable:
- fieldalignment
- shadow
enable-all: true
misspell:
locale: US
ignore-rules:
- traefik
- oidc
- keycloak
nolintlint:
require-explanation: true
require-specific: true
allow-unused: false
prealloc:
simple: true
range-loops: true
for-loops: false
revive:
rules:
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
- name: if-return
- name: increment-decrement
- name: var-naming
- name: var-declaration
- name: package-comments
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
- name: indent-error-flow
- name: errorf
- name: empty-block
- name: superfluous-else
- name: unused-parameter
- name: unreachable-code
- name: redefines-builtin-id
unparam:
check-exported: false
staticcheck:
checks:
- all
- -QF1001 # De Morgan's law - style preference, may affect Yaegi
- -QF1003 # Tagged switch - style preference, may affect Yaegi
- -QF1007 # Merge conditional assignment - style preference
- -QF1008 # Remove embedded field - may break Yaegi compatibility
- -QF1012 # Use fmt.Fprintf - style preference
- -ST1003 # Package name format - allowed for test packages
exclusions:
generated: lax
rules:
- linters:
- bodyclose
- dupl
- errcheck
- goconst
- gocyclo
- gosec
- noctx
- prealloc
- unparam
path: _test\.go
- linters:
- dupl
- gocyclo
path: test.*\.go
- linters:
- gocritic
- unused
path: mocks.*\.go
- linters:
- gosec
text: 'G404:'
- linters:
- all
path: vendor/
- linters:
- goconst
path: (.+)_test\.go
- linters:
- dupl
path: internal/providers/(auth0|keycloak|okta|google|azure|github|gitlab|cognito|generic)\.go
- linters:
- dupl
path: session\.go
- linters:
- dupl
path: session_chunk_manager\.go
text: "(extractJWTExpiration|extractJWTIssuedAt)"
paths:
- third_party$
- builtin$
- examples$
issues:
max-issues-per-linter: 0
max-same-issues: 0
uniq-by-line: true
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+58 -719
View File
@@ -4,46 +4,24 @@ type: middleware
import: github.com/lukaszraczylo/traefikoidc
summary: |
Universal OpenID Connect (OIDC) authentication middleware for Traefik.
Middleware adding OpenID Connect (OIDC) authentication to Traefik routes.
This middleware replaces the need for forward-auth and oauth2-proxy when using Traefik as a reverse proxy.
It provides a complete OIDC authentication solution with features including domain restrictions,
role-based access control, session management, comprehensive security headers, automatic token refresh,
and support for all major OIDC providers with automatic configuration.
It provides a complete OIDC authentication solution with features like domain restrictions,
role-based access control, token caching, and more.
🎯 SUPPORTED PROVIDERS (Auto-Detection):
✅ Google - Full OIDC, auto-configured for Workspace
✅ Azure AD - Enterprise OIDC with tenant/group support
✅ Auth0 - Flexible OIDC with custom claims
✅ Okta - Enterprise SSO with MFA support
✅ Keycloak - Self-hosted OIDC with full customization
✅ AWS Cognito - Managed OIDC with regional endpoints
✅ GitLab - Both GitLab.com and self-hosted instances
⚠️ GitHub - OAuth 2.0 only (limited: API access, no user claims)
✅ Generic OIDC - Any RFC-compliant OIDC provider
🔧 KEY FEATURES:
- Automatic provider detection and configuration
- Comprehensive security headers (CSP, HSTS, CORS, custom profiles)
- Domain restrictions and role-based access control
- Automatic token refresh and session management
- Rate limiting and brute force protection
- Flexible configuration with multiple deployment scenarios
- Memory-efficient operation with automatic cleanup
- Extensive logging and debugging capabilities
The middleware has been tested with Auth0, Logto, Google, and other standard OIDC providers.
It includes special handling for Google's OAuth implementation to ensure compatibility.
It supports various authentication scenarios including:
- Basic authentication with customizable callback and logout URLs
- Email domain restrictions to limit access to specific organizations
- Role and group-based access control based on OIDC claims
- Public URLs that bypass authentication (excluded paths)
- Role and group-based access control
- Public URLs that bypass authentication
- Rate limiting to prevent brute force attacks
- Custom post-logout redirect behavior
- Secure session management with encrypted cookies
- Automatic token validation and refresh
- Comprehensive security headers with multiple security profiles
- Rate limiting to prevent brute force attacks
- Custom headers using templated values from OIDC claims
- Flexible CORS configuration for API endpoints
- Configurable logging levels for debugging and monitoring
testData:
# Required parameters
@@ -73,11 +51,7 @@ testData:
- admin
- developer
# ⚠️ CRITICAL for TLS termination scenarios (AWS ALB, Cloud Load Balancers, etc.)
# When NOT specified in config: defaults to FALSE (Go zero value)
# When running behind load balancer that terminates TLS: MUST set to TRUE
# See: https://github.com/lukaszraczylo/traefikoidc/issues/82
forceHTTPS: true # Forces HTTPS scheme for redirect URIs (default when not specified: false)
forceHTTPS: false # Forces the use of HTTPS for all URLs (default: true for security)
logLevel: debug # Sets logging verbosity: debug, info, error (default: info)
rateLimit: 100 # Maximum number of requests per second (default: 100, minimum: 10)
@@ -88,16 +62,16 @@ testData:
- /metrics
headers: # Custom headers to set with templated values from claims and tokens
# NOTE: Use double curly braces to escape template expressions in YAML
# See the headers section in configuration below for details
# NOTE: If you encounter "can't evaluate field AccessToken in type bool" errors,
# you may need to escape the templates. See the headers section in configuration below.
- name: "X-User-Email"
value: "{{{{.Claims.email}}}}"
value: "{{.Claims.email}}"
- name: "X-User-ID"
value: "{{{{.Claims.sub}}}}"
value: "{{.Claims.sub}}"
- name: "Authorization"
value: "Bearer {{{{.AccessToken}}}}"
value: "Bearer {{.AccessToken}}"
- name: "X-User-Roles"
value: "{{{{range $i, $e := .Claims.roles}}}}{{{{if $i}}}},{{{{end}}}}{{{{$e}}}}{{{{end}}}}"
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
# Advanced parameters (usually discovered automatically from provider metadata)
revocationURL: https://accounts.google.com/revoke # Endpoint for revoking tokens
@@ -107,108 +81,15 @@ testData:
overrideScopes: false # When true, replaces default scopes instead of appending (default: false)
refreshGracePeriodSeconds: 60 # Seconds before token expiry to attempt proactive refresh (default: 60)
# Auth0 / Custom API Audience Configuration
audience: "" # Custom audience for access token validation (default: clientID)
strictAudienceValidation: false # Reject sessions with audience mismatch (prevents token confusion attacks)
allowOpaqueTokens: false # Enable opaque (non-JWT) access token support via RFC 7662 introspection
requireTokenIntrospection: false # Force introspection for opaque tokens (requires introspection endpoint)
disableReplayDetection: false # Disable JTI replay detection for multi-replica deployments (default: false)
# Security Headers Configuration (enabled by default with 'default' profile)
securityHeaders:
enabled: true
profile: "default" # Options: default, strict, development, api, custom
# CORS configuration for API endpoints
corsEnabled: false
corsAllowedOrigins:
- "https://your-frontend.com"
- "https://*.example.com"
corsAllowCredentials: true
# Cross-origin policies
permissionsPolicy: "geolocation=(), camera=(), microphone=()"
crossOriginEmbedderPolicy: "require-corp"
crossOriginOpenerPolicy: "same-origin"
crossOriginResourcePolicy: "same-origin"
# Custom headers
customHeaders:
X-Custom-Header: "production"
X-API-Version: "v1"
# --- Common Configuration Examples ---
#
# 🔒 HIGH-SECURITY CONFIGURATION
# testDataHighSecurity:
# providerURL: https://login.microsoftonline.com/your-tenant-id/v2.0
# clientID: your-azure-client-id
# clientSecret: your-azure-client-secret
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "maximum-security-key-at-least-32-bytes-long"
# rateLimit: 50 # Restrictive rate limiting
# allowedUserDomains: ["company.com"] # Domain restriction
# allowedRolesAndGroups: ["admin", "security-team"] # Role restriction
# securityHeaders:
# enabled: true
# profile: "strict" # Maximum security headers
# corsEnabled: false # No CORS in high-security mode
# logLevel: info
# 🧑‍💻 DEVELOPMENT CONFIGURATION
# testDataDevelopment:
# providerURL: https://your-dev-provider.com
# clientID: dev-client-id
# clientSecret: dev-client-secret
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "development-key-at-least-32-bytes-long"
# forceHTTPS: false # Allow HTTP in development
# excludedURLs: ["/health", "/metrics", "/debug"]
# securityHeaders:
# enabled: true
# profile: "development" # Relaxed security for development
# corsEnabled: true
# corsAllowedOrigins: ["http://localhost:*", "http://127.0.0.1:*"]
# logLevel: debug
# 🌐 API CONFIGURATION
# testDataAPI:
# providerURL: https://your-auth0-domain.auth0.com
# clientID: api-client-id
# clientSecret: api-client-secret
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "api-gateway-key-at-least-32-bytes-long"
# refreshGracePeriodSeconds: 120
# securityHeaders:
# enabled: true
# profile: "api"
# corsEnabled: true
# corsAllowedOrigins: ["https://app.example.com"]
# corsAllowedMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
# corsAllowedHeaders: ["Authorization", "Content-Type", "X-API-Key"]
# headers: # Custom headers with OIDC claims (use double curly braces)
# - name: "X-User-Email"
# value: "{{{{.Claims.email}}}}"
# - name: "X-User-ID"
# value: "{{{{.Claims.sub}}}}"
# --- Provider Specific Configuration Examples ---
#
# This middleware supports 9+ OIDC providers with automatic detection:
# ✅ Google - Full OIDC with auto-configuration
# ✅ Azure AD - Enterprise OIDC with tenant support
# ✅ Auth0 - Flexible OIDC with custom claims
# ✅ Okta - Enterprise OIDC with MFA support
# ✅ Keycloak - Self-hosted OIDC with full customization
# ✅ AWS Cognito - Managed OIDC with regional endpoints
# ✅ GitLab - Both GitLab.com and self-hosted
# ⚠️ GitHub - OAuth 2.0 only (not OIDC, limited functionality)
# ✅ Generic OIDC - Any RFC-compliant OIDC provider
#
# Below are example configurations tailored for specific OIDC providers.
# Uncomment and adapt the relevant section for your provider.
# Remember to replace placeholder values with your actual credentials.
# Remember to replace placeholder values (like client IDs, secrets, domains)
# with your actual credentials and settings.
#
# For all providers, ensure claims like email, roles, and groups are
# configured to be included in the ID TOKEN (this plugin validates ID tokens).
# configured to be included in the ID TOKEN. This plugin validates ID tokens.
# --- Keycloak Example ---
# testDataKeycloak:
@@ -246,81 +127,18 @@ testData:
# --- Google Workspace / Google Cloud Identity Example ---
# testDataGoogle:
# providerURL: https://accounts.google.com # Standard Google OIDC endpoint
# providerURL: https://accounts.google.com # This is standard for Google
# clientID: your-google-client-id.apps.googleusercontent.com
# clientSecret: your-google-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-google"
# scopes: # Auto-detects Google and applies proper configuration
# # Do NOT add 'offline_access' - plugin automatically handles Google-specific parameters
# allowedUserDomains: # Useful for Google Workspace domain restriction
# scopes: # Defaults ["openid", "profile", "email"] are handled. Plugin manages Google-specifics.
# # Do NOT add 'offline_access' - plugin handles this.
# allowedUserDomains: # Useful for Google Workspace users
# - your-gsuite-domain.com
# refreshGracePeriodSeconds: 300 # Optional: Refresh 5 min before expiry
# # Google auto-config: Uses access_type=offline, prompt=consent, filters unsupported scopes
# # Available claims: email, sub, name, given_name, family_name, picture, hd (hosted domain)
# --- Okta Example ---
# testDataOkta:
# providerURL: https://your-tenant.okta.com/oauth2/default # Use your Okta domain and auth server
# clientID: your-okta-client-id
# clientSecret: your-okta-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-okta"
# scopes:
# - groups # Include for group-based access control
# allowedRolesAndGroups:
# - admin
# - developer
# - "Everyone" # Default Okta group
# # Okta config: Create OIDC Web App in admin console, configure Groups claim
# # Available claims: email, sub, name, groups, custom attributes
# --- AWS Cognito Example ---
# testDataCognito:
# providerURL: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_YourUserPool # Regional endpoint
# clientID: your-cognito-client-id
# clientSecret: your-cognito-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-cognito"
# scopes:
# - aws.cognito.signin.user.admin # Cognito-specific scope
# allowedRolesAndGroups:
# - admin
# - user
# # Cognito config: Create User Pool, App Client with authorization code grant
# # Available claims: email, sub, cognito:username, cognito:groups, custom attributes
# --- GitLab Example ---
# testDataGitLab:
# providerURL: https://gitlab.com # For GitLab.com, or use your self-hosted URL
# clientID: your-gitlab-client-id
# clientSecret: your-gitlab-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-gitlab"
# scopes:
# - read_user
# - read_api # For GitLab API access
# allowedUserDomains:
# - yourcompany.com # Optional domain restriction
# # GitLab config: Create application in GitLab Admin Area > Applications
# # Available claims: email, sub, name, nickname, preferred_username
# --- GitHub OAuth 2.0 Example (⚠️ Limited Functionality) ---
# testDataGitHub:
# providerURL: https://github.com/login/oauth # GitHub OAuth endpoint (NOT OIDC)
# clientID: your-github-client-id
# clientSecret: your-github-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-github"
# scopes:
# - user:email
# - read:user
# # ⚠️ IMPORTANT: GitHub uses OAuth 2.0, NOT OpenID Connect
# # - No ID tokens available (access tokens only)
# # - No refresh tokens (users must re-authenticate when tokens expire)
# # - No standard OIDC claims
# # - Use only for GitHub API access, not for user authentication with claims
# # GitHub config: Create OAuth App in GitHub Settings > Developer settings
# # Google includes 'hd' (hosted domain) claim which can be used with allowedUserDomains.
# # Other claims like 'email', 'sub', 'name' are standard.
# # See README.md "Provider Configuration Recommendations" for Google.
# --- Auth0 Example ---
# testDataAuth0:
@@ -329,20 +147,6 @@ testData:
# clientSecret: your-auth0-client-secret # Store securely
# callbackURL: /oauth2/callback
# sessionEncryptionKey: "a-very-secure-key-at-least-32-bytes-long-for-auth0"
#
# # Auth0 Audience Configuration (for custom APIs)
# # Scenario 1 (Recommended): Custom API with JWT access tokens
# audience: "https://my-api.example.com" # Your API identifier from Auth0 dashboard
# strictAudienceValidation: true # Enforce proper audience validation for security
#
# # Scenario 2 (Backward Compatible): Default audience (uses client_id)
# # audience: "" # Leave empty or omit to use client_id as audience
# # strictAudienceValidation: false # Allows fallback to ID token validation (logs warnings)
#
# # Scenario 3: Opaque (non-JWT) access tokens
# # allowOpaqueTokens: true # Enable opaque token support
# # requireTokenIntrospection: true # Require RFC 7662 token introspection
#
# scopes: # Defaults ["openid", "profile", "email"]. Add custom scopes if your Auth0 Rules/Actions require them.
# - read:custom_data # Example custom scope
# allowedRolesAndGroups: # Based on claims added via Auth0 Rules or Actions (e.g. namespaced claims)
@@ -350,7 +154,7 @@ testData:
# - editor
# # Use Auth0 Rules or Actions to add custom claims (roles, permissions) to the ID Token.
# # Ensure postLogoutRedirectURI is in Auth0 app's "Allowed Logout URLs".
# # For detailed Auth0 audience configuration, see AUTH0_AUDIENCE_GUIDE.md
# # See README.md "Provider Configuration Recommendations" for Auth0.
# --- Generic OIDC Provider Example ---
# testDataGenericOIDC:
@@ -378,16 +182,11 @@ configuration:
The base URL of the OIDC provider. This is the issuer URL that will be used to discover
OIDC endpoints like authorization, token, and JWKS URIs.
Supported providers (auto-detected from URL):
- https://accounts.google.com (Google)
- https://login.microsoftonline.com/tenant-id/v2.0 (Azure AD)
- https://your-auth0-domain.auth0.com (Auth0)
- https://your-tenant.okta.com/oauth2/default (Okta)
- https://your-keycloak/auth/realms/your-realm (Keycloak)
- https://cognito-idp.region.amazonaws.com/pool-id (AWS Cognito)
- https://gitlab.com (GitLab)
- https://github.com/login/oauth (GitHub - OAuth 2.0 only)
- Any RFC-compliant OIDC provider (Generic)
Examples:
- https://accounts.google.com
- https://login.microsoftonline.com/tenant-id/v2.0
- https://your-auth0-domain.auth0.com
- https://your-logto-instance.com/oidc
required: true
clientID:
@@ -479,24 +278,9 @@ configuration:
forceHTTPS:
type: boolean
description: |
Forces HTTPS scheme for redirect URIs regardless of request headers or TLS state.
⚠️ CRITICAL CONFIGURATION for TLS Termination Scenarios:
When running Traefik behind a load balancer that terminates TLS (AWS ALB,
Google Cloud Load Balancer, Azure Application Gateway, etc.), you MUST set
this to true. Without it, redirect URIs will use http:// instead of https://,
causing OAuth callback failures.
How it works:
- When true: Always uses https:// for redirect URIs (highest priority)
- When false: Detects scheme from X-Forwarded-Proto header or TLS state
- When NOT specified: Defaults to false (Go zero value for bool)
Default: false (when not specified in configuration)
Recommended: true (for production environments and TLS termination scenarios)
See: https://github.com/lukaszraczylo/traefikoidc/issues/82
Forces the use of HTTPS for all URLs.
This is recommended for security in production environments.
Default: true
required: false
rateLimit:
@@ -634,159 +418,16 @@ configuration:
type: integer
description: |
The number of seconds before a token expires to attempt proactive refresh.
When a request is made and the access token will expire within this grace period,
the middleware will attempt to refresh the token proactively. This helps prevent
authentication interruptions for active users.
Setting this to 0 disables proactive refresh (tokens are only refreshed after expiry).
Default: 60 (1 minute before expiry)
required: false
audience:
type: string
description: |
Custom audience value for access token validation.
This configures the expected audience claim in access tokens. Per OAuth 2.0 and OIDC
specifications:
- ID tokens always have aud=client_id (per OIDC Core 1.0)
- Access tokens can have custom audiences (e.g., API identifiers)
Auth0 Scenarios:
1. Custom API audience (recommended): Set to your API identifier from Auth0
Example: "https://my-api.example.com"
Result: Access tokens will contain this audience
2. Default audience: Leave empty or omit (uses client_id)
Result: Access tokens may not contain client_id, triggering warnings
3. Opaque tokens: Use with allowOpaqueTokens=true for non-JWT tokens
When configured and different from client_id, the middleware automatically adds
the audience parameter to the authorize endpoint request.
Default: "" (uses client_id as audience)
See: AUTH0_AUDIENCE_GUIDE.md for detailed configuration
required: false
strictAudienceValidation:
type: boolean
description: |
Enforce strict audience validation for access tokens.
When enabled, sessions are rejected if access token validation fails due to
audience mismatch. This prevents falling back to ID token validation, addressing
potential token confusion attacks where tokens intended for different APIs could
be used to grant access.
Auth0 Scenario 2 Protection:
- When true: Rejects sessions with mismatched access token audience
- When false: Logs security warnings but allows fallback to ID token (backward compatible)
Security Recommendation:
- Production environments: Set to true for maximum security
- Development/testing: Can use false with monitoring of security warnings
This setting addresses security concerns where access tokens without proper
audience claims could bypass API-specific authorization checks.
Default: false (backward compatible)
See: https://github.com/lukaszraczylo/traefikoidc/issues/74
required: false
allowOpaqueTokens:
type: boolean
description: |
Enable acceptance of opaque (non-JWT) access tokens.
When enabled, the middleware accepts access tokens that are not in JWT format
(3-part base64 structure). Opaque tokens are validated using OAuth 2.0 Token
Introspection (RFC 7662) if the provider exposes an introspection endpoint.
Auth0 Scenario 3:
Some Auth0 configurations issue opaque access tokens when no default API is
configured. This setting allows those tokens to be validated.
Requirements:
- Provider must support introspection_endpoint in OIDC discovery
- Client must have appropriate introspection permissions
Validation Process:
1. Detects opaque token (not 3-part JWT structure)
2. Calls provider's introspection endpoint with client credentials
3. Validates response (active status, expiration, audience if present)
4. Caches result for 5 minutes or token expiry (whichever is shorter)
5. Falls back to ID token validation if introspection unavailable
(unless requireTokenIntrospection=true)
Default: false (only JWT access tokens accepted)
See: AUTH0_AUDIENCE_GUIDE.md for configuration examples
required: false
requireTokenIntrospection:
type: boolean
description: |
Require token introspection for all opaque access tokens.
When enabled with allowOpaqueTokens=true, opaque tokens are rejected if:
- Introspection endpoint is not available from provider metadata
- Introspection request fails
- Introspection response indicates token is not active
Security Levels:
- requireTokenIntrospection=true + allowOpaqueTokens=true:
Maximum security - rejects opaque tokens without successful introspection
- requireTokenIntrospection=false + allowOpaqueTokens=true:
Backward compatible - falls back to ID token validation if introspection fails
- requireTokenIntrospection=true + allowOpaqueTokens=false:
No effect - opaque tokens are already rejected
Recommended Configuration:
When accepting opaque tokens, always set this to true for maximum security:
```yaml
allowOpaqueTokens: true
requireTokenIntrospection: true
```
Default: false (allows fallback to ID token)
See: RFC 7662 OAuth 2.0 Token Introspection specification
required: false
disableReplayDetection:
type: boolean
description: |
Disable JTI-based replay attack detection for multi-replica deployments.
When running multiple Traefik replicas, each instance maintains its own in-memory
JTI (JWT Token ID) cache. This causes false positives when the same valid token
hits different replicas:
- Request → Replica A → JTI added to cache → OK
- Request → Replica B → JTI not in Replica B's cache → OK
- Request → Replica A again → JTI found → FALSE POSITIVE "replay detected"
Security Impact:
When disabled, the following validations remain active:
- RSA/ECDSA signature verification
- Token expiration (exp claim)
- Issuer validation (iss claim)
- Audience validation (aud claim)
- Not-before validation (nbf claim)
- Issued-at validation (iat claim)
Only the JTI replay check is skipped.
Recommendations:
- Single-instance deployment: false (default, enables replay protection)
- Multi-replica deployment: true (prevents false positives)
- Production with shared cache: false (use Redis/Memcached for shared JTI cache)
Default: false (replay detection enabled)
required: false
headers:
type: array
description: |
@@ -803,23 +444,29 @@ configuration:
IMPORTANT: Template Escaping
If you encounter the error "can't evaluate field AccessToken in type bool" when
starting Traefik, this means Traefik is trying to evaluate the template expressions
before passing them to the plugin.
before passing them to the plugin. To fix this, you need to escape the templates
using one of these methods:
SOLUTION: You must escape the template expressions using double curly braces:
1. Use YAML literal style (recommended):
headers:
- name: "Authorization"
value: |
Bearer {{.AccessToken}}
headers:
- name: "Authorization"
value: "Bearer {{{{.AccessToken}}}}"
2. Use single quotes:
headers:
- name: "Authorization"
value: 'Bearer {{.AccessToken}}'
This is the only reliable method that works consistently. Here's why:
- The YAML parser converts {{{{ → {{ and }}}} → }}
- Result: Bearer {{.AccessToken}} reaches the Go template engine correctly
- Other methods (YAML literal style, single quotes) do NOT work reliably
3. For inline double quotes, escape the braces:
headers:
- name: "Authorization"
value: "Bearer {{"{{.AccessToken}}"}}"
Examples:
- name: "X-User-Email", value: "{{{{.Claims.email}}}}"
- name: "Authorization", value: "Bearer {{{{.AccessToken}}}}"
- name: "X-User-Roles", value: "{{{{range $i, $e := .Claims.roles}}}}{{{{if $i}}}},{{{{end}}}}{{{{$e}}}}{{{{end}}}}"
- name: "X-User-Email", value: "{{.Claims.email}}"
- name: "Authorization", value: "Bearer {{.AccessToken}}"
- name: "X-User-Roles", value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
required: false
items:
type: object
@@ -830,311 +477,3 @@ configuration:
value:
type: string
description: Template string for the header value
securityHeaders:
type: object
description: |
Configuration for security headers to protect against common web vulnerabilities.
Security headers are applied to all authenticated responses.
The middleware includes comprehensive security headers support with multiple profiles:
- default: Balanced security for standard web applications
- strict: Maximum security for high-security applications
- development: Relaxed policies for local development
- api: API-friendly configuration with CORS support
- custom: Full control over all security header settings
Security features include:
- Content Security Policy (CSP) to prevent XSS attacks
- HTTP Strict Transport Security (HSTS) to enforce HTTPS
- Frame Options to prevent clickjacking
- XSS Protection for browser-level filtering
- Content Type Options to prevent MIME sniffing
- CORS headers for cross-origin resource sharing
- Custom headers for additional security requirements
Example configurations:
Basic security (recommended):
securityHeaders:
enabled: true
profile: "default"
API with CORS:
securityHeaders:
enabled: true
profile: "api"
corsEnabled: true
corsAllowedOrigins: ["https://app.example.com"]
Custom configuration:
securityHeaders:
enabled: true
profile: "custom"
contentSecurityPolicy: "default-src 'self'"
corsEnabled: true
corsAllowedOrigins: ["https://*.example.com"]
customHeaders:
X-Security-Level: "high"
required: false
properties:
enabled:
type: boolean
description: |
Enable or disable security headers.
When disabled, only basic fallback headers are applied.
Default: true
required: false
profile:
type: string
description: |
Security profile to use. Each profile provides a different balance of security and functionality:
- default: Balanced security suitable for most web applications
- strict: Maximum security with very restrictive policies
- development: Relaxed policies for local development (enables localhost CORS)
- api: API-friendly configuration with configurable CORS
- custom: No defaults, use only explicitly configured settings
Default: "default"
required: false
enum:
- default
- strict
- development
- api
- custom
contentSecurityPolicy:
type: string
description: |
Content Security Policy header value to prevent XSS and code injection attacks.
Only applied when using "custom" profile or to override profile defaults.
Examples:
- "default-src 'self'" (strict)
- "default-src 'self'; script-src 'self' 'unsafe-inline'" (moderate)
- "default-src 'self' 'unsafe-inline' 'unsafe-eval'" (permissive)
required: false
strictTransportSecurity:
type: boolean
description: |
Enable HTTP Strict Transport Security (HSTS) to force HTTPS connections.
Only applied when HTTPS is detected (via TLS or X-Forwarded-Proto header).
Default: true
required: false
strictTransportSecurityMaxAge:
type: integer
description: |
HSTS max-age value in seconds. Determines how long browsers should enforce HTTPS.
Common values:
- 31536000 (1 year) - recommended for production
- 86400 (1 day) - for testing
Default: 31536000
required: false
strictTransportSecuritySubdomains:
type: boolean
description: |
Include subdomains in HSTS policy.
When true, HSTS applies to all subdomains of the current domain.
Default: true
required: false
strictTransportSecurityPreload:
type: boolean
description: |
Enable HSTS preload list eligibility.
Allows the domain to be included in browser HSTS preload lists.
Default: true
required: false
frameOptions:
type: string
description: |
X-Frame-Options header value to prevent clickjacking attacks.
Options:
- DENY: Prevents framing completely
- SAMEORIGIN: Allows framing only from the same origin
- ALLOW-FROM uri: Allows framing from specific URI
Default: "DENY"
required: false
contentTypeOptions:
type: string
description: |
X-Content-Type-Options header value to prevent MIME type sniffing.
Should typically be set to "nosniff".
Default: "nosniff"
required: false
xssProtection:
type: string
description: |
X-XSS-Protection header value for browser XSS filtering.
Recommended value: "1; mode=block"
Default: "1; mode=block"
required: false
referrerPolicy:
type: string
description: |
Referrer-Policy header value to control referrer information sharing.
Common values:
- strict-origin-when-cross-origin (recommended)
- no-referrer (most restrictive)
- same-origin (moderate)
Default: "strict-origin-when-cross-origin"
required: false
corsEnabled:
type: boolean
description: |
Enable Cross-Origin Resource Sharing (CORS) headers.
Essential for API endpoints that need to be accessed from web browsers.
Default: false
required: false
corsAllowedOrigins:
type: array
description: |
List of allowed origins for CORS requests.
Supports wildcards for flexible origin matching:
- "https://example.com" (exact match)
- "https://*.example.com" (subdomain wildcard)
- "http://localhost:*" (port wildcard, useful for development)
- "*" (allow all origins - not recommended for production)
Examples: ["https://app.example.com", "https://*.api.example.com"]
required: false
items:
type: string
corsAllowedMethods:
type: array
description: |
HTTP methods allowed for CORS requests.
Default: ["GET", "POST", "OPTIONS"]
Common additions: ["PUT", "DELETE", "PATCH"]
required: false
items:
type: string
corsAllowedHeaders:
type: array
description: |
HTTP headers allowed for CORS requests.
Default: ["Authorization", "Content-Type"]
Common additions: ["X-Requested-With", "X-API-Key"]
required: false
items:
type: string
corsAllowCredentials:
type: boolean
description: |
Allow credentials (cookies, authorization headers) in CORS requests.
Required for authenticated API requests from browsers.
Default: false
required: false
corsMaxAge:
type: integer
description: |
Maximum age in seconds for CORS preflight cache.
Reduces preflight request frequency for better performance.
Default: 86400 (24 hours)
required: false
customHeaders:
type: object
description: |
Additional custom headers to include in responses.
Useful for application-specific security requirements.
Examples:
X-Security-Level: "high"
X-API-Version: "v1"
X-Environment: "production"
required: false
disableServerHeader:
type: boolean
description: |
Remove the Server header to hide server information.
Recommended for security through obscurity.
Default: true
required: false
disablePoweredByHeader:
type: boolean
description: |
Remove the X-Powered-By header to hide technology stack information.
Default: true
required: false
permissionsPolicy:
type: string
description: |
Permissions-Policy header to control browser feature permissions.
This header allows you to control which features and APIs can be used.
Examples:
- "geolocation=(), camera=(), microphone=()" (deny all)
- "geolocation=(self), camera=()" (allow geolocation for same origin only)
Common directives: accelerometer, camera, geolocation, gyroscope,
magnetometer, microphone, payment, usb
required: false
crossOriginEmbedderPolicy:
type: string
description: |
Cross-Origin-Embedder-Policy (COEP) header to prevent untrusted
resources from being loaded.
Options:
- "require-corp": Resources must explicitly grant permission
- "credentialless": Load without credentials for cross-origin resources
- "unsafe-none": No restrictions (default)
Required for certain browser features like SharedArrayBuffer.
required: false
crossOriginOpenerPolicy:
type: string
description: |
Cross-Origin-Opener-Policy (COOP) header to isolate browsing context
from cross-origin windows.
Options:
- "same-origin": Isolate from cross-origin documents
- "same-origin-allow-popups": Allow popups that don't set COOP
- "unsafe-none": No isolation (default)
Helps prevent cross-origin attacks and Spectre-like vulnerabilities.
required: false
crossOriginResourcePolicy:
type: string
description: |
Cross-Origin-Resource-Policy (CORP) header to control which origins
can load this resource.
Options:
- "same-origin": Only same-origin requests can load the resource
- "same-site": Only same-site requests can load the resource
- "cross-origin": Any origin can load the resource (default)
Prevents your resources from being embedded on other sites.
required: false
-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.
+138 -886
View File
File diff suppressed because it is too large Load Diff
-143
View File
@@ -1,143 +0,0 @@
package traefikoidc
import (
"context"
"net/http"
"strings"
"testing"
)
// TestAudienceConfiguration tests the custom audience configuration feature
func TestAudienceConfiguration(t *testing.T) {
tests := []struct {
name string
configAudience string
clientID string
expectedAudience string
}{
{
name: "no custom audience - uses clientID",
configAudience: "",
clientID: "test-client-id",
expectedAudience: "test-client-id",
},
{
name: "custom audience specified",
configAudience: "api://custom-audience",
clientID: "test-client-id",
expectedAudience: "api://custom-audience",
},
{
name: "auth0 style custom audience",
configAudience: "https://api.example.com",
clientID: "test-client-id",
expectedAudience: "https://api.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create config with custom audience
config := CreateConfig()
config.ProviderURL = "https://provider.example.com"
config.ClientID = tt.clientID
config.ClientSecret = "test-secret"
config.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
config.CallbackURL = "/callback"
config.Audience = tt.configAudience
// Create middleware instance
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
traefikOidc, err := NewWithContext(context.Background(), config, next, "test")
if err != nil {
t.Fatalf("Failed to create middleware: %v", err)
}
// Verify audience is set correctly
if traefikOidc.audience != tt.expectedAudience {
t.Errorf("Expected audience %s, got %s", tt.expectedAudience, traefikOidc.audience)
}
// Cleanup
_ = traefikOidc.Close()
})
}
}
// TestAudienceValidation tests the audience validation in Config.Validate()
func TestAudienceValidation(t *testing.T) {
tests := []struct {
name string
audience string
expectError bool
errorContains string
}{
{
name: "valid custom audience URL",
audience: "https://api.example.com",
expectError: false,
},
{
name: "valid azure style audience",
audience: "api://12345678-1234-1234-1234-123456789012",
expectError: false,
},
{
name: "empty audience is valid (uses clientID)",
audience: "",
expectError: false,
},
{
name: "http URL not allowed",
audience: "http://api.example.com",
expectError: true,
errorContains: "audience URL must use HTTPS",
},
{
name: "wildcard not allowed",
audience: "https://*.example.com",
expectError: true,
errorContains: "audience must not contain wildcards",
},
{
name: "too long audience",
audience: "https://" + string(make([]byte, 250)) + ".com",
expectError: true,
errorContains: "audience must not exceed 256 characters",
},
{
name: "invalid characters",
audience: "api://test\ninjection",
expectError: true,
errorContains: "audience 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"
config.ClientSecret = "test-secret"
config.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
config.CallbackURL = "/callback"
config.Audience = tt.audience
err := config.Validate()
if tt.expectError {
if err == nil {
t.Errorf("Expected error but got none")
} else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("Expected error containing '%s', got: %v", tt.errorContains, err)
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}
-931
View File
@@ -1,931 +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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"email": "[email protected]",
"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": "[email protected]",
"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, "", 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": {}},
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": "[email protected]",
"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("[email protected]")
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())
}
})
}
+71 -120
View File
@@ -11,24 +11,17 @@ import (
"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
// AuthHandler provides core authentication functionality for OIDC flows
type AuthHandler struct {
logger Logger
enablePKCE bool
isGoogleProv func() bool
isAzureProv func() bool
clientID string
authURL string
issuerURL string
scopes []string
overrideScopes bool
}
// Logger interface for dependency injection
@@ -37,31 +30,29 @@ type Logger interface {
Errorf(format string, args ...interface{})
}
// NewAuthHandler creates a new Handler instance
// NewAuthHandler creates a new AuthHandler instance
func NewAuthHandler(logger Logger, enablePKCE bool, isGoogleProv, isAzureProv func() bool,
clientID, authURL, issuerURL string, scopes []string, overrideScopes bool,
scopeFilter ScopeFilter, scopesSupported []string) *Handler {
return &Handler{
logger: logger,
enablePKCE: enablePKCE,
isGoogleProv: isGoogleProv,
isAzureProv: isAzureProv,
clientID: clientID,
authURL: authURL,
issuerURL: issuerURL,
scopes: scopes,
overrideScopes: overrideScopes,
scopeFilter: scopeFilter, // NEW
scopesSupported: scopesSupported, // NEW
clientID, authURL, issuerURL string, scopes []string, overrideScopes bool) *AuthHandler {
return &AuthHandler{
logger: logger,
enablePKCE: enablePKCE,
isGoogleProv: isGoogleProv,
isAzureProv: isAzureProv,
clientID: clientID,
authURL: authURL,
issuerURL: issuerURL,
scopes: scopes,
overrideScopes: overrideScopes,
}
}
// 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,
func (h *AuthHandler) 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
@@ -137,7 +128,7 @@ func (h *Handler) InitiateAuthentication(rw http.ResponseWriter, req *http.Reque
// 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 {
func (h *AuthHandler) BuildAuthURL(redirectURL, state, nonce, codeChallenge string) string {
params := url.Values{}
params.Set("client_id", h.clientID)
params.Set("response_type", "code")
@@ -153,19 +144,49 @@ func (h *Handler) BuildAuthURL(redirectURL, state, nonce, codeChallenge string)
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)
}
if h.isGoogleProv() {
params.Set("access_type", "offline")
h.logger.Debugf("Google OIDC provider detected, added access_type=offline for refresh tokens")
// Apply provider-specific modifications
scopes, params = h.applyProviderSpecificConfig(scopes, params)
params.Set("prompt", "consent")
h.logger.Debugf("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
} else if h.isAzureProv() {
params.Set("response_mode", "query")
h.logger.Debugf("Azure AD provider detected, added response_mode=query")
// 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)
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !h.overrideScopes || (h.overrideScopes && len(h.scopes) == 0) {
if !hasOfflineAccess {
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))
}
} else {
if !h.overrideScopes || (h.overrideScopes && len(h.scopes) == 0) {
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
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))
}
}
if len(scopes) > 0 {
@@ -177,80 +198,10 @@ func (h *Handler) BuildAuthURL(redirectURL, state, nonce, codeChallenge string)
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 {
func (h *AuthHandler) 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 {
@@ -301,7 +252,7 @@ func (h *Handler) buildURLWithParams(baseURL string, params url.Values) 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 {
func (h *AuthHandler) validateURL(urlStr string) error {
if urlStr == "" {
return fmt.Errorf("empty URL")
}
@@ -316,7 +267,7 @@ func (h *Handler) validateURL(urlStr string) error {
// 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 {
func (h *AuthHandler) validateParsedURL(u *url.URL) error {
allowedSchemes := map[string]bool{
"https": true,
"http": true,
@@ -347,7 +298,7 @@ func (h *Handler) validateParsedURL(u *url.URL) error {
// validateHost validates a hostname for security and reachability.
// It prevents access to private networks and localhost addresses.
func (h *Handler) validateHost(host string) error {
func (h *AuthHandler) validateHost(host string) error {
if host == "" {
return fmt.Errorf("empty host")
}
File diff suppressed because it is too large Load Diff
-562
View File
@@ -1,562 +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)
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)
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)
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)
// 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)
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")
}
}
}
})
}
}
-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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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")
}
}
-336
View File
@@ -1,336 +0,0 @@
package traefikoidc
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
redirectCount := session.GetRedirectCount()
if redirectCount >= maxRedirects {
t.logger.Errorf("Maximum redirect limit (%d) exceeded, possible redirect loop detected", maxRedirects)
session.ResetRedirectCount()
t.sendErrorResponse(rw, req, "Authentication failed: Too many redirects", http.StatusLoopDetected)
return fmt.Errorf("redirect limit exceeded")
}
session.IncrementRedirectCount()
return nil
}
// generatePKCEParameters generates PKCE code verifier and challenge if PKCE is enabled
func (t *TraefikOidc) generatePKCEParameters() (string, string, error) {
if !t.enablePKCE {
return "", "", nil
}
codeVerifier, err := generateCodeVerifier()
if err != nil {
return "", "", fmt.Errorf("failed to generate code verifier: %w", err)
}
codeChallenge := deriveCodeChallenge(codeVerifier)
t.logger.Debugf("PKCE enabled, generated code challenge")
return codeVerifier, codeChallenge, nil
}
// prepareSessionForAuthentication clears existing session data and sets new authentication state
func (t *TraefikOidc) prepareSessionForAuthentication(session *SessionData, csrfToken, nonce, codeVerifier, incomingPath string) {
// Clear all existing session data
_ = session.SetAuthenticated(false) // Safe to ignore: clearing authentication state on new flow
session.SetEmail("")
session.SetAccessToken("")
session.SetRefreshToken("")
session.SetIDToken("")
session.SetNonce("")
session.SetCodeVerifier("")
// Set new authentication state
session.SetCSRF(csrfToken)
session.SetNonce(nonce)
if t.enablePKCE && codeVerifier != "" {
session.SetCodeVerifier(codeVerifier)
}
session.SetIncomingPath(incomingPath)
t.logger.Debugf("Storing incoming path: %s", incomingPath)
}
// defaultInitiateAuthentication 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.
// Parameters:
// - rw: The HTTP response writer.
// - req: The HTTP request initiating authentication.
// - session: The session data to prepare for authentication.
// - redirectURL: The pre-calculated callback URL (redirect_uri) for this middleware instance.
func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI())
// Check and handle redirect limits
if err := t.validateRedirectCount(session, rw, req); err != nil {
return
}
csrfToken := uuid.NewString()
nonce, err := generateNonce()
if err != nil {
t.logger.Errorf("Failed to generate nonce: %v", err)
http.Error(rw, "Failed to generate nonce", http.StatusInternalServerError)
return
}
// Generate PKCE parameters if enabled
codeVerifier, codeChallenge, err := t.generatePKCEParameters()
if err != nil {
t.logger.Errorf("Failed to generate PKCE parameters: %v", err)
http.Error(rw, "Failed to generate PKCE parameters", http.StatusInternalServerError)
return
}
// Clear existing session data and set new authentication state
t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, req.URL.RequestURI())
session.MarkDirty()
if err := session.Save(req, rw); err != nil {
t.logger.Errorf("Failed to save session before redirecting to provider: %v", err)
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
return
}
t.logger.Debugf("Session saved before redirect. CSRF: %s, Nonce: %s",
csrfToken, nonce)
authURL := t.buildAuthURL(redirectURL, csrfToken, nonce, codeChallenge)
t.logger.Debugf("Redirecting user to OIDC provider: %s", authURL)
http.Redirect(rw, req, authURL, http.StatusFound)
}
// handleCallback processes the OIDC callback after user authentication.
// It validates state/CSRF tokens, exchanges authorization code for tokens,
// verifies the received tokens, extracts claims, and establishes the session.
// Parameters:
// - rw: The HTTP response writer.
// - req: The callback request containing authorization code and state.
// - redirectURL: The fully qualified callback URL (used in the token exchange request).
func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request, redirectURL string) {
session, err := t.sessionManager.GetSession(req)
if err != nil {
t.logger.Errorf("Session error during callback: %v", err)
t.sendErrorResponse(rw, req, "Session error during callback", http.StatusInternalServerError)
return
}
defer session.returnToPoolSafely()
t.logger.Debugf("Handling callback, URL: %s", req.URL.String())
if req.URL.Query().Get("error") != "" {
errorDescription := req.URL.Query().Get("error_description")
if errorDescription == "" {
errorDescription = req.URL.Query().Get("error")
}
t.logger.Errorf("Authentication error from provider during callback: %s - %s", req.URL.Query().Get("error"), errorDescription)
t.sendErrorResponse(rw, req, fmt.Sprintf("Authentication error from provider: %s", errorDescription), http.StatusBadRequest)
return
}
state := req.URL.Query().Get("state")
if state == "" {
t.logger.Error("No state in callback")
t.sendErrorResponse(rw, req, "State parameter missing in callback", http.StatusBadRequest)
return
}
csrfToken := session.GetCSRF()
if csrfToken == "" {
t.logger.Errorf("CSRF token missing in session during callback. Authenticated: %v, Request URL: %s",
session.GetAuthenticated(), req.URL.String())
cookie, err := req.Cookie("_oidc_raczylo_m")
if err != nil {
t.logger.Errorf("Main session cookie not found in request: %v", err)
} else {
t.logger.Errorf("Main session cookie exists but CSRF token is empty. Cookie value length: %d", len(cookie.Value))
}
t.sendErrorResponse(rw, req, "CSRF token missing in session", http.StatusBadRequest)
return
}
if state != csrfToken {
t.logger.Error("State parameter does not match CSRF token in session during callback")
t.sendErrorResponse(rw, req, "Invalid state parameter (CSRF mismatch)", http.StatusBadRequest)
return
}
code := req.URL.Query().Get("code")
if code == "" {
t.logger.Error("No code in callback")
t.sendErrorResponse(rw, req, "No authorization code received in callback", http.StatusBadRequest)
return
}
codeVerifier := session.GetCodeVerifier()
tokenResponse, err := t.tokenExchanger.ExchangeCodeForToken(req.Context(), "authorization_code", code, redirectURL, codeVerifier)
if err != nil {
t.logger.Errorf("Failed to exchange code for token during callback: %v", err)
t.sendErrorResponse(rw, req, "Authentication failed: Could not exchange code for token", http.StatusInternalServerError)
return
}
if err = t.verifyToken(tokenResponse.IDToken); err != nil {
t.logger.Errorf("Failed to verify id_token during callback: %v", err)
t.sendErrorResponse(rw, req, "Authentication failed: Could not verify ID token", http.StatusInternalServerError)
return
}
claims, err := t.extractClaimsFunc(tokenResponse.IDToken)
if err != nil {
t.logger.Errorf("Failed to extract claims during callback: %v", err)
t.sendErrorResponse(rw, req, "Authentication failed: Could not extract claims from token", http.StatusInternalServerError)
return
}
nonceClaim, ok := claims["nonce"].(string)
if !ok || nonceClaim == "" {
t.logger.Error("Nonce claim missing in id_token during callback")
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in token", http.StatusInternalServerError)
return
}
sessionNonce := session.GetNonce()
if sessionNonce == "" {
t.logger.Error("Nonce not found in session during callback")
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in session", http.StatusInternalServerError)
return
}
if nonceClaim != sessionNonce {
t.logger.Error("Nonce claim does not match session nonce during callback")
t.sendErrorResponse(rw, req, "Authentication failed: Nonce mismatch", http.StatusInternalServerError)
return
}
email, _ := claims["email"].(string)
if email == "" {
t.logger.Errorf("Email claim missing or empty in token during callback")
t.sendErrorResponse(rw, req, "Authentication failed: Email missing in token", http.StatusInternalServerError)
return
}
if !t.isAllowedDomain(email) {
t.logger.Errorf("Disallowed email domain during callback: %s", email)
t.sendErrorResponse(rw, req, "Authentication failed: Email domain not allowed", http.StatusForbidden)
return
}
if err := session.SetAuthenticated(true); err != nil {
t.logger.Errorf("Failed to set authenticated state and regenerate session ID: %v", err)
t.sendErrorResponse(rw, req, "Failed to update session", http.StatusInternalServerError)
return
}
session.SetEmail(email)
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 != t.redirURLPath {
redirectPath = incomingPath
}
session.SetIncomingPath("")
if err := session.Save(req, rw); err != nil {
t.logger.Errorf("Failed to save session after callback: %v", err)
t.sendErrorResponse(rw, req, "Failed to save session after callback", http.StatusInternalServerError)
return
}
t.logger.Debugf("Callback successful, redirecting to %s", redirectPath)
http.Redirect(rw, req, redirectPath, http.StatusFound)
}
// handleExpiredToken handles requests with expired or invalid tokens.
// It clears the session data and initiates a new authentication flow.
// Parameters:
// - rw: The HTTP response writer.
// - req: The HTTP request with expired token.
// - session: The session data to clear.
// - redirectURL: The callback URL to be used in the new authentication flow.
func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
t.logger.Debug("Handling expired token: Clearing session and initiating re-authentication.")
_ = session.SetAuthenticated(false) // Safe to ignore: clearing authentication on expired token
session.SetIDToken("")
session.SetAccessToken("")
session.SetRefreshToken("")
session.SetEmail("")
// Clear CSRF tokens to prevent replay attacks
session.SetCSRF("")
session.SetNonce("")
session.SetCodeVerifier("")
// Reset redirect count to prevent loops when handling expired tokens
session.ResetRedirectCount()
if err := session.Save(req, rw); err != nil {
t.logger.Errorf("Failed to save cleared session during expired token handling: %v", err)
}
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
}
// isUserAuthenticated determines the authentication status and refresh requirements.
// It delegates to provider-specific validation methods that handle different token types
// and expiration behaviors.
// Parameters:
// - session: The session data containing authentication tokens.
//
// Returns:
// - authenticated (bool): True if the user has valid tokens.
// - needsRefresh (bool): True if tokens are valid but nearing expiration.
// - expired (bool): True if the session is unauthenticated, the token is missing,
// or the token verification failed for reasons other than nearing/actual expiration.
func (t *TraefikOidc) isUserAuthenticated(session *SessionData) (bool, bool, bool) {
if t.isAzureProvider() {
return t.validateAzureTokens(session)
} else if t.isGoogleProvider() {
return t.validateGoogleTokens(session)
}
// Auth0 and other providers can now use standard validation
// which handles opaque tokens generically
return t.validateStandardTokens(session)
}
// isAjaxRequest determines if this is an AJAX request that should receive 401 instead of redirect
func (t *TraefikOidc) isAjaxRequest(req *http.Request) bool {
xhr := req.Header.Get("X-Requested-With")
contentType := req.Header.Get("Content-Type")
accept := req.Header.Get("Accept")
return xhr == "XMLHttpRequest" ||
strings.Contains(contentType, "application/json") ||
strings.Contains(accept, "application/json")
}
// 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
// the specific provider and token metadata
return false // Placeholder implementation
}
-101
View File
@@ -1,101 +0,0 @@
package traefikoidc
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestGeneratePKCEParameters tests the generatePKCEParameters method
func TestGeneratePKCEParameters(t *testing.T) {
t.Run("PKCE enabled - successful generation", func(t *testing.T) {
// Create a TraefikOidc instance with PKCE enabled
plugin := &TraefikOidc{
enablePKCE: true,
logger: NewLogger("debug"),
}
verifier, challenge, err := plugin.generatePKCEParameters()
require.NoError(t, err)
assert.NotEmpty(t, verifier, "code verifier should not be empty when PKCE is enabled")
assert.NotEmpty(t, challenge, "code challenge should not be empty when PKCE is enabled")
// Verify the challenge is derived from the verifier
expectedChallenge := deriveCodeChallenge(verifier)
assert.Equal(t, expectedChallenge, challenge, "challenge should match derived challenge from verifier")
})
t.Run("PKCE disabled - returns empty strings", func(t *testing.T) {
// Create a TraefikOidc instance with PKCE disabled
plugin := &TraefikOidc{
enablePKCE: false,
logger: NewLogger("debug"),
}
verifier, challenge, err := plugin.generatePKCEParameters()
require.NoError(t, err)
assert.Empty(t, verifier, "code verifier should be empty when PKCE is disabled")
assert.Empty(t, challenge, "code challenge should be empty when PKCE is disabled")
})
t.Run("PKCE enabled - generates different values each time", func(t *testing.T) {
plugin := &TraefikOidc{
enablePKCE: true,
logger: NewLogger("debug"),
}
verifier1, challenge1, err1 := plugin.generatePKCEParameters()
require.NoError(t, err1)
verifier2, challenge2, err2 := plugin.generatePKCEParameters()
require.NoError(t, err2)
assert.NotEqual(t, verifier1, verifier2, "verifiers should be different")
assert.NotEqual(t, challenge1, challenge2, "challenges should be different")
})
t.Run("PKCE enabled - verifier and challenge relationship", func(t *testing.T) {
plugin := &TraefikOidc{
enablePKCE: true,
logger: NewLogger("debug"),
}
verifier, challenge, err := plugin.generatePKCEParameters()
require.NoError(t, err)
// The challenge should always be derivable from the verifier
recalculatedChallenge := deriveCodeChallenge(verifier)
assert.Equal(t, challenge, recalculatedChallenge,
"challenge should always match the SHA256 hash of verifier")
})
t.Run("PKCE enabled - verifier meets RFC 7636 requirements", func(t *testing.T) {
plugin := &TraefikOidc{
enablePKCE: true,
logger: NewLogger("debug"),
}
verifier, _, err := plugin.generatePKCEParameters()
require.NoError(t, err)
// RFC 7636 requires verifier to be 43-128 characters
assert.GreaterOrEqual(t, len(verifier), 43, "verifier should be at least 43 characters")
assert.LessOrEqual(t, len(verifier), 128, "verifier should be at most 128 characters")
})
t.Run("PKCE enabled - challenge meets RFC 7636 requirements", func(t *testing.T) {
plugin := &TraefikOidc{
enablePKCE: true,
logger: NewLogger("debug"),
}
_, challenge, err := plugin.generatePKCEParameters()
require.NoError(t, err)
// SHA256 hash base64 encoded should be 43 characters
assert.Equal(t, 43, len(challenge), "S256 challenge should be exactly 43 characters")
})
}
+10 -10
View File
@@ -173,7 +173,7 @@ func (bt *BackgroundTask) run() {
if bt.logger != nil {
if !isTestMode() {
bt.logger.Debug("Starting background task: %s", bt.name)
bt.logger.Info("Starting background task: %s", bt.name)
}
}
@@ -182,7 +182,7 @@ func (bt *BackgroundTask) run() {
case <-bt.stopChan:
if bt.logger != nil {
if !isTestMode() {
bt.logger.Debug("Stopping background task: %s (before initial execution)", bt.name)
bt.logger.Info("Stopping background task: %s (before initial execution)", bt.name)
}
}
return
@@ -201,7 +201,7 @@ func (bt *BackgroundTask) run() {
case <-bt.stopChan:
if bt.logger != nil {
if !isTestMode() {
bt.logger.Debug("Stopping background task: %s (during periodic execution)", bt.name)
bt.logger.Info("Stopping background task: %s (during periodic execution)", bt.name)
}
}
return
@@ -211,7 +211,7 @@ func (bt *BackgroundTask) run() {
case <-bt.stopChan:
if bt.logger != nil {
if !isTestMode() {
bt.logger.Debug("Stopping background task: %s (direct stop signal)", bt.name)
bt.logger.Info("Stopping background task: %s (direct stop signal)", bt.name)
}
}
return
@@ -315,7 +315,7 @@ func (cb *TaskCircuitBreaker) CanCreateTask(taskName string) error {
if time.Now().Unix()-lastFailure > int64(cb.timeout.Seconds()) {
atomic.StoreInt32(&cb.state, int32(CircuitBreakerHalfOpen))
if cb.logger != nil {
cb.logger.Debug("Circuit breaker transitioning to half-open for task: %s", taskName)
cb.logger.Info("Circuit breaker transitioning to half-open for task: %s", taskName)
}
return nil
}
@@ -467,7 +467,7 @@ func (tr *TaskRegistry) RegisterTask(name string, task *BackgroundTask) error {
tr.cb.OnTaskSuccess(name)
if tr.logger != nil {
tr.logger.Debug("Registered background task: %s", name)
tr.logger.Info("Registered background task: %s", name)
}
return nil
@@ -483,7 +483,7 @@ func (tr *TaskRegistry) UnregisterTask(name string) {
delete(tr.tasks, name)
if tr.logger != nil {
tr.logger.Debug("Unregistered background task: %s", name)
tr.logger.Info("Unregistered background task: %s", name)
}
}
}
@@ -513,7 +513,7 @@ func (tr *TaskRegistry) StopAllTasks() {
for name, task := range tasksCopy {
task.Stop()
if tr.logger != nil {
tr.logger.Debug("Stopped background task during shutdown: %s", name)
tr.logger.Info("Stopped background task during shutdown: %s", name)
}
}
}
@@ -538,7 +538,7 @@ func (tr *TaskRegistry) CreateSingletonTask(name string, interval time.Duration,
// Start the task if not already running
if !rm.IsTaskRunning(name) {
_ = rm.StartBackgroundTask(name) // Safe to ignore: task registration succeeded, start is best-effort
rm.StartBackgroundTask(name)
}
// Get the task from resource manager's internal registry
@@ -641,7 +641,7 @@ func (mm *TaskMemoryMonitor) Start(interval time.Duration) error {
mm.started = true
if mm.logger != nil && !isTestMode() {
mm.logger.Debug("Started global task memory monitoring with %v interval", interval)
mm.logger.Info("Started global task memory monitoring with %v interval", interval)
}
return nil
-224
View File
@@ -1,224 +0,0 @@
package traefikoidc
import (
"errors"
"sync"
"testing"
"time"
)
// globalRegistryMutex protects only the global registry operations
var globalRegistryMutex sync.Mutex
// TestTaskCircuitBreakerOnTaskFailure tests the OnTaskFailure method
func TestTaskCircuitBreakerOnTaskFailure(t *testing.T) {
logger := NewLogger("debug") // Create a real logger
cb := NewTaskCircuitBreaker(3, time.Minute, logger)
// Test failure doesn't trigger open state before threshold
cb.OnTaskFailure("test-task", errors.New("test error"))
if err := cb.CanCreateTask("test-task"); err != nil {
t.Error("Circuit breaker should allow task creation after 1 failure (threshold: 3)")
}
// Test failure count reaches threshold and opens circuit
cb.OnTaskFailure("test-task", errors.New("test error 2"))
cb.OnTaskFailure("test-task", errors.New("test error 3"))
if err := cb.CanCreateTask("test-task"); err == nil {
t.Error("Circuit breaker should prevent task creation after reaching failure threshold")
}
}
// TestResetGlobalTaskRegistry tests the reset functionality
func TestResetGlobalTaskRegistry(t *testing.T) {
globalRegistryMutex.Lock()
defer globalRegistryMutex.Unlock()
// Get the global registry first
registry := GetGlobalTaskRegistry()
// Create and register a dummy task
logger := NewLogger("debug")
task := NewBackgroundTask("test-task", time.Second, func() {
// Do nothing
}, logger)
registry.RegisterTask("test-task", task)
// Verify task is registered
if registry.GetTaskCount() == 0 {
t.Error("Expected task to be registered")
}
// Reset the registry
ResetGlobalTaskRegistry()
// Get registry again and verify it's empty
newRegistry := GetGlobalTaskRegistry()
if newRegistry.GetTaskCount() != 0 {
t.Error("Expected registry to be empty after reset")
}
}
// TestGetTask tests the GetTask method
func TestGetTask(t *testing.T) {
globalRegistryMutex.Lock()
defer globalRegistryMutex.Unlock()
// Reset registry to ensure clean state
ResetGlobalTaskRegistry()
registry := GetGlobalTaskRegistry()
// Test getting non-existent task
task, exists := registry.GetTask("non-existent")
if task != nil || exists {
t.Error("Expected nil and false for non-existent task")
}
// Create and register a task
logger := NewLogger("debug")
newTask := NewBackgroundTask("test-task", time.Second, func() {
// Do nothing
}, logger)
registry.RegisterTask("test-task", newTask)
// Test getting existing task
retrievedTask, exists := registry.GetTask("test-task")
if retrievedTask == nil || !exists {
t.Error("Expected to retrieve registered task")
return
}
if retrievedTask.name != "test-task" {
t.Errorf("Expected task name 'test-task', got '%s'", retrievedTask.name)
}
}
// TestNewTaskMemoryMonitor tests the NewTaskMemoryMonitor function
func TestNewTaskMemoryMonitor(t *testing.T) {
// No mutex needed - this doesn't modify global state
logger := NewLogger("debug")
registry := GetGlobalTaskRegistry()
monitor := NewTaskMemoryMonitor(logger, registry)
if monitor == nil {
t.Error("Expected NewTaskMemoryMonitor to return non-nil monitor")
}
}
// TestGetCurrentStats tests the GetCurrentStats method
func TestGetCurrentStats(t *testing.T) {
// Don't hold mutex during background task execution to avoid deadlocks
logger := NewLogger("debug")
registry := GetGlobalTaskRegistry()
monitor := NewTaskMemoryMonitor(logger, registry)
// Start the monitor and let it collect at least one statistic
err := monitor.Start(50 * time.Millisecond)
if err != nil {
t.Fatalf("Failed to start monitor: %v", err)
}
// Ensure monitor is stopped even if test fails
defer func() {
monitor.Stop()
// Give extra time for cleanup
time.Sleep(50 * time.Millisecond)
}()
// Wait a bit for the monitor to collect stats
time.Sleep(150 * time.Millisecond)
stats, err := monitor.GetCurrentStats()
if err != nil {
// If no stats are available yet, that's acceptable for this test
t.Logf("No memory statistics available yet: %v", err)
return
}
// TaskMemoryStats is a struct, not a pointer, so it can't be nil
if stats.Timestamp.IsZero() {
t.Error("Expected GetCurrentStats to return valid timestamp")
}
}
// TestGetStatsHistory tests the GetStatsHistory method
func TestGetStatsHistory(t *testing.T) {
// No mutex needed - this just creates a monitor and checks its initial state
logger := NewLogger("debug")
registry := GetGlobalTaskRegistry()
monitor := NewTaskMemoryMonitor(logger, registry)
history := monitor.GetStatsHistory()
if history == nil {
t.Error("Expected GetStatsHistory to return non-nil history")
}
// A fresh monitor should have empty history
if len(history) != 0 {
t.Logf("History length: %d (may be non-empty due to shared global state)", len(history))
}
}
// TestForceGC tests the ForceGC method
func TestForceGC(t *testing.T) {
// No mutex needed - this doesn't modify global state
logger := NewLogger("debug")
registry := GetGlobalTaskRegistry()
monitor := NewTaskMemoryMonitor(logger, registry)
// This should not panic and should work
monitor.ForceGC()
// No specific verification needed, just ensuring it doesn't crash
}
// TestShutdownAllTasks tests the ShutdownAllTasks function
func TestShutdownAllTasks(t *testing.T) {
// Use a unique task name prefix to avoid conflicts with other tests
taskPrefix := "shutdown-test-"
// Create a temporary clean registry state
func() {
globalRegistryMutex.Lock()
defer globalRegistryMutex.Unlock()
ResetGlobalTaskRegistry()
}()
registry := GetGlobalTaskRegistry()
logger := NewLogger("debug")
// Create some test tasks with unique names
task1 := NewBackgroundTask(taskPrefix+"task1", time.Millisecond, func() {
time.Sleep(100 * time.Millisecond) // Simulate work
}, logger)
task2 := NewBackgroundTask(taskPrefix+"task2", time.Millisecond, func() {
time.Sleep(100 * time.Millisecond) // Simulate work
}, logger)
// Register tasks under mutex protection
func() {
globalRegistryMutex.Lock()
defer globalRegistryMutex.Unlock()
registry.RegisterTask(taskPrefix+"task1", task1)
registry.RegisterTask(taskPrefix+"task2", task2)
}()
// Start the tasks (outside mutex to avoid deadlock)
task1.Start()
task2.Start()
// Give tasks time to start
time.Sleep(50 * time.Millisecond)
// Shutdown all tasks
ShutdownAllTasks()
// Give shutdown time to complete
time.Sleep(200 * time.Millisecond)
// Note: We can't reliably verify task count due to other tests
// Just ensure shutdown doesn't panic
}
+1 -2
View File
@@ -58,13 +58,12 @@ func TestAzureOIDCRegression(t *testing.T) {
tokenURL: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token",
jwksURL: "https://login.microsoftonline.com/tenant-id/discovery/v2.0/keys",
clientID: "test-client-id",
audience: "test-client-id",
clientSecret: "test-client-secret",
scopes: []string{"openid", "profile", "email"},
refreshGracePeriod: 60 * time.Second,
limiter: rate.NewLimiter(rate.Every(time.Second), 100), // Add rate limiter
logger: mockLogger,
httpClient: CreateDefaultHTTPClient(), // Add HTTP client
httpClient: createDefaultHTTPClient(), // Add HTTP client
jwkCache: &JWKCache{}, // Add JWK cache
tokenCache: tokenCache,
tokenBlacklist: tokenBlacklist,
-536
View File
@@ -1,536 +0,0 @@
package traefikoidc
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// TestMemoryMonitorComprehensive tests memory monitor edge cases
func TestMemoryMonitorComprehensive(t *testing.T) {
t.Run("TriggerGC calls runtime GC", func(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
// Should not panic
assert.NotPanics(t, func() {
monitor.TriggerGC()
})
})
t.Run("GetMemoryPressure returns pressure level", func(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
// Initially should return None (no stats yet)
pressure := monitor.GetMemoryPressure()
assert.Equal(t, MemoryPressureNone, pressure)
// Collect stats to populate lastStats
monitor.GetCurrentStats()
// Now should return a valid pressure level
pressure = monitor.GetMemoryPressure()
assert.NotNil(t, pressure)
})
t.Run("StartMonitoring can be called", func(t *testing.T) {
ResetGlobalMemoryMonitor()
ResetGlobalTaskRegistry()
defer ResetGlobalMemoryMonitor()
defer ResetGlobalTaskRegistry()
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
// Start monitoring should not panic
assert.NotPanics(t, func() {
ctx := context.Background()
monitor.StartMonitoring(ctx, 100*time.Millisecond)
time.Sleep(GetTestDuration(50 * time.Millisecond))
})
// Clean up
monitor.StopMonitoring()
})
t.Run("StopMonitoring can be called safely", func(t *testing.T) {
ResetGlobalMemoryMonitor()
defer ResetGlobalMemoryMonitor()
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
// StopMonitoring should not panic even if not started
assert.NotPanics(t, func() {
monitor.StopMonitoring()
})
// Can be called multiple times safely
assert.NotPanics(t, func() {
monitor.StopMonitoring()
monitor.StopMonitoring()
})
})
t.Run("ResetGlobalMemoryMonitor resets singleton", func(t *testing.T) {
ResetGlobalMemoryMonitor()
defer ResetGlobalMemoryMonitor()
// Get initial instance
GetGlobalMemoryMonitor()
// Reset
ResetGlobalMemoryMonitor()
// Should be able to get a new instance
monitor := GetGlobalMemoryMonitor()
assert.NotNil(t, monitor)
// Clean up
monitor.StopMonitoring()
ResetGlobalMemoryMonitor()
})
t.Run("String method returns pressure name", func(t *testing.T) {
pressures := []struct {
level MemoryPressureLevel
name string
}{
{MemoryPressureNone, "None"},
{MemoryPressureLow, "Low"},
{MemoryPressureModerate, "Moderate"},
{MemoryPressureHigh, "High"},
{MemoryPressureCritical, "Critical"},
{MemoryPressureLevel(999), "Unknown"},
}
for _, p := range pressures {
assert.Equal(t, p.name, p.level.String(), "pressure level %d should return %s", p.level, p.name)
}
})
t.Run("GetCurrentStats collects statistics", func(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
stats := monitor.GetCurrentStats()
assert.NotNil(t, stats)
assert.Greater(t, stats.HeapAllocBytes, uint64(0))
assert.Greater(t, stats.NumGoroutines, 0)
assert.NotZero(t, stats.Timestamp)
})
}
// TestBackgroundTaskRegistry tests background task registry edge cases
func TestBackgroundTaskRegistry(t *testing.T) {
t.Run("GetGlobalTaskRegistry returns singleton", func(t *testing.T) {
registry1 := GetGlobalTaskRegistry()
registry2 := GetGlobalTaskRegistry()
assert.Equal(t, registry1, registry2, "should return same instance")
})
t.Run("RegisterTask adds task to registry", func(t *testing.T) {
ResetGlobalTaskRegistry()
registry := GetGlobalTaskRegistry()
defer ResetGlobalTaskRegistry()
taskName := "test-register-task"
task := NewBackgroundTask(
taskName,
100*time.Millisecond,
func() {},
newNoOpLogger(),
)
err := registry.RegisterTask(taskName, task)
assert.NoError(t, err)
// Verify task was registered
_, exists := registry.GetTask(taskName)
assert.True(t, exists, "task should be registered")
// Clean up
task.Stop()
})
t.Run("CreateSingletonTask is idempotent", func(t *testing.T) {
ResetGlobalTaskRegistry()
registry := GetGlobalTaskRegistry()
defer ResetGlobalTaskRegistry()
taskName := "test-singleton-idempotent"
callCount := 0
var mu sync.Mutex
taskFunc := func() {
mu.Lock()
callCount++
mu.Unlock()
}
// First creation should succeed
task1, err1 := registry.CreateSingletonTask(
taskName,
100*time.Millisecond,
taskFunc,
newNoOpLogger(),
nil,
)
assert.NoError(t, err1)
assert.NotNil(t, task1)
// Second creation should also succeed (idempotent)
// Returns same task without error
task2, err2 := registry.CreateSingletonTask(
taskName,
100*time.Millisecond,
taskFunc,
newNoOpLogger(),
nil,
)
assert.NoError(t, err2, "CreateSingletonTask should be idempotent")
assert.NotNil(t, task2)
// Clean up
if task1 != nil {
task1.Stop()
}
})
t.Run("GetTaskCount returns active task count", func(t *testing.T) {
ResetGlobalTaskRegistry()
registry := GetGlobalTaskRegistry()
defer ResetGlobalTaskRegistry()
// Initially should be 0 or small number
initialCount := registry.GetTaskCount()
// Create a task
task := NewBackgroundTask(
"count-test-task",
100*time.Millisecond,
func() {},
newNoOpLogger(),
)
err := registry.RegisterTask("count-test-task", task)
assert.NoError(t, err)
// Count should increase
newCount := registry.GetTaskCount()
assert.Equal(t, initialCount+1, newCount)
// Clean up
task.Stop()
})
t.Run("StopAllTasks stops all tasks", func(t *testing.T) {
ResetGlobalTaskRegistry()
registry := GetGlobalTaskRegistry()
defer ResetGlobalTaskRegistry()
// Create multiple tasks
for i := 0; i < 3; i++ {
taskName := "multi-task-" + string(rune(i+'0'))
task := NewBackgroundTask(
taskName,
100*time.Millisecond,
func() {},
newNoOpLogger(),
)
registry.RegisterTask(taskName, task)
}
// Verify tasks were created
assert.GreaterOrEqual(t, registry.GetTaskCount(), 3)
// Stop all tasks
registry.StopAllTasks()
// Verify all tasks are removed
taskCount := registry.GetTaskCount()
assert.Equal(t, 0, taskCount, "all tasks should be stopped")
})
t.Run("ResetGlobalTaskRegistry clears registry", func(t *testing.T) {
ResetGlobalTaskRegistry()
registry := GetGlobalTaskRegistry()
// Create a task
task := NewBackgroundTask(
"reset-test-task",
100*time.Millisecond,
func() {},
newNoOpLogger(),
)
registry.RegisterTask("reset-test-task", task)
// Reset
ResetGlobalTaskRegistry()
// Get new registry
newRegistry := GetGlobalTaskRegistry()
assert.Equal(t, 0, newRegistry.GetTaskCount(), "new registry should be empty")
})
}
// TestBackgroundTaskLifecycle tests background task lifecycle
func TestBackgroundTaskLifecycle(t *testing.T) {
t.Run("Start begins task execution", func(t *testing.T) {
if testing.Short() {
t.Skip("Skipping background task test in short mode")
}
ResetGlobalTaskRegistry()
defer ResetGlobalTaskRegistry()
executed := false
var mu sync.Mutex
task := NewBackgroundTask(
"lifecycle-test",
50*time.Millisecond,
func() {
mu.Lock()
executed = true
mu.Unlock()
},
newNoOpLogger(),
)
// Start task
task.Start()
// Wait for execution
time.Sleep(GetTestDuration(100 * time.Millisecond))
// Stop task
task.Stop()
// Verify it executed
mu.Lock()
wasExecuted := executed
mu.Unlock()
assert.True(t, wasExecuted, "task should have executed")
})
t.Run("Stop halts task execution", func(t *testing.T) {
if testing.Short() {
t.Skip("Skipping background task test in short mode")
}
ResetGlobalTaskRegistry()
defer ResetGlobalTaskRegistry()
execCount := 0
var mu sync.Mutex
task := NewBackgroundTask(
"stop-test",
30*time.Millisecond,
func() {
mu.Lock()
execCount++
mu.Unlock()
},
newNoOpLogger(),
)
// Start task
task.Start()
// Let it run a few times
time.Sleep(GetTestDuration(100 * time.Millisecond))
// Stop task
task.Stop()
// Record count
mu.Lock()
countAfterStop := execCount
mu.Unlock()
// Wait more
time.Sleep(GetTestDuration(100 * time.Millisecond))
// Count should not increase
mu.Lock()
finalCount := execCount
mu.Unlock()
assert.Equal(t, countAfterStop, finalCount, "task should not execute after stop")
})
t.Run("Multiple Start calls are safe", func(t *testing.T) {
if testing.Short() {
t.Skip("Skipping background task test in short mode")
}
ResetGlobalTaskRegistry()
defer ResetGlobalTaskRegistry()
execCount := 0
var mu sync.Mutex
task := NewBackgroundTask(
"multi-start-test",
100*time.Millisecond,
func() {
mu.Lock()
execCount++
mu.Unlock()
},
newNoOpLogger(),
)
// Multiple starts should be safe
task.Start()
task.Start()
task.Start()
// Wait a bit
time.Sleep(GetTestDuration(50 * time.Millisecond))
// Stop task
task.Stop()
// Should have executed, but only one goroutine
mu.Lock()
count := execCount
mu.Unlock()
assert.GreaterOrEqual(t, count, 0, "task should have executed at least once")
})
t.Run("Multiple Stop calls are safe", func(t *testing.T) {
ResetGlobalTaskRegistry()
defer ResetGlobalTaskRegistry()
task := NewBackgroundTask(
"multi-stop-test",
100*time.Millisecond,
func() {},
newNoOpLogger(),
)
// Start and stop
task.Start()
time.Sleep(GetTestDuration(20 * time.Millisecond))
// Multiple stops should be safe
assert.NotPanics(t, func() {
task.Stop()
task.Stop()
task.Stop()
})
})
}
// TestMemoryMonitorIntegration tests memory monitor integration
func TestMemoryMonitorIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping memory monitor integration test in short mode")
}
t.Run("monitoring updates stats", func(t *testing.T) {
ResetGlobalMemoryMonitor()
ResetGlobalTaskRegistry()
defer ResetGlobalMemoryMonitor()
defer ResetGlobalTaskRegistry()
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
defer monitor.StopMonitoring()
// Start monitoring
ctx := context.Background()
monitor.StartMonitoring(ctx, 50*time.Millisecond)
// Wait for at least one check
time.Sleep(GetTestDuration(150 * time.Millisecond))
// Get pressure (should be a valid pressure level)
pressure := monitor.GetMemoryPressure()
assert.Contains(t, []MemoryPressureLevel{
MemoryPressureNone,
MemoryPressureLow,
MemoryPressureModerate,
MemoryPressureHigh,
MemoryPressureCritical,
}, pressure, "pressure should be a valid level")
// Stop monitoring
monitor.StopMonitoring()
})
t.Run("global memory monitor singleton", func(t *testing.T) {
ResetGlobalMemoryMonitor()
defer ResetGlobalMemoryMonitor()
monitor1 := GetGlobalMemoryMonitor()
monitor2 := GetGlobalMemoryMonitor()
assert.Equal(t, monitor1, monitor2, "should return same instance")
})
}
// TestMemoryStatsCollection tests memory statistics collection
func TestMemoryStatsCollection(t *testing.T) {
t.Run("GetCurrentStats returns valid data", func(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
stats := monitor.GetCurrentStats()
assert.NotNil(t, stats)
assert.Greater(t, stats.HeapAllocBytes, uint64(0))
assert.Greater(t, stats.HeapSysBytes, uint64(0))
assert.Greater(t, stats.NumGoroutines, 0)
assert.False(t, stats.Timestamp.IsZero())
})
t.Run("Stats include memory pressure", func(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
stats := monitor.GetCurrentStats()
// Should calculate and include pressure level
assert.NotNil(t, stats.MemoryPressure)
assert.Contains(t, []MemoryPressureLevel{
MemoryPressureNone,
MemoryPressureLow,
MemoryPressureModerate,
MemoryPressureHigh,
MemoryPressureCritical,
}, stats.MemoryPressure)
})
t.Run("TriggerGC reduces memory", func(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
// Allocate some memory
_ = make([]byte, 1024*1024) // 1MB
// Get stats before GC
beforeStats := monitor.GetCurrentStats()
// Trigger GC
monitor.TriggerGC()
// Get stats after GC
afterStats := monitor.GetCurrentStats()
// After GC should have different stats
assert.NotEqual(t, beforeStats.LastGCTime, afterStats.LastGCTime)
})
}
-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)
}
}
+2 -18
View File
@@ -61,22 +61,6 @@ func (cm *CacheManager) GetSharedJWKCache() JWKCacheInterface {
return &JWKCache{cache: cm.manager.GetJWKCache()}
}
// GetSharedIntrospectionCache returns the shared token introspection cache
// for caching OAuth 2.0 Token Introspection (RFC 7662) results
func (cm *CacheManager) GetSharedIntrospectionCache() CacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &CacheInterfaceWrapper{cache: cm.manager.GetIntrospectionCache()}
}
// GetSharedTokenTypeCache returns the shared token type cache
// for caching token type detection results to improve performance
func (cm *CacheManager) GetSharedTokenTypeCache() CacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &CacheInterfaceWrapper{cache: cm.manager.GetTokenTypeCache()}
}
// Close gracefully shuts down all cache components
func (cm *CacheManager) Close() error {
cm.mu.Lock()
@@ -99,7 +83,7 @@ type CacheInterfaceWrapper struct {
// Set stores a value
func (c *CacheInterfaceWrapper) Set(key string, value interface{}, ttl time.Duration) {
_ = c.cache.Set(key, value, ttl) // Safe to ignore: cache set failures are non-critical
c.cache.Set(key, value, ttl)
}
// Get retrieves a value
@@ -126,7 +110,7 @@ func (c *CacheInterfaceWrapper) Cleanup() {
func (c *CacheInterfaceWrapper) Close() {
// Close the underlying cache to stop goroutines
if c.cache != nil {
_ = c.cache.Close() // Safe to ignore: closing cache is best-effort during shutdown
c.cache.Close()
}
}
-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)
}
}
+994 -865
View File
File diff suppressed because it is too large Load Diff
+121 -421
View File
@@ -2,10 +2,12 @@
package config
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const (
@@ -47,111 +49,27 @@ type Logger interface {
// Config represents the configuration for the OIDC middleware
type Config struct {
ProviderURL string `json:"providerUrl"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
CallbackURL string `json:"callbackUrl"`
LogoutURL string `json:"logoutUrl"`
PostLogoutRedirectURI string `json:"postLogoutRedirectUri"`
SessionEncryptionKey string `json:"sessionEncryptionKey"`
ForceHTTPS bool `json:"forceHttps"`
LogLevel string `json:"logLevel"`
Scopes []string `json:"scopes"`
OverrideScopes bool `json:"overrideScopes"`
AllowedUsers []string `json:"allowedUsers"`
AllowedUserDomains []string `json:"allowedUserDomains"`
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
ExcludedURLs []string `json:"excludedUrls"`
EnablePKCE bool `json:"enablePkce"`
RateLimit int `json:"rateLimit"`
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
Headers []HeaderConfig `json:"headers"`
HTTPClient *http.Client `json:"-"`
CookieDomain string `json:"cookieDomain"`
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"`
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"`
}
// HeaderConfig represents header template configuration
@@ -160,59 +78,6 @@ type HeaderConfig struct {
Value string `json:"value"`
}
// SecurityHeadersConfig configures security headers for the plugin
type SecurityHeadersConfig struct {
// Enable security headers (default: true)
Enabled bool `json:"enabled"`
// Security profile: "default", "strict", "development", "api", or "custom"
Profile string `json:"profile"`
// Content Security Policy
ContentSecurityPolicy string `json:"contentSecurityPolicy,omitempty"`
// HSTS settings
StrictTransportSecurity bool `json:"strictTransportSecurity"`
StrictTransportSecurityMaxAge int `json:"strictTransportSecurityMaxAge"` // seconds
StrictTransportSecuritySubdomains bool `json:"strictTransportSecuritySubdomains"`
StrictTransportSecurityPreload bool `json:"strictTransportSecurityPreload"`
// Frame options: "DENY", "SAMEORIGIN", or "ALLOW-FROM uri"
FrameOptions string `json:"frameOptions,omitempty"`
// Content type options (default: "nosniff")
ContentTypeOptions string `json:"contentTypeOptions,omitempty"`
// XSS protection (default: "1; mode=block")
XSSProtection string `json:"xssProtection,omitempty"`
// Referrer policy
ReferrerPolicy string `json:"referrerPolicy,omitempty"`
// Permissions policy
PermissionsPolicy string `json:"permissionsPolicy,omitempty"`
// Cross-origin settings
CrossOriginEmbedderPolicy string `json:"crossOriginEmbedderPolicy,omitempty"`
CrossOriginOpenerPolicy string `json:"crossOriginOpenerPolicy,omitempty"`
CrossOriginResourcePolicy string `json:"crossOriginResourcePolicy,omitempty"`
// CORS settings
CORSEnabled bool `json:"corsEnabled"`
CORSAllowedOrigins []string `json:"corsAllowedOrigins,omitempty"`
CORSAllowedMethods []string `json:"corsAllowedMethods,omitempty"`
CORSAllowedHeaders []string `json:"corsAllowedHeaders,omitempty"`
CORSAllowCredentials bool `json:"corsAllowCredentials"`
CORSMaxAge int `json:"corsMaxAge"` // seconds
// Custom headers (in addition to standard security headers)
CustomHeaders map[string]string `json:"customHeaders,omitempty"`
// Security features
DisableServerHeader bool `json:"disableServerHeader"`
DisablePoweredByHeader bool `json:"disablePoweredByHeader"`
}
// NewSettings creates a new Settings instance
func NewSettings(logger Logger) *Settings {
return &Settings{
@@ -230,282 +95,117 @@ func CreateConfig() *Config {
RefreshGracePeriodSeconds: 60,
Scopes: []string{"openid", "profile", "email"},
Headers: []HeaderConfig{},
SecurityHeaders: createDefaultSecurityConfig(),
}
}
// createDefaultSecurityConfig creates a default security headers configuration
func createDefaultSecurityConfig() *SecurityHeadersConfig {
return &SecurityHeadersConfig{
Enabled: true,
Profile: "default",
// Default security headers
StrictTransportSecurity: true,
StrictTransportSecurityMaxAge: 31536000, // 1 year
StrictTransportSecuritySubdomains: true,
StrictTransportSecurityPreload: true,
FrameOptions: "DENY",
ContentTypeOptions: "nosniff",
XSSProtection: "1; mode=block",
ReferrerPolicy: "strict-origin-when-cross-origin",
// CORS disabled by default
CORSEnabled: false,
CORSAllowedMethods: []string{"GET", "POST", "OPTIONS"},
CORSAllowedHeaders: []string{"Authorization", "Content-Type"},
CORSAllowCredentials: false,
CORSMaxAge: 86400, // 24 hours
// Security features
DisableServerHeader: true,
DisablePoweredByHeader: true,
}
// InitializeTraefikOidc would initialize and configure a new TraefikOidc instance
// This functionality has been moved to the main New function in main.go
// This function is kept for compatibility but should not be used
func (s *Settings) InitializeTraefikOidc(ctx context.Context, next http.Handler, config *Config, name string) (interface{}, error) {
return nil, fmt.Errorf("InitializeTraefikOidc is deprecated - use New function from main package instead")
}
// 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
//lint:ignore U1000 Kept for backward compatibility
func (s *Settings) setupHeaderTemplates(t interface{}, config *Config, logger Logger) error {
logger.Debug("setupHeaderTemplates is deprecated")
return nil
}
// 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"
//lint:ignore U1000 May be needed for future background service management
func (s *Settings) startBackgroundServices(ctx context.Context, logger Logger) {
startReplayCacheCleanup(ctx, logger)
// Start memory monitoring for leak detection and performance insights
memoryMonitor := GetGlobalMemoryMonitor()
memoryMonitor.StartMonitoring(ctx, 60*time.Second) // Monitor every minute
logger.Debug("Started global memory monitoring")
}
// 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"
}
// Utility functions
// 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")
//lint:ignore U1000 May be needed for future scope processing
func deduplicateScopes(scopes []string) []string {
seen := make(map[string]bool)
result := []string{}
for _, scope := range scopes {
if !seen[scope] {
seen[scope] = true
result = append(result, scope)
}
}
return result
}
// 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
}
}
}
//lint:ignore U1000 May be needed for future scope merging operations
func mergeScopes(defaultScopes, userScopes []string) []string {
result := make([]string, len(defaultScopes))
copy(result, defaultScopes)
return append(result, userScopes...)
}
//lint:ignore U1000 May be needed for future utility operations
func createStringMap(items []string) map[string]struct{} {
result := make(map[string]struct{})
for _, item := range items {
result[item] = struct{}{}
}
return result
}
//lint:ignore U1000 May be needed for future case-insensitive operations
func createCaseInsensitiveStringMap(items []string) map[string]struct{} {
result := make(map[string]struct{})
for _, item := range items {
result[strings.ToLower(item)] = struct{}{}
}
return result
}
//lint:ignore U1000 May be needed for future test environment detection
func isTestMode() bool {
// This function should be implemented based on environment detection logic
return false
}
// External dependencies that need to be provided
// TraefikOidc struct is defined in types.go
// These functions need to be provided by external packages
func NewLogger(level string) Logger { return nil }
func CreateDefaultHTTPClient() *http.Client { return nil }
func CreateTokenHTTPClient() *http.Client { return nil }
func GetGlobalCacheManager(*sync.WaitGroup) CacheManager { return nil }
func NewSessionManager(string, bool, string, Logger) (SessionManager, error) { return nil, nil }
func NewErrorRecoveryManager(Logger) ErrorRecoveryManager { return nil }
//lint:ignore U1000 May be needed for future token claim extraction
func extractClaims(string) (map[string]interface{}, error) { return nil, nil }
//lint:ignore U1000 May be needed for future replay attack prevention
func startReplayCacheCleanup(context.Context, Logger) {}
func GetGlobalMemoryMonitor() MemoryMonitor { return nil }
// Interfaces for external dependencies
type CacheManager interface {
GetSharedTokenBlacklist() CacheInterface
GetSharedTokenCache() *TokenCache
GetSharedMetadataCache() *MetadataCache
GetSharedJWKCache() JWKCacheInterface
Close() error
}
type SessionManager interface{}
type ErrorRecoveryManager interface{}
type MemoryMonitor interface {
StartMonitoring(ctx context.Context, interval time.Duration)
}
type CacheInterface interface {
Set(key string, value interface{}, ttl time.Duration)
Get(key string) (interface{}, bool)
Delete(key string)
SetMaxSize(size int)
Cleanup()
Close()
}
type TokenCache struct{}
type MetadataCache struct{}
type JWKCacheInterface interface{}
-424
View File
@@ -1,424 +0,0 @@
# Auth0 Audience Validation Guide
## Overview
This guide explains how to configure audience validation for Auth0 and other OIDC providers that support custom API audiences. It covers three common Auth0 scenarios and how to configure the middleware for maximum security.
## Table of Contents
1. [Understanding Audiences](#understanding-audiences)
2. [The Three Auth0 Scenarios](#the-three-auth0-scenarios)
3. [Configuration Options](#configuration-options)
4. [Security Recommendations](#security-recommendations)
5. [Troubleshooting](#troubleshooting)
---
## Understanding Audiences
### What is an Audience?
The **audience** (`aud`) claim in a JWT identifies the intended recipient of the token. Per OAuth 2.0 and OIDC specifications:
- **ID Tokens**: MUST have `aud = client_id` (per OIDC Core 1.0 spec)
- **Access Tokens**: Can have custom audiences (e.g., API identifiers)
### Why Does This Matter?
Proper audience validation prevents **token confusion attacks** where a token intended for one API is used to access another API.
---
## The Three Auth0 Scenarios
### Scenario 1: Custom API Audience ✅ **RECOMMENDED**
**Configuration:**
```yaml
audience: "https://my-api.example.com" # Your API identifier from Auth0
```
**What Happens:**
1. Authorization request includes `audience` parameter
2. Auth0 issues:
- **ID Token**: `aud = client_id`
- **Access Token**: `aud = ["https://issuer/userinfo", "https://my-api.example.com"]`
3. Middleware validates:
- ID tokens against `client_id`
- Access tokens against custom audience
**Result:** ✅ Fully secure, OIDC compliant
---
### Scenario 2: Default Audience (No Custom API) ⚠️ **USE WITH CAUTION**
**Configuration:**
```yaml
# audience not specified (defaults to client_id)
```
**What Happens:**
1. Authorization request WITHOUT `audience` parameter
2. Auth0 issues:
- **ID Token**: `aud = client_id`
- **Access Token**: `aud = ["https://issuer/userinfo", "default_api"]` (no `client_id`)
3. Access token validation fails (audience mismatch)
4. Middleware falls back to ID token validation
**Security Warning:**
```
⚠️⚠️⚠️ SECURITY WARNING: Falling back to ID token validation despite access token audience mismatch!
⚠️ This could allow tokens intended for different APIs to grant access
⚠️ Set strictAudienceValidation=true to enforce proper audience validation
⚠️ See: https://github.com/lukaszraczylo/traefikoidc/issues/74
```
**Recommended Fix:**
```yaml
strictAudienceValidation: true # Reject sessions with audience mismatch
```
**Result:**
- Default: ⚠️ Works but logs security warnings
- With strict mode: ✅ Secure (rejects mismatched tokens)
---
### Scenario 3: Opaque Access Tokens ✅ **SUPPORTED**
**Configuration:**
```yaml
allowOpaqueTokens: true # Enable opaque token support
requireTokenIntrospection: true # Require introspection (recommended)
```
**What Happens:**
1. Auth0 issues opaque (non-JWT) access token
2. Middleware detects opaque token (not 3 parts separated by dots)
3. Uses OAuth 2.0 Token Introspection (RFC 7662) to validate
4. Falls back to ID token if introspection unavailable (unless `requireTokenIntrospection=true`)
**Requirements:**
- Provider must support `introspection_endpoint` in OIDC discovery
- Client must have introspection permissions
**Result:** ✅ Secure with introspection, ⚠️ risky without
---
## Configuration Options
### Audience Settings
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `audience` | string | `client_id` | Expected audience for access tokens |
**Example:**
```yaml
# .traefik.yml
http:
middlewares:
oidc-auth:
plugin:
traefikoidc:
audience: "https://my-api.example.com"
```
---
### Security Mode Settings
#### `strictAudienceValidation`
**Type:** boolean
**Default:** `false`
**Recommended:** `true` for production
**What it does:**
- When `true`: Rejects sessions if access token audience doesn't match (prevents Scenario 2)
- When `false`: Logs warnings but allows fallback to ID token (backward compatible)
**Example:**
```yaml
strictAudienceValidation: true
```
**When to use:**
- ✅ Always use in production environments
- ✅ When you have custom API audiences configured in Auth0
- ⚠️ May break existing deployments relying on Scenario 2 behavior
---
#### `allowOpaqueTokens`
**Type:** boolean
**Default:** `false`
**What it does:**
- When `true`: Accepts opaque (non-JWT) access tokens
- When `false`: Only accepts JWT access tokens
**Example:**
```yaml
allowOpaqueTokens: true
```
**When to use:**
- ✅ When Auth0 issues opaque tokens (no default API configured)
- ✅ When using Auth0 Management API tokens
- ⚠️ Requires introspection endpoint for security
---
#### `requireTokenIntrospection`
**Type:** boolean
**Default:** `false`
**Recommended:** `true` when `allowOpaqueTokens=true`
**What it does:**
- When `true`: Rejects opaque tokens if introspection fails or endpoint unavailable
- When `false`: Falls back to ID token validation for opaque tokens
**Example:**
```yaml
allowOpaqueTokens: true
requireTokenIntrospection: true
```
**When to use:**
- ✅ Always use when `allowOpaqueTokens=true` for maximum security
- ⚠️ Requires provider to expose introspection endpoint
---
## Security Recommendations
### Recommended Configuration for Auth0
**For APIs with custom audiences (Scenario 1):**
```yaml
audience: "https://my-api.example.com"
strictAudienceValidation: true
allowOpaqueTokens: false
```
**For default Auth0 setup (Scenario 2):**
```yaml
# Don't set audience (defaults to client_id)
strictAudienceValidation: true # Enforce proper configuration
```
**For opaque tokens (Scenario 3):**
```yaml
allowOpaqueTokens: true
requireTokenIntrospection: true
strictAudienceValidation: true
```
### Security Best Practices
1. ✅ **Always set `strictAudienceValidation: true` in production**
2. ✅ **Configure custom API audiences in Auth0 dashboard**
3. ✅ **Use `requireTokenIntrospection: true` if accepting opaque tokens**
4. ✅ **Monitor logs for security warnings**
5. ❌ **Don't rely on Scenario 2 fallback behavior**
---
## Troubleshooting
### "Access token validation failed due to audience mismatch"
**Symptom:**
```
⚠️ SCENARIO 2 DETECTED: Access token validation failed due to audience mismatch
```
**Cause:** Access token audience doesn't match configured audience
**Solutions:**
1. **Configure correct audience:**
```yaml
audience: "https://your-api-identifier" # From Auth0 API settings
```
2. **Update Auth0 authorization request:**
- Ensure `audience` parameter is included in authorize URL
- Middleware automatically adds this when `audience != client_id`
3. **Accept the behavior (not recommended):**
```yaml
strictAudienceValidation: false # Logs warnings but allows
```
---
### "Opaque token detected but allowOpaqueTokens=false"
**Symptom:**
```
⚠️ Opaque access token detected but allowOpaqueTokens=false
```
**Cause:** Auth0 issued non-JWT access token but middleware not configured to accept them
**Solutions:**
1. **Enable opaque tokens:**
```yaml
allowOpaqueTokens: true
requireTokenIntrospection: true
```
2. **Configure Auth0 to issue JWT access tokens:**
- Create an API in Auth0 dashboard
- Set API identifier as `audience` in configuration
---
### "Introspection endpoint not available"
**Symptom:**
```
⚠️ Opaque tokens enabled but no introspection endpoint available from provider
```
**Cause:** Auth0 provider metadata doesn't include `introspection_endpoint`
**Solutions:**
1. **Check provider discovery:**
```bash
curl https://YOUR_DOMAIN/.well-known/openid-configuration
```
Look for `introspection_endpoint`
2. **Disable required introspection (less secure):**
```yaml
allowOpaqueTokens: true
requireTokenIntrospection: false # Falls back to ID token
```
3. **Use JWT access tokens instead** (recommended)
---
### "Token introspection required but endpoint not available"
**Symptom:**
```
❌ SECURITY: Opaque token rejected (introspection required but failed)
```
**Cause:** `requireTokenIntrospection=true` but provider doesn't support it
**Solutions:**
1. **Disable required introspection:**
```yaml
requireTokenIntrospection: false
```
2. **Configure Auth0 to issue JWT tokens** (better solution)
---
## Advanced Topics
### Token Type Detection
The middleware uses a sophisticated 6-step detection algorithm:
1. **RFC 9068 `typ` header**: `at+jwt` → Access Token
2. **Explicit type claims**: `token_use`, `token_type`
3. **`scope` claim**: Present → Access Token
4. **`nonce` claim**: Present → ID Token (OIDC spec)
5. **Audience check**: `aud == client_id` only → ID Token
6. **Default**: Access Token
### OAuth 2.0 Token Introspection (RFC 7662)
When opaque tokens are detected:
1. Middleware calls provider's `introspection_endpoint`
2. Authenticates using client credentials
3. Receives response with `active` status and claims
4. Caches result for 5 minutes (configurable via TTL)
5. Validates expiration, not-before, and audience if present
**Cache behavior:**
- Cache key: Token hash
- TTL: 5 minutes or token expiry (whichever is shorter)
- Reduces introspection requests for frequently used tokens
---
## Reference Links
- [GitHub Issue #74](https://github.com/lukaszraczylo/traefikoidc/issues/74) - Original Auth0 audience discussion
- [OIDC Core 1.0 Spec](https://openid.net/specs/openid-connect-core-1_0.html) - ID Token requirements
- [OAuth 2.0 RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) - OAuth 2.0 specification
- [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) - OAuth 2.0 Token Introspection
- [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068) - JWT Access Token Profile
- [Auth0 API Authorization](https://auth0.com/docs/secure/tokens/access-tokens) - Auth0 audience documentation
---
## Migration Guide
### From Previous Versions
**If you're upgrading from a version without these features:**
1. **No action required for default behavior** - backward compatible
2. **Recommended: Enable strict mode gradually**
```yaml
# Step 1: Enable and monitor logs
strictAudienceValidation: false # Default
# Step 2: After confirming no warnings, enable
strictAudienceValidation: true
```
3. **For opaque tokens: Enable explicitly**
```yaml
allowOpaqueTokens: true
requireTokenIntrospection: true
```
### Testing Your Configuration
1. **Check logs for warnings:**
```bash
# Look for Scenario 2 warnings
grep "SCENARIO 2 DETECTED" /var/log/traefik.log
# Look for opaque token warnings
grep "Opaque" /var/log/traefik.log
```
2. **Test with curl:**
```bash
# Get token from Auth0
ACCESS_TOKEN="your_access_token"
# Test request
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
https://your-app.example.com/api
```
3. **Monitor for security warnings in production logs**
---
## Support
For issues or questions:
- GitHub Issues: https://github.com/lukaszraczylo/traefikoidc/issues
- Security issues: See SECURITY.md for responsible disclosure
---
**Last Updated:** 2025-01-09
**Version:** 0.7.8+
-1
View File
@@ -1 +0,0 @@
traefikoidc.raczylo.com
-955
View File
@@ -1,955 +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: ["[email protected]", "[email protected]"]
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
---
## 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: ["[email protected]", "[email protected]"]
# 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
```
-831
View File
@@ -1,831 +0,0 @@
<!doctype html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Traefik OIDC - OpenID Connect Authentication Middleware</title>
<meta
name="description"
content="Production-ready OIDC authentication middleware for Traefik. Supports Google, Azure AD, Auth0, Okta, Keycloak, and more. Drop-in replacement for oauth2-proxy."
/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class'
}
</script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<style>
body { font-family: "Inter", sans-serif; }
code, pre { font-family: "JetBrains Mono", monospace; }
.theme-transition {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.animate-fade-in-up { animation: fadeInUp 0.6s ease-out; }
.animate-float { animation: float 3s ease-in-out infinite; }
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .glass {
background: rgba(17, 24, 39, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.gradient-text {
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark .gradient-text {
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 50%, #22d3ee 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.1); }
.dark .shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.4); }
html { scroll-behavior: smooth; }
</style>
<script>
if (localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
</script>
</head>
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 theme-transition">
<!-- Navigation -->
<nav class="fixed w-full glass shadow-modern z-50 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="flex justify-between h-16 items-center">
<a href="#" class="flex items-center hover:opacity-80 transition-opacity duration-300 gap-2">
<i class="fas fa-shield-alt text-2xl text-blue-500"></i>
<span class="text-xl font-bold gradient-text">Traefik OIDC</span>
</a>
<div class="hidden md:flex space-x-6">
<a href="#features" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Features</a>
<a href="#providers" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Providers</a>
<a href="#installation" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Installation</a>
<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="#security" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Security</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">
<i class="fas fa-moon dark:hidden text-xl"></i>
<i class="fas fa-sun hidden dark:inline text-xl"></i>
</button>
<a href="https://github.com/lukaszraczylo/traefikoidc" target="_blank" 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="View on GitHub">
<i class="fab fa-github text-xl"></i>
</a>
<button id="mobile-menu-toggle" class="md:hidden 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 menu">
<i class="fas fa-bars text-xl" id="menu-open-icon"></i>
<i class="fas fa-times text-xl hidden" id="menu-close-icon"></i>
</button>
</div>
</div>
</div>
<div id="mobile-menu" class="hidden md:hidden border-t border-gray-200 dark:border-gray-700">
<div class="px-4 py-3 space-y-1 bg-white dark:bg-gray-800">
<a href="#features" 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">Features</a>
<a href="#providers" 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">Providers</a>
<a href="#installation" 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">Installation</a>
<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="#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>
</div>
</div>
</nav>
<!-- Hero Section -->
<section class="relative pt-24 sm:pt-32 pb-12 sm:pb-20 overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-blue-50 via-purple-50 to-cyan-50 dark:from-gray-900 dark:via-blue-900/20 dark:to-purple-900/20 theme-transition"></div>
<div class="absolute top-0 -left-4 w-72 h-72 bg-blue-300 dark:bg-blue-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float"></div>
<div class="absolute top-0 -right-4 w-72 h-72 bg-purple-300 dark:bg-purple-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 1s;"></div>
<div class="absolute -bottom-8 left-20 w-72 h-72 bg-cyan-300 dark:bg-cyan-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 2s;"></div>
<div class="relative max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center">
<div class="mb-6 sm:mb-8 flex justify-center animate-fade-in-up">
<div class="w-24 h-24 sm:w-32 sm:h-32 rounded-2xl bg-gradient-to-br from-blue-500 via-purple-500 to-cyan-500 flex items-center justify-center shadow-xl animate-float">
<i class="fas fa-shield-alt text-white text-4xl sm:text-5xl"></i>
</div>
</div>
<h1 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-100 mb-4 sm:mb-6 leading-tight animate-fade-in-up" style="animation-delay: 0.1s;">
OpenID Connect for<br /><span class="gradient-text">Traefik</span>
</h1>
<p class="text-base sm:text-lg md:text-xl text-gray-600 dark:text-gray-300 mb-8 sm:mb-10 max-w-2xl mx-auto leading-relaxed px-4 animate-fade-in-up" style="animation-delay: 0.2s;">
Production-ready OIDC authentication middleware. Drop-in replacement for oauth2-proxy and forward-auth with support for 9+ identity providers.
</p>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center mb-8 sm:mb-12 px-4 animate-fade-in-up" style="animation-delay: 0.3s;">
<a href="#installation" class="group relative bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center shadow-lg hover:shadow-xl hover:scale-105">
<span class="relative z-10">Get Started</span>
</a>
<a href="https://github.com/lukaszraczylo/traefikoidc" class="group glass hover:shadow-lg text-gray-900 dark:text-gray-100 px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center hover:scale-105">
<i class="fab fa-github mr-2"></i>View on GitHub
</a>
</div>
<div class="flex flex-wrap justify-center gap-2 sm:gap-4 text-sm px-4 animate-fade-in-up" style="animation-delay: 0.4s;">
<img src="https://img.shields.io/github/v/release/lukaszraczylo/traefikoidc" alt="Version" class="h-5" />
<img src="https://img.shields.io/github/license/lukaszraczylo/traefikoidc" alt="License" class="h-5" />
<img src="https://goreportcard.com/badge/github.com/lukaszraczylo/traefikoidc" alt="Go Report" class="h-5" />
<img src="https://codecov.io/gh/lukaszraczylo/traefikoidc/branch/main/graph/badge.svg" alt="Coverage" class="h-5" />
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section id="features" 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">Features</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Enterprise-grade authentication for your Traefik deployments</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-globe text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Universal Provider Support</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Works with Google, Azure AD, Auth0, Okta, Keycloak, Cognito, GitLab, and any OIDC-compliant provider</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-magic text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Auto-Detection</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Automatically detects and configures provider-specific settings from OIDC discovery</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-cyan-500 to-cyan-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-user-plus text-white"></i>
</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>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-filter text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Automatic Scope Filtering</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Intelligently filters OAuth scopes based on provider capabilities from discovery documents</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-shield-alt text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Security Headers</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Comprehensive security headers including CORS, CSP, HSTS, and customizable profiles</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-rose-500 to-rose-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-user-lock text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Domain & User Restrictions</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Limit access to specific email domains, individual users, or role-based groups</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-indigo-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-users-cog text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Role-Based Access</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Restrict access based on roles and groups from OIDC claims</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-teal-500 to-teal-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-sync-alt text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Automatic Token Refresh</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Secure session handling with proactive token refresh before expiry</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-pink-500 to-pink-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-tachometer-alt text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Rate Limiting</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Built-in protection against brute force attacks with configurable limits</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-code text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Custom Headers</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Template-based headers using OIDC claims and tokens for downstream services</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-slate-500 to-slate-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-key text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">PKCE Support</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Proof Key for Code Exchange for enhanced security in authorization code flow</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-violet-500 to-violet-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-memory text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Memory Management</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Bounded caches with LRU eviction, automatic cleanup, and zero goroutine leaks</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Providers Section -->
<section id="providers" 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">
<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">Supported Providers</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Works with all major identity providers out of the box</p>
</div>
<!-- Provider Grid -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-red-500 to-yellow-500 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<i class="fab fa-google text-white text-xl"></i>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Google</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<i class="fab fa-microsoft text-white text-xl"></i>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Azure AD</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<span class="text-white font-bold text-sm">A0</span>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Auth0</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-indigo-500 to-indigo-600 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<span class="text-white font-bold text-sm">OK</span>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Okta</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-red-600 to-red-700 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<span class="text-white font-bold text-sm">KC</span>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">Keycloak</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<i class="fab fa-aws text-white text-xl"></i>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">AWS Cognito</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-orange-600 to-orange-700 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<i class="fab fa-gitlab text-white text-xl"></i>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">GitLab</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full OIDC</p>
</div>
<div class="glass p-4 rounded-xl text-center group hover:shadow-lg transition-all duration-300">
<div class="w-12 h-12 mx-auto mb-3 rounded-xl bg-gradient-to-br from-gray-700 to-gray-800 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<i class="fab fa-github text-white text-xl"></i>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm">GitHub</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">OAuth 2.0</p>
</div>
</div>
<!-- Comparison Table -->
<div class="glass rounded-xl overflow-hidden shadow-modern">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
<tr>
<th class="px-4 py-3 text-left font-semibold">Feature</th>
<th class="px-4 py-3 text-center font-semibold">Google</th>
<th class="px-4 py-3 text-center font-semibold">Azure AD</th>
<th class="px-4 py-3 text-center font-semibold">Auth0</th>
<th class="px-4 py-3 text-center font-semibold">Okta</th>
<th class="px-4 py-3 text-center font-semibold">Keycloak</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">ID Tokens</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Refresh Tokens</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Auto-Configuration</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Custom Claims</td>
<td class="px-4 py-3 text-center text-gray-400">Limited</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Group/Role Claims</td>
<td class="px-4 py-3 text-center text-gray-400">Limited</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Self-Hosted</td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Installation Section -->
<section id="installation" 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">Installation</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Get started in under 5 minutes</p>
</div>
<div class="max-w-3xl mx-auto space-y-6">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<span class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold mr-3">1</span>
Enable the Plugin
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm">Add to your Traefik static configuration:</p>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code># traefik.yml
experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v0.7.10</code></pre>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<span class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold mr-3">2</span>
Configure the Middleware
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm">Create your middleware configuration:</p>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code># dynamic/middleware.yml
http:
middlewares:
oidc-auth:
plugin:
traefikoidc:
providerURL: "https://accounts.google.com"
clientID: "your-client-id"
clientSecret: "your-client-secret"
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-byte-secret-key-here!!"
scopes:
- "openid"
- "profile"
- "email"</code></pre>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<span class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold mr-3">3</span>
Apply to Your Routes
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm">Use the middleware on your services:</p>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code># dynamic/routers.yml
http:
routers:
my-secure-app:
rule: "Host(`app.example.com`)"
service: my-service
middlewares:
- oidc-auth
tls:
certResolver: letsencrypt</code></pre>
</div>
</div>
</div>
</section>
<!-- Configuration Section -->
<section id="configuration" 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">
<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">Configuration</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Flexible options for any deployment scenario</p>
</div>
<div class="max-w-4xl mx-auto space-y-6">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Required Parameters</h3>
<div class="overflow-x-auto">
<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">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">providerURL</code></td>
<td class="py-2 px-3">Base URL of your OIDC provider</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">clientID</code></td>
<td class="py-2 px-3">OAuth 2.0 client identifier</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">clientSecret</code></td>
<td class="py-2 px-3">OAuth 2.0 client secret</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">sessionEncryptionKey</code></td>
<td class="py-2 px-3">32+ byte key for session encryption</td>
</tr>
<tr>
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">callbackURL</code></td>
<td class="py-2 px-3">OAuth callback path (e.g., /oauth2/callback)</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-4">Popular Optional Parameters</h3>
<div class="overflow-x-auto">
<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">forceHTTPS</code></td>
<td class="py-2 px-3">false</td>
<td class="py-2 px-3">Required for TLS termination at load balancer</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">allowedUserDomains</code></td>
<td class="py-2 px-3">none</td>
<td class="py-2 px-3">Restrict to specific email domains</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">allowedRolesAndGroups</code></td>
<td class="py-2 px-3">none</td>
<td class="py-2 px-3">Restrict to users with specific roles</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">excludedURLs</code></td>
<td class="py-2 px-3">none</td>
<td class="py-2 px-3">Paths that bypass authentication</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">enablePKCE</code></td>
<td class="py-2 px-3">false</td>
<td class="py-2 px-3">Enable PKCE for enhanced security</td>
</tr>
<tr>
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">rateLimit</code></td>
<td class="py-2 px-3">100</td>
<td class="py-2 px-3">Maximum requests per second</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: Google Workspace with Domain Restriction</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code>http:
middlewares:
google-oidc:
plugin:
traefikoidc:
providerURL: "https://accounts.google.com"
clientID: "1234567890.apps.googleusercontent.com"
clientSecret: "your-client-secret"
callbackURL: "/oauth2/callback"
sessionEncryptionKey: "your-32-byte-encryption-key!!"
allowedUserDomains:
- "yourcompany.com"
- "subsidiary.com"
excludedURLs:
- "/health"
- "/metrics"
- "/api/public"
forceHTTPS: true
logLevel: "info"</code></pre>
</div>
</div>
</div>
</section>
<!-- Security Section -->
<section id="security" 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">Security First</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Built with enterprise security requirements in mind</p>
</div>
<div class="max-w-4xl mx-auto">
<div class="grid md:grid-cols-2 gap-6">
<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-lock mr-2 text-blue-500"></i>
Token Security
</h3>
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
<li>&#8226; JWT signature verification with JWK rotation</li>
<li>&#8226; Replay attack detection via JTI claims</li>
<li>&#8226; Strict audience and issuer validation</li>
<li>&#8226; Automatic token refresh before expiry</li>
<li>&#8226; Token revocation on logout</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-shield-alt mr-2 text-purple-500"></i>
Session Security
</h3>
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
<li>&#8226; AES-256-GCM encrypted session cookies</li>
<li>&#8226; CSRF protection with state parameter</li>
<li>&#8226; Secure, HttpOnly, SameSite cookies</li>
<li>&#8226; Configurable session timeouts</li>
<li>&#8226; Bounded session cache with LRU eviction</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-globe mr-2 text-cyan-500"></i>
Security Headers
</h3>
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
<li>&#8226; Content Security Policy (CSP)</li>
<li>&#8226; HTTP Strict Transport Security (HSTS)</li>
<li>&#8226; X-Frame-Options, X-Content-Type-Options</li>
<li>&#8226; CORS configuration</li>
<li>&#8226; Customizable header profiles</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-tachometer-alt mr-2 text-emerald-500"></i>
Rate Limiting
</h3>
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
<li>&#8226; Configurable request rate limits</li>
<li>&#8226; Protection against brute force attacks</li>
<li>&#8226; Per-client rate limiting</li>
<li>&#8226; Graceful handling of limit exceeded</li>
<li>&#8226; Customizable response codes</li>
</ul>
</div>
</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">
<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">Why Choose Traefik OIDC?</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">A better alternative to oauth2-proxy and forward-auth</p>
</div>
<div class="max-w-4xl mx-auto">
<div class="glass rounded-xl overflow-hidden shadow-modern">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
<tr>
<th class="px-4 py-3 text-left font-semibold">Feature</th>
<th class="px-4 py-3 text-center font-semibold">Traefik OIDC</th>
<th class="px-4 py-3 text-center font-semibold">oauth2-proxy</th>
<th class="px-4 py-3 text-center font-semibold">forward-auth</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Native Plugin</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">No Extra Service</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Auto Provider Detection</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Dynamic Client Registration</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Automatic Scope Filtering</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Built-in Security Headers</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
<td class="px-4 py-3 text-center"><span class="text-red-500">&#10007;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Template Headers</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span></td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-medium">Memory Efficient</td>
<td class="px-4 py-3 text-center"><span class="text-green-500">&#10003;</span> <span class="text-xs text-gray-500">LRU caches</span></td>
<td class="px-4 py-3 text-center"><span class="text-gray-400">Varies</span></td>
<td class="px-4 py-3 text-center"><span class="text-gray-400">Varies</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="py-12 sm:py-16 md:py-20 bg-gradient-to-r from-blue-600 to-purple-600">
<div class="max-w-4xl mx-auto px-4 sm:px-6 text-center">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-white mb-4">Ready to Secure Your Applications?</h2>
<p class="text-lg text-blue-100 mb-8 max-w-2xl mx-auto">
Get started with Traefik OIDC in minutes. Full documentation and examples available on GitHub.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="https://github.com/lukaszraczylo/traefikoidc" class="bg-white hover:bg-gray-100 text-blue-600 px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center shadow-lg hover:shadow-xl hover:scale-105">
<i class="fab fa-github mr-2"></i>View on GitHub
</a>
<a href="https://github.com/lukaszraczylo/traefikoidc/blob/main/README.md" class="bg-transparent border-2 border-white hover:bg-white/10 text-white px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center hover:scale-105">
<i class="fas fa-book mr-2"></i>Documentation
</a>
</div>
</div>
</section>
<!-- Footer -->
<footer class="py-8 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
<div class="flex items-center gap-2">
<i class="fas fa-shield-alt text-xl text-blue-500"></i>
<span class="text-xl font-bold gradient-text">Traefik OIDC</span>
</div>
<div class="flex items-center space-x-6">
<a href="https://github.com/lukaszraczylo/traefikoidc" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">
<i class="fab fa-github text-xl"></i>
</a>
<a href="https://github.com/lukaszraczylo/traefikoidc/issues" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 text-sm">Issues</a>
<a href="https://github.com/lukaszraczylo/traefikoidc/releases" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 text-sm">Releases</a>
</div>
<p class="text-gray-500 dark:text-gray-400 text-sm">Apache 2.0 License</p>
</div>
</div>
</footer>
<script>
// Theme toggle
document.getElementById('theme-toggle').addEventListener('click', function() {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.theme = 'light';
} else {
document.documentElement.classList.add('dark');
localStorage.theme = 'dark';
}
});
// Mobile menu toggle
document.getElementById('mobile-menu-toggle').addEventListener('click', function() {
const menu = document.getElementById('mobile-menu');
const openIcon = document.getElementById('menu-open-icon');
const closeIcon = document.getElementById('menu-close-icon');
menu.classList.toggle('hidden');
openIcon.classList.toggle('hidden');
closeIcon.classList.toggle('hidden');
});
// Close mobile menu on link click
document.querySelectorAll('#mobile-menu a').forEach(link => {
link.addEventListener('click', function() {
document.getElementById('mobile-menu').classList.add('hidden');
document.getElementById('menu-open-icon').classList.remove('hidden');
document.getElementById('menu-close-icon').classList.add('hidden');
});
});
</script>
</body>
</html>
-550
View File
@@ -1,550 +0,0 @@
// Package traefikoidc provides OIDC authentication middleware for Traefik
package traefikoidc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
)
// 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"`
Scope string `json:"scope,omitempty"`
}
// ClientRegistrationError represents an error response from client registration (RFC 7591)
type ClientRegistrationError struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
// 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
registrationResponse *ClientRegistrationResponse
}
// NewDynamicClientRegistrar creates a new dynamic client registrar
func NewDynamicClientRegistrar(
httpClient *http.Client,
logger *Logger,
dcrConfig *DynamicClientRegistrationConfig,
providerURL string,
) *DynamicClientRegistrar {
if logger == nil {
logger = GetSingletonNoOpLogger()
}
return &DynamicClientRegistrar{
httpClient: httpClient,
logger: logger,
config: dcrConfig,
providerURL: providerURL,
}
}
// RegisterClient performs dynamic client registration with the OIDC provider
// It first attempts to load existing credentials from a file 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 {
return nil, fmt.Errorf("dynamic client registration is not enabled")
}
// Try to load existing credentials if persistence is enabled
if r.config.PersistCredentials {
if resp, err := r.loadCredentials(); err == nil && resp != nil {
// Check if credentials are still valid (not expired)
if r.areCredentialsValid(resp) {
r.logger.Info("Loaded existing client credentials from file")
r.mu.Lock()
r.registrationResponse = resp
r.mu.Unlock()
return resp, nil
}
r.logger.Info("Existing credentials expired or invalid, registering new client")
}
}
// Determine registration endpoint
endpoint := registrationEndpoint
if r.config.RegistrationEndpoint != "" {
endpoint = r.config.RegistrationEndpoint
}
if endpoint == "" {
return nil, fmt.Errorf("no registration endpoint available: provider does not support dynamic client registration or endpoint not configured")
}
// Validate the endpoint URL
if !strings.HasPrefix(endpoint, "https://") {
// Allow http only for localhost/development
if !strings.HasPrefix(endpoint, "http://localhost") && !strings.HasPrefix(endpoint, "http://127.0.0.1") {
return nil, fmt.Errorf("registration endpoint must use HTTPS for security")
}
r.logger.Infof("Warning: using insecure HTTP for registration endpoint (development only): %s", endpoint)
}
// Build registration request
reqBody, err := r.buildRegistrationRequest()
if err != nil {
return nil, fmt.Errorf("failed to build registration request: %w", err)
}
r.logger.Debugf("Registering client at endpoint: %s", endpoint)
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create registration request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
// Add Initial Access Token if provided
if r.config.InitialAccessToken != "" {
req.Header.Set("Authorization", "Bearer "+r.config.InitialAccessToken)
}
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("registration request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit
if err != nil {
return nil, fmt.Errorf("failed to read registration response: %w", err)
}
// Handle error responses
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
var regError ClientRegistrationError
if jsonErr := json.Unmarshal(body, &regError); jsonErr == nil && regError.Error != "" {
return nil, fmt.Errorf("registration failed: %s - %s", regError.Error, regError.ErrorDescription)
}
return nil, fmt.Errorf("registration failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse successful response
var regResp ClientRegistrationResponse
if err := json.Unmarshal(body, &regResp); err != nil {
return nil, fmt.Errorf("failed to parse registration response: %w", err)
}
// Validate response
if regResp.ClientID == "" {
return nil, fmt.Errorf("registration response missing client_id")
}
r.logger.Infof("Successfully registered client with ID: %s", regResp.ClientID)
// Cache the response
r.mu.Lock()
r.registrationResponse = &regResp
r.mu.Unlock()
// Persist credentials if enabled
if r.config.PersistCredentials {
if err := r.saveCredentials(&regResp); err != nil {
r.logger.Errorf("Failed to persist client credentials: %v", err)
// Don't fail registration if persistence fails
}
}
return &regResp, nil
}
// buildRegistrationRequest creates the JSON request body for client registration
func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
metadata := r.config.ClientMetadata
if metadata == nil {
metadata = &ClientRegistrationMetadata{}
}
// Build request object
reqData := make(map[string]interface{})
// Required: redirect_uris
if len(metadata.RedirectURIs) > 0 {
reqData["redirect_uris"] = metadata.RedirectURIs
} else {
return nil, fmt.Errorf("redirect_uris is required for client registration")
}
// Optional fields - only include if set
if len(metadata.ResponseTypes) > 0 {
reqData["response_types"] = metadata.ResponseTypes
} else {
// Default to authorization code flow
reqData["response_types"] = []string{"code"}
}
if len(metadata.GrantTypes) > 0 {
reqData["grant_types"] = metadata.GrantTypes
} else {
// Default grant types for authorization code flow
reqData["grant_types"] = []string{"authorization_code", "refresh_token"}
}
if metadata.ApplicationType != "" {
reqData["application_type"] = metadata.ApplicationType
}
if len(metadata.Contacts) > 0 {
reqData["contacts"] = metadata.Contacts
}
if metadata.ClientName != "" {
reqData["client_name"] = metadata.ClientName
}
if metadata.LogoURI != "" {
reqData["logo_uri"] = metadata.LogoURI
}
if metadata.ClientURI != "" {
reqData["client_uri"] = metadata.ClientURI
}
if metadata.PolicyURI != "" {
reqData["policy_uri"] = metadata.PolicyURI
}
if metadata.TOSURI != "" {
reqData["tos_uri"] = metadata.TOSURI
}
if metadata.JWKSURI != "" {
reqData["jwks_uri"] = metadata.JWKSURI
}
if metadata.SubjectType != "" {
reqData["subject_type"] = metadata.SubjectType
}
if metadata.TokenEndpointAuthMethod != "" {
reqData["token_endpoint_auth_method"] = metadata.TokenEndpointAuthMethod
} else {
// Default to client_secret_basic for confidential clients
reqData["token_endpoint_auth_method"] = "client_secret_basic"
}
if metadata.DefaultMaxAge > 0 {
reqData["default_max_age"] = metadata.DefaultMaxAge
}
if metadata.RequireAuthTime {
reqData["require_auth_time"] = metadata.RequireAuthTime
}
if len(metadata.DefaultACRValues) > 0 {
reqData["default_acr_values"] = metadata.DefaultACRValues
}
if metadata.Scope != "" {
reqData["scope"] = metadata.Scope
}
return json.Marshal(reqData)
}
// GetCachedResponse returns the cached registration response
func (r *DynamicClientRegistrar) GetCachedResponse() *ClientRegistrationResponse {
r.mu.RLock()
defer r.mu.RUnlock()
return r.registrationResponse
}
// areCredentialsValid checks if the cached credentials are still valid
func (r *DynamicClientRegistrar) areCredentialsValid(resp *ClientRegistrationResponse) bool {
if resp == nil || resp.ClientID == "" {
return false
}
// Check if secret has expired
if resp.ClientSecretExpiresAt > 0 {
expiresAt := time.Unix(resp.ClientSecretExpiresAt, 0)
// Add 5 minute buffer before expiration
if time.Now().Add(5 * time.Minute).After(expiresAt) {
return false
}
}
return true
}
// credentialsFilePath returns the path for storing credentials
func (r *DynamicClientRegistrar) credentialsFilePath() string {
if r.config.CredentialsFile != "" {
return r.config.CredentialsFile
}
return "/tmp/oidc-client-credentials.json"
}
// saveCredentials persists client credentials to a file
func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationResponse) error {
filePath := r.credentialsFilePath()
data, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal credentials: %w", err)
}
// Write with restrictive permissions (owner read/write only)
if err := os.WriteFile(filePath, data, 0600); err != nil {
return fmt.Errorf("failed to write credentials file: %w", err)
}
r.logger.Debugf("Saved client credentials to %s", filePath)
return nil
}
// loadCredentials loads client credentials from a file
func (r *DynamicClientRegistrar) loadCredentials() (*ClientRegistrationResponse, error) {
filePath := r.credentialsFilePath()
data, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No credentials file exists
}
return nil, fmt.Errorf("failed to read credentials file: %w", err)
}
var resp ClientRegistrationResponse
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("failed to parse credentials file: %w", err)
}
return &resp, nil
}
// UpdateClientRegistration updates an existing client registration using RFC 7592
// This requires the registration_client_uri and registration_access_token from the original registration
func (r *DynamicClientRegistrar) UpdateClientRegistration(ctx context.Context) (*ClientRegistrationResponse, error) {
r.mu.RLock()
cachedResp := r.registrationResponse
r.mu.RUnlock()
if cachedResp == nil {
return nil, fmt.Errorf("no existing registration to update")
}
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
return nil, fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
}
// Build update request
reqBody, err := r.buildRegistrationRequest()
if err != nil {
return nil, fmt.Errorf("failed to build update request: %w", err)
}
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodPut, cachedResp.RegistrationClientURI, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create update request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("update request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read update response: %w", err)
}
// Handle error responses
if resp.StatusCode != http.StatusOK {
var regError ClientRegistrationError
if jsonErr := json.Unmarshal(body, &regError); jsonErr == nil && regError.Error != "" {
return nil, fmt.Errorf("update failed: %s - %s", regError.Error, regError.ErrorDescription)
}
return nil, fmt.Errorf("update failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse successful response
var regResp ClientRegistrationResponse
if err := json.Unmarshal(body, &regResp); err != nil {
return nil, fmt.Errorf("failed to parse update response: %w", err)
}
// Update cache
r.mu.Lock()
r.registrationResponse = &regResp
r.mu.Unlock()
// Persist updated credentials if enabled
if r.config.PersistCredentials {
if err := r.saveCredentials(&regResp); err != nil {
r.logger.Errorf("Failed to persist updated credentials: %v", err)
}
}
r.logger.Infof("Successfully updated client registration for client ID: %s", regResp.ClientID)
return &regResp, nil
}
// ReadClientRegistration reads the current client registration using RFC 7592
func (r *DynamicClientRegistrar) ReadClientRegistration(ctx context.Context) (*ClientRegistrationResponse, error) {
r.mu.RLock()
cachedResp := r.registrationResponse
r.mu.RUnlock()
if cachedResp == nil {
return nil, fmt.Errorf("no existing registration to read")
}
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
return nil, fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
}
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cachedResp.RegistrationClientURI, nil)
if err != nil {
return nil, fmt.Errorf("failed to create read request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("read request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Handle error responses
if resp.StatusCode != http.StatusOK {
var regError ClientRegistrationError
if jsonErr := json.Unmarshal(body, &regError); jsonErr == nil && regError.Error != "" {
return nil, fmt.Errorf("read failed: %s - %s", regError.Error, regError.ErrorDescription)
}
return nil, fmt.Errorf("read failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse successful response
var regResp ClientRegistrationResponse
if err := json.Unmarshal(body, &regResp); err != nil {
return nil, fmt.Errorf("failed to parse read response: %w", err)
}
return &regResp, nil
}
// DeleteClientRegistration deletes the client registration using RFC 7592
func (r *DynamicClientRegistrar) DeleteClientRegistration(ctx context.Context) error {
r.mu.RLock()
cachedResp := r.registrationResponse
r.mu.RUnlock()
if cachedResp == nil {
return fmt.Errorf("no existing registration to delete")
}
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
return fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
}
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, cachedResp.RegistrationClientURI, nil)
if err != nil {
return fmt.Errorf("failed to create delete request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete request failed: %w", err)
}
defer resp.Body.Close()
// Handle error responses (204 No Content is success)
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var regError ClientRegistrationError
if jsonErr := json.Unmarshal(body, &regError); jsonErr == nil && regError.Error != "" {
return fmt.Errorf("delete failed: %s - %s", regError.Error, regError.ErrorDescription)
}
return fmt.Errorf("delete failed with status %d: %s", resp.StatusCode, string(body))
}
// Clear cache
r.mu.Lock()
r.registrationResponse = nil
r.mu.Unlock()
// Remove credentials file 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)
}
}
r.logger.Info("Successfully deleted client registration")
return nil
}
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -123,10 +123,8 @@ func (b *BaseRecoveryMechanism) GetBaseMetrics() map[string]interface{} {
metrics["seconds_since_last_success"] = time.Since(b.lastSuccessTime).Seconds()
}
totalReq, _ := metrics["total_requests"].(int64) // Safe to ignore: type assertion with fallback
totalSucc, _ := metrics["total_successes"].(int64) // Safe to ignore: type assertion with fallback
if totalReq > 0 {
successRate := float64(totalSucc) / float64(totalReq)
if metrics["total_requests"].(int64) > 0 {
successRate := float64(metrics["total_successes"].(int64)) / float64(metrics["total_requests"].(int64))
metrics["success_rate"] = successRate
} else {
metrics["success_rate"] = 1.0
@@ -965,7 +963,7 @@ func (gd *GracefulDegradation) Close() {
// Don't set to nil to avoid race conditions
}
gd.logger.Debug("GracefulDegradation shut down successfully")
gd.logger.Info("GracefulDegradation shut down successfully")
})
}
-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)
}
-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("", ""))
})
}
-848
View File
@@ -1,848 +0,0 @@
package traefikoidc
import (
"context"
"errors"
"net"
"sync"
"sync/atomic"
"testing"
"time"
)
// Test Circuit Breaker State Transitions
func TestCircuitBreakerStateTransitions(t *testing.T) {
tests := []struct {
name string
failures int
maxFailures int
expectedStateBefore string
expectedStateAfter string
}{
{
name: "stays closed below threshold",
failures: 1,
maxFailures: 3,
expectedStateBefore: "closed",
expectedStateAfter: "closed",
},
{
name: "opens at threshold",
failures: 3,
maxFailures: 3,
expectedStateBefore: "closed",
expectedStateAfter: "open",
},
{
name: "opens above threshold",
failures: 5,
maxFailures: 3,
expectedStateBefore: "closed",
expectedStateAfter: "open",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cb := NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: tt.maxFailures,
Timeout: time.Second,
ResetTimeout: time.Second,
}, nil)
// Verify initial state
if state := circuitBreakerStateToString(cb.GetState()); state != tt.expectedStateBefore {
t.Errorf("Expected initial state %s, got %s", tt.expectedStateBefore, state)
}
// Trigger failures
for i := 0; i < tt.failures; i++ {
_ = cb.Execute(func() error {
return errors.New("test failure")
})
}
// Verify final state
if state := circuitBreakerStateToString(cb.GetState()); state != tt.expectedStateAfter {
t.Errorf("Expected final state %s, got %s", tt.expectedStateAfter, state)
}
})
}
}
func TestCircuitBreakerHalfOpenTransition(t *testing.T) {
cb := NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 100 * time.Millisecond,
ResetTimeout: 50 * time.Millisecond,
}, nil)
// Open the circuit
_ = cb.Execute(func() error { return errors.New("fail") })
_ = cb.Execute(func() error { return errors.New("fail") })
if cb.GetState() != CircuitBreakerOpen {
t.Error("Circuit should be open after failures")
}
// Wait for timeout to trigger half-open
time.Sleep(150 * time.Millisecond)
// Next request should be allowed (half-open)
allowed := false
_ = cb.Execute(func() error {
allowed = true
return nil
})
if !allowed {
t.Error("Request should be allowed in half-open state")
}
// Successful request should close the circuit
if cb.GetState() != CircuitBreakerClosed {
t.Errorf("Circuit should be closed after successful half-open request, got %v", cb.GetState())
}
}
func TestCircuitBreakerHalfOpenFailure(t *testing.T) {
cb := NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 100 * time.Millisecond,
ResetTimeout: 50 * time.Millisecond,
}, nil)
// Open the circuit
_ = cb.Execute(func() error { return errors.New("fail") })
_ = cb.Execute(func() error { return errors.New("fail") })
// Wait for half-open
time.Sleep(150 * time.Millisecond)
// Fail in half-open state
_ = cb.Execute(func() error {
return errors.New("fail again")
})
// Should return to open state
if cb.GetState() != CircuitBreakerOpen {
t.Errorf("Circuit should be open after half-open failure, got %v", cb.GetState())
}
}
func TestCircuitBreakerConcurrency(t *testing.T) {
cb := NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: 10,
Timeout: time.Second,
ResetTimeout: time.Second,
}, nil)
var wg sync.WaitGroup
successCount := int64(0)
failureCount := int64(0)
// Concurrent successful requests
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
err := cb.Execute(func() error {
return nil
})
if err == nil {
atomic.AddInt64(&successCount, 1)
} else {
atomic.AddInt64(&failureCount, 1)
}
}()
}
wg.Wait()
if successCount != 100 {
t.Errorf("Expected 100 successful requests, got %d", successCount)
}
metrics := cb.GetMetrics()
if metrics["total_requests"].(int64) != 100 {
t.Errorf("Expected 100 total requests, got %d", metrics["total_requests"])
}
}
func TestCircuitBreakerReset(t *testing.T) {
cb := NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: 2,
Timeout: time.Second,
ResetTimeout: time.Second,
}, nil)
// Open the circuit
_ = cb.Execute(func() error { return errors.New("fail") })
_ = cb.Execute(func() error { return errors.New("fail") })
if cb.GetState() != CircuitBreakerOpen {
t.Error("Circuit should be open")
}
// Reset
cb.Reset()
if cb.GetState() != CircuitBreakerClosed {
t.Error("Circuit should be closed after reset")
}
// Should allow requests after reset
err := cb.Execute(func() error {
return nil
})
if err != nil {
t.Errorf("Should allow requests after reset, got error: %v", err)
}
}
func TestCircuitBreakerMetrics(t *testing.T) {
cb := NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: 3,
Timeout: time.Second,
ResetTimeout: time.Second,
}, nil)
// Execute some requests
_ = cb.Execute(func() error { return nil })
_ = cb.Execute(func() error { return errors.New("fail") })
_ = cb.Execute(func() error { return nil })
metrics := cb.GetMetrics()
if metrics["total_requests"].(int64) != 3 {
t.Errorf("Expected 3 requests, got %d", metrics["total_requests"])
}
if metrics["total_successes"].(int64) != 2 {
t.Errorf("Expected 2 successes, got %d", metrics["total_successes"])
}
if metrics["total_failures"].(int64) != 1 {
t.Errorf("Expected 1 failure, got %d", metrics["total_failures"])
}
if metrics["state"] != "closed" {
t.Errorf("Expected state 'closed', got %v", metrics["state"])
}
}
func TestCircuitBreakerIsAvailable(t *testing.T) {
cb := NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 100 * time.Millisecond,
ResetTimeout: 50 * time.Millisecond,
}, nil)
// Should be available initially
if !cb.IsAvailable() {
t.Error("Circuit should be available initially")
}
// Open the circuit
_ = cb.Execute(func() error { return errors.New("fail") })
_ = cb.Execute(func() error { return errors.New("fail") })
// Should not be available when open
if cb.IsAvailable() {
t.Error("Circuit should not be available when open")
}
// Wait for timeout
time.Sleep(150 * time.Millisecond)
// Should be available in half-open
if !cb.IsAvailable() {
t.Error("Circuit should be available in half-open state")
}
}
// Test Retry Executor
func TestRetryExecutorSuccess(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 3,
InitialDelay: 10 * time.Millisecond,
MaxDelay: time.Second,
BackoffFactor: 2.0,
EnableJitter: false,
}, nil)
attempts := 0
err := re.ExecuteWithContext(context.Background(), func() error {
attempts++
return nil
})
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if attempts != 1 {
t.Errorf("Expected 1 attempt for immediate success, got %d", attempts)
}
}
func TestRetryExecutorEventualSuccess(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 3,
InitialDelay: 10 * time.Millisecond,
MaxDelay: time.Second,
BackoffFactor: 2.0,
EnableJitter: false,
RetryableErrors: []string{"temporary failure"},
}, nil)
attempts := 0
err := re.ExecuteWithContext(context.Background(), func() error {
attempts++
if attempts < 3 {
return errors.New("temporary failure")
}
return nil
})
if err != nil {
t.Errorf("Expected success after retries, got %v", err)
}
if attempts != 3 {
t.Errorf("Expected 3 attempts, got %d", attempts)
}
}
func TestRetryExecutorMaxAttemptsExceeded(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 3,
InitialDelay: 10 * time.Millisecond,
MaxDelay: time.Second,
BackoffFactor: 2.0,
EnableJitter: false,
RetryableErrors: []string{"temporary failure"},
}, nil)
attempts := 0
err := re.ExecuteWithContext(context.Background(), func() error {
attempts++
return errors.New("temporary failure")
})
if err == nil {
t.Error("Expected error after max attempts")
}
if attempts != 3 {
t.Errorf("Expected 3 attempts, got %d", attempts)
}
}
func TestRetryExecutorNonRetryableError(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 3,
InitialDelay: 10 * time.Millisecond,
MaxDelay: time.Second,
BackoffFactor: 2.0,
EnableJitter: false,
RetryableErrors: []string{"temporary failure"},
}, nil)
attempts := 0
err := re.ExecuteWithContext(context.Background(), func() error {
attempts++
return errors.New("permanent failure")
})
if err == nil {
t.Error("Expected error for non-retryable failure")
}
if attempts != 1 {
t.Errorf("Expected 1 attempt for non-retryable error, got %d", attempts)
}
}
func TestRetryExecutorContextCancellation(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 5,
InitialDelay: 100 * time.Millisecond,
MaxDelay: time.Second,
BackoffFactor: 2.0,
EnableJitter: false,
RetryableErrors: []string{"temporary failure"},
}, nil)
ctx, cancel := context.WithCancel(context.Background())
attempts := 0
done := make(chan error, 1)
go func() {
done <- re.ExecuteWithContext(ctx, func() error {
attempts++
return errors.New("temporary failure")
})
}()
// Cancel after short delay
time.Sleep(150 * time.Millisecond)
cancel()
err := <-done
if err != context.Canceled {
t.Errorf("Expected context.Canceled error, got %v", err)
}
if attempts == 0 {
t.Error("Should have attempted at least once")
}
if attempts >= 5 {
t.Error("Should not have completed all attempts after cancellation")
}
}
func TestRetryExecutorExponentialBackoff(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 4,
InitialDelay: 100 * time.Millisecond,
MaxDelay: time.Second,
BackoffFactor: 2.0,
EnableJitter: false,
RetryableErrors: []string{"temporary failure"},
}, nil)
attempts := 0
startTime := time.Now()
_ = re.ExecuteWithContext(context.Background(), func() error {
attempts++
return errors.New("temporary failure")
})
elapsed := time.Since(startTime)
// Should have delays: 100ms, 200ms, 400ms = 700ms total (approx)
if elapsed < 650*time.Millisecond || elapsed > 850*time.Millisecond {
t.Errorf("Expected ~700ms elapsed with exponential backoff, got %v", elapsed)
}
if attempts != 4 {
t.Errorf("Expected 4 attempts, got %d", attempts)
}
}
func TestRetryExecutorWithJitter(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 3,
InitialDelay: 100 * time.Millisecond,
MaxDelay: time.Second,
BackoffFactor: 2.0,
EnableJitter: true,
RetryableErrors: []string{"temporary failure"},
}, nil)
// Run multiple times to verify jitter adds variability
durations := make([]time.Duration, 5)
for i := 0; i < 5; i++ {
startTime := time.Now()
_ = re.ExecuteWithContext(context.Background(), func() error {
return errors.New("temporary failure")
})
durations[i] = time.Since(startTime)
}
// Check that not all durations are identical (jitter should add variance)
allSame := true
for i := 1; i < len(durations); i++ {
if durations[i] != durations[0] {
allSame = false
break
}
}
if allSame {
t.Error("Expected jitter to add variability to retry delays")
}
}
func TestRetryExecutorNetworkErrors(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 3,
InitialDelay: 10 * time.Millisecond,
MaxDelay: time.Second,
BackoffFactor: 2.0,
EnableJitter: false,
}, nil)
tests := []struct {
name string
err error
shouldRetry bool
}{
{
name: "timeout error",
err: &mockNetError{timeout: true, temporary: true},
shouldRetry: true,
},
{
name: "temporary network error",
err: &mockNetError{timeout: false, temporary: true, msg: "temporary failure"},
shouldRetry: true,
},
{
name: "connection refused",
err: &mockNetError{timeout: false, temporary: false, msg: "connection refused"},
shouldRetry: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
attempts := 0
_ = re.ExecuteWithContext(context.Background(), func() error {
attempts++
return tt.err
})
expectedAttempts := 1
if tt.shouldRetry {
expectedAttempts = 3
}
if attempts != expectedAttempts {
t.Errorf("Expected %d attempts, got %d", expectedAttempts, attempts)
}
})
}
}
func TestRetryExecutorHTTPErrors(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 3,
InitialDelay: 10 * time.Millisecond,
MaxDelay: time.Second,
BackoffFactor: 2.0,
EnableJitter: false,
}, nil)
tests := []struct {
name string
statusCode int
shouldRetry bool
}{
{"500 Internal Server Error", 500, true},
{"502 Bad Gateway", 502, true},
{"503 Service Unavailable", 503, true},
{"429 Too Many Requests", 429, true},
{"400 Bad Request", 400, false},
{"404 Not Found", 404, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
attempts := 0
_ = re.ExecuteWithContext(context.Background(), func() error {
attempts++
return &HTTPError{StatusCode: tt.statusCode, Message: "test"}
})
expectedAttempts := 1
if tt.shouldRetry {
expectedAttempts = 3
}
if attempts != expectedAttempts {
t.Errorf("Expected %d attempts, got %d", expectedAttempts, attempts)
}
})
}
}
func TestRetryExecutorMetrics(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 3,
InitialDelay: 10 * time.Millisecond,
MaxDelay: time.Second,
BackoffFactor: 2.0,
EnableJitter: true,
}, nil)
_ = re.ExecuteWithContext(context.Background(), func() error {
return nil
})
metrics := re.GetMetrics()
if metrics["max_attempts"] != 3 {
t.Errorf("Expected max_attempts 3, got %v", metrics["max_attempts"])
}
if metrics["backoff_factor"] != 2.0 {
t.Errorf("Expected backoff_factor 2.0, got %v", metrics["backoff_factor"])
}
if metrics["enable_jitter"] != true {
t.Errorf("Expected enable_jitter true, got %v", metrics["enable_jitter"])
}
}
// Test Error Types
func TestOIDCErrorCreation(t *testing.T) {
err := NewOIDCError("invalid_token", "Token is expired", nil)
if err.Code != "invalid_token" {
t.Errorf("Expected code 'invalid_token', got %s", err.Code)
}
if err.Message != "Token is expired" {
t.Errorf("Expected message 'Token is expired', got %s", err.Message)
}
expectedMsg := "OIDC error [invalid_token]: Token is expired"
if err.Error() != expectedMsg {
t.Errorf("Expected error string '%s', got '%s'", expectedMsg, err.Error())
}
}
func TestOIDCErrorWithCause(t *testing.T) {
cause := errors.New("underlying error")
err := NewOIDCError("token_error", "Failed to validate", cause)
if err.Unwrap() != cause {
t.Error("Expected unwrap to return underlying cause")
}
if err.Error() == "" {
t.Error("Error string should include cause")
}
}
func TestOIDCErrorWithContext(t *testing.T) {
err := NewOIDCError("auth_failed", "Authentication failed", nil).
WithContext("provider", "google").
WithContext("user_id", "12345")
if err.Context["provider"] != "google" {
t.Errorf("Expected provider 'google', got %v", err.Context["provider"])
}
if err.Context["user_id"] != "12345" {
t.Errorf("Expected user_id '12345', got %v", err.Context["user_id"])
}
}
func TestSessionErrorCreation(t *testing.T) {
err := NewSessionError("save", "Failed to save session", nil)
if err.Operation != "save" {
t.Errorf("Expected operation 'save', got %s", err.Operation)
}
expectedMsg := "Session error in save: Failed to save session"
if err.Error() != expectedMsg {
t.Errorf("Expected error string '%s', got '%s'", expectedMsg, err.Error())
}
}
func TestSessionErrorWithSessionID(t *testing.T) {
err := NewSessionError("load", "Session not found", nil).
WithSessionID("sess_12345")
if err.SessionID != "sess_12345" {
t.Errorf("Expected session ID 'sess_12345', got %s", err.SessionID)
}
}
func TestTokenErrorCreation(t *testing.T) {
err := NewTokenError("id_token", "expired", "Token has expired", nil)
if err.TokenType != "id_token" {
t.Errorf("Expected token type 'id_token', got %s", err.TokenType)
}
if err.Reason != "expired" {
t.Errorf("Expected reason 'expired', got %s", err.Reason)
}
expectedMsg := "Token error (id_token) - expired: Token has expired"
if err.Error() != expectedMsg {
t.Errorf("Expected error string '%s', got '%s'", expectedMsg, err.Error())
}
}
// Test Base Recovery Mechanism
func TestBaseRecoveryMechanismMetrics(t *testing.T) {
base := NewBaseRecoveryMechanism("test-mechanism", nil)
base.RecordRequest()
base.RecordSuccess()
base.RecordRequest()
base.RecordFailure()
metrics := base.GetBaseMetrics()
if metrics["total_requests"].(int64) != 2 {
t.Errorf("Expected 2 requests, got %d", metrics["total_requests"])
}
if metrics["total_successes"].(int64) != 1 {
t.Errorf("Expected 1 success, got %d", metrics["total_successes"])
}
if metrics["total_failures"].(int64) != 1 {
t.Errorf("Expected 1 failure, got %d", metrics["total_failures"])
}
if metrics["success_rate"].(float64) != 0.5 {
t.Errorf("Expected success rate 0.5, got %v", metrics["success_rate"])
}
}
func TestBaseRecoveryMechanismConcurrentUpdates(t *testing.T) {
base := NewBaseRecoveryMechanism("concurrent-test", nil)
var wg sync.WaitGroup
iterations := 1000
// Concurrent requests
for i := 0; i < iterations; i++ {
wg.Add(1)
go func() {
defer wg.Done()
base.RecordRequest()
if i%2 == 0 {
base.RecordSuccess()
} else {
base.RecordFailure()
}
}()
}
wg.Wait()
metrics := base.GetBaseMetrics()
if metrics["total_requests"].(int64) != int64(iterations) {
t.Errorf("Expected %d requests, got %d", iterations, metrics["total_requests"])
}
totalSuccessesAndFailures := metrics["total_successes"].(int64) + metrics["total_failures"].(int64)
if totalSuccessesAndFailures != int64(iterations) {
t.Errorf("Expected %d total successes+failures, got %d", iterations, totalSuccessesAndFailures)
}
}
// Test Error Recovery Manager
func TestErrorRecoveryManagerCreation(t *testing.T) {
erm := NewErrorRecoveryManager(nil)
if erm == nil {
t.Fatal("Expected non-nil error recovery manager")
}
if erm.retryExecutor == nil {
t.Error("Expected retry executor to be initialized")
}
if erm.gracefulDegradation == nil {
t.Error("Expected graceful degradation to be initialized")
}
}
func TestErrorRecoveryManagerGetCircuitBreaker(t *testing.T) {
erm := NewErrorRecoveryManager(nil)
cb1 := erm.GetCircuitBreaker("service1")
cb2 := erm.GetCircuitBreaker("service1")
cb3 := erm.GetCircuitBreaker("service2")
if cb1 == nil || cb2 == nil || cb3 == nil {
t.Fatal("Expected non-nil circuit breakers")
}
// Should return same instance for same service
if cb1 != cb2 {
t.Error("Expected same circuit breaker instance for same service")
}
// Should return different instances for different services
if cb1 == cb3 {
t.Error("Expected different circuit breaker instances for different services")
}
}
func TestErrorRecoveryManagerExecuteWithRecovery(t *testing.T) {
erm := NewErrorRecoveryManager(nil)
success := false
err := erm.ExecuteWithRecovery(context.Background(), "test-service", func() error {
success = true
return nil
})
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if !success {
t.Error("Expected function to execute")
}
}
func TestErrorRecoveryManagerMetrics(t *testing.T) {
erm := NewErrorRecoveryManager(nil)
// Create some circuit breakers
_ = erm.GetCircuitBreaker("service1")
_ = erm.GetCircuitBreaker("service2")
metrics := erm.GetRecoveryMetrics()
cbMetrics, ok := metrics["circuit_breakers"].(map[string]interface{})
if !ok {
t.Fatal("Expected circuit_breakers in metrics")
}
if len(cbMetrics) != 2 {
t.Errorf("Expected 2 circuit breakers in metrics, got %d", len(cbMetrics))
}
}
// Helper functions and types
func circuitBreakerStateToString(state CircuitBreakerState) string {
switch state {
case CircuitBreakerClosed:
return "closed"
case CircuitBreakerOpen:
return "open"
case CircuitBreakerHalfOpen:
return "half-open"
default:
return "unknown"
}
}
// Mock network error for testing
type mockNetError struct {
timeout bool
temporary bool
msg string
}
func (e *mockNetError) Error() string { return e.msg }
func (e *mockNetError) Timeout() bool { return e.timeout }
func (e *mockNetError) Temporary() bool { return e.temporary }
// Ensure mockNetError implements net.Error
var _ net.Error = (*mockNetError)(nil)
+1 -1
View File
@@ -6,7 +6,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.3.0
github.com/stretchr/testify v1.10.0
golang.org/x/time v0.14.0
golang.org/x/time v0.13.0
)
require (
+2 -2
View File
@@ -12,8 +12,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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+1 -1
View File
@@ -86,7 +86,7 @@ func (m *GoroutineManager) StartPeriodicTask(name string, interval time.Duration
for {
select {
case <-ctx.Done():
m.logger.Debugf("Periodic task %s canceled", name)
m.logger.Debugf("Periodic task %s cancelled", name)
return
case <-ticker.C:
task()
-625
View File
@@ -1,625 +0,0 @@
package traefikoidc
import (
"context"
"sync/atomic"
"testing"
"time"
)
// Test GoroutineManager Creation
func TestNewGoroutineManager(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
if gm == nil {
t.Fatal("Expected non-nil goroutine manager")
}
if gm.ctx == nil {
t.Error("Expected context to be initialized")
}
if gm.cancel == nil {
t.Error("Expected cancel function to be initialized")
}
if gm.goroutines == nil {
t.Error("Expected goroutines map to be initialized")
}
if gm.logger != logger {
t.Error("Expected logger to be set")
}
}
// Test Starting Goroutines
func TestStartGoroutine(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
executed := atomic.Bool{}
gm.StartGoroutine("test-goroutine", func(ctx context.Context) {
executed.Store(true)
})
// Give goroutine time to execute
time.Sleep(50 * time.Millisecond)
if !executed.Load() {
t.Error("Expected goroutine to execute")
}
status := gm.GetStatus()
if len(status) != 1 {
t.Errorf("Expected 1 goroutine in status, got %d", len(status))
}
if _, exists := status["test-goroutine"]; !exists {
t.Error("Expected goroutine 'test-goroutine' in status")
}
}
func TestStartGoroutineDuplicate(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
counter := atomic.Int32{}
// Start a long-running goroutine
gm.StartGoroutine("duplicate-test", func(ctx context.Context) {
counter.Add(1)
<-ctx.Done()
})
// Give first goroutine time to start
time.Sleep(50 * time.Millisecond)
// Try to start another with same name (should be skipped)
gm.StartGoroutine("duplicate-test", func(ctx context.Context) {
counter.Add(1)
})
time.Sleep(50 * time.Millisecond)
// Should only have executed once
if counter.Load() != 1 {
t.Errorf("Expected counter to be 1 (duplicate should be skipped), got %d", counter.Load())
}
}
func TestStartGoroutineContextCancellation(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
started := atomic.Bool{}
canceled := atomic.Bool{}
gm.StartGoroutine("cancel-test", func(ctx context.Context) {
started.Store(true)
<-ctx.Done()
canceled.Store(true)
})
// Wait for goroutine to start
time.Sleep(50 * time.Millisecond)
if !started.Load() {
t.Error("Expected goroutine to start")
}
// Stop the goroutine
gm.StopGoroutine("cancel-test")
// Wait for cancellation
time.Sleep(50 * time.Millisecond)
if !canceled.Load() {
t.Error("Expected goroutine to be canceled")
}
}
func TestStartGoroutineWithPanic(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
executed := atomic.Bool{}
gm.StartGoroutine("panic-test", func(ctx context.Context) {
executed.Store(true)
panic("test panic")
})
// Give goroutine time to panic and recover
time.Sleep(100 * time.Millisecond)
if !executed.Load() {
t.Error("Expected goroutine to execute before panic")
}
// Check that goroutine is marked as not running after panic
status := gm.GetStatus()
if goroutineStatus, exists := status["panic-test"]; exists {
if goroutineStatus.Running {
t.Error("Expected goroutine to be marked as not running after panic")
}
}
// Manager should still be functional
counter := atomic.Int32{}
gm.StartGoroutine("after-panic", func(ctx context.Context) {
counter.Add(1)
})
time.Sleep(50 * time.Millisecond)
if counter.Load() != 1 {
t.Error("Expected manager to still be functional after panic recovery")
}
}
// Test Periodic Tasks
func TestStartPeriodicTask(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
counter := atomic.Int32{}
gm.StartPeriodicTask("periodic-test", 50*time.Millisecond, func() {
counter.Add(1)
})
// Wait for multiple executions
time.Sleep(160 * time.Millisecond)
// Should have executed at least 2-3 times
count := counter.Load()
if count < 2 {
t.Errorf("Expected periodic task to execute at least 2 times, got %d", count)
}
}
func TestStartPeriodicTaskCancellation(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
counter := atomic.Int32{}
gm.StartPeriodicTask("cancel-periodic", 50*time.Millisecond, func() {
counter.Add(1)
})
// Wait for some executions
time.Sleep(120 * time.Millisecond)
// Stop the task
gm.StopGoroutine("cancel-periodic")
countBeforeStop := counter.Load()
// Wait and verify no more executions
time.Sleep(120 * time.Millisecond)
countAfterStop := counter.Load()
// Allow 1 additional execution (could be in progress when stopped)
if countAfterStop > countBeforeStop+1 {
t.Errorf("Expected periodic task to stop executing, before: %d, after: %d",
countBeforeStop, countAfterStop)
}
}
// Test Stopping Goroutines
func TestStopGoroutine(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
stopped := atomic.Bool{}
gm.StartGoroutine("stop-test", func(ctx context.Context) {
<-ctx.Done()
stopped.Store(true)
})
// Wait for goroutine to start
time.Sleep(50 * time.Millisecond)
gm.StopGoroutine("stop-test")
// Wait for goroutine to stop
time.Sleep(50 * time.Millisecond)
if !stopped.Load() {
t.Error("Expected goroutine to be stopped")
}
status := gm.GetStatus()
if goroutineStatus, exists := status["stop-test"]; exists {
if goroutineStatus.Running {
t.Error("Expected goroutine to be marked as not running")
}
}
}
func TestStopGoroutineNonExistent(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
// Should not panic or error when stopping non-existent goroutine
gm.StopGoroutine("non-existent")
}
func TestStopGoroutineAlreadyStopped(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
gm.StartGoroutine("already-stopped", func(ctx context.Context) {
// Exit immediately
})
// Wait for goroutine to finish
time.Sleep(50 * time.Millisecond)
// Try to stop already-stopped goroutine (should be safe)
gm.StopGoroutine("already-stopped")
}
// Test Shutdown
func TestShutdownGraceful(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
counter := atomic.Int32{}
// Start multiple goroutines
for i := 0; i < 5; i++ {
name := "goroutine-" + string(rune('0'+i))
gm.StartGoroutine(name, func(ctx context.Context) {
counter.Add(1)
<-ctx.Done()
counter.Add(-1)
})
}
// Wait for all to start
time.Sleep(100 * time.Millisecond)
if counter.Load() != 5 {
t.Errorf("Expected 5 goroutines running, got %d", counter.Load())
}
// Shutdown with generous timeout
err := gm.Shutdown(time.Second)
if err != nil {
t.Errorf("Expected graceful shutdown, got error: %v", err)
}
if counter.Load() != 0 {
t.Errorf("Expected all goroutines to complete cleanup, got %d still running", counter.Load())
}
}
func TestShutdownWithTimeout(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
// Start a goroutine that ignores cancellation (bad behavior, but testing timeout)
gm.StartGoroutine("stubborn", func(ctx context.Context) {
// Simulate a goroutine that takes too long to stop
time.Sleep(500 * time.Millisecond)
})
time.Sleep(50 * time.Millisecond)
// Shutdown with very short timeout
err := gm.Shutdown(10 * time.Millisecond)
if err == nil {
t.Error("Expected timeout error")
}
if err != ErrShutdownTimeout {
t.Errorf("Expected ErrShutdownTimeout, got %v", err)
}
}
func TestShutdownEmpty(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
// Shutdown with no goroutines should succeed immediately
err := gm.Shutdown(time.Second)
if err != nil {
t.Errorf("Expected no error for empty shutdown, got: %v", err)
}
}
// Test Status
func TestGetStatus(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
// Start multiple goroutines with different states
gm.StartGoroutine("running", func(ctx context.Context) {
<-ctx.Done()
})
gm.StartGoroutine("quick", func(ctx context.Context) {
// Exits immediately
})
time.Sleep(50 * time.Millisecond)
status := gm.GetStatus()
if len(status) != 2 {
t.Errorf("Expected 2 goroutines in status, got %d", len(status))
}
if runningStatus, exists := status["running"]; exists {
if !runningStatus.Running {
t.Error("Expected 'running' goroutine to be marked as running")
}
if runningStatus.Name != "running" {
t.Errorf("Expected name 'running', got %s", runningStatus.Name)
}
if runningStatus.StartTime.IsZero() {
t.Error("Expected non-zero start time")
}
if runningStatus.Runtime <= 0 {
t.Error("Expected positive runtime")
}
} else {
t.Error("Expected 'running' goroutine in status")
}
if quickStatus, exists := status["quick"]; exists {
if quickStatus.Running {
t.Error("Expected 'quick' goroutine to be marked as not running")
}
} else {
t.Error("Expected 'quick' goroutine in status")
}
}
func TestGetStatusEmpty(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
status := gm.GetStatus()
if status == nil {
t.Fatal("Expected non-nil status map")
}
if len(status) != 0 {
t.Errorf("Expected empty status, got %d entries", len(status))
}
}
// Test Concurrent Operations
func TestConcurrentStartGoroutine(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(2 * time.Second)
counter := atomic.Int32{}
const numGoroutines = 50
// Start many goroutines concurrently
for i := 0; i < numGoroutines; i++ {
go func(id int) {
name := "concurrent-" + string(rune('0'+id%10)) + string(rune('0'+id/10))
gm.StartGoroutine(name, func(ctx context.Context) {
counter.Add(1)
time.Sleep(50 * time.Millisecond)
counter.Add(-1)
})
}(i)
}
// Wait for all to start
time.Sleep(150 * time.Millisecond)
// Verify goroutines are tracked
status := gm.GetStatus()
if len(status) < numGoroutines/2 {
t.Errorf("Expected at least %d goroutines, got %d", numGoroutines/2, len(status))
}
}
func TestConcurrentStopGoroutine(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
const numGoroutines = 20
// Start goroutines
for i := 0; i < numGoroutines; i++ {
name := "stop-concurrent-" + string(rune('0'+i%10))
gm.StartGoroutine(name, func(ctx context.Context) {
<-ctx.Done()
})
}
time.Sleep(50 * time.Millisecond)
// Stop all concurrently
for i := 0; i < numGoroutines; i++ {
go func(id int) {
name := "stop-concurrent-" + string(rune('0'+id%10))
gm.StopGoroutine(name)
}(i)
}
time.Sleep(100 * time.Millisecond)
// Verify all stopped
status := gm.GetStatus()
for _, s := range status {
if s.Running {
t.Errorf("Expected goroutine %s to be stopped", s.Name)
}
}
}
func TestConcurrentGetStatus(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
// Start some goroutines
for i := 0; i < 10; i++ {
name := "status-test-" + string(rune('0'+i))
gm.StartGoroutine(name, func(ctx context.Context) {
<-ctx.Done()
})
}
// Concurrently read status many times (should not race)
done := make(chan struct{})
for i := 0; i < 20; i++ {
go func() {
for j := 0; j < 100; j++ {
_ = gm.GetStatus()
}
done <- struct{}{}
}()
}
// Wait for all concurrent reads
for i := 0; i < 20; i++ {
<-done
}
}
// Test Error Cases
func TestShutdownTimeoutError(t *testing.T) {
err := ErrShutdownTimeout
if err.Error() != "shutdown timeout: some goroutines did not stop in time" {
t.Errorf("Unexpected error message: %s", err.Error())
}
}
// Test Edge Cases
func TestStartGoroutineAfterShutdown(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
// Shutdown immediately
_ = gm.Shutdown(time.Second)
executed := atomic.Bool{}
// Try to start goroutine after shutdown
gm.StartGoroutine("after-shutdown", func(ctx context.Context) {
executed.Store(true)
<-ctx.Done()
})
time.Sleep(50 * time.Millisecond)
// Goroutine should have started but context already canceled
// It may or may not execute depending on timing, but shouldn't panic
status := gm.GetStatus()
if _, exists := status["after-shutdown"]; exists {
// If it's in status, it was tracked (acceptable)
t.Log("Goroutine was tracked even after shutdown")
}
}
func TestMultipleShutdowns(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
// First shutdown
err1 := gm.Shutdown(time.Second)
if err1 != nil {
t.Errorf("Expected first shutdown to succeed, got: %v", err1)
}
// Second shutdown (should not panic or error)
err2 := gm.Shutdown(time.Second)
if err2 != nil {
t.Errorf("Expected second shutdown to succeed, got: %v", err2)
}
}
func TestGoroutineWithImmediateReturn(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
executed := atomic.Bool{}
gm.StartGoroutine("immediate", func(ctx context.Context) {
executed.Store(true)
// Return immediately
})
time.Sleep(50 * time.Millisecond)
if !executed.Load() {
t.Error("Expected goroutine to execute")
}
status := gm.GetStatus()
if goroutineStatus, exists := status["immediate"]; exists {
if goroutineStatus.Running {
t.Error("Expected immediately-returning goroutine to be marked as not running")
}
}
}
func TestPeriodicTaskPanicRecovery(t *testing.T) {
logger := GetSingletonNoOpLogger()
gm := NewGoroutineManager(logger)
defer gm.Shutdown(time.Second)
counter := atomic.Int32{}
gm.StartPeriodicTask("panic-periodic", 50*time.Millisecond, func() {
counter.Add(1)
if counter.Load() == 2 {
panic("periodic panic")
}
})
// Wait for panic to occur
time.Sleep(200 * time.Millisecond)
// After panic, the goroutine should have stopped
status := gm.GetStatus()
if goroutineStatus, exists := status["panic-periodic"]; exists {
if goroutineStatus.Running {
t.Error("Expected panicked periodic task to stop")
}
}
}
-7
View File
@@ -648,13 +648,6 @@ func (s *MockSession) SetEmail(email string) {
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()
-32
View File
@@ -39,10 +39,6 @@ type SessionData interface {
GetCodeVerifier() string
GetIncomingPath() string
GetAuthenticated() bool
GetAccessToken() string
GetRefreshToken() string
GetIDToken() string
GetEmail() string
SetAuthenticated(bool) error
SetEmail(string)
SetIDToken(string)
@@ -104,20 +100,6 @@ func (h *OAuthHandler) HandleCallback(rw http.ResponseWriter, req *http.Request,
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 == "" {
@@ -135,36 +117,22 @@ func (h *OAuthHandler) HandleCallback(rw http.ResponseWriter, req *http.Request,
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)
h.logger.Debugf("Available cookies: %v", req.Header.Get("Cookie"))
} 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)
-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": "[email protected]", "nonce": "test-nonce"}, nil
}
isAllowed := func(email string) bool { return true }
sendError := func(rw http.ResponseWriter, req *http.Request, msg string, code int) {}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/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, "/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, "/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, "/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, "/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, "/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, "/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, "/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, "/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, "/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": "[email protected]"}, 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, "/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": "[email protected]", "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, "/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": "[email protected]", "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, "/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, "Email missing in token") {
t.Errorf("Expected error message to contain 'Email missing in token', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/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": "[email protected]", "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, "Email domain not allowed") {
t.Errorf("Expected error message to contain 'Email domain not allowed', got '%s'", msg)
}
}
handler := NewOAuthHandler(logger, sessionManager, tokenExchanger, tokenVerifier,
extractClaims, isAllowed, "/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": "[email protected]", "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, "/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": "[email protected]", "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, "/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": "[email protected]", "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, "/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 != "[email protected]" {
t.Errorf("Expected email '[email protected]', 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": "[email protected]", "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, "/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": "[email protected]", "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, "/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)
}
}
+8 -18
View File
@@ -109,7 +109,7 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
client := t.tokenHTTPClient
if client == nil {
// Use shared transport pool to prevent memory leaks
jar, _ := cookiejar.New(nil) // Safe to ignore: cookiejar creation with nil options rarely fails
jar, _ := cookiejar.New(nil)
pooledClient := CreateTokenHTTPClient()
client = &http.Client{
Transport: pooledClient.Transport,
@@ -124,12 +124,7 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
}
}
// Read tokenURL with RLock
t.metadataMu.RLock()
tokenURL := t.tokenURL
t.metadataMu.RUnlock()
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode()))
req, err := http.NewRequestWithContext(ctx, "POST", t.tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
@@ -140,13 +135,13 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
return nil, fmt.Errorf("failed to exchange tokens: %w", err)
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body) // Safe to ignore: draining response body on defer
_ = resp.Body.Close() // Safe to ignore: closing body on defer
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
limitReader := io.LimitReader(resp.Body, 1024*10)
bodyBytes, _ := io.ReadAll(limitReader) // Safe to ignore: reading error body for diagnostics
bodyBytes, _ := io.ReadAll(limitReader)
return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
@@ -237,7 +232,7 @@ func NewTokenCache() *TokenCache {
// - expiration: The duration for which the cache entry should be valid
func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
token = "t-" + token
_ = tc.cache.Set(token, claims, expiration) // Safe to ignore: cache failures are non-critical
tc.cache.Set(token, claims, expiration)
}
// Get retrieves cached claims for a token.
@@ -360,13 +355,8 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
postLogoutRedirectURI = fmt.Sprintf("%s%s", baseURL, postLogoutRedirectURI)
}
// Read endSessionURL with RLock
t.metadataMu.RLock()
endSessionURL := t.endSessionURL
t.metadataMu.RUnlock()
if endSessionURL != "" && idToken != "" {
logoutURL, err := BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI)
if t.endSessionURL != "" && idToken != "" {
logoutURL, err := BuildLogoutURL(t.endSessionURL, idToken, postLogoutRedirectURI)
if err != nil {
t.logger.Errorf("Failed to build logout URL: %v", err)
http.Error(rw, "Logout error", http.StatusInternalServerError)
+5 -17
View File
@@ -49,10 +49,10 @@ func DefaultHTTPClientConfig() HTTPClientConfig {
TLSHandshakeTimeout: 2 * time.Second,
ResponseHeaderTimeout: 3 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 30 * time.Second, // OPTIMIZATION: Increased for better connection reuse
MaxIdleConns: 50, // OPTIMIZATION: Increased from 20 for better connection pooling
MaxIdleConnsPerHost: 10, // OPTIMIZATION: Increased from 2 for better connection reuse
MaxConnsPerHost: 20, // OPTIMIZATION: Increased from 5 while maintaining security
IdleConnTimeout: 5 * time.Second,
MaxIdleConns: 20, // SECURITY FIX: Reduced from 100 to limit resource usage
MaxIdleConnsPerHost: 2, // SECURITY FIX: Reduced from 10 to prevent connection exhaustion
MaxConnsPerHost: 5, // SECURITY FIX: Reduced from 10 to limit concurrent connections
WriteBufferSize: 4096,
ReadBufferSize: 4096,
ForceHTTP2: true,
@@ -70,18 +70,6 @@ func TokenHTTPClientConfig() HTTPClientConfig {
return config
}
// OIDCProviderHTTPClientConfig returns configuration optimized for OIDC provider calls
func OIDCProviderHTTPClientConfig() HTTPClientConfig {
config := DefaultHTTPClientConfig()
config.Timeout = 15 * time.Second // Slightly longer for OIDC operations
config.MaxIdleConns = 100 // Higher pool for frequent OIDC calls
config.MaxIdleConnsPerHost = 25 // More connections per OIDC provider
config.MaxConnsPerHost = 50 // Allow more concurrent requests to OIDC provider
config.IdleConnTimeout = 90 * time.Second // Keep connections alive longer for reuse
config.UseCookieJar = true // Enable cookie jar for session management
return config
}
// HTTPClientFactory provides methods for creating configured HTTP clients
type HTTPClientFactory struct{}
@@ -245,7 +233,7 @@ func (f *HTTPClientFactory) CreateHTTPClient(config HTTPClientConfig) *http.Clie
// Add cookie jar if requested
if config.UseCookieJar {
jar, _ := cookiejar.New(nil) // Safe to ignore: cookiejar creation with nil options rarely fails
jar, _ := cookiejar.New(nil)
client.Jar = jar
}
-210
View File
@@ -1,210 +0,0 @@
package traefikoidc
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestOIDCProviderHTTPClientConfigUnit tests OIDCProviderHTTPClientConfig function
func TestOIDCProviderHTTPClientConfigUnit(t *testing.T) {
config := OIDCProviderHTTPClientConfig()
// Verify OIDC-specific settings
assert.Equal(t, 15*time.Second, config.Timeout, "OIDC provider should have 15s timeout")
assert.Equal(t, 100, config.MaxIdleConns, "OIDC provider should have 100 max idle conns")
assert.Equal(t, 25, config.MaxIdleConnsPerHost, "OIDC provider should have 25 max idle conns per host")
assert.Equal(t, 50, config.MaxConnsPerHost, "OIDC provider should have 50 max conns per host")
assert.Equal(t, 90*time.Second, config.IdleConnTimeout, "OIDC provider should have 90s idle conn timeout")
assert.True(t, config.UseCookieJar, "OIDC provider should have cookie jar enabled")
}
// TestCreateDefaultClientUnit tests CreateDefaultClient function
func TestCreateDefaultClientUnit(t *testing.T) {
factory := NewHTTPClientFactory()
client := factory.CreateDefaultClient()
require.NotNil(t, client)
assert.NotNil(t, client.Transport, "client should have transport")
assert.Equal(t, 10*time.Second, client.Timeout, "default client should have 10s timeout")
}
// TestCreateTokenClientUnit tests CreateTokenClient function
func TestCreateTokenClientUnit(t *testing.T) {
factory := NewHTTPClientFactory()
client := factory.CreateTokenClient()
require.NotNil(t, client)
assert.NotNil(t, client.Transport, "client should have transport")
assert.NotNil(t, client.Jar, "token client should have cookie jar")
assert.Equal(t, 10*time.Second, client.Timeout, "token client should have 10s timeout")
}
// TestCreateHTTPClientWithConfigUnit tests CreateHTTPClientWithConfig function
func TestCreateHTTPClientWithConfigUnit(t *testing.T) {
config := HTTPClientConfig{
Timeout: 5 * time.Second,
MaxIdleConns: 20,
MaxIdleConnsPerHost: 5,
UseCookieJar: true,
}
client := CreateHTTPClientWithConfig(config)
require.NotNil(t, client)
assert.Equal(t, 5*time.Second, client.Timeout)
assert.NotNil(t, client.Jar, "client should have cookie jar when configured")
}
// TestHTTPClientFactoryCreateHTTPClientValidation tests validation in CreateHTTPClient
func TestHTTPClientFactoryCreateHTTPClientValidation(t *testing.T) {
factory := NewHTTPClientFactory()
t.Run("zero values get defaults", func(t *testing.T) {
config := HTTPClientConfig{
// All zero values
}
client := factory.CreateHTTPClient(config)
require.NotNil(t, client)
// Verify defaults were applied
assert.Equal(t, 30*time.Second, client.Timeout)
})
t.Run("custom values preserved", func(t *testing.T) {
config := HTTPClientConfig{
Timeout: 15 * time.Second,
MaxIdleConns: 50,
MaxRedirects: 3,
UseCookieJar: true,
ForceHTTP2: true,
DisableKeepAlives: true,
}
client := factory.CreateHTTPClient(config)
require.NotNil(t, client)
assert.Equal(t, 15*time.Second, client.Timeout)
assert.NotNil(t, client.Jar)
})
t.Run("invalid timeout gets default", func(t *testing.T) {
config := HTTPClientConfig{
Timeout: -1 * time.Second, // Invalid
}
client := factory.CreateHTTPClient(config)
require.NotNil(t, client)
// Should get default due to validation failure
assert.Equal(t, 30*time.Second, client.Timeout)
})
}
// TestHTTPClientFactoryValidateHTTPClientConfig tests ValidateHTTPClientConfig
func TestHTTPClientFactoryValidateHTTPClientConfig(t *testing.T) {
factory := NewHTTPClientFactory()
tests := []struct {
name string
config HTTPClientConfig
wantError bool
errorMsg string
}{
{
name: "valid config",
config: HTTPClientConfig{
Timeout: 10 * time.Second,
DialTimeout: 5 * time.Second,
TLSHandshakeTimeout: 2 * time.Second,
MaxIdleConns: 50,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
},
wantError: false,
},
{
name: "negative MaxIdleConns",
config: HTTPClientConfig{
Timeout: 10 * time.Second,
DialTimeout: 5 * time.Second,
TLSHandshakeTimeout: 2 * time.Second,
MaxIdleConns: -1,
},
wantError: true,
errorMsg: "MaxIdleConns cannot be negative",
},
{
name: "MaxIdleConns too high",
config: HTTPClientConfig{
Timeout: 10 * time.Second,
DialTimeout: 5 * time.Second,
TLSHandshakeTimeout: 2 * time.Second,
MaxIdleConns: 1500,
},
wantError: true,
errorMsg: "MaxIdleConns too high",
},
{
name: "negative MaxIdleConnsPerHost",
config: HTTPClientConfig{
Timeout: 10 * time.Second,
DialTimeout: 5 * time.Second,
TLSHandshakeTimeout: 2 * time.Second,
MaxIdleConnsPerHost: -1,
},
wantError: true,
errorMsg: "MaxIdleConnsPerHost cannot be negative",
},
{
name: "timeout too high",
config: HTTPClientConfig{
Timeout: 10 * time.Minute,
DialTimeout: 5 * time.Second,
TLSHandshakeTimeout: 2 * time.Second,
},
wantError: true,
errorMsg: "timeout too high",
},
{
name: "negative timeout",
config: HTTPClientConfig{
Timeout: -1 * time.Second,
DialTimeout: 5 * time.Second,
TLSHandshakeTimeout: 2 * time.Second,
},
wantError: true,
errorMsg: "timeout must be positive",
},
{
name: "MaxIdleConnsPerHost exceeds MaxConnsPerHost",
config: HTTPClientConfig{
Timeout: 10 * time.Second,
DialTimeout: 5 * time.Second,
TLSHandshakeTimeout: 2 * time.Second,
MaxIdleConnsPerHost: 50,
MaxConnsPerHost: 10,
},
wantError: true,
errorMsg: "MaxIdleConnsPerHost (50) cannot exceed MaxConnsPerHost (10)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := factory.ValidateHTTPClientConfig(&tt.config)
if tt.wantError {
assert.Error(t, err)
if tt.errorMsg != "" {
assert.Contains(t, err.Error(), tt.errorMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
+10 -42
View File
@@ -146,9 +146,6 @@ func (p *SharedTransportPool) ReleaseTransport(transport *http.Transport) {
}
// cleanupIdleTransports periodically cleans up unused transports
// Uses two-phase cleanup to minimize lock contention:
// 1. Find candidates while holding read lock
// 2. Remove and close transports with minimal lock duration
func (p *SharedTransportPool) cleanupIdleTransports(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
@@ -158,46 +155,17 @@ func (p *SharedTransportPool) cleanupIdleTransports(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
p.performCleanup()
}
}
}
// performCleanup does the actual cleanup with optimized locking
func (p *SharedTransportPool) performCleanup() {
now := time.Now()
// Phase 1: Find candidates while holding read lock (fast)
p.mu.RLock()
candidates := make([]string, 0)
for transportKey, shared := range p.transports {
// Clean up transports not used for 2 minutes with no references
if shared.refCount <= 0 && now.Sub(shared.lastUsed) > 2*time.Minute {
candidates = append(candidates, transportKey)
}
}
p.mu.RUnlock()
if len(candidates) == 0 {
return
}
// Phase 2: Remove and close each candidate individually
// This minimizes lock contention and allows concurrent access
for _, key := range candidates {
p.mu.Lock()
shared, exists := p.transports[key]
if exists && shared.refCount <= 0 && now.Sub(shared.lastUsed) > 2*time.Minute {
// Remove from map first (releases memory)
delete(p.transports, key)
atomic.AddInt32(&p.clientCount, -1)
p.mu.Unlock()
// Close idle connections outside the lock (can be slow)
if shared.transport != nil {
shared.transport.CloseIdleConnections()
p.mu.Lock()
now := time.Now()
for transportKey, shared := range p.transports {
// Clean up transports not used for 2 minutes with no references
if shared.refCount <= 0 && now.Sub(shared.lastUsed) > 2*time.Minute {
shared.transport.CloseIdleConnections()
delete(p.transports, transportKey)
// SECURITY FIX: Decrement client count when removing transport
atomic.AddInt32(&p.clientCount, -1)
}
}
} else {
p.mu.Unlock()
}
}
-691
View File
@@ -1,691 +0,0 @@
package traefikoidc
import (
"context"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSharedTransportPoolGetOrCreateTransport tests transport creation and reuse
func TestSharedTransportPoolGetOrCreateTransport(t *testing.T) {
t.Run("create new transport", func(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
}
config := DefaultHTTPClientConfig()
transport := pool.GetOrCreateTransport(config)
require.NotNil(t, transport)
assert.Equal(t, int32(1), atomic.LoadInt32(&pool.clientCount))
assert.Len(t, pool.transports, 1)
})
t.Run("reuse existing transport", func(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
}
config := DefaultHTTPClientConfig()
transport1 := pool.GetOrCreateTransport(config)
transport2 := pool.GetOrCreateTransport(config)
assert.Equal(t, transport1, transport2, "should reuse same transport")
assert.Equal(t, int32(1), atomic.LoadInt32(&pool.clientCount), "client count should not increase")
// Check ref count
pool.mu.RLock()
key := pool.configKey(config)
shared := pool.transports[key]
pool.mu.RUnlock()
assert.Equal(t, 2, shared.refCount, "ref count should be 2")
})
t.Run("client limit enforcement", func(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 5, // Already at max
maxClients: 5,
}
config := DefaultHTTPClientConfig()
transport := pool.GetOrCreateTransport(config)
assert.Nil(t, transport, "should return nil when at client limit")
})
t.Run("client limit with existing transport", func(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
}
// Create first transport
config1 := DefaultHTTPClientConfig()
transport1 := pool.GetOrCreateTransport(config1)
require.NotNil(t, transport1)
// Set client count to max
atomic.StoreInt32(&pool.clientCount, 5)
// Try to create with different config
config2 := DefaultHTTPClientConfig()
config2.MaxConnsPerHost = 15 // Different config
transport2 := pool.GetOrCreateTransport(config2)
// Should return existing transport since at limit
assert.NotNil(t, transport2)
assert.Equal(t, transport1, transport2)
})
t.Run("updates last used time", func(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
}
config := DefaultHTTPClientConfig()
transport := pool.GetOrCreateTransport(config)
require.NotNil(t, transport)
pool.mu.RLock()
key := pool.configKey(config)
firstTime := pool.transports[key].lastUsed
pool.mu.RUnlock()
time.Sleep(10 * time.Millisecond)
// Get again
transport2 := pool.GetOrCreateTransport(config)
require.NotNil(t, transport2)
pool.mu.RLock()
secondTime := pool.transports[key].lastUsed
pool.mu.RUnlock()
assert.True(t, secondTime.After(firstTime), "lastUsed should be updated")
})
}
// TestSharedTransportPoolReleaseTransport tests transport release
func TestSharedTransportPoolReleaseTransport(t *testing.T) {
t.Run("decrement ref count", func(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
}
config := DefaultHTTPClientConfig()
transport := pool.GetOrCreateTransport(config)
require.NotNil(t, transport)
// Get again to increase ref count
pool.GetOrCreateTransport(config)
pool.mu.RLock()
key := pool.configKey(config)
refCount := pool.transports[key].refCount
pool.mu.RUnlock()
assert.Equal(t, 2, refCount)
// Release
pool.ReleaseTransport(transport)
pool.mu.RLock()
newRefCount := pool.transports[key].refCount
pool.mu.RUnlock()
assert.Equal(t, 1, newRefCount)
})
t.Run("ref count reaches zero", func(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
}
config := DefaultHTTPClientConfig()
transport := pool.GetOrCreateTransport(config)
require.NotNil(t, transport)
pool.mu.RLock()
key := pool.configKey(config)
pool.mu.RUnlock()
// Release to zero
pool.ReleaseTransport(transport)
pool.mu.RLock()
shared := pool.transports[key]
pool.mu.RUnlock()
assert.Equal(t, 0, shared.refCount)
assert.NotZero(t, shared.lastUsed, "lastUsed should be set")
})
t.Run("release non-existent transport", func(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
}
// Create a transport not in the pool
fakeTransport := &http.Transport{}
// Should not panic
assert.NotPanics(t, func() {
pool.ReleaseTransport(fakeTransport)
})
})
t.Run("release updates last used", func(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
}
config := DefaultHTTPClientConfig()
transport := pool.GetOrCreateTransport(config)
require.NotNil(t, transport)
time.Sleep(10 * time.Millisecond)
beforeRelease := time.Now()
pool.ReleaseTransport(transport)
pool.mu.RLock()
key := pool.configKey(config)
lastUsed := pool.transports[key].lastUsed
pool.mu.RUnlock()
assert.True(t, lastUsed.After(beforeRelease) || lastUsed.Equal(beforeRelease))
})
}
// TestSharedTransportPoolCleanup tests cleanup functionality
func TestSharedTransportPoolCleanup(t *testing.T) {
t.Run("cleanup all transports", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
ctx: ctx,
cancel: cancel,
}
// Create multiple transports
config1 := DefaultHTTPClientConfig()
pool.GetOrCreateTransport(config1)
config2 := DefaultHTTPClientConfig()
config2.MaxConnsPerHost = 15
pool.GetOrCreateTransport(config2)
assert.Greater(t, len(pool.transports), 0)
// Cleanup
pool.Cleanup()
assert.Len(t, pool.transports, 0, "all transports should be removed")
})
t.Run("cleanup cancels context", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
ctx: ctx,
cancel: cancel,
}
pool.Cleanup()
select {
case <-pool.ctx.Done():
// Context was canceled
case <-time.After(100 * time.Millisecond):
t.Error("context should be canceled")
}
})
t.Run("cleanup with no transports", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
ctx: ctx,
cancel: cancel,
}
assert.NotPanics(t, func() {
pool.Cleanup()
})
})
t.Run("cleanup closes idle connections", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
ctx: ctx,
cancel: cancel,
}
config := DefaultHTTPClientConfig()
transport := pool.GetOrCreateTransport(config)
require.NotNil(t, transport)
// Cleanup should call CloseIdleConnections on each transport
pool.Cleanup()
// Verify transports map is cleared
assert.Empty(t, pool.transports)
})
}
// TestSharedTransportPoolCleanupIdleTransports tests periodic cleanup
func TestSharedTransportPoolCleanupIdleTransports(t *testing.T) {
if testing.Short() {
t.Skip("Skipping cleanup goroutine test in short mode")
}
t.Run("cleanup removes idle transports", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
ctx: ctx,
cancel: cancel,
}
// Create transport and release it
config := DefaultHTTPClientConfig()
transport := pool.GetOrCreateTransport(config)
require.NotNil(t, transport)
pool.ReleaseTransport(transport)
// Set lastUsed to old time
pool.mu.Lock()
key := pool.configKey(config)
pool.transports[key].lastUsed = time.Now().Add(-3 * time.Minute)
pool.mu.Unlock()
// Start cleanup in background (simulating what would happen)
// Note: We're testing the cleanup logic manually here
pool.mu.Lock()
now := time.Now()
for transportKey, shared := range pool.transports {
if shared.refCount <= 0 && now.Sub(shared.lastUsed) > 2*time.Minute {
shared.transport.CloseIdleConnections()
delete(pool.transports, transportKey)
atomic.AddInt32(&pool.clientCount, -1)
}
}
pool.mu.Unlock()
// Transport should be removed
pool.mu.RLock()
_, exists := pool.transports[key]
pool.mu.RUnlock()
assert.False(t, exists, "old idle transport should be removed")
})
t.Run("cleanup preserves active transports", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
ctx: ctx,
cancel: cancel,
}
// Create transport with refs
config := DefaultHTTPClientConfig()
transport := pool.GetOrCreateTransport(config)
require.NotNil(t, transport)
// Keep ref count > 0, but set old lastUsed
pool.mu.Lock()
key := pool.configKey(config)
pool.transports[key].lastUsed = time.Now().Add(-3 * time.Minute)
pool.mu.Unlock()
// Run cleanup logic
pool.mu.Lock()
now := time.Now()
for transportKey, shared := range pool.transports {
if shared.refCount <= 0 && now.Sub(shared.lastUsed) > 2*time.Minute {
shared.transport.CloseIdleConnections()
delete(pool.transports, transportKey)
}
}
pool.mu.Unlock()
// Transport should still exist (has ref count)
pool.mu.RLock()
_, exists := pool.transports[key]
pool.mu.RUnlock()
assert.True(t, exists, "transport with references should be preserved")
})
t.Run("cleanup respects context cancellation", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
ctx: ctx,
cancel: cancel,
}
// Start cleanup goroutine
done := make(chan bool)
go func() {
pool.cleanupIdleTransports(ctx)
done <- true
}()
// Cancel context
cancel()
// Should exit quickly
select {
case <-done:
// Success
case <-time.After(2 * time.Second):
t.Error("cleanup goroutine should exit on context cancellation")
}
})
}
// TestCreatePooledHTTPClient tests pooled client creation
func TestCreatePooledHTTPClient(t *testing.T) {
t.Run("create client with default config", func(t *testing.T) {
config := DefaultHTTPClientConfig()
client := CreatePooledHTTPClient(config)
require.NotNil(t, client)
assert.NotNil(t, client.Transport)
assert.Equal(t, config.Timeout, client.Timeout)
})
t.Run("create multiple clients reuse transport", func(t *testing.T) {
// Reset global pool for clean test
globalTransportPoolOnce = sync.Once{}
globalTransportPool = nil
config := DefaultHTTPClientConfig()
client1 := CreatePooledHTTPClient(config)
client2 := CreatePooledHTTPClient(config)
require.NotNil(t, client1)
require.NotNil(t, client2)
// Should use same transport
assert.Equal(t, client1.Transport, client2.Transport)
})
t.Run("redirect policy is set", func(t *testing.T) {
config := DefaultHTTPClientConfig()
config.MaxRedirects = 3
client := CreatePooledHTTPClient(config)
require.NotNil(t, client)
assert.NotNil(t, client.CheckRedirect)
// Test redirect limit
var redirects []*http.Request
for i := 0; i < 3; i++ {
redirects = append(redirects, &http.Request{})
}
err := client.CheckRedirect(nil, redirects)
assert.Error(t, err, "should error after max redirects")
})
t.Run("default redirect limit", func(t *testing.T) {
config := DefaultHTTPClientConfig()
config.MaxRedirects = 0 // Should default to 10
client := CreatePooledHTTPClient(config)
require.NotNil(t, client)
// Test default redirect limit (10)
var redirects []*http.Request
for i := 0; i < 10; i++ {
redirects = append(redirects, &http.Request{})
}
err := client.CheckRedirect(nil, redirects)
assert.Error(t, err, "should error after 10 redirects")
})
}
// TestGetGlobalTransportPool tests singleton pattern
func TestGetGlobalTransportPool(t *testing.T) {
t.Run("returns same instance", func(t *testing.T) {
pool1 := GetGlobalTransportPool()
pool2 := GetGlobalTransportPool()
assert.Equal(t, pool1, pool2, "should return same singleton instance")
})
t.Run("pool is initialized", func(t *testing.T) {
pool := GetGlobalTransportPool()
require.NotNil(t, pool)
assert.NotNil(t, pool.transports)
assert.Equal(t, 20, pool.maxConns)
assert.Equal(t, int32(5), pool.maxClients)
assert.NotNil(t, pool.ctx)
assert.NotNil(t, pool.cancel)
})
}
// TestSharedTransportPoolConcurrency tests thread safety
func TestSharedTransportPoolConcurrency(t *testing.T) {
if testing.Short() {
t.Skip("Skipping concurrency test in short mode")
}
t.Run("concurrent GetOrCreateTransport", func(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 10, // Allow more for concurrency test
}
config := DefaultHTTPClientConfig()
const numGoroutines = 20
var wg sync.WaitGroup
transports := make([]*http.Transport, numGoroutines)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
transports[idx] = pool.GetOrCreateTransport(config)
}(i)
}
wg.Wait()
// All should get same transport
firstTransport := transports[0]
for i := 1; i < numGoroutines; i++ {
if transports[i] != nil {
assert.Equal(t, firstTransport, transports[i])
}
}
})
t.Run("concurrent ReleaseTransport", func(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 10,
}
config := DefaultHTTPClientConfig()
transport := pool.GetOrCreateTransport(config)
// Increase ref count
for i := 0; i < 20; i++ {
pool.GetOrCreateTransport(config)
}
const numReleases = 20
var wg sync.WaitGroup
for i := 0; i < numReleases; i++ {
wg.Add(1)
go func() {
defer wg.Done()
pool.ReleaseTransport(transport)
}()
}
wg.Wait()
// Should not panic and ref count should be decremented
pool.mu.RLock()
key := pool.configKey(config)
refCount := pool.transports[key].refCount
pool.mu.RUnlock()
assert.Equal(t, 1, refCount, "ref count should be 1 after 20 releases from initial 21")
})
}
// TestSharedTransportPoolEdgeCases tests edge cases
func TestSharedTransportPoolEdgeCases(t *testing.T) {
t.Run("config key generation", func(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
}
config1 := DefaultHTTPClientConfig()
config1.MaxConnsPerHost = 10
config1.MaxIdleConnsPerHost = 5
config2 := DefaultHTTPClientConfig()
config2.MaxConnsPerHost = 10
config2.MaxIdleConnsPerHost = 5
key1 := pool.configKey(config1)
key2 := pool.configKey(config2)
assert.Equal(t, key1, key2, "same config should produce same key")
})
t.Run("different configs produce different keys", func(t *testing.T) {
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
}
config1 := DefaultHTTPClientConfig()
config1.MaxConnsPerHost = 10
config2 := DefaultHTTPClientConfig()
config2.MaxConnsPerHost = 20
key1 := pool.configKey(config1)
key2 := pool.configKey(config2)
assert.NotEqual(t, key1, key2, "different configs should produce different keys")
})
t.Run("client count decrements on cleanup", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
ctx: ctx,
cancel: cancel,
}
config := DefaultHTTPClientConfig()
transport := pool.GetOrCreateTransport(config)
require.NotNil(t, transport)
initialCount := atomic.LoadInt32(&pool.clientCount)
assert.Equal(t, int32(1), initialCount)
// Release and mark as old
pool.ReleaseTransport(transport)
pool.mu.Lock()
key := pool.configKey(config)
pool.transports[key].lastUsed = time.Now().Add(-3 * time.Minute)
pool.mu.Unlock()
// Run cleanup
pool.mu.Lock()
now := time.Now()
for transportKey, shared := range pool.transports {
if shared.refCount <= 0 && now.Sub(shared.lastUsed) > 2*time.Minute {
shared.transport.CloseIdleConnections()
delete(pool.transports, transportKey)
atomic.AddInt32(&pool.clientCount, -1)
}
}
pool.mu.Unlock()
finalCount := atomic.LoadInt32(&pool.clientCount)
assert.Equal(t, int32(0), finalCount, "client count should decrement on cleanup")
})
}
+1 -1
View File
@@ -355,7 +355,7 @@ func (c *Cache) removeItem(key string, item *Item) {
func (c *Cache) evictLRU() {
if elem := c.lruList.Back(); elem != nil {
item, _ := elem.Value.(*Item) // Safe to ignore: type assertion from known type
item := elem.Value.(*Item)
c.removeItem(item.Key, item)
atomic.AddInt64(&c.evictions, 1)
c.logger.Debugf("Cache: Evicted LRU item key=%s", item.Key)
-2
View File
@@ -1,5 +1,3 @@
// Package cache provides high-performance caching implementations for OIDC tokens, metadata, and JWKs.
// It includes compatibility wrappers for backward compatibility with existing cache interfaces.
package cache
import (
+3 -17
View File
@@ -1,12 +1,9 @@
package cache
import (
"bytes"
"encoding/json"
"fmt"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/pool"
)
// TypedCache provides a type-safe wrapper around Cache for specific types
@@ -45,24 +42,13 @@ func (tc *TypedCache[T]) Get(key string) (T, bool) {
}
// If that fails, try JSON marshaling/unmarshaling for complex types
// Use pooled buffer for encoding
pm := pool.Get()
buf := pm.GetBuffer(256)
defer pm.PutBuffer(buf)
encoder := pm.GetJSONEncoder(buf)
defer pm.PutJSONEncoder(encoder)
if err := encoder.Encode(value); err != nil {
data, err := json.Marshal(value)
if err != nil {
return zero, false
}
// Decode using pooled decoder
var result T
decoder := pm.GetJSONDecoder(bytes.NewReader(buf.Bytes()))
defer pm.PutJSONDecoder(decoder)
if err := decoder.Decode(&result); err != nil {
if err := json.Unmarshal(data, &result); err != nil {
return zero, false
}
-219
View File
@@ -1,219 +0,0 @@
// Package errors provides unified error handling for OIDC operations
package errors
import (
"fmt"
"net/http"
)
// ErrorCode represents specific error types
type ErrorCode string
const (
// Authentication errors
ErrCodeAuthenticationFailed ErrorCode = "AUTH_FAILED"
ErrCodeTokenExpired ErrorCode = "TOKEN_EXPIRED"
ErrCodeTokenInvalid ErrorCode = "TOKEN_INVALID"
ErrCodeSessionExpired ErrorCode = "SESSION_EXPIRED"
ErrCodeCSRFMismatch ErrorCode = "CSRF_MISMATCH"
ErrCodeNonceMismatch ErrorCode = "NONCE_MISMATCH"
// Configuration errors
ErrCodeConfigInvalid ErrorCode = "CONFIG_INVALID"
ErrCodeProviderUnreachable ErrorCode = "PROVIDER_UNREACHABLE"
ErrCodeMetadataFailed ErrorCode = "METADATA_FAILED"
// Network errors
ErrCodeNetworkTimeout ErrorCode = "NETWORK_TIMEOUT"
ErrCodeRateLimited ErrorCode = "RATE_LIMITED"
ErrCodeServiceUnavailable ErrorCode = "SERVICE_UNAVAILABLE"
// Validation errors
ErrCodeValidationFailed ErrorCode = "VALIDATION_FAILED"
ErrCodeDomainNotAllowed ErrorCode = "DOMAIN_NOT_ALLOWED"
ErrCodeUserNotAllowed ErrorCode = "USER_NOT_ALLOWED"
ErrCodeRoleNotAllowed ErrorCode = "ROLE_NOT_ALLOWED"
)
// OIDCError represents a structured error with context
type OIDCError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
HTTPStatus int `json:"http_status"`
Internal error `json:"-"` // Internal error, not exposed
}
// Error implements the error interface
func (e *OIDCError) Error() string {
if e.Details != "" {
return fmt.Sprintf("%s: %s (%s)", e.Code, e.Message, e.Details)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
// Unwrap returns the internal error for error wrapping
func (e *OIDCError) Unwrap() error {
return e.Internal
}
// IsRetryable indicates if the error is temporary and can be retried
func (e *OIDCError) IsRetryable() bool {
return e.Code == ErrCodeNetworkTimeout ||
e.Code == ErrCodeServiceUnavailable ||
e.Code == ErrCodeProviderUnreachable
}
// IsAuthenticationError indicates if this is an authentication-related error
func (e *OIDCError) IsAuthenticationError() bool {
return e.Code == ErrCodeAuthenticationFailed ||
e.Code == ErrCodeTokenExpired ||
e.Code == ErrCodeTokenInvalid ||
e.Code == ErrCodeSessionExpired ||
e.Code == ErrCodeCSRFMismatch ||
e.Code == ErrCodeNonceMismatch
}
// IsAuthorizationError indicates if this is an authorization-related error
func (e *OIDCError) IsAuthorizationError() bool {
return e.Code == ErrCodeDomainNotAllowed ||
e.Code == ErrCodeUserNotAllowed ||
e.Code == ErrCodeRoleNotAllowed
}
// ToJSON converts the error to a JSON response
func (e *OIDCError) ToJSON() map[string]any {
result := map[string]any{
"error": map[string]any{
"code": string(e.Code),
"message": e.Message,
},
}
if e.Details != "" {
errorMap, _ := result["error"].(map[string]any) // Safe to ignore: type assertion from known type
errorMap["details"] = e.Details
}
return result
}
// Error constructors for common scenarios
// NewAuthenticationError creates an authentication-related error
func NewAuthenticationError(code ErrorCode, message string, internal error) *OIDCError {
status := http.StatusUnauthorized
if code == ErrCodeSessionExpired {
status = http.StatusForbidden
}
return &OIDCError{
Code: code,
Message: message,
HTTPStatus: status,
Internal: internal,
}
}
// NewAuthorizationError creates an authorization-related error
func NewAuthorizationError(code ErrorCode, message string, details string) *OIDCError {
return &OIDCError{
Code: code,
Message: message,
Details: details,
HTTPStatus: http.StatusForbidden,
}
}
// NewConfigurationError creates a configuration-related error
func NewConfigurationError(code ErrorCode, message string, internal error) *OIDCError {
return &OIDCError{
Code: code,
Message: message,
HTTPStatus: http.StatusInternalServerError,
Internal: internal,
}
}
// NewNetworkError creates a network-related error
func NewNetworkError(code ErrorCode, message string, internal error) *OIDCError {
status := http.StatusServiceUnavailable
if code == ErrCodeRateLimited {
status = http.StatusTooManyRequests
}
return &OIDCError{
Code: code,
Message: message,
HTTPStatus: status,
Internal: internal,
}
}
// NewValidationError creates a validation-related error
func NewValidationError(code ErrorCode, message string, details string) *OIDCError {
return &OIDCError{
Code: code,
Message: message,
Details: details,
HTTPStatus: http.StatusBadRequest,
}
}
// Convenience functions for common error patterns
// WrapAuthenticationError wraps an existing error as an authentication error
func WrapAuthenticationError(err error, message string) *OIDCError {
return NewAuthenticationError(ErrCodeAuthenticationFailed, message, err)
}
// WrapTokenError wraps a token-related error
func WrapTokenError(err error, tokenType string) *OIDCError {
message := fmt.Sprintf("Token validation failed: %s", tokenType)
return NewAuthenticationError(ErrCodeTokenInvalid, message, err)
}
// WrapProviderError wraps a provider communication error
func WrapProviderError(err error, providerURL string) *OIDCError {
message := fmt.Sprintf("Provider communication failed: %s", providerURL)
return NewNetworkError(ErrCodeProviderUnreachable, message, err)
}
// IsOIDCError checks if an error is an OIDCError
func IsOIDCError(err error) (*OIDCError, bool) {
oidcErr, ok := err.(*OIDCError)
return oidcErr, ok
}
// GetHTTPStatus extracts HTTP status from error, defaulting to 500
func GetHTTPStatus(err error) int {
if oidcErr, ok := IsOIDCError(err); ok {
return oidcErr.HTTPStatus
}
return http.StatusInternalServerError
}
// FormatUserMessage creates a user-friendly error message
func FormatUserMessage(err error) string {
if oidcErr, ok := IsOIDCError(err); ok {
switch oidcErr.Code {
case ErrCodeDomainNotAllowed:
return "Your email domain is not authorized for this application"
case ErrCodeUserNotAllowed:
return "Your account is not authorized for this application"
case ErrCodeRoleNotAllowed:
return "You do not have the required permissions for this application"
case ErrCodeSessionExpired:
return "Your session has expired. Please log in again"
case ErrCodeTokenExpired:
return "Your authentication has expired. Please log in again"
case ErrCodeProviderUnreachable:
return "Authentication service is temporarily unavailable. Please try again later"
case ErrCodeRateLimited:
return "Too many requests. Please wait a moment and try again"
default:
return "Authentication failed. Please try again"
}
}
return "An unexpected error occurred. Please try again"
}
-529
View File
@@ -1,529 +0,0 @@
package errors
import (
"errors"
"net/http"
"reflect"
"testing"
)
func TestOIDCError_Error(t *testing.T) {
tests := []struct {
name string
oidcErr *OIDCError
expected string
}{
{
name: "Error with details",
oidcErr: &OIDCError{
Code: ErrCodeTokenInvalid,
Message: "Token validation failed",
Details: "JWT signature invalid",
},
expected: "TOKEN_INVALID: Token validation failed (JWT signature invalid)",
},
{
name: "Error without details",
oidcErr: &OIDCError{
Code: ErrCodeAuthenticationFailed,
Message: "Authentication failed",
},
expected: "AUTH_FAILED: Authentication failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.oidcErr.Error()
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestOIDCError_Unwrap(t *testing.T) {
internalErr := errors.New("internal error")
oidcErr := &OIDCError{
Code: ErrCodeTokenInvalid,
Message: "Token validation failed",
Internal: internalErr,
}
unwrapped := oidcErr.Unwrap()
if unwrapped != internalErr {
t.Errorf("Expected internal error, got %v", unwrapped)
}
// Test with nil internal error
oidcErrNoInternal := &OIDCError{
Code: ErrCodeTokenInvalid,
Message: "Token validation failed",
}
unwrappedNil := oidcErrNoInternal.Unwrap()
if unwrappedNil != nil {
t.Errorf("Expected nil, got %v", unwrappedNil)
}
}
func TestOIDCError_IsRetryable(t *testing.T) {
tests := []struct {
name string
code ErrorCode
expected bool
}{
{"Network timeout", ErrCodeNetworkTimeout, true},
{"Service unavailable", ErrCodeServiceUnavailable, true},
{"Provider unreachable", ErrCodeProviderUnreachable, true},
{"Authentication failed", ErrCodeAuthenticationFailed, false},
{"Token invalid", ErrCodeTokenInvalid, false},
{"Rate limited", ErrCodeRateLimited, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oidcErr := &OIDCError{Code: tt.code}
result := oidcErr.IsRetryable()
if result != tt.expected {
t.Errorf("Expected %v, got %v for code %s", tt.expected, result, tt.code)
}
})
}
}
func TestOIDCError_IsAuthenticationError(t *testing.T) {
tests := []struct {
name string
code ErrorCode
expected bool
}{
{"Authentication failed", ErrCodeAuthenticationFailed, true},
{"Token expired", ErrCodeTokenExpired, true},
{"Token invalid", ErrCodeTokenInvalid, true},
{"Session expired", ErrCodeSessionExpired, true},
{"CSRF mismatch", ErrCodeCSRFMismatch, true},
{"Nonce mismatch", ErrCodeNonceMismatch, true},
{"Config invalid", ErrCodeConfigInvalid, false},
{"Domain not allowed", ErrCodeDomainNotAllowed, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oidcErr := &OIDCError{Code: tt.code}
result := oidcErr.IsAuthenticationError()
if result != tt.expected {
t.Errorf("Expected %v, got %v for code %s", tt.expected, result, tt.code)
}
})
}
}
func TestOIDCError_IsAuthorizationError(t *testing.T) {
tests := []struct {
name string
code ErrorCode
expected bool
}{
{"Domain not allowed", ErrCodeDomainNotAllowed, true},
{"User not allowed", ErrCodeUserNotAllowed, true},
{"Role not allowed", ErrCodeRoleNotAllowed, true},
{"Authentication failed", ErrCodeAuthenticationFailed, false},
{"Token expired", ErrCodeTokenExpired, false},
{"Config invalid", ErrCodeConfigInvalid, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oidcErr := &OIDCError{Code: tt.code}
result := oidcErr.IsAuthorizationError()
if result != tt.expected {
t.Errorf("Expected %v, got %v for code %s", tt.expected, result, tt.code)
}
})
}
}
func TestOIDCError_ToJSON(t *testing.T) {
tests := []struct {
name string
oidcErr *OIDCError
expected map[string]any
}{
{
name: "Error with details",
oidcErr: &OIDCError{
Code: ErrCodeTokenInvalid,
Message: "Token validation failed",
Details: "JWT signature invalid",
},
expected: map[string]any{
"error": map[string]any{
"code": "TOKEN_INVALID",
"message": "Token validation failed",
"details": "JWT signature invalid",
},
},
},
{
name: "Error without details",
oidcErr: &OIDCError{
Code: ErrCodeAuthenticationFailed,
Message: "Authentication failed",
},
expected: map[string]any{
"error": map[string]any{
"code": "AUTH_FAILED",
"message": "Authentication failed",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.oidcErr.ToJSON()
if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("Expected %+v, got %+v", tt.expected, result)
}
})
}
}
func TestNewAuthenticationError(t *testing.T) {
internalErr := errors.New("internal error")
tests := []struct {
name string
code ErrorCode
message string
internal error
expectedHTTP int
}{
{
name: "Regular auth error",
code: ErrCodeAuthenticationFailed,
message: "Auth failed",
internal: internalErr,
expectedHTTP: http.StatusUnauthorized,
},
{
name: "Session expired error",
code: ErrCodeSessionExpired,
message: "Session expired",
internal: internalErr,
expectedHTTP: http.StatusForbidden,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := NewAuthenticationError(tt.code, tt.message, tt.internal)
if err.Code != tt.code {
t.Errorf("Expected code %s, got %s", tt.code, err.Code)
}
if err.Message != tt.message {
t.Errorf("Expected message '%s', got '%s'", tt.message, err.Message)
}
if err.Internal != tt.internal {
t.Errorf("Expected internal error %v, got %v", tt.internal, err.Internal)
}
if err.HTTPStatus != tt.expectedHTTP {
t.Errorf("Expected HTTP status %d, got %d", tt.expectedHTTP, err.HTTPStatus)
}
})
}
}
func TestNewAuthorizationError(t *testing.T) {
err := NewAuthorizationError(ErrCodeDomainNotAllowed, "Domain not allowed", "example.com not in whitelist")
if err.Code != ErrCodeDomainNotAllowed {
t.Errorf("Expected code %s, got %s", ErrCodeDomainNotAllowed, err.Code)
}
if err.Message != "Domain not allowed" {
t.Errorf("Expected message 'Domain not allowed', got '%s'", err.Message)
}
if err.Details != "example.com not in whitelist" {
t.Errorf("Expected details 'example.com not in whitelist', got '%s'", err.Details)
}
if err.HTTPStatus != http.StatusForbidden {
t.Errorf("Expected HTTP status %d, got %d", http.StatusForbidden, err.HTTPStatus)
}
}
func TestNewConfigurationError(t *testing.T) {
internalErr := errors.New("config parse error")
err := NewConfigurationError(ErrCodeConfigInvalid, "Invalid config", internalErr)
if err.Code != ErrCodeConfigInvalid {
t.Errorf("Expected code %s, got %s", ErrCodeConfigInvalid, err.Code)
}
if err.HTTPStatus != http.StatusInternalServerError {
t.Errorf("Expected HTTP status %d, got %d", http.StatusInternalServerError, err.HTTPStatus)
}
if err.Internal != internalErr {
t.Errorf("Expected internal error %v, got %v", internalErr, err.Internal)
}
}
func TestNewNetworkError(t *testing.T) {
internalErr := errors.New("network error")
tests := []struct {
name string
code ErrorCode
expectedHTTP int
}{
{
name: "Rate limited",
code: ErrCodeRateLimited,
expectedHTTP: http.StatusTooManyRequests,
},
{
name: "Service unavailable",
code: ErrCodeServiceUnavailable,
expectedHTTP: http.StatusServiceUnavailable,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := NewNetworkError(tt.code, "Network error", internalErr)
if err.Code != tt.code {
t.Errorf("Expected code %s, got %s", tt.code, err.Code)
}
if err.HTTPStatus != tt.expectedHTTP {
t.Errorf("Expected HTTP status %d, got %d", tt.expectedHTTP, err.HTTPStatus)
}
})
}
}
func TestNewValidationError(t *testing.T) {
err := NewValidationError(ErrCodeValidationFailed, "Validation failed", "field 'email' is required")
if err.Code != ErrCodeValidationFailed {
t.Errorf("Expected code %s, got %s", ErrCodeValidationFailed, err.Code)
}
if err.HTTPStatus != http.StatusBadRequest {
t.Errorf("Expected HTTP status %d, got %d", http.StatusBadRequest, err.HTTPStatus)
}
if err.Details != "field 'email' is required" {
t.Errorf("Expected details 'field 'email' is required', got '%s'", err.Details)
}
}
func TestWrapAuthenticationError(t *testing.T) {
internalErr := errors.New("original error")
err := WrapAuthenticationError(internalErr, "Custom auth message")
if err.Code != ErrCodeAuthenticationFailed {
t.Errorf("Expected code %s, got %s", ErrCodeAuthenticationFailed, err.Code)
}
if err.Message != "Custom auth message" {
t.Errorf("Expected message 'Custom auth message', got '%s'", err.Message)
}
if err.Internal != internalErr {
t.Errorf("Expected internal error %v, got %v", internalErr, err.Internal)
}
}
func TestWrapTokenError(t *testing.T) {
internalErr := errors.New("token error")
err := WrapTokenError(internalErr, "ID token")
if err.Code != ErrCodeTokenInvalid {
t.Errorf("Expected code %s, got %s", ErrCodeTokenInvalid, err.Code)
}
if err.Message != "Token validation failed: ID token" {
t.Errorf("Expected message 'Token validation failed: ID token', got '%s'", err.Message)
}
if err.Internal != internalErr {
t.Errorf("Expected internal error %v, got %v", internalErr, err.Internal)
}
}
func TestWrapProviderError(t *testing.T) {
internalErr := errors.New("provider error")
err := WrapProviderError(internalErr, "https://provider.example.com")
if err.Code != ErrCodeProviderUnreachable {
t.Errorf("Expected code %s, got %s", ErrCodeProviderUnreachable, err.Code)
}
if err.Message != "Provider communication failed: https://provider.example.com" {
t.Errorf("Expected specific message, got '%s'", err.Message)
}
if err.Internal != internalErr {
t.Errorf("Expected internal error %v, got %v", internalErr, err.Internal)
}
}
func TestIsOIDCError(t *testing.T) {
// Test with OIDCError
oidcErr := &OIDCError{Code: ErrCodeTokenInvalid, Message: "test"}
result, ok := IsOIDCError(oidcErr)
if !ok {
t.Error("Expected IsOIDCError to return true for OIDCError")
}
if result != oidcErr {
t.Error("Expected to get the same OIDCError back")
}
// Test with regular error
regularErr := errors.New("regular error")
result, ok = IsOIDCError(regularErr)
if ok {
t.Error("Expected IsOIDCError to return false for regular error")
}
if result != nil {
t.Error("Expected nil result for regular error")
}
}
func TestGetHTTPStatus(t *testing.T) {
// Test with OIDCError
oidcErr := &OIDCError{
Code: ErrCodeTokenInvalid,
HTTPStatus: http.StatusUnauthorized,
}
status := GetHTTPStatus(oidcErr)
if status != http.StatusUnauthorized {
t.Errorf("Expected %d, got %d", http.StatusUnauthorized, status)
}
// Test with regular error
regularErr := errors.New("regular error")
status = GetHTTPStatus(regularErr)
if status != http.StatusInternalServerError {
t.Errorf("Expected %d, got %d", http.StatusInternalServerError, status)
}
}
func TestFormatUserMessage(t *testing.T) {
tests := []struct {
name string
err error
expected string
}{
{
name: "Domain not allowed",
err: &OIDCError{Code: ErrCodeDomainNotAllowed},
expected: "Your email domain is not authorized for this application",
},
{
name: "User not allowed",
err: &OIDCError{Code: ErrCodeUserNotAllowed},
expected: "Your account is not authorized for this application",
},
{
name: "Role not allowed",
err: &OIDCError{Code: ErrCodeRoleNotAllowed},
expected: "You do not have the required permissions for this application",
},
{
name: "Session expired",
err: &OIDCError{Code: ErrCodeSessionExpired},
expected: "Your session has expired. Please log in again",
},
{
name: "Token expired",
err: &OIDCError{Code: ErrCodeTokenExpired},
expected: "Your authentication has expired. Please log in again",
},
{
name: "Provider unreachable",
err: &OIDCError{Code: ErrCodeProviderUnreachable},
expected: "Authentication service is temporarily unavailable. Please try again later",
},
{
name: "Rate limited",
err: &OIDCError{Code: ErrCodeRateLimited},
expected: "Too many requests. Please wait a moment and try again",
},
{
name: "Unknown OIDC error",
err: &OIDCError{Code: ErrCodeConfigInvalid},
expected: "Authentication failed. Please try again",
},
{
name: "Regular error",
err: errors.New("regular error"),
expected: "An unexpected error occurred. Please try again",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FormatUserMessage(tt.err)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestErrorCodes(t *testing.T) {
// Test that all error codes are defined correctly
codes := []ErrorCode{
ErrCodeAuthenticationFailed,
ErrCodeTokenExpired,
ErrCodeTokenInvalid,
ErrCodeSessionExpired,
ErrCodeCSRFMismatch,
ErrCodeNonceMismatch,
ErrCodeConfigInvalid,
ErrCodeProviderUnreachable,
ErrCodeMetadataFailed,
ErrCodeNetworkTimeout,
ErrCodeRateLimited,
ErrCodeServiceUnavailable,
ErrCodeValidationFailed,
ErrCodeDomainNotAllowed,
ErrCodeUserNotAllowed,
ErrCodeRoleNotAllowed,
}
for _, code := range codes {
if string(code) == "" {
t.Errorf("Error code %v is empty", code)
}
}
}
func TestErrorConstructorCompleteness(t *testing.T) {
// Test each constructor function to ensure they set all required fields
internalErr := errors.New("test error")
// Test NewAuthenticationError
authErr := NewAuthenticationError(ErrCodeAuthenticationFailed, "auth message", internalErr)
if authErr.Code == "" || authErr.Message == "" || authErr.HTTPStatus == 0 {
t.Error("NewAuthenticationError did not set all required fields")
}
// Test NewAuthorizationError
authzErr := NewAuthorizationError(ErrCodeDomainNotAllowed, "authz message", "details")
if authzErr.Code == "" || authzErr.Message == "" || authzErr.HTTPStatus == 0 {
t.Error("NewAuthorizationError did not set all required fields")
}
// Test NewConfigurationError
configErr := NewConfigurationError(ErrCodeConfigInvalid, "config message", internalErr)
if configErr.Code == "" || configErr.Message == "" || configErr.HTTPStatus == 0 {
t.Error("NewConfigurationError did not set all required fields")
}
// Test NewNetworkError
netErr := NewNetworkError(ErrCodeNetworkTimeout, "network message", internalErr)
if netErr.Code == "" || netErr.Message == "" || netErr.HTTPStatus == 0 {
t.Error("NewNetworkError did not set all required fields")
}
// Test NewValidationError
validErr := NewValidationError(ErrCodeValidationFailed, "validation message", "details")
if validErr.Code == "" || validErr.Message == "" || validErr.HTTPStatus == 0 {
t.Error("NewValidationError did not set all required fields")
}
}
-224
View File
@@ -1,224 +0,0 @@
// Package handlers provides authentication flow management
package handlers
import (
"net/http"
"time"
)
// AuthFlowHandler manages the complete OIDC authentication flow
type AuthFlowHandler struct {
sessionHandler *SessionHandler
tokenHandler TokenHandler
logger Logger
excludedURLs map[string]struct{}
initComplete chan struct{}
issuerURL string
}
// TokenHandler interface for token operations
type TokenHandler interface {
VerifyToken(token string) error
RefreshToken(refreshToken string) (*TokenResponse, error)
}
// TokenResponse represents token exchange response
type TokenResponse struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
// AuthFlowResult represents the result of authentication flow processing
type AuthFlowResult struct {
Authenticated bool
RequiresAuth bool
RequiresRefresh bool
Error error
RedirectURL string
StatusCode int
}
// NewAuthFlowHandler creates a new authentication flow handler
func NewAuthFlowHandler(sessionHandler *SessionHandler, tokenHandler TokenHandler, logger Logger, excludedURLs map[string]struct{}, initComplete chan struct{}, issuerURL string) *AuthFlowHandler {
return &AuthFlowHandler{
sessionHandler: sessionHandler,
tokenHandler: tokenHandler,
logger: logger,
excludedURLs: excludedURLs,
initComplete: initComplete,
issuerURL: issuerURL,
}
}
// ProcessRequest handles the main authentication flow
func (h *AuthFlowHandler) ProcessRequest(rw http.ResponseWriter, req *http.Request) AuthFlowResult {
// Check if URL should be excluded
if h.shouldExcludeURL(req.URL.Path) {
h.logger.Debugf("Request path %s excluded by configuration, bypassing OIDC", req.URL.Path)
return AuthFlowResult{Authenticated: true}
}
// Check for streaming requests
if h.isStreamingRequest(req) {
h.logger.Debugf("Streaming request detected, bypassing OIDC")
return AuthFlowResult{Authenticated: true}
}
// Wait for initialization
if !h.waitForInitialization(req) {
return AuthFlowResult{
Error: ErrInitializationTimeout,
StatusCode: http.StatusServiceUnavailable,
}
}
// Get and validate session
session, err := h.sessionHandler.sessionManager.GetSession(req)
if err != nil {
h.logger.Errorf("Error getting session: %v", err)
return AuthFlowResult{
RequiresAuth: true,
Error: err,
}
}
defer session.ReturnToPoolSafely()
// Clean up old cookies
h.sessionHandler.sessionManager.CleanupOldCookies(rw, req)
// Validate session
validationResult := h.sessionHandler.ValidateSession(session)
if !validationResult.Valid {
if validationResult.NeedsAuth {
return AuthFlowResult{RequiresAuth: true}
}
return AuthFlowResult{
Error: ErrSessionInvalid,
StatusCode: http.StatusUnauthorized,
}
}
// Check token validity and refresh if needed
return h.validateAndRefreshTokens(session, req, rw)
}
// shouldExcludeURL checks if a URL should bypass authentication
func (h *AuthFlowHandler) shouldExcludeURL(path string) bool {
for excludedURL := range h.excludedURLs {
if len(path) >= len(excludedURL) && path[:len(excludedURL)] == excludedURL {
return true
}
}
return false
}
// isStreamingRequest checks if request is a streaming request that should bypass auth
func (h *AuthFlowHandler) isStreamingRequest(req *http.Request) bool {
acceptHeader := req.Header.Get("Accept")
return acceptHeader == "text/event-stream"
}
// waitForInitialization waits for OIDC provider initialization
func (h *AuthFlowHandler) waitForInitialization(req *http.Request) bool {
select {
case <-h.initComplete:
if h.issuerURL == "" {
h.logger.Error("OIDC provider metadata initialization failed")
return false
}
return true
case <-req.Context().Done():
h.logger.Debug("Request canceled while waiting for OIDC initialization")
return false
case <-time.After(30 * time.Second):
h.logger.Error("Timeout waiting for OIDC initialization")
return false
}
}
// validateAndRefreshTokens handles token validation and refresh logic
func (h *AuthFlowHandler) validateAndRefreshTokens(session Session, req *http.Request, rw http.ResponseWriter) AuthFlowResult {
// Check access token if present
if accessToken := session.GetAccessToken(); accessToken != "" {
if err := h.tokenHandler.VerifyToken(accessToken); err != nil {
h.logger.Errorf("Access token validation failed: %v", err)
// Try refresh if refresh token is available
if refreshToken := session.GetRefreshToken(); refreshToken != "" {
return h.attemptTokenRefresh(session, req, rw)
}
return AuthFlowResult{RequiresAuth: true}
}
}
// Check ID token
if idToken := session.GetIDToken(); idToken != "" {
if err := h.tokenHandler.VerifyToken(idToken); err != nil {
h.logger.Errorf("ID token validation failed: %v", err)
// Try refresh if refresh token is available
if refreshToken := session.GetRefreshToken(); refreshToken != "" {
return h.attemptTokenRefresh(session, req, rw)
}
return AuthFlowResult{RequiresAuth: true}
}
}
return AuthFlowResult{Authenticated: true}
}
// attemptTokenRefresh tries to refresh tokens
func (h *AuthFlowHandler) attemptTokenRefresh(session Session, req *http.Request, rw http.ResponseWriter) AuthFlowResult {
refreshToken := session.GetRefreshToken()
if refreshToken == "" {
return AuthFlowResult{RequiresAuth: true}
}
// Check if this is an AJAX request
if h.sessionHandler.IsAjaxRequest(req) {
return AuthFlowResult{
Error: ErrSessionExpiredAjax,
StatusCode: http.StatusUnauthorized,
}
}
_, err := h.tokenHandler.RefreshToken(refreshToken)
if err != nil {
h.logger.Errorf("Token refresh failed: %v", err)
return AuthFlowResult{RequiresAuth: true}
}
// Update session with new tokens would be handled here
// Implementation depends on the actual session interface
if err := session.Save(req, rw); err != nil {
h.logger.Errorf("Failed to save refreshed session: %v", err)
return AuthFlowResult{
Error: err,
StatusCode: http.StatusInternalServerError,
}
}
return AuthFlowResult{Authenticated: true}
}
// Common errors
var (
ErrInitializationTimeout = &AuthFlowError{Code: "INIT_TIMEOUT", Message: "OIDC initialization timeout"}
ErrSessionInvalid = &AuthFlowError{Code: "SESSION_INVALID", Message: "Invalid session"}
ErrSessionExpiredAjax = &AuthFlowError{Code: "SESSION_EXPIRED_AJAX", Message: "Session expired for AJAX request"}
)
// AuthFlowError represents authentication flow errors
type AuthFlowError struct {
Code string
Message string
}
func (e *AuthFlowError) Error() string {
return e.Message
}
-588
View File
@@ -1,588 +0,0 @@
package handlers
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// Mock implementations that embed SessionHandler
type MockSessionHandlerWrapper struct {
*SessionHandler
}
func NewMockSessionHandlerWrapper() *MockSessionHandlerWrapper {
sessionManager := &MockSessionManager{}
logger := &MockLogger{}
sessionHandler := NewSessionHandler(
sessionManager,
logger,
"/logout",
"https://example.com/post-logout",
"https://provider.example.com/logout",
"test-client-id",
)
return &MockSessionHandlerWrapper{
SessionHandler: sessionHandler,
}
}
type MockSessionManager struct {
session Session
err error
}
func (m *MockSessionManager) GetSession(req *http.Request) (Session, error) {
return m.session, m.err
}
func (m *MockSessionManager) CleanupOldCookies(rw http.ResponseWriter, req *http.Request) {
// Mock implementation
}
type MockSession struct {
authenticated bool
email string
idToken string
accessToken string
refreshToken string
saveError error
clearError error
}
func (m *MockSession) GetAuthenticated() bool { return m.authenticated }
func (m *MockSession) SetAuthenticated(auth bool) error { m.authenticated = auth; return nil }
func (m *MockSession) GetEmail() string { return m.email }
func (m *MockSession) SetEmail(email string) { m.email = email }
func (m *MockSession) GetIDToken() string { return m.idToken }
func (m *MockSession) GetAccessToken() string { return m.accessToken }
func (m *MockSession) GetRefreshToken() string { return m.refreshToken }
func (m *MockSession) SetRefreshToken(token string) { m.refreshToken = token }
func (m *MockSession) Clear(req *http.Request, rw http.ResponseWriter) error { return m.clearError }
func (m *MockSession) Save(req *http.Request, rw http.ResponseWriter) error { return m.saveError }
func (m *MockSession) ReturnToPoolSafely() {}
type MockTokenHandler struct {
verifyError error
refreshError error
tokenResponse *TokenResponse
}
func (m *MockTokenHandler) VerifyToken(token string) error {
return m.verifyError
}
func (m *MockTokenHandler) RefreshToken(refreshToken string) (*TokenResponse, error) {
return m.tokenResponse, m.refreshError
}
type MockLogger struct {
debugMessages []string
errorMessages []string
}
func (m *MockLogger) Debug(msg string) {
m.debugMessages = append(m.debugMessages, msg)
}
func (m *MockLogger) Debugf(format string, args ...interface{}) {
m.debugMessages = append(m.debugMessages, format)
}
func (m *MockLogger) Info(msg string) {}
func (m *MockLogger) Infof(format string, args ...interface{}) {}
func (m *MockLogger) Error(msg string) {
m.errorMessages = append(m.errorMessages, msg)
}
func (m *MockLogger) Errorf(format string, args ...interface{}) {
m.errorMessages = append(m.errorMessages, format)
}
func TestNewAuthFlowHandler(t *testing.T) {
sessionHandler := NewMockSessionHandlerWrapper()
tokenHandler := &MockTokenHandler{}
logger := &MockLogger{}
excludedURLs := map[string]struct{}{"/health": {}}
initComplete := make(chan struct{})
issuerURL := "https://issuer.example.com"
handler := NewAuthFlowHandler(sessionHandler.SessionHandler, tokenHandler, logger, excludedURLs, initComplete, issuerURL)
if handler == nil {
t.Fatal("NewAuthFlowHandler returned nil")
}
if handler.sessionHandler == nil {
t.Error("SessionHandler not set correctly")
}
if handler.tokenHandler != tokenHandler {
t.Error("TokenHandler not set correctly")
}
if handler.logger != logger {
t.Error("Logger not set correctly")
}
if handler.issuerURL != issuerURL {
t.Error("IssuerURL not set correctly")
}
}
func TestAuthFlowHandler_shouldExcludeURL(t *testing.T) {
excludedURLs := map[string]struct{}{
"/health": {},
"/metrics": {},
"/api/public": {},
}
handler := &AuthFlowHandler{excludedURLs: excludedURLs}
tests := []struct {
path string
expected bool
}{
{"/health", true},
{"/health/check", true},
{"/metrics", true},
{"/metrics/prometheus", true},
{"/api/public", true},
{"/api/public/endpoint", true},
{"/api/private", false},
{"/login", false},
{"/dashboard", false},
}
for _, test := range tests {
result := handler.shouldExcludeURL(test.path)
if result != test.expected {
t.Errorf("For path '%s': expected %v, got %v", test.path, test.expected, result)
}
}
}
func TestAuthFlowHandler_isStreamingRequest(t *testing.T) {
handler := &AuthFlowHandler{}
tests := []struct {
name string
accept string
expected bool
}{
{
name: "SSE request",
accept: "text/event-stream",
expected: true,
},
{
name: "Regular HTML request",
accept: "text/html,application/xhtml+xml",
expected: false,
},
{
name: "JSON request",
accept: "application/json",
expected: false,
},
{
name: "Empty accept header",
accept: "",
expected: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Accept", test.accept)
result := handler.isStreamingRequest(req)
if result != test.expected {
t.Errorf("Expected %v, got %v", test.expected, result)
}
})
}
}
func TestAuthFlowHandler_waitForInitialization(t *testing.T) {
tests := []struct {
name string
setupHandler func() (*AuthFlowHandler, context.CancelFunc)
expectedResult bool
}{
{
name: "Initialization complete successfully",
setupHandler: func() (*AuthFlowHandler, context.CancelFunc) {
initComplete := make(chan struct{})
close(initComplete) // Already complete
handler := &AuthFlowHandler{
initComplete: initComplete,
issuerURL: "https://issuer.example.com",
}
return handler, nil
},
expectedResult: true,
},
{
name: "Initialization complete but no issuer URL",
setupHandler: func() (*AuthFlowHandler, context.CancelFunc) {
initComplete := make(chan struct{})
close(initComplete)
handler := &AuthFlowHandler{
initComplete: initComplete,
issuerURL: "",
logger: &MockLogger{},
}
return handler, nil
},
expectedResult: false,
},
{
name: "Request canceled",
setupHandler: func() (*AuthFlowHandler, context.CancelFunc) {
initComplete := make(chan struct{})
handler := &AuthFlowHandler{
initComplete: initComplete,
logger: &MockLogger{},
}
_, cancel := context.WithCancel(context.Background())
return handler, cancel
},
expectedResult: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler, cancelFunc := test.setupHandler()
req := httptest.NewRequest("GET", "/", nil)
if cancelFunc != nil {
ctx, cancel := context.WithCancel(context.Background())
req = req.WithContext(ctx)
cancel() // Cancel immediately
}
result := handler.waitForInitialization(req)
if result != test.expectedResult {
t.Errorf("Expected %v, got %v", test.expectedResult, result)
}
})
}
}
func TestAuthFlowHandler_ProcessRequest(t *testing.T) {
tests := []struct {
name string
setupRequest func() *http.Request
setupHandler func() *AuthFlowHandler
expectedResult AuthFlowResult
}{
{
name: "Excluded URL bypasses authentication",
setupRequest: func() *http.Request {
return httptest.NewRequest("GET", "/health", nil)
},
setupHandler: func() *AuthFlowHandler {
return &AuthFlowHandler{
excludedURLs: map[string]struct{}{"/health": {}},
logger: &MockLogger{},
}
},
expectedResult: AuthFlowResult{Authenticated: true},
},
{
name: "Streaming request bypasses authentication",
setupRequest: func() *http.Request {
req := httptest.NewRequest("GET", "/events", nil)
req.Header.Set("Accept", "text/event-stream")
return req
},
setupHandler: func() *AuthFlowHandler {
return &AuthFlowHandler{
excludedURLs: map[string]struct{}{},
logger: &MockLogger{},
}
},
expectedResult: AuthFlowResult{Authenticated: true},
},
{
name: "Initialization timeout",
setupRequest: func() *http.Request {
return httptest.NewRequest("GET", "/dashboard", nil)
},
setupHandler: func() *AuthFlowHandler {
return &AuthFlowHandler{
excludedURLs: map[string]struct{}{},
initComplete: make(chan struct{}), // Never closes
logger: &MockLogger{},
}
},
expectedResult: AuthFlowResult{
Error: ErrInitializationTimeout,
StatusCode: http.StatusServiceUnavailable,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := test.setupRequest()
handler := test.setupHandler()
rw := httptest.NewRecorder()
// For timeout test, use context with timeout
if test.name == "Initialization timeout" {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
req = req.WithContext(ctx)
}
result := handler.ProcessRequest(rw, req)
if result.Authenticated != test.expectedResult.Authenticated {
t.Errorf("Expected Authenticated %v, got %v", test.expectedResult.Authenticated, result.Authenticated)
}
if result.StatusCode != test.expectedResult.StatusCode {
t.Errorf("Expected StatusCode %d, got %d", test.expectedResult.StatusCode, result.StatusCode)
}
if test.expectedResult.Error != nil && result.Error == nil {
t.Error("Expected error but got nil")
}
})
}
}
func TestAuthFlowHandler_validateAndRefreshTokens(t *testing.T) {
tests := []struct {
name string
session *MockSession
tokenHandler *MockTokenHandler
expectedResult AuthFlowResult
}{
{
name: "Valid access token",
session: &MockSession{
authenticated: true,
accessToken: "valid-access-token",
},
tokenHandler: &MockTokenHandler{
verifyError: nil,
},
expectedResult: AuthFlowResult{Authenticated: true},
},
{
name: "Invalid access token, successful refresh",
session: &MockSession{
authenticated: true,
accessToken: "invalid-access-token",
refreshToken: "valid-refresh-token",
},
tokenHandler: &MockTokenHandler{
verifyError: errors.New("token expired"),
refreshError: nil,
tokenResponse: &TokenResponse{
IDToken: "new-id-token",
AccessToken: "new-access-token",
},
},
expectedResult: AuthFlowResult{Authenticated: true},
},
{
name: "Invalid access token, no refresh token",
session: &MockSession{
authenticated: true,
accessToken: "invalid-access-token",
refreshToken: "",
},
tokenHandler: &MockTokenHandler{
verifyError: errors.New("token expired"),
},
expectedResult: AuthFlowResult{RequiresAuth: true},
},
{
name: "Valid ID token only",
session: &MockSession{
authenticated: true,
idToken: "valid-id-token",
},
tokenHandler: &MockTokenHandler{
verifyError: nil,
},
expectedResult: AuthFlowResult{Authenticated: true},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := &AuthFlowHandler{
tokenHandler: test.tokenHandler,
logger: &MockLogger{},
}
req := httptest.NewRequest("GET", "/", nil)
rw := httptest.NewRecorder()
result := handler.validateAndRefreshTokens(test.session, req, rw)
if result.Authenticated != test.expectedResult.Authenticated {
t.Errorf("Expected Authenticated %v, got %v", test.expectedResult.Authenticated, result.Authenticated)
}
if result.RequiresAuth != test.expectedResult.RequiresAuth {
t.Errorf("Expected RequiresAuth %v, got %v", test.expectedResult.RequiresAuth, result.RequiresAuth)
}
})
}
}
func TestAuthFlowHandler_attemptTokenRefresh(t *testing.T) {
tests := []struct {
name string
session *MockSession
tokenHandler *MockTokenHandler
isAjax bool
expectedResult AuthFlowResult
}{
{
name: "No refresh token",
session: &MockSession{
refreshToken: "",
},
tokenHandler: &MockTokenHandler{},
expectedResult: AuthFlowResult{RequiresAuth: true},
},
{
name: "AJAX request with expired session",
session: &MockSession{
refreshToken: "refresh-token",
},
tokenHandler: &MockTokenHandler{},
isAjax: true,
expectedResult: AuthFlowResult{
Error: ErrSessionExpiredAjax,
StatusCode: http.StatusUnauthorized,
},
},
{
name: "Successful token refresh",
session: &MockSession{
refreshToken: "valid-refresh-token",
},
tokenHandler: &MockTokenHandler{
refreshError: nil,
tokenResponse: &TokenResponse{
IDToken: "new-id-token",
AccessToken: "new-access-token",
},
},
expectedResult: AuthFlowResult{Authenticated: true},
},
{
name: "Failed token refresh",
session: &MockSession{
refreshToken: "invalid-refresh-token",
},
tokenHandler: &MockTokenHandler{
refreshError: errors.New("refresh failed"),
},
expectedResult: AuthFlowResult{RequiresAuth: true},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
sessionHandlerWrapper := NewMockSessionHandlerWrapper()
handler := &AuthFlowHandler{
sessionHandler: sessionHandlerWrapper.SessionHandler,
tokenHandler: test.tokenHandler,
logger: &MockLogger{},
}
req := httptest.NewRequest("GET", "/", nil)
if test.isAjax {
req.Header.Set("X-Requested-With", "XMLHttpRequest")
}
rw := httptest.NewRecorder()
result := handler.attemptTokenRefresh(test.session, req, rw)
if result.Authenticated != test.expectedResult.Authenticated {
t.Errorf("Expected Authenticated %v, got %v", test.expectedResult.Authenticated, result.Authenticated)
}
if result.RequiresAuth != test.expectedResult.RequiresAuth {
t.Errorf("Expected RequiresAuth %v, got %v", test.expectedResult.RequiresAuth, result.RequiresAuth)
}
if result.StatusCode != test.expectedResult.StatusCode {
t.Errorf("Expected StatusCode %d, got %d", test.expectedResult.StatusCode, result.StatusCode)
}
})
}
}
func TestAuthFlowError_Error(t *testing.T) {
err := &AuthFlowError{
Code: "TEST_ERROR",
Message: "This is a test error",
}
expected := "This is a test error"
result := err.Error()
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
}
func TestAuthFlowResult(t *testing.T) {
// Test AuthFlowResult struct
result := AuthFlowResult{
Authenticated: true,
RequiresAuth: false,
RequiresRefresh: false,
Error: nil,
RedirectURL: "https://example.com",
StatusCode: 200,
}
if !result.Authenticated {
t.Error("Expected Authenticated to be true")
}
if result.RequiresAuth {
t.Error("Expected RequiresAuth to be false")
}
if result.StatusCode != 200 {
t.Errorf("Expected StatusCode 200, got %d", result.StatusCode)
}
}
func TestTokenResponse(t *testing.T) {
response := &TokenResponse{
IDToken: "id-token-value",
AccessToken: "access-token-value",
RefreshToken: "refresh-token-value",
ExpiresIn: 3600,
}
if response.IDToken != "id-token-value" {
t.Errorf("Expected IDToken 'id-token-value', got '%s'", response.IDToken)
}
if response.ExpiresIn != 3600 {
t.Errorf("Expected ExpiresIn 3600, got %d", response.ExpiresIn)
}
}
-247
View File
@@ -1,247 +0,0 @@
// Package handlers provides HTTP request handlers for OIDC operations
package handlers
import (
"fmt"
"net/http"
"strings"
)
// SessionHandler manages session-related HTTP operations
type SessionHandler struct {
sessionManager SessionManager
logger Logger
logoutURLPath string
postLogoutRedirectURI string
endSessionURL string
clientID string
}
// SessionManager interface for session operations
type SessionManager interface {
GetSession(req *http.Request) (Session, error)
CleanupOldCookies(rw http.ResponseWriter, req *http.Request)
}
// Session interface for session data
type Session interface {
GetAuthenticated() bool
SetAuthenticated(bool) error
GetEmail() string
SetEmail(string)
GetIDToken() string
GetAccessToken() string
GetRefreshToken() string
SetRefreshToken(string)
Clear(req *http.Request, rw http.ResponseWriter) error
Save(req *http.Request, rw http.ResponseWriter) error
ReturnToPoolSafely()
}
// Logger interface for logging operations
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{})
}
// NewSessionHandler creates a new session handler
func NewSessionHandler(sessionManager SessionManager, logger Logger, logoutURLPath, postLogoutRedirectURI, endSessionURL, clientID string) *SessionHandler {
return &SessionHandler{
sessionManager: sessionManager,
logger: logger,
logoutURLPath: logoutURLPath,
postLogoutRedirectURI: postLogoutRedirectURI,
endSessionURL: endSessionURL,
clientID: clientID,
}
}
// HandleLogout processes logout requests
func (h *SessionHandler) HandleLogout(rw http.ResponseWriter, req *http.Request) {
h.logger.Debug("Processing logout request")
session, err := h.sessionManager.GetSession(req)
if err != nil {
h.logger.Errorf("Error getting session during logout: %v", err)
// Continue with logout even if session retrieval fails
}
var idToken string
if session != nil {
defer session.ReturnToPoolSafely()
idToken = session.GetIDToken()
// Clear the session
if err := session.Clear(req, rw); err != nil {
h.logger.Errorf("Error clearing session during logout: %v", err)
}
}
// Build logout URL
logoutURL := h.buildLogoutURL(idToken)
h.logger.Debugf("Redirecting to logout URL: %s", logoutURL)
http.Redirect(rw, req, logoutURL, http.StatusFound)
}
// buildLogoutURL constructs the provider logout URL
func (h *SessionHandler) buildLogoutURL(idToken string) string {
if h.endSessionURL == "" {
// If no end session URL, redirect to post-logout redirect URI
return h.postLogoutRedirectURI
}
logoutURL := h.endSessionURL
// Add query parameters
params := make([]string, 0, 3)
if idToken != "" {
params = append(params, fmt.Sprintf("id_token_hint=%s", idToken))
}
if h.postLogoutRedirectURI != "" {
params = append(params, fmt.Sprintf("post_logout_redirect_uri=%s", h.postLogoutRedirectURI))
}
if h.clientID != "" {
params = append(params, fmt.Sprintf("client_id=%s", h.clientID))
}
if len(params) > 0 {
separator := "?"
if strings.Contains(logoutURL, "?") {
separator = "&"
}
logoutURL += separator + strings.Join(params, "&")
}
return logoutURL
}
// ValidateSession checks if a session is valid and authenticated
func (h *SessionHandler) ValidateSession(session Session) SessionValidationResult {
if session == nil {
return SessionValidationResult{
Valid: false,
NeedsAuth: true,
ErrorMessage: "session is nil",
}
}
if !session.GetAuthenticated() {
return SessionValidationResult{
Valid: false,
NeedsAuth: true,
ErrorMessage: "session not authenticated",
}
}
email := session.GetEmail()
if email == "" {
return SessionValidationResult{
Valid: false,
NeedsAuth: true,
ErrorMessage: "no email in session",
}
}
return SessionValidationResult{
Valid: true,
NeedsAuth: false,
}
}
// SessionValidationResult represents the result of session validation
type SessionValidationResult struct {
Valid bool
NeedsAuth bool
ErrorMessage string
}
// CleanupExpiredSession clears an expired session
func (h *SessionHandler) CleanupExpiredSession(rw http.ResponseWriter, req *http.Request, session Session) error {
h.logger.Debug("Cleaning up expired session")
if session == nil {
return nil
}
// Clear all session data
if err := session.SetAuthenticated(false); err != nil {
h.logger.Errorf("Failed to set authenticated to false: %v", err)
}
session.SetEmail("")
session.SetRefreshToken("")
// Save the cleared session
if err := session.Save(req, rw); err != nil {
h.logger.Errorf("Failed to save cleared session: %v", err)
return err
}
return nil
}
// IsAjaxRequest determines if the request is an AJAX/XHR request
func (h *SessionHandler) IsAjaxRequest(req *http.Request) bool {
// Check X-Requested-With header (commonly used by jQuery and other libraries)
if req.Header.Get("X-Requested-With") == "XMLHttpRequest" {
return true
}
// Check Accept header for JSON preference
accept := req.Header.Get("Accept")
if strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/html") {
return true
}
// Check for fetch API indication
if req.Header.Get("Sec-Fetch-Mode") == "cors" {
return true
}
return false
}
// SendErrorResponse sends an appropriate error response based on request type
func (h *SessionHandler) SendErrorResponse(rw http.ResponseWriter, req *http.Request, message string, statusCode int) {
if h.IsAjaxRequest(req) {
// For AJAX requests, send JSON response
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(statusCode)
_, _ = fmt.Fprintf(rw, `{"error": "%s"}`, message) // Safe to ignore: writing error response
} else {
// For browser requests, send HTML response
rw.Header().Set("Content-Type", "text/html")
rw.WriteHeader(statusCode)
_, _ = fmt.Fprintf(rw, `<html><body><h1>Error %d</h1><p>%s</p></body></html>`, statusCode, message) // Safe to ignore: writing error response
}
}
// SetSecurityHeaders sets standard security headers
func (h *SessionHandler) SetSecurityHeaders(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("X-Frame-Options", "DENY")
rw.Header().Set("X-Content-Type-Options", "nosniff")
rw.Header().Set("X-XSS-Protection", "1; mode=block")
rw.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Handle CORS for AJAX requests
origin := req.Header.Get("Origin")
if origin != "" {
rw.Header().Set("Access-Control-Allow-Origin", origin)
rw.Header().Set("Access-Control-Allow-Credentials", "true")
rw.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
rw.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
if req.Method == "OPTIONS" {
rw.WriteHeader(http.StatusOK)
return
}
}
}
-587
View File
@@ -1,587 +0,0 @@
package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNewSessionHandler(t *testing.T) {
sessionManager := &MockSessionManager{}
logger := &MockLogger{}
logoutURLPath := "/logout"
postLogoutRedirectURI := "https://example.com/post-logout"
endSessionURL := "https://provider.example.com/logout"
clientID := "test-client-id"
handler := NewSessionHandler(
sessionManager,
logger,
logoutURLPath,
postLogoutRedirectURI,
endSessionURL,
clientID,
)
if handler == nil {
t.Fatal("NewSessionHandler returned nil")
}
if handler.sessionManager != sessionManager {
t.Error("SessionManager not set correctly")
}
if handler.logger != logger {
t.Error("Logger not set correctly")
}
if handler.logoutURLPath != logoutURLPath {
t.Error("LogoutURLPath not set correctly")
}
if handler.postLogoutRedirectURI != postLogoutRedirectURI {
t.Error("PostLogoutRedirectURI not set correctly")
}
if handler.endSessionURL != endSessionURL {
t.Error("EndSessionURL not set correctly")
}
if handler.clientID != clientID {
t.Error("ClientID not set correctly")
}
}
func TestSessionHandler_HandleLogout(t *testing.T) {
tests := []struct {
name string
setupSession func() *MockSession
setupManager func() *MockSessionManager
expectedCode int
expectedURL string
}{
{
name: "Successful logout with ID token",
setupSession: func() *MockSession {
return &MockSession{
authenticated: true,
idToken: "test-id-token",
}
},
setupManager: func() *MockSessionManager {
return &MockSessionManager{
session: &MockSession{
authenticated: true,
idToken: "test-id-token",
},
}
},
expectedCode: http.StatusFound,
expectedURL: "https://provider.example.com/logout?id_token_hint=test-id-token&post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id",
},
{
name: "Logout without ID token",
setupSession: func() *MockSession {
return &MockSession{
authenticated: true,
idToken: "",
}
},
setupManager: func() *MockSessionManager {
return &MockSessionManager{
session: &MockSession{
authenticated: true,
idToken: "",
},
}
},
expectedCode: http.StatusFound,
expectedURL: "https://provider.example.com/logout?post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id",
},
{
name: "Session retrieval error",
setupSession: func() *MockSession { return nil },
setupManager: func() *MockSessionManager {
return &MockSessionManager{
err: fmt.Errorf("session error"),
}
},
expectedCode: http.StatusFound,
expectedURL: "https://provider.example.com/logout?post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := &SessionHandler{
sessionManager: test.setupManager(),
logger: &MockLogger{},
logoutURLPath: "/logout",
postLogoutRedirectURI: "https://example.com/post-logout",
endSessionURL: "https://provider.example.com/logout",
clientID: "test-client-id",
}
req := httptest.NewRequest("POST", "/logout", nil)
rw := httptest.NewRecorder()
handler.HandleLogout(rw, req)
if rw.Code != test.expectedCode {
t.Errorf("Expected status code %d, got %d", test.expectedCode, rw.Code)
}
location := rw.Header().Get("Location")
if location != test.expectedURL {
t.Errorf("Expected location '%s', got '%s'", test.expectedURL, location)
}
})
}
}
func TestSessionHandler_buildLogoutURL(t *testing.T) {
tests := []struct {
name string
endSessionURL string
postLogoutRedirectURI string
clientID string
idToken string
expected string
}{
{
name: "Complete logout URL with all parameters",
endSessionURL: "https://provider.example.com/logout",
postLogoutRedirectURI: "https://example.com/post-logout",
clientID: "test-client-id",
idToken: "test-id-token",
expected: "https://provider.example.com/logout?id_token_hint=test-id-token&post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id",
},
{
name: "Logout URL without ID token",
endSessionURL: "https://provider.example.com/logout",
postLogoutRedirectURI: "https://example.com/post-logout",
clientID: "test-client-id",
idToken: "",
expected: "https://provider.example.com/logout?post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id",
},
{
name: "No end session URL",
endSessionURL: "",
postLogoutRedirectURI: "https://example.com/post-logout",
clientID: "test-client-id",
idToken: "test-id-token",
expected: "https://example.com/post-logout",
},
{
name: "End session URL with existing query parameters",
endSessionURL: "https://provider.example.com/logout?foo=bar",
postLogoutRedirectURI: "https://example.com/post-logout",
clientID: "test-client-id",
idToken: "",
expected: "https://provider.example.com/logout?foo=bar&post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := &SessionHandler{
endSessionURL: test.endSessionURL,
postLogoutRedirectURI: test.postLogoutRedirectURI,
clientID: test.clientID,
}
result := handler.buildLogoutURL(test.idToken)
if result != test.expected {
t.Errorf("Expected '%s', got '%s'", test.expected, result)
}
})
}
}
func TestSessionHandler_ValidateSession(t *testing.T) {
handler := &SessionHandler{}
tests := []struct {
name string
session Session
expectedValid bool
expectedAuth bool
expectedMessage string
}{
{
name: "Nil session",
session: nil,
expectedValid: false,
expectedAuth: true,
expectedMessage: "session is nil",
},
{
name: "Not authenticated session",
session: &MockSession{
authenticated: false,
},
expectedValid: false,
expectedAuth: true,
expectedMessage: "session not authenticated",
},
{
name: "Authenticated session without email",
session: &MockSession{
authenticated: true,
email: "",
},
expectedValid: false,
expectedAuth: true,
expectedMessage: "no email in session",
},
{
name: "Valid authenticated session with email",
session: &MockSession{
authenticated: true,
email: "[email protected]",
},
expectedValid: true,
expectedAuth: false,
expectedMessage: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := handler.ValidateSession(test.session)
if result.Valid != test.expectedValid {
t.Errorf("Expected Valid %v, got %v", test.expectedValid, result.Valid)
}
if result.NeedsAuth != test.expectedAuth {
t.Errorf("Expected NeedsAuth %v, got %v", test.expectedAuth, result.NeedsAuth)
}
if result.ErrorMessage != test.expectedMessage {
t.Errorf("Expected ErrorMessage '%s', got '%s'", test.expectedMessage, result.ErrorMessage)
}
})
}
}
func TestSessionHandler_CleanupExpiredSession(t *testing.T) {
tests := []struct {
name string
session *MockSession
expectError bool
}{
{
name: "Successful cleanup",
session: &MockSession{
authenticated: true,
email: "[email protected]",
refreshToken: "refresh-token",
},
expectError: false,
},
{
name: "Save error during cleanup",
session: &MockSession{
authenticated: true,
email: "[email protected]",
refreshToken: "refresh-token",
saveError: fmt.Errorf("save failed"),
},
expectError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := &SessionHandler{
logger: &MockLogger{},
}
req := httptest.NewRequest("GET", "/", nil)
rw := httptest.NewRecorder()
err := handler.CleanupExpiredSession(rw, req, test.session)
if test.expectError && err == nil {
t.Error("Expected error but got nil")
}
if !test.expectError && err != nil {
t.Errorf("Expected no error but got: %v", err)
}
if test.session != nil && !test.expectError {
if test.session.authenticated {
t.Error("Expected session authenticated to be false after cleanup")
}
if test.session.email != "" {
t.Error("Expected session email to be empty after cleanup")
}
if test.session.refreshToken != "" {
t.Error("Expected session refresh token to be empty after cleanup")
}
}
})
}
// Test nil session separately
t.Run("Nil session", func(t *testing.T) {
handler := &SessionHandler{
logger: &MockLogger{},
}
req := httptest.NewRequest("GET", "/", nil)
rw := httptest.NewRecorder()
var nilSession Session = nil
err := handler.CleanupExpiredSession(rw, req, nilSession)
if err != nil {
t.Errorf("Expected no error for nil session, got: %v", err)
}
})
}
func TestSessionHandler_IsAjaxRequest(t *testing.T) {
handler := &SessionHandler{}
tests := []struct {
name string
headers map[string]string
expected bool
}{
{
name: "XMLHttpRequest header",
headers: map[string]string{
"X-Requested-With": "XMLHttpRequest",
},
expected: true,
},
{
name: "JSON Accept header without HTML",
headers: map[string]string{
"Accept": "application/json",
},
expected: true,
},
{
name: "JSON Accept header with HTML",
headers: map[string]string{
"Accept": "application/json, text/html",
},
expected: false,
},
{
name: "Fetch API CORS mode",
headers: map[string]string{
"Sec-Fetch-Mode": "cors",
},
expected: true,
},
{
name: "Regular browser request",
headers: map[string]string{
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
},
expected: false,
},
{
name: "No special headers",
headers: map[string]string{},
expected: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
for key, value := range test.headers {
req.Header.Set(key, value)
}
result := handler.IsAjaxRequest(req)
if result != test.expected {
t.Errorf("Expected %v, got %v", test.expected, result)
}
})
}
}
func TestSessionHandler_SendErrorResponse(t *testing.T) {
tests := []struct {
name string
isAjax bool
message string
statusCode int
expectedContentType string
expectedBodyContains string
}{
{
name: "AJAX error response",
isAjax: true,
message: "Authentication failed",
statusCode: http.StatusUnauthorized,
expectedContentType: "application/json",
expectedBodyContains: `{"error": "Authentication failed"}`,
},
{
name: "Browser error response",
isAjax: false,
message: "Session expired",
statusCode: http.StatusForbidden,
expectedContentType: "text/html",
expectedBodyContains: "<h1>Error 403</h1>",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := &SessionHandler{}
req := httptest.NewRequest("GET", "/", nil)
if test.isAjax {
req.Header.Set("X-Requested-With", "XMLHttpRequest")
}
rw := httptest.NewRecorder()
handler.SendErrorResponse(rw, req, test.message, test.statusCode)
if rw.Code != test.statusCode {
t.Errorf("Expected status code %d, got %d", test.statusCode, rw.Code)
}
contentType := rw.Header().Get("Content-Type")
if contentType != test.expectedContentType {
t.Errorf("Expected Content-Type '%s', got '%s'", test.expectedContentType, contentType)
}
body := rw.Body.String()
if !strings.Contains(body, test.expectedBodyContains) {
t.Errorf("Expected body to contain '%s', got '%s'", test.expectedBodyContains, body)
}
})
}
}
func TestSessionHandler_SetSecurityHeaders(t *testing.T) {
tests := []struct {
name string
method string
origin string
expectedCORS bool
expectedStatus int
}{
{
name: "Regular request without CORS",
method: "GET",
origin: "",
expectedCORS: false,
expectedStatus: 0, // No status written
},
{
name: "CORS request with origin",
method: "GET",
origin: "https://example.com",
expectedCORS: true,
expectedStatus: 0,
},
{
name: "OPTIONS preflight request",
method: "OPTIONS",
origin: "https://example.com",
expectedCORS: true,
expectedStatus: http.StatusOK,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := &SessionHandler{}
req := httptest.NewRequest(test.method, "/", nil)
if test.origin != "" {
req.Header.Set("Origin", test.origin)
}
rw := httptest.NewRecorder()
handler.SetSecurityHeaders(rw, req)
// Check standard security headers
expectedSecurityHeaders := map[string]string{
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
}
for header, expectedValue := range expectedSecurityHeaders {
actualValue := rw.Header().Get(header)
if actualValue != expectedValue {
t.Errorf("Expected %s header '%s', got '%s'", header, expectedValue, actualValue)
}
}
// Check CORS headers
if test.expectedCORS {
corsOrigin := rw.Header().Get("Access-Control-Allow-Origin")
if corsOrigin != test.origin {
t.Errorf("Expected CORS origin '%s', got '%s'", test.origin, corsOrigin)
}
corsCredentials := rw.Header().Get("Access-Control-Allow-Credentials")
if corsCredentials != "true" {
t.Errorf("Expected CORS credentials 'true', got '%s'", corsCredentials)
}
corsMethods := rw.Header().Get("Access-Control-Allow-Methods")
if corsMethods != "GET, POST, OPTIONS" {
t.Errorf("Expected CORS methods 'GET, POST, OPTIONS', got '%s'", corsMethods)
}
corsHeaders := rw.Header().Get("Access-Control-Allow-Headers")
if corsHeaders != "Authorization, Content-Type" {
t.Errorf("Expected CORS headers 'Authorization, Content-Type', got '%s'", corsHeaders)
}
} else {
corsOrigin := rw.Header().Get("Access-Control-Allow-Origin")
if corsOrigin != "" {
t.Errorf("Expected no CORS origin header, got '%s'", corsOrigin)
}
}
// Check status code for OPTIONS requests
if test.expectedStatus > 0 {
if rw.Code != test.expectedStatus {
t.Errorf("Expected status code %d, got %d", test.expectedStatus, rw.Code)
}
}
})
}
}
func TestSessionValidationResult(t *testing.T) {
result := SessionValidationResult{
Valid: true,
NeedsAuth: false,
ErrorMessage: "test message",
}
if !result.Valid {
t.Error("Expected Valid to be true")
}
if result.NeedsAuth {
t.Error("Expected NeedsAuth to be false")
}
if result.ErrorMessage != "test message" {
t.Errorf("Expected ErrorMessage 'test message', got '%s'", result.ErrorMessage)
}
}
@@ -1,408 +0,0 @@
package httpclient
import (
"context"
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestCreateProxy tests the CreateProxy method
func TestCreateProxy(t *testing.T) {
factory := NewFactory(nil)
client, err := factory.CreateProxy()
if err != nil {
t.Fatalf("Failed to create proxy client: %v", err)
}
if client == nil {
t.Fatal("Expected non-nil proxy client")
}
// Verify proxy configuration specifics
if client.Timeout != 60*time.Second {
t.Errorf("Expected proxy timeout to be 60s, got %v", client.Timeout)
}
}
// TestValidateConfigEdgeCases tests additional validation scenarios
func TestValidateConfigEdgeCases(t *testing.T) {
factory := NewFactory(nil)
testCases := []struct {
name string
config Config
shouldFail bool
errorMsg string
}{
{
name: "Negative MaxIdleConnsPerHost",
config: Config{
MaxIdleConnsPerHost: -1,
},
shouldFail: true,
errorMsg: "MaxIdleConnsPerHost cannot be negative",
},
{
name: "Excessive MaxIdleConnsPerHost",
config: Config{
MaxIdleConnsPerHost: 200,
},
shouldFail: true,
errorMsg: "MaxIdleConnsPerHost too high",
},
{
name: "Negative MaxConnsPerHost",
config: Config{
MaxConnsPerHost: -1,
},
shouldFail: true,
errorMsg: "MaxConnsPerHost cannot be negative",
},
{
name: "Excessive MaxConnsPerHost",
config: Config{
MaxConnsPerHost: 300,
},
shouldFail: true,
errorMsg: "MaxConnsPerHost too high",
},
{
name: "Negative WriteBufferSize",
config: Config{
WriteBufferSize: -1,
},
shouldFail: true,
errorMsg: "buffer sizes cannot be negative",
},
{
name: "Negative ReadBufferSize",
config: Config{
ReadBufferSize: -1,
},
shouldFail: true,
errorMsg: "buffer sizes cannot be negative",
},
{
name: "Excessive WriteBufferSize",
config: Config{
WriteBufferSize: 2 * 1024 * 1024,
},
shouldFail: true,
errorMsg: "buffer sizes too large",
},
{
name: "Excessive ReadBufferSize",
config: Config{
ReadBufferSize: 2 * 1024 * 1024,
},
shouldFail: true,
errorMsg: "buffer sizes too large",
},
{
name: "Valid edge values",
config: Config{
MaxIdleConns: 1000,
MaxIdleConnsPerHost: 100,
MaxConnsPerHost: 200,
Timeout: 5 * time.Minute,
WriteBufferSize: 1024 * 1024,
ReadBufferSize: 1024 * 1024,
},
shouldFail: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := factory.ValidateConfig(&tc.config)
if tc.shouldFail {
if err == nil {
t.Fatalf("Expected validation to fail with message containing: %s", tc.errorMsg)
}
} else {
if err != nil {
t.Fatalf("Unexpected validation error: %v", err)
}
}
})
}
}
// TestTransportPoolClose tests the Close method of TransportPool
func TestTransportPoolClose(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
ctx: ctx,
cancel: cancel,
clientCount: 0,
maxClients: 5,
}
// Create some transports
config := PresetConfigs[ClientTypeDefault]
transport1 := pool.GetOrCreateTransport(config)
if transport1 == nil {
t.Fatal("Failed to create transport")
}
// Modify config slightly to create a different transport
config.Timeout = 20 * time.Second
transport2 := pool.GetOrCreateTransport(config)
if transport2 == nil {
t.Fatal("Failed to create second transport")
}
// Verify transports were created
pool.mu.RLock()
initialCount := len(pool.transports)
pool.mu.RUnlock()
if initialCount == 0 {
t.Fatal("Expected transports to be created")
}
// Close the pool
err := pool.Close()
if err != nil {
t.Fatalf("Failed to close pool: %v", err)
}
// Verify all transports were removed
pool.mu.RLock()
finalCount := len(pool.transports)
pool.mu.RUnlock()
if finalCount != 0 {
t.Fatalf("Expected 0 transports after close, got %d", finalCount)
}
// Verify client count was reset
if pool.clientCount != 0 {
t.Fatalf("Expected client count to be 0 after close, got %d", pool.clientCount)
}
}
// TestNoOpLogger tests the no-op logger implementation
func TestNoOpLogger(t *testing.T) {
logger := &noOpLogger{}
// These should not panic or cause any issues
logger.Debug("test debug")
logger.Debugf("test debug %s", "formatted")
logger.Info("test info")
logger.Infof("test info %s", "formatted")
logger.Error("test error")
logger.Errorf("test error %s", "formatted")
// Test using logger with factory
factory := NewFactory(logger)
client, err := factory.CreateDefault()
if err != nil {
t.Fatalf("Failed to create client with no-op logger: %v", err)
}
if client == nil {
t.Fatal("Expected non-nil client")
}
}
// TestCreateClientWithCustomTLS tests creating client with custom TLS config
func TestCreateClientWithCustomTLS(t *testing.T) {
factory := NewFactory(nil)
customTLS := &tls.Config{
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
}
config := Config{
Timeout: 10 * time.Second,
MaxIdleConns: 10,
MaxIdleConnsPerHost: 2,
MaxConnsPerHost: 5,
TLSConfig: customTLS,
}
client, err := factory.CreateClient(config)
if err != nil {
t.Fatalf("Failed to create client with custom TLS: %v", err)
}
if client == nil {
t.Fatal("Expected non-nil client")
}
}
// TestCreateClientWithMaxRedirects tests redirect limiting
func TestCreateClientWithMaxRedirects(t *testing.T) {
redirectCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirectCount++
if redirectCount <= 3 {
http.Redirect(w, r, "/redirect", http.StatusFound)
} else {
w.WriteHeader(http.StatusOK)
w.Write([]byte("final"))
}
}))
defer server.Close()
factory := NewFactory(nil)
// Test with max redirects = 2 (should fail)
config := Config{
Timeout: 10 * time.Second,
MaxRedirects: 2,
MaxIdleConns: 10,
MaxIdleConnsPerHost: 2,
MaxConnsPerHost: 5,
}
client, err := factory.CreateClient(config)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
redirectCount = 0
_, err = client.Get(server.URL)
if err == nil {
t.Fatal("Expected redirect limit error")
}
// Test with max redirects = 5 (should succeed)
config.MaxRedirects = 5
client, err = factory.CreateClient(config)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
redirectCount = 0
resp, err := client.Get(server.URL)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
}
}
// TestTransportPoolMaxClientsLimit tests the max clients limitation
func TestTransportPoolMaxClientsLimit(t *testing.T) {
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 2, // Set low limit for testing
}
// Create transports up to the limit
configs := []Config{
{Timeout: 10 * time.Second},
{Timeout: 20 * time.Second},
{Timeout: 30 * time.Second}, // This should not create a new transport
}
for i, config := range configs {
transport := pool.GetOrCreateTransport(config)
if i < 2 {
if transport == nil {
t.Fatalf("Expected transport %d to be created", i)
}
// Transport created successfully within limit
} else {
// When limit is reached, should return existing transport or nil
if transport == nil {
// This is acceptable - nil when limit reached
t.Log("Transport creation blocked due to client limit")
}
}
}
// Verify client count doesn't exceed limit
if pool.clientCount > pool.maxClients {
t.Fatalf("Client count %d exceeds max %d", pool.clientCount, pool.maxClients)
}
}
// TestCleanupIdleTransportsContext tests cleanup goroutine with context
func TestCleanupIdleTransportsContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
ctx: ctx,
cancel: cancel,
clientCount: 0,
maxClients: 5,
}
// Start cleanup goroutine
done := make(chan bool)
go func() {
pool.cleanupIdleTransports(ctx)
done <- true
}()
// Give it a moment to start
time.Sleep(10 * time.Millisecond)
// Cancel context to stop cleanup
cancel()
// Wait for goroutine to exit
select {
case <-done:
// Success - goroutine exited
case <-time.After(1 * time.Second):
t.Fatal("Cleanup goroutine did not exit after context cancellation")
}
}
// TestFactoryWithLogger tests factory creation with custom logger
func TestFactoryWithLogger(t *testing.T) {
// Create a mock logger that implements the Logger interface
logger := &MockLogger{}
factory := NewFactory(logger)
if factory.logger == nil {
t.Fatal("Expected logger to be set")
}
}
// MockLogger for testing
type MockLogger struct {
debugCalled bool
debugfCalled bool
infoCalled bool
infofCalled bool
errorCalled bool
errorfCalled bool
}
func (m *MockLogger) Debug(msg string) { m.debugCalled = true }
func (m *MockLogger) Debugf(format string, args ...interface{}) { m.debugfCalled = true }
func (m *MockLogger) Info(msg string) { m.infoCalled = true }
func (m *MockLogger) Infof(format string, args ...interface{}) { m.infofCalled = true }
func (m *MockLogger) Error(msg string) { m.errorCalled = true }
func (m *MockLogger) Errorf(format string, args ...interface{}) { m.errorfCalled = true }
// TestCreateClientLogging tests that logger is called during client creation
func TestCreateClientLogging(t *testing.T) {
logger := &MockLogger{}
factory := NewFactory(logger)
client, err := factory.CreateDefault()
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
if client == nil {
t.Fatal("Expected non-nil client")
}
// Verify logger was called
if !logger.debugfCalled {
t.Error("Expected Debugf to be called during client creation")
}
}
-122
View File
@@ -1,122 +0,0 @@
package middleware
import (
"fmt"
"net/http"
"strings"
"time"
)
// RequestContext holds request processing context
type RequestContext struct {
Writer http.ResponseWriter
Request *http.Request
RedirectURL string
Scheme string
Host string
}
// RequestProcessor handles common request processing operations
type RequestProcessor struct {
logger Logger
}
// Logger interface for logging operations
type Logger interface {
Debug(msg string)
Debugf(format string, args ...interface{})
Error(msg string)
Errorf(format string, args ...interface{})
Info(msg string)
Infof(format string, args ...interface{})
}
// NewRequestProcessor creates a new request processor
func NewRequestProcessor(logger Logger) *RequestProcessor {
return &RequestProcessor{
logger: logger,
}
}
// BuildRequestContext creates a request context with scheme and host detection
func (rp *RequestProcessor) BuildRequestContext(rw http.ResponseWriter, req *http.Request, redirectPath string) *RequestContext {
scheme := rp.determineScheme(req)
host := rp.determineHost(req)
redirectURL := buildFullURL(scheme, host, redirectPath)
return &RequestContext{
Writer: rw,
Request: req,
RedirectURL: redirectURL,
Scheme: scheme,
Host: host,
}
}
// IsHealthCheckRequest checks if request is a health check
func (rp *RequestProcessor) IsHealthCheckRequest(req *http.Request) bool {
return strings.HasPrefix(req.URL.Path, "/health")
}
// IsEventStreamRequest checks if request expects event stream
func (rp *RequestProcessor) IsEventStreamRequest(req *http.Request) bool {
acceptHeader := req.Header.Get("Accept")
return strings.Contains(acceptHeader, "text/event-stream")
}
// IsAjaxRequest determines if this is an AJAX request
func (rp *RequestProcessor) IsAjaxRequest(req *http.Request) bool {
xhr := req.Header.Get("X-Requested-With")
contentType := req.Header.Get("Content-Type")
accept := req.Header.Get("Accept")
return xhr == "XMLHttpRequest" ||
strings.Contains(contentType, "application/json") ||
strings.Contains(accept, "application/json")
}
// WaitForInitialization waits for OIDC provider initialization with timeout
func (rp *RequestProcessor) WaitForInitialization(req *http.Request, initComplete <-chan struct{}) error {
select {
case <-initComplete:
return nil
case <-req.Context().Done():
rp.logger.Debug("Request canceled while waiting for OIDC initialization")
return fmt.Errorf("request canceled")
case <-time.After(30 * time.Second):
rp.logger.Error("Timeout waiting for OIDC initialization")
return fmt.Errorf("timeout waiting for OIDC provider initialization")
}
}
// determineScheme determines the URL scheme for building redirect URLs
func (rp *RequestProcessor) 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
func (rp *RequestProcessor) determineHost(req *http.Request) string {
if host := req.Header.Get("X-Forwarded-Host"); host != "" {
return host
}
return req.Host
}
// buildFullURL constructs a complete URL from scheme, host, and path components
func buildFullURL(scheme, host, path string) string {
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return fmt.Sprintf("%s://%s%s", scheme, host, path)
}
-655
View File
@@ -1,655 +0,0 @@
package middleware
import (
"context"
"crypto/tls"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// MockLogger implements the Logger interface for testing
type MockLogger struct {
DebugCalls []string
DebugfCalls []string
ErrorCalls []string
ErrorfCalls []string
InfoCalls []string
InfofCalls []string
}
func (m *MockLogger) Debug(msg string) {
m.DebugCalls = append(m.DebugCalls, msg)
}
func (m *MockLogger) Debugf(format string, args ...interface{}) {
m.DebugfCalls = append(m.DebugfCalls, format)
}
func (m *MockLogger) Error(msg string) {
m.ErrorCalls = append(m.ErrorCalls, msg)
}
func (m *MockLogger) Errorf(format string, args ...interface{}) {
m.ErrorfCalls = append(m.ErrorfCalls, format)
}
func (m *MockLogger) Info(msg string) {
m.InfoCalls = append(m.InfoCalls, msg)
}
func (m *MockLogger) Infof(format string, args ...interface{}) {
m.InfofCalls = append(m.InfofCalls, format)
}
// TestNewRequestProcessor tests the constructor
func TestNewRequestProcessor(t *testing.T) {
logger := &MockLogger{}
processor := NewRequestProcessor(logger)
if processor == nil {
t.Error("Expected NewRequestProcessor to return non-nil processor")
return
}
if processor.logger != logger {
t.Error("Expected processor to use provided logger")
}
}
// TestBuildRequestContext tests request context building
func TestBuildRequestContext(t *testing.T) {
logger := &MockLogger{}
processor := NewRequestProcessor(logger)
tests := []struct {
name string
setupRequest func() (*http.Request, http.ResponseWriter)
redirectPath string
expectedURL string
expectedHost string
}{
{
name: "Basic HTTP request",
setupRequest: func() (*http.Request, http.ResponseWriter) {
req := httptest.NewRequest("GET", "http://example.com/test", nil)
rw := httptest.NewRecorder()
return req, rw
},
redirectPath: "/callback",
expectedURL: "http://example.com/callback",
expectedHost: "example.com",
},
{
name: "HTTPS request with TLS",
setupRequest: func() (*http.Request, http.ResponseWriter) {
req := httptest.NewRequest("GET", "https://secure.com/test", nil)
req.TLS = &tls.ConnectionState{} // Simulate TLS
rw := httptest.NewRecorder()
return req, rw
},
redirectPath: "/auth",
expectedURL: "https://secure.com/auth",
expectedHost: "secure.com",
},
{
name: "Request with X-Forwarded-Proto header",
setupRequest: func() (*http.Request, http.ResponseWriter) {
req := httptest.NewRequest("GET", "http://internal.com/test", nil)
req.Header.Set("X-Forwarded-Proto", "https")
rw := httptest.NewRecorder()
return req, rw
},
redirectPath: "/callback",
expectedURL: "https://internal.com/callback",
expectedHost: "internal.com",
},
{
name: "Request with X-Forwarded-Host header",
setupRequest: func() (*http.Request, http.ResponseWriter) {
req := httptest.NewRequest("GET", "http://internal.com/test", nil)
req.Header.Set("X-Forwarded-Host", "public.com")
rw := httptest.NewRecorder()
return req, rw
},
redirectPath: "/callback",
expectedURL: "http://public.com/callback",
expectedHost: "public.com",
},
{
name: "Request with both forwarded headers",
setupRequest: func() (*http.Request, http.ResponseWriter) {
req := httptest.NewRequest("GET", "http://internal.com/test", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "public.com")
rw := httptest.NewRecorder()
return req, rw
},
redirectPath: "/auth",
expectedURL: "https://public.com/auth",
expectedHost: "public.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, rw := tt.setupRequest()
ctx := processor.BuildRequestContext(rw, req, tt.redirectPath)
if ctx == nil {
t.Error("Expected BuildRequestContext to return non-nil context")
return
}
if ctx.Writer != rw {
t.Error("Expected context writer to match provided writer")
}
if ctx.Request != req {
t.Error("Expected context request to match provided request")
}
if ctx.RedirectURL != tt.expectedURL {
t.Errorf("Expected redirect URL '%s', got '%s'", tt.expectedURL, ctx.RedirectURL)
}
if ctx.Host != tt.expectedHost {
t.Errorf("Expected host '%s', got '%s'", tt.expectedHost, ctx.Host)
}
})
}
}
// TestIsHealthCheckRequest tests health check detection
func TestIsHealthCheckRequest(t *testing.T) {
logger := &MockLogger{}
processor := NewRequestProcessor(logger)
tests := []struct {
name string
path string
expected bool
}{
{
name: "Health check path",
path: "/health",
expected: true,
},
{
name: "Health check subpath",
path: "/health/status",
expected: true,
},
{
name: "Health check with query params",
path: "/health?check=db",
expected: true,
},
{
name: "Not a health check",
path: "/api/users",
expected: false,
},
{
name: "Health-related path (matches prefix)",
path: "/healthiness",
expected: true, // HasPrefix behavior - this actually matches
},
{
name: "Root path",
path: "/",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com"+tt.path, nil)
result := processor.IsHealthCheckRequest(req)
if result != tt.expected {
t.Errorf("Expected IsHealthCheckRequest to return %v for path '%s', got %v", tt.expected, tt.path, result)
}
})
}
}
// TestIsEventStreamRequest tests event stream detection
func TestIsEventStreamRequest(t *testing.T) {
logger := &MockLogger{}
processor := NewRequestProcessor(logger)
tests := []struct {
name string
acceptHeader string
expected bool
}{
{
name: "Event stream accept header",
acceptHeader: "text/event-stream",
expected: true,
},
{
name: "Event stream with other types",
acceptHeader: "text/html, text/event-stream, application/json",
expected: true,
},
{
name: "JSON accept header",
acceptHeader: "application/json",
expected: false,
},
{
name: "HTML accept header",
acceptHeader: "text/html,application/xhtml+xml",
expected: false,
},
{
name: "Empty accept header",
acceptHeader: "",
expected: false,
},
{
name: "Similar but not event stream",
acceptHeader: "text/event-source",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/test", nil)
if tt.acceptHeader != "" {
req.Header.Set("Accept", tt.acceptHeader)
}
result := processor.IsEventStreamRequest(req)
if result != tt.expected {
t.Errorf("Expected IsEventStreamRequest to return %v for accept header '%s', got %v", tt.expected, tt.acceptHeader, result)
}
})
}
}
// TestIsAjaxRequest tests AJAX request detection
func TestIsAjaxRequest(t *testing.T) {
logger := &MockLogger{}
processor := NewRequestProcessor(logger)
tests := []struct {
name string
setupHeader func(*http.Request)
expected bool
}{
{
name: "XMLHttpRequest header",
setupHeader: func(req *http.Request) {
req.Header.Set("X-Requested-With", "XMLHttpRequest")
},
expected: true,
},
{
name: "JSON content type",
setupHeader: func(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
},
expected: true,
},
{
name: "JSON content type with charset",
setupHeader: func(req *http.Request) {
req.Header.Set("Content-Type", "application/json; charset=utf-8")
},
expected: true,
},
{
name: "JSON accept header",
setupHeader: func(req *http.Request) {
req.Header.Set("Accept", "application/json")
},
expected: true,
},
{
name: "JSON accept with other types",
setupHeader: func(req *http.Request) {
req.Header.Set("Accept", "text/html, application/json, application/xml")
},
expected: true,
},
{
name: "Multiple AJAX indicators",
setupHeader: func(req *http.Request) {
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
},
expected: true,
},
{
name: "Regular HTML request",
setupHeader: func(req *http.Request) {
req.Header.Set("Accept", "text/html,application/xhtml+xml")
},
expected: false,
},
{
name: "Form submission",
setupHeader: func(req *http.Request) {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
},
expected: false,
},
{
name: "No special headers",
setupHeader: func(req *http.Request) {},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "http://example.com/api", nil)
tt.setupHeader(req)
result := processor.IsAjaxRequest(req)
if result != tt.expected {
t.Errorf("Expected IsAjaxRequest to return %v, got %v", tt.expected, result)
}
})
}
}
// TestWaitForInitialization tests initialization waiting
func TestWaitForInitialization(t *testing.T) {
logger := &MockLogger{}
processor := NewRequestProcessor(logger)
t.Run("Initialization completes successfully", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/test", nil)
initComplete := make(chan struct{})
go func() {
time.Sleep(10 * time.Millisecond)
close(initComplete)
}()
err := processor.WaitForInitialization(req, initComplete)
if err != nil {
t.Errorf("Expected no error when initialization completes, got: %v", err)
}
})
t.Run("Request context canceled", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
req := httptest.NewRequest("GET", "http://example.com/test", nil)
req = req.WithContext(ctx)
initComplete := make(chan struct{})
go func() {
time.Sleep(10 * time.Millisecond)
cancel()
}()
err := processor.WaitForInitialization(req, initComplete)
if err == nil {
t.Error("Expected error when request context is canceled")
}
if !strings.Contains(err.Error(), "request canceled") {
t.Errorf("Expected 'request canceled' error, got: %v", err)
}
if len(logger.DebugCalls) == 0 {
t.Error("Expected debug log when request is canceled")
}
})
t.Run("Initialization timeout", func(t *testing.T) {
if testing.Short() {
t.Skip("Skipping timeout test in short mode")
}
req := httptest.NewRequest("GET", "http://example.com/test", nil)
initComplete := make(chan struct{}) // Never closes
// Note: This test takes 30 seconds due to hardcoded timeout in implementation
start := time.Now()
err := processor.WaitForInitialization(req, initComplete)
duration := time.Since(start)
if err == nil {
t.Error("Expected timeout error")
}
if !strings.Contains(err.Error(), "timeout") {
t.Errorf("Expected timeout error, got: %v", err)
}
// The timeout should be around 30 seconds, allow some variance
if duration < 29*time.Second || duration > 31*time.Second {
t.Errorf("Expected timeout after ~30 seconds, but got %v", duration)
}
if len(logger.ErrorCalls) == 0 {
t.Error("Expected error log when timeout occurs")
}
})
}
// TestDetermineScheme tests scheme determination
func TestDetermineScheme(t *testing.T) {
logger := &MockLogger{}
processor := NewRequestProcessor(logger)
tests := []struct {
name string
setup func(*http.Request)
expected string
}{
{
name: "X-Forwarded-Proto HTTPS",
setup: func(req *http.Request) {
req.Header.Set("X-Forwarded-Proto", "https")
},
expected: "https",
},
{
name: "X-Forwarded-Proto HTTP",
setup: func(req *http.Request) {
req.Header.Set("X-Forwarded-Proto", "http")
},
expected: "http",
},
{
name: "TLS connection without header",
setup: func(req *http.Request) {
req.TLS = &tls.ConnectionState{}
},
expected: "https",
},
{
name: "No TLS, no header",
setup: func(req *http.Request) {
// No special setup
},
expected: "http",
},
{
name: "X-Forwarded-Proto takes precedence over TLS",
setup: func(req *http.Request) {
req.Header.Set("X-Forwarded-Proto", "http")
req.TLS = &tls.ConnectionState{}
},
expected: "http",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/test", nil)
tt.setup(req)
result := processor.determineScheme(req)
if result != tt.expected {
t.Errorf("Expected scheme '%s', got '%s'", tt.expected, result)
}
})
}
}
// TestDetermineHost tests host determination
func TestDetermineHost(t *testing.T) {
logger := &MockLogger{}
processor := NewRequestProcessor(logger)
tests := []struct {
name string
setup func(*http.Request)
expected string
}{
{
name: "X-Forwarded-Host header present",
setup: func(req *http.Request) {
req.Header.Set("X-Forwarded-Host", "public.example.com")
},
expected: "public.example.com",
},
{
name: "No X-Forwarded-Host, use req.Host",
setup: func(req *http.Request) {
// No special setup, will use req.Host
},
expected: "example.com",
},
{
name: "Empty X-Forwarded-Host, fallback to req.Host",
setup: func(req *http.Request) {
req.Header.Set("X-Forwarded-Host", "")
},
expected: "example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/test", nil)
tt.setup(req)
result := processor.determineHost(req)
if result != tt.expected {
t.Errorf("Expected host '%s', got '%s'", tt.expected, result)
}
})
}
}
// TestBuildFullURL tests URL building
func TestBuildFullURL(t *testing.T) {
tests := []struct {
name string
scheme string
host string
path string
expected string
}{
{
name: "Basic URL construction",
scheme: "https",
host: "example.com",
path: "/callback",
expected: "https://example.com/callback",
},
{
name: "Path without leading slash",
scheme: "http",
host: "test.com",
path: "auth",
expected: "http://test.com/auth",
},
{
name: "Absolute HTTP URL in path",
scheme: "https",
host: "example.com",
path: "http://other.com/callback",
expected: "http://other.com/callback",
},
{
name: "Absolute HTTPS URL in path",
scheme: "http",
host: "example.com",
path: "https://secure.com/auth",
expected: "https://secure.com/auth",
},
{
name: "Root path",
scheme: "https",
host: "example.com:8080",
path: "/",
expected: "https://example.com:8080/",
},
{
name: "Empty path",
scheme: "https",
host: "example.com",
path: "",
expected: "https://example.com/",
},
{
name: "Path with query parameters",
scheme: "https",
host: "example.com",
path: "/callback?state=abc123",
expected: "https://example.com/callback?state=abc123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildFullURL(tt.scheme, tt.host, tt.path)
if result != tt.expected {
t.Errorf("Expected URL '%s', got '%s'", tt.expected, result)
}
})
}
}
// TestRequestContext tests the RequestContext struct
func TestRequestContext(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/test", nil)
rw := httptest.NewRecorder()
ctx := &RequestContext{
Writer: rw,
Request: req,
RedirectURL: "https://example.com/callback",
Scheme: "https",
Host: "example.com",
}
if ctx.Writer != rw {
t.Error("Expected Writer to be set correctly")
}
if ctx.Request != req {
t.Error("Expected Request to be set correctly")
}
if ctx.RedirectURL != "https://example.com/callback" {
t.Error("Expected RedirectURL to be set correctly")
}
if ctx.Scheme != "https" {
t.Error("Expected Scheme to be set correctly")
}
if ctx.Host != "example.com" {
t.Error("Expected Host to be set correctly")
}
}
-309
View File
@@ -1,309 +0,0 @@
// Package patterns provides cached compiled regex patterns for performance optimization
package patterns
import (
"regexp"
"sync"
)
// RegexCache manages compiled regex patterns with thread-safe access
type RegexCache struct {
patterns map[string]*regexp.Regexp
mu sync.RWMutex
}
// NewRegexCache creates a new regex cache instance
func NewRegexCache() *RegexCache {
return &RegexCache{
patterns: make(map[string]*regexp.Regexp),
}
}
// Get retrieves a compiled regex pattern, compiling and caching it if not present
func (c *RegexCache) Get(pattern string) (*regexp.Regexp, error) {
// First try read lock for existing pattern
c.mu.RLock()
if regex, exists := c.patterns[pattern]; exists {
c.mu.RUnlock()
return regex, nil
}
c.mu.RUnlock()
// Pattern not found, acquire write lock to compile and cache
c.mu.Lock()
defer c.mu.Unlock()
// Double-check in case another goroutine compiled it while we waited
if regex, exists := c.patterns[pattern]; exists {
return regex, nil
}
// Compile the pattern
regex, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
// Cache the compiled pattern
c.patterns[pattern] = regex
return regex, nil
}
// MustGet is like Get but panics if the pattern cannot be compiled
func (c *RegexCache) MustGet(pattern string) *regexp.Regexp {
regex, err := c.Get(pattern)
if err != nil {
panic("regex compilation failed for pattern '" + pattern + "': " + err.Error())
}
return regex
}
// Precompile compiles and caches multiple patterns at once
func (c *RegexCache) Precompile(patterns []string) error {
c.mu.Lock()
defer c.mu.Unlock()
for _, pattern := range patterns {
if _, exists := c.patterns[pattern]; !exists {
regex, err := regexp.Compile(pattern)
if err != nil {
return err
}
c.patterns[pattern] = regex
}
}
return nil
}
// Size returns the number of cached patterns
func (c *RegexCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.patterns)
}
// Clear removes all cached patterns
func (c *RegexCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.patterns = make(map[string]*regexp.Regexp)
}
// Global regex cache instance
var globalCache = NewRegexCache()
// Common regex patterns used throughout the OIDC implementation
const (
// Email validation pattern (RFC 5322 compliant)
EmailPattern = `^[a-zA-Z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`
// Domain validation pattern
DomainPattern = `^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`
// URL validation pattern (http/https)
URLPattern = `^https?://[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*(/.*)?$`
// JWT token pattern (three base64url parts separated by dots)
JWTPattern = `^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$`
// Bearer token pattern (Authorization header)
BearerTokenPattern = `^Bearer\s+([A-Za-z0-9._~+/-]+=*)$`
// Client ID pattern (alphanumeric with common separators)
ClientIDPattern = `^[a-zA-Z0-9._-]+$`
// Scope pattern (space-separated alphanumeric with underscores)
ScopePattern = `^[a-zA-Z0-9_]+(\s+[a-zA-Z0-9_]+)*$`
// Session ID pattern (hexadecimal)
SessionIDPattern = `^[a-fA-F0-9]{32,128}$`
// CSRF token pattern (base64url)
CSRFTokenPattern = `^[A-Za-z0-9_-]+$`
// Nonce pattern (base64url)
NoncePattern = `^[A-Za-z0-9_-]+$`
// Code verifier pattern for PKCE (base64url, 43-128 chars)
CodeVerifierPattern = `^[A-Za-z0-9_-]{43,128}$`
// Authorization code pattern (base64url)
AuthCodePattern = `^[A-Za-z0-9._~+/-]+=*$`
// Redirect URI validation (must be absolute HTTP/HTTPS URL)
RedirectURIPattern = `^https?://[^\s/$.?#].[^\s]*$`
// User-Agent pattern for bot detection
BotUserAgentPattern = `(?i)(bot|crawler|spider|scraper|curl|wget|python|java|go-http)`
// IP address pattern (IPv4)
IPv4Pattern = `^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$`
// Tenant ID pattern (UUID format for Azure, etc.)
TenantIDPattern = `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`
)
// Precompiled common patterns for immediate use
var (
EmailRegex *regexp.Regexp
DomainRegex *regexp.Regexp
URLRegex *regexp.Regexp
JWTRegex *regexp.Regexp
BearerTokenRegex *regexp.Regexp
ClientIDRegex *regexp.Regexp
ScopeRegex *regexp.Regexp
SessionIDRegex *regexp.Regexp
CSRFTokenRegex *regexp.Regexp
NonceRegex *regexp.Regexp
CodeVerifierRegex *regexp.Regexp
AuthCodeRegex *regexp.Regexp
RedirectURIRegex *regexp.Regexp
BotUserAgentRegex *regexp.Regexp
IPv4Regex *regexp.Regexp
TenantIDRegex *regexp.Regexp
)
// Initialize precompiled patterns
func init() {
commonPatterns := []string{
EmailPattern,
DomainPattern,
URLPattern,
JWTPattern,
BearerTokenPattern,
ClientIDPattern,
ScopePattern,
SessionIDPattern,
CSRFTokenPattern,
NoncePattern,
CodeVerifierPattern,
AuthCodePattern,
RedirectURIPattern,
BotUserAgentPattern,
IPv4Pattern,
TenantIDPattern,
}
if err := globalCache.Precompile(commonPatterns); err != nil {
panic("Failed to precompile common regex patterns: " + err.Error())
}
// Assign precompiled patterns to global variables for easy access
EmailRegex = globalCache.MustGet(EmailPattern)
DomainRegex = globalCache.MustGet(DomainPattern)
URLRegex = globalCache.MustGet(URLPattern)
JWTRegex = globalCache.MustGet(JWTPattern)
BearerTokenRegex = globalCache.MustGet(BearerTokenPattern)
ClientIDRegex = globalCache.MustGet(ClientIDPattern)
ScopeRegex = globalCache.MustGet(ScopePattern)
SessionIDRegex = globalCache.MustGet(SessionIDPattern)
CSRFTokenRegex = globalCache.MustGet(CSRFTokenPattern)
NonceRegex = globalCache.MustGet(NoncePattern)
CodeVerifierRegex = globalCache.MustGet(CodeVerifierPattern)
AuthCodeRegex = globalCache.MustGet(AuthCodePattern)
RedirectURIRegex = globalCache.MustGet(RedirectURIPattern)
BotUserAgentRegex = globalCache.MustGet(BotUserAgentPattern)
IPv4Regex = globalCache.MustGet(IPv4Pattern)
TenantIDRegex = globalCache.MustGet(TenantIDPattern)
}
// Global helper functions for common validations
// ValidateEmail checks if an email address is valid
func ValidateEmail(email string) bool {
return EmailRegex.MatchString(email)
}
// ValidateDomain checks if a domain name is valid
func ValidateDomain(domain string) bool {
return DomainRegex.MatchString(domain)
}
// ValidateURL checks if a URL is valid (http/https)
func ValidateURL(url string) bool {
return URLRegex.MatchString(url)
}
// ValidateJWT checks if a token has valid JWT format
func ValidateJWT(token string) bool {
return JWTRegex.MatchString(token)
}
// ExtractBearerToken extracts the token from a Bearer authorization header
func ExtractBearerToken(authHeader string) (string, bool) {
matches := BearerTokenRegex.FindStringSubmatch(authHeader)
if len(matches) == 2 {
return matches[1], true
}
return "", false
}
// ValidateClientID checks if a client ID has valid format
func ValidateClientID(clientID string) bool {
return ClientIDRegex.MatchString(clientID)
}
// ValidateScopes checks if scopes string has valid format
func ValidateScopes(scopes string) bool {
return ScopeRegex.MatchString(scopes)
}
// ValidateSessionID checks if a session ID has valid format
func ValidateSessionID(sessionID string) bool {
return SessionIDRegex.MatchString(sessionID)
}
// ValidateCSRFToken checks if a CSRF token has valid format
func ValidateCSRFToken(token string) bool {
return CSRFTokenRegex.MatchString(token)
}
// ValidateNonce checks if a nonce has valid format
func ValidateNonce(nonce string) bool {
return NonceRegex.MatchString(nonce)
}
// ValidateCodeVerifier checks if a PKCE code verifier has valid format
func ValidateCodeVerifier(verifier string) bool {
return CodeVerifierRegex.MatchString(verifier)
}
// ValidateAuthCode checks if an authorization code has valid format
func ValidateAuthCode(code string) bool {
return AuthCodeRegex.MatchString(code)
}
// ValidateRedirectURI checks if a redirect URI is valid
func ValidateRedirectURI(uri string) bool {
return RedirectURIRegex.MatchString(uri)
}
// IsBotUserAgent checks if a User-Agent suggests an automated client
func IsBotUserAgent(userAgent string) bool {
return BotUserAgentRegex.MatchString(userAgent)
}
// ValidateIPv4 checks if an IP address is valid IPv4
func ValidateIPv4(ip string) bool {
return IPv4Regex.MatchString(ip)
}
// ValidateTenantID checks if a tenant ID has valid UUID format
func ValidateTenantID(tenantID string) bool {
return TenantIDRegex.MatchString(tenantID)
}
// GetGlobalCache returns the global regex cache instance
func GetGlobalCache() *RegexCache {
return globalCache
}
// CompilePattern compiles a pattern using the global cache
func CompilePattern(pattern string) (*regexp.Regexp, error) {
return globalCache.Get(pattern)
}
// MustCompilePattern compiles a pattern using the global cache, panicking on error
func MustCompilePattern(pattern string) *regexp.Regexp {
return globalCache.MustGet(pattern)
}
-484
View File
@@ -1,484 +0,0 @@
package patterns
import (
"regexp"
"sync"
"testing"
)
func TestRegexCache_Get(t *testing.T) {
cache := NewRegexCache()
pattern := `^test\d+$`
// First call should compile and cache
regex1, err := cache.Get(pattern)
if err != nil {
t.Fatalf("Failed to get regex: %v", err)
}
// Second call should return cached version
regex2, err := cache.Get(pattern)
if err != nil {
t.Fatalf("Failed to get cached regex: %v", err)
}
// Should be the same instance
if regex1 != regex2 {
t.Error("Expected same regex instance from cache")
}
// Test the regex works
if !regex1.MatchString("test123") {
t.Error("Regex should match 'test123'")
}
if regex1.MatchString("test") {
t.Error("Regex should not match 'test'")
}
}
func TestRegexCache_ConcurrentAccess(t *testing.T) {
cache := NewRegexCache()
pattern := `^concurrent\d+$`
var wg sync.WaitGroup
results := make([]*regexp.Regexp, 10)
errors := make([]error, 10)
// Launch multiple goroutines to access the same pattern
for i := 0; i < 10; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
regex, err := cache.Get(pattern)
results[index] = regex
errors[index] = err
}(i)
}
wg.Wait()
// Check all succeeded
for i, err := range errors {
if err != nil {
t.Fatalf("Goroutine %d failed: %v", i, err)
}
}
// All should return the same instance
first := results[0]
for i, regex := range results[1:] {
if regex != first {
t.Errorf("Goroutine %d got different regex instance", i+1)
}
}
}
func TestRegexCache_InvalidPattern(t *testing.T) {
cache := NewRegexCache()
_, err := cache.Get(`[invalid`)
if err == nil {
t.Error("Expected error for invalid regex pattern")
}
}
func TestRegexCache_Precompile(t *testing.T) {
cache := NewRegexCache()
patterns := []string{
`^test1$`,
`^test2$`,
`^test3$`,
}
err := cache.Precompile(patterns)
if err != nil {
t.Fatalf("Failed to precompile patterns: %v", err)
}
if cache.Size() != 3 {
t.Errorf("Expected cache size 3, got %d", cache.Size())
}
// Should be able to get precompiled patterns without error
for _, pattern := range patterns {
_, err := cache.Get(pattern)
if err != nil {
t.Errorf("Failed to get precompiled pattern %s: %v", pattern, err)
}
}
}
func TestValidationFunctions(t *testing.T) {
tests := []struct {
name string
function func(string) bool
valid []string
invalid []string
}{
{
name: "ValidateEmail",
function: ValidateEmail,
valid: []string{"[email protected]", "[email protected]", "[email protected]"},
invalid: []string{"invalid-email", "@domain.com", "user@", ""},
},
{
name: "ValidateDomain",
function: ValidateDomain,
valid: []string{"example.com", "sub.domain.org", "test.co.uk"},
invalid: []string{"", "invalid..domain", ".example.com", "domain."},
},
{
name: "ValidateJWT",
function: ValidateJWT,
valid: []string{"eyJ0.eyJ1.sig", "a.b.c"},
invalid: []string{"invalid", "a.b", "a.b.c.d", ""},
},
{
name: "ValidateClientID",
function: ValidateClientID,
valid: []string{"client123", "my-client_id", "123.456"},
invalid: []string{"", "client with spaces", "client@invalid"},
},
{
name: "ValidateURL",
function: ValidateURL,
valid: []string{"https://example.com", "https://sub.domain.org/path", "http://localhost", "https://example.com/path?query=value", "http://192.168.1.1"},
invalid: []string{"", "ftp://example.com", "not-a-url", "https://", "example.com", "http://localhost:8080"},
},
{
name: "ValidateScopes",
function: ValidateScopes,
valid: []string{"openid", "openid profile", "read write admin", "user_info"},
invalid: []string{"", "scope-with-dash", "scope@invalid", "scope with.dot", " "},
},
{
name: "ValidateSessionID",
function: ValidateSessionID,
valid: []string{"a1b2c3d4e5f6789012345678901234567890abcdef", "ABCDEF1234567890abcdef1234567890", "0123456789abcdef0123456789abcdef"},
invalid: []string{"", "too-short", "contains-invalid-chars!", "g123456789abcdef0123456789abcdef", "1234567890abcdef1234567890abcde"},
},
{
name: "ValidateCSRFToken",
function: ValidateCSRFToken,
valid: []string{"abc123", "ABC_123-xyz", "token-value_123", "_valid-token_"},
invalid: []string{"", "token with spaces", "token@invalid", "token.with.dots!", "token/with/slash"},
},
{
name: "ValidateNonce",
function: ValidateNonce,
valid: []string{"abc123", "ABC_123-xyz", "nonce-value_123", "_valid-nonce_"},
invalid: []string{"", "nonce with spaces", "nonce@invalid", "nonce.with.dots!", "nonce/with/slash"},
},
{
name: "ValidateCodeVerifier",
function: ValidateCodeVerifier,
valid: []string{"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"},
invalid: []string{"", "too-short", "short", "verifier with spaces", "verifier@invalid", "a"},
},
{
name: "ValidateAuthCode",
function: ValidateAuthCode,
valid: []string{"auth_code_123", "ABC.123-xyz/code+value=", "simple-code"},
invalid: []string{"", "code with spaces", "code@invalid"},
},
{
name: "ValidateRedirectURI",
function: ValidateRedirectURI,
valid: []string{"https://example.com/callback", "http://localhost:8080/auth", "https://app.example.org/oauth/callback", "http://127.0.0.1:3000"},
invalid: []string{"", "ftp://example.com", "not-a-url", "example.com/callback", "https://"},
},
{
name: "ValidateIPv4",
function: ValidateIPv4,
valid: []string{"192.168.1.1", "10.0.0.1", "127.0.0.1", "255.255.255.255", "0.0.0.0"},
invalid: []string{"", "256.1.1.1", "192.168.1", "192.168.1.1.1", "not-an-ip"},
},
{
name: "ValidateTenantID",
function: ValidateTenantID,
valid: []string{"12345678-1234-1234-1234-123456789abc", "ABCDEF12-3456-7890-ABCD-EF1234567890"},
invalid: []string{"", "not-a-uuid", "12345678-1234-1234-1234", "12345678-1234-1234-1234-123456789abcd", "123456781234123412341234567890ab"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for _, valid := range tt.valid {
if !tt.function(valid) {
t.Errorf("%s should be valid: %s", tt.name, valid)
}
}
for _, invalid := range tt.invalid {
if tt.function(invalid) {
t.Errorf("%s should be invalid: %s", tt.name, invalid)
}
}
})
}
}
func TestExtractBearerToken(t *testing.T) {
tests := []struct {
header string
expected string
valid bool
}{
{"Bearer abc123", "abc123", true},
{"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9", true},
{"bearer token123", "", false}, // case sensitive
{"Basic abc123", "", false},
{"Bearer", "", false},
{"", "", false},
}
for _, tt := range tests {
token, valid := ExtractBearerToken(tt.header)
if valid != tt.valid {
t.Errorf("ExtractBearerToken(%q) valid = %v, want %v", tt.header, valid, tt.valid)
}
if token != tt.expected {
t.Errorf("ExtractBearerToken(%q) token = %q, want %q", tt.header, token, tt.expected)
}
}
}
func BenchmarkRegexCache_Get(b *testing.B) {
cache := NewRegexCache()
pattern := `^benchmark\d+$`
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, err := cache.Get(pattern)
if err != nil {
b.Fatal(err)
}
}
})
}
func BenchmarkRegexCache_Validation(b *testing.B) {
email := "[email protected]"
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
ValidateEmail(email)
}
})
}
func BenchmarkRegex_DirectCompile(b *testing.B) {
pattern := `^benchmark\d+$`
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := regexp.Compile(pattern)
if err != nil {
b.Fatal(err)
}
}
}
func TestRegexCache_Clear(t *testing.T) {
cache := NewRegexCache()
// Add some patterns to the cache
patterns := []string{`^test1$`, `^test2$`, `^test3$`}
for _, pattern := range patterns {
_, err := cache.Get(pattern)
if err != nil {
t.Fatalf("Failed to add pattern %s: %v", pattern, err)
}
}
// Verify cache has patterns
if cache.Size() != 3 {
t.Errorf("Expected cache size 3, got %d", cache.Size())
}
// Clear the cache
cache.Clear()
// Verify cache is empty
if cache.Size() != 0 {
t.Errorf("Expected cache size 0 after clear, got %d", cache.Size())
}
}
func TestIsBotUserAgent(t *testing.T) {
tests := []struct {
userAgent string
isBot bool
}{
{"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", true},
{"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", true},
{"facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)", false},
{"crawler-bot/1.0", true},
{"spider-agent/2.0", true},
{"curl/7.68.0", true},
{"wget/1.20.3", true},
{"python-requests/2.25.1", true},
{"Go-http-client/1.1", true},
{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", false},
{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.userAgent, func(t *testing.T) {
result := IsBotUserAgent(tt.userAgent)
if result != tt.isBot {
t.Errorf("IsBotUserAgent(%q) = %v, want %v", tt.userAgent, result, tt.isBot)
}
})
}
}
func TestGetGlobalCache(t *testing.T) {
cache := GetGlobalCache()
if cache == nil {
t.Error("GetGlobalCache() should not return nil")
}
// Should return the same instance
cache2 := GetGlobalCache()
if cache != cache2 {
t.Error("GetGlobalCache() should return the same instance")
}
// Should have precompiled patterns
if cache.Size() == 0 {
t.Error("Global cache should have precompiled patterns")
}
}
func TestCompilePattern(t *testing.T) {
pattern := `^test_compile\d+$`
regex, err := CompilePattern(pattern)
if err != nil {
t.Fatalf("CompilePattern failed: %v", err)
}
if !regex.MatchString("test_compile123") {
t.Error("Compiled pattern should match 'test_compile123'")
}
if regex.MatchString("test_compile") {
t.Error("Compiled pattern should not match 'test_compile'")
}
// Test invalid pattern
_, err = CompilePattern(`[invalid`)
if err == nil {
t.Error("Expected error for invalid pattern")
}
}
func TestMustCompilePattern(t *testing.T) {
pattern := `^test_must_compile\d+$`
regex := MustCompilePattern(pattern)
if regex == nil {
t.Fatal("MustCompilePattern should not return nil")
}
if !regex.MatchString("test_must_compile456") {
t.Error("Compiled pattern should match 'test_must_compile456'")
}
// Test that it panics with invalid pattern
defer func() {
if r := recover(); r == nil {
t.Error("MustCompilePattern should panic with invalid pattern")
}
}()
MustCompilePattern(`[invalid`)
}
func TestAdditionalValidationEdgeCases(t *testing.T) {
// Test edge cases for ValidateURL
t.Run("ValidateURL_EdgeCases", func(t *testing.T) {
edgeCases := []struct {
url string
valid bool
}{
{"https://a.b", true},
{"http://localhost", true},
{"https://example.com/path?query=value#fragment", true},
{"http://192.168.0.1:8080/api", false},
{"https://", false},
{"http://", false},
{"https://example", true},
}
for _, tc := range edgeCases {
result := ValidateURL(tc.url)
if result != tc.valid {
t.Errorf("ValidateURL(%q) = %v, want %v", tc.url, result, tc.valid)
}
}
})
// Test edge cases for ValidateScopes
t.Run("ValidateScopes_EdgeCases", func(t *testing.T) {
edgeCases := []struct {
scopes string
valid bool
}{
{"a", true},
{"a b", true},
{"openid profile email", true},
{"user_profile", true},
{"read_all write_all", true},
{"scope-with-dash", false},
{"scope.with.dot", false},
{"scope@email", false},
{" scope", false},
{"scope ", false},
{"a b", true}, // pattern allows multiple spaces
}
for _, tc := range edgeCases {
result := ValidateScopes(tc.scopes)
if result != tc.valid {
t.Errorf("ValidateScopes(%q) = %v, want %v", tc.scopes, result, tc.valid)
}
}
})
// Test edge cases for ValidateSessionID
t.Run("ValidateSessionID_EdgeCases", func(t *testing.T) {
edgeCases := []struct {
sessionID string
valid bool
}{
{"12345678901234567890123456789012", true}, // 32 chars (min)
{"1234567890123456789012345678901", false}, // 31 chars (too short)
{string(make([]byte, 128)), false}, // 128 non-hex chars
{"abcdef1234567890ABCDEF1234567890" + string(make([]byte, 96)), false}, // 128+ chars with non-hex
}
// Generate valid 128-char hex string (max length)
validLongHex := ""
for i := 0; i < 128; i++ {
validLongHex += "a"
}
edgeCases = append(edgeCases, struct {
sessionID string
valid bool
}{validLongHex, true})
for _, tc := range edgeCases {
result := ValidateSessionID(tc.sessionID)
if result != tc.valid {
t.Errorf("ValidateSessionID(%q) = %v, want %v", tc.sessionID, result, tc.valid)
}
}
})
}
+12 -88
View File
@@ -6,8 +6,6 @@ package pool
import (
"bytes"
"compress/gzip"
"encoding/json"
"io"
"strings"
"sync"
"sync/atomic"
@@ -56,10 +54,6 @@ type PoolStats struct {
JWTPuts uint64
HTTPGets uint64
HTTPPuts uint64
JSONEncoderGets uint64
JSONEncoderPuts uint64
JSONDecoderGets uint64
JSONDecoderPuts uint64
OversizedRejects uint64
}
@@ -119,7 +113,7 @@ func newManager() *Manager {
// Initialize compression pools
m.gzipWriterPool = &sync.Pool{
New: func() interface{} {
w, _ := gzip.NewWriterLevel(nil, gzip.BestSpeed) // Safe to ignore: factory function
w, _ := gzip.NewWriterLevel(nil, gzip.BestSpeed)
return w
},
}
@@ -178,17 +172,13 @@ func (m *Manager) GetBuffer(sizeHint int) *bytes.Buffer {
switch {
case sizeHint <= 1024:
buf, _ := m.smallBufferPool.Get().(*bytes.Buffer) // Safe to ignore: pool return is best-effort
return buf
return m.smallBufferPool.Get().(*bytes.Buffer)
case sizeHint <= 4096:
buf, _ := m.mediumBufferPool.Get().(*bytes.Buffer) // Safe to ignore: pool return is best-effort
return buf
return m.mediumBufferPool.Get().(*bytes.Buffer)
case sizeHint <= 8192:
buf, _ := m.largeBufferPool.Get().(*bytes.Buffer) // Safe to ignore: pool return is best-effort
return buf
return m.largeBufferPool.Get().(*bytes.Buffer)
case sizeHint <= 16384:
buf, _ := m.xlBufferPool.Get().(*bytes.Buffer) // Safe to ignore: pool return is best-effort
return buf
return m.xlBufferPool.Get().(*bytes.Buffer)
default:
// For very large buffers, create new ones
return bytes.NewBuffer(make([]byte, 0, sizeHint))
@@ -229,8 +219,7 @@ func (m *Manager) PutBuffer(buf *bytes.Buffer) {
// GetGzipWriter returns a gzip writer from the pool
func (m *Manager) GetGzipWriter() *gzip.Writer {
atomic.AddUint64(&m.stats.GzipGets, 1)
w, _ := m.gzipWriterPool.Get().(*gzip.Writer) // Safe to ignore: pool return is best-effort
return w
return m.gzipWriterPool.Get().(*gzip.Writer)
}
// PutGzipWriter returns a gzip writer to the pool
@@ -250,8 +239,7 @@ func (m *Manager) GetGzipReader() *gzip.Reader {
if r == nil {
return nil
}
reader, _ := r.(*gzip.Reader) // Safe to ignore: pool return is best-effort
return reader
return r.(*gzip.Reader)
}
// PutGzipReader returns a gzip reader to the pool
@@ -260,14 +248,14 @@ func (m *Manager) PutGzipReader(r *gzip.Reader) {
return
}
atomic.AddUint64(&m.stats.GzipPuts, 1)
_ = r.Reset(nil) // Safe to ignore: resetting to nil reader for pool reuse
r.Reset(nil)
m.gzipReaderPool.Put(r)
}
// GetStringBuilder returns a string builder from the pool
func (m *Manager) GetStringBuilder() *strings.Builder {
atomic.AddUint64(&m.stats.StringGets, 1)
sb, _ := m.stringBuilderPool.Get().(*strings.Builder) // Safe to ignore: pool return is best-effort
sb := m.stringBuilderPool.Get().(*strings.Builder)
sb.Reset()
return sb
}
@@ -293,8 +281,7 @@ func (m *Manager) PutStringBuilder(sb *strings.Builder) {
// GetJWTBuffer returns JWT parsing buffers from the pool
func (m *Manager) GetJWTBuffer() *JWTBuffer {
atomic.AddUint64(&m.stats.JWTGets, 1)
buf, _ := m.jwtBufferPool.Get().(*JWTBuffer) // Safe to ignore: pool return is best-effort
return buf
return m.jwtBufferPool.Get().(*JWTBuffer)
}
// PutJWTBuffer returns JWT parsing buffers to the pool
@@ -321,8 +308,7 @@ func (m *Manager) PutJWTBuffer(buf *JWTBuffer) {
// GetHTTPResponseBuffer returns an HTTP response buffer from the pool
func (m *Manager) GetHTTPResponseBuffer() []byte {
atomic.AddUint64(&m.stats.HTTPGets, 1)
buf, _ := m.httpResponsePool.Get().(*[]byte) // Safe to ignore: pool return is best-effort
return *buf
return *m.httpResponsePool.Get().(*[]byte)
}
// PutHTTPResponseBuffer returns an HTTP response buffer to the pool
@@ -371,7 +357,7 @@ func (m *Manager) GetByteSlice(size int) []byte {
m.poolMu.Unlock()
}
b, _ := pool.Get().(*[]byte) // Safe to ignore: pool return is best-effort
b := pool.Get().(*[]byte)
return (*b)[:size]
}
@@ -392,40 +378,6 @@ func (m *Manager) PutByteSlice(b []byte) {
}
}
// GetJSONEncoder returns a JSON encoder from the pool configured for the given writer
func (m *Manager) GetJSONEncoder(w io.Writer) *json.Encoder {
atomic.AddUint64(&m.stats.JSONEncoderGets, 1)
// Since json.Encoder doesn't support resetting, we create new ones each time
encoder := json.NewEncoder(w)
encoder.SetEscapeHTML(false) // Disable HTML escaping for performance
return encoder
}
// PutJSONEncoder returns a JSON encoder to the pool
func (m *Manager) PutJSONEncoder(encoder *json.Encoder) {
if encoder == nil {
return
}
atomic.AddUint64(&m.stats.JSONEncoderPuts, 1)
// JSON encoders can't be reset, so we don't pool them
}
// GetJSONDecoder returns a JSON decoder from the pool configured for the given reader
func (m *Manager) GetJSONDecoder(r io.Reader) *json.Decoder {
atomic.AddUint64(&m.stats.JSONDecoderGets, 1)
// Since json.Decoder doesn't support resetting, we create new ones each time
return json.NewDecoder(r)
}
// PutJSONDecoder returns a JSON decoder to the pool
func (m *Manager) PutJSONDecoder(decoder *json.Decoder) {
if decoder == nil {
return
}
atomic.AddUint64(&m.stats.JSONDecoderPuts, 1)
// JSON decoders can't be reset, so we don't pool them
}
// GetStats returns current pool statistics
func (m *Manager) GetStats() PoolStats {
return PoolStats{
@@ -439,10 +391,6 @@ func (m *Manager) GetStats() PoolStats {
JWTPuts: atomic.LoadUint64(&m.stats.JWTPuts),
HTTPGets: atomic.LoadUint64(&m.stats.HTTPGets),
HTTPPuts: atomic.LoadUint64(&m.stats.HTTPPuts),
JSONEncoderGets: atomic.LoadUint64(&m.stats.JSONEncoderGets),
JSONEncoderPuts: atomic.LoadUint64(&m.stats.JSONEncoderPuts),
JSONDecoderGets: atomic.LoadUint64(&m.stats.JSONDecoderGets),
JSONDecoderPuts: atomic.LoadUint64(&m.stats.JSONDecoderPuts),
OversizedRejects: atomic.LoadUint64(&m.stats.OversizedRejects),
}
}
@@ -459,10 +407,6 @@ func (m *Manager) ResetStats() {
atomic.StoreUint64(&m.stats.JWTPuts, 0)
atomic.StoreUint64(&m.stats.HTTPGets, 0)
atomic.StoreUint64(&m.stats.HTTPPuts, 0)
atomic.StoreUint64(&m.stats.JSONEncoderGets, 0)
atomic.StoreUint64(&m.stats.JSONEncoderPuts, 0)
atomic.StoreUint64(&m.stats.JSONDecoderGets, 0)
atomic.StoreUint64(&m.stats.JSONDecoderPuts, 0)
atomic.StoreUint64(&m.stats.OversizedRejects, 0)
}
@@ -527,23 +471,3 @@ func ByteSlice(size int) []byte {
func ReturnByteSlice(b []byte) {
Get().PutByteSlice(b)
}
// JSONEncoder returns a JSON encoder from the global pool
func JSONEncoder(w io.Writer) *json.Encoder {
return Get().GetJSONEncoder(w)
}
// ReturnJSONEncoder returns a JSON encoder to the global pool
func ReturnJSONEncoder(encoder *json.Encoder) {
Get().PutJSONEncoder(encoder)
}
// JSONDecoder returns a JSON decoder from the global pool
func JSONDecoder(r io.Reader) *json.Decoder {
return Get().GetJSONDecoder(r)
}
// ReturnJSONDecoder returns a JSON decoder to the global pool
func ReturnJSONDecoder(decoder *json.Decoder) {
Get().PutJSONDecoder(decoder)
}
-70
View File
@@ -1,70 +0,0 @@
// Package pool provides centralized memory pool management utilities
package pool
import (
"strings"
)
// BuildSessionName efficiently builds session names using pooled string builders
func BuildSessionName(baseName string, index int) string {
sb := StringBuilder()
defer ReturnStringBuilder(sb)
sb.WriteString(baseName)
sb.WriteRune('_')
// Efficient int to string conversion
if index < 10 {
sb.WriteRune('0' + rune(index))
} else {
sb.WriteString(intToString(index))
}
return sb.String()
}
// BuildCacheKey efficiently builds cache keys using pooled string builders
func BuildCacheKey(parts ...string) string {
sb := StringBuilder()
defer ReturnStringBuilder(sb)
for i, part := range parts {
if i > 0 {
sb.WriteRune(':')
}
sb.WriteString(part)
}
return sb.String()
}
// FormatString efficiently formats a string using a pooled string builder
func FormatString(format func(*strings.Builder)) string {
sb := StringBuilder()
defer ReturnStringBuilder(sb)
format(sb)
return sb.String()
}
// intToString converts int to string without allocation (for small numbers)
func intToString(n int) string {
if n < 0 {
return "-" + intToString(-n)
}
if n < 10 {
return string(rune('0' + n))
}
if n < 100 {
return string(rune('0'+n/10)) + string(rune('0'+n%10))
}
// Fall back to standard conversion for larger numbers
buf := make([]byte, 0, 20)
for n > 0 {
buf = append(buf, byte('0'+n%10))
n /= 10
}
// Reverse the buffer
for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 {
buf[i], buf[j] = buf[j], buf[i]
}
return string(buf)
}
-72
View File
@@ -1,72 +0,0 @@
package providers
import (
"net/url"
)
// Auth0Provider encapsulates Auth0-specific OIDC logic.
type Auth0Provider struct {
*BaseProvider
}
// NewAuth0Provider creates a new instance of the Auth0Provider.
func NewAuth0Provider() *Auth0Provider {
return &Auth0Provider{
BaseProvider: NewBaseProvider(),
}
}
// GetType returns the provider's type.
func (p *Auth0Provider) GetType() ProviderType {
return ProviderTypeAuth0
}
// GetCapabilities returns the specific capabilities of the Auth0 provider.
func (p *Auth0Provider) GetCapabilities() ProviderCapabilities {
return ProviderCapabilities{
SupportsRefreshTokens: true,
RequiresOfflineAccessScope: true,
RequiresPromptConsent: false,
PreferredTokenValidation: "id", // Auth0 typically uses ID tokens
}
}
// BuildAuthParams configures Auth0-specific authentication parameters.
func (p *Auth0Provider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) {
// Auth0 supports various response types and connection parameters
baseParams.Set("response_type", "code")
// Ensure offline_access scope is present for refresh tokens
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
}
// Ensure openid scope is present
hasOpenID := false
for _, scope := range scopes {
if scope == "openid" {
hasOpenID = true
break
}
}
if !hasOpenID {
scopes = append(scopes, "openid")
}
return &AuthParams{
URLValues: baseParams,
Scopes: deduplicateScopes(scopes),
}, nil
}
// Auth0 requires specific tenant configuration and connection handling.
func (p *Auth0Provider) ValidateConfig() error {
return p.BaseProvider.ValidateConfig()
}
-124
View File
@@ -1,124 +0,0 @@
package providers
import (
"net/url"
"testing"
)
// TestAuth0Provider_NewAuth0Provider tests the constructor
func TestAuth0Provider_NewAuth0Provider(t *testing.T) {
provider := NewAuth0Provider()
if provider == nil {
t.Fatal("Expected provider to be created, got nil")
}
if provider.BaseProvider == nil {
t.Error("BaseProvider should be initialized")
}
}
// TestAuth0Provider_GetType tests provider type
func TestAuth0Provider_GetType(t *testing.T) {
provider := NewAuth0Provider()
if provider.GetType() != ProviderTypeAuth0 {
t.Errorf("Expected ProviderTypeAuth0, got %v", provider.GetType())
}
}
// TestAuth0Provider_GetCapabilities tests Auth0-specific capabilities
func TestAuth0Provider_GetCapabilities(t *testing.T) {
provider := NewAuth0Provider()
capabilities := provider.GetCapabilities()
if !capabilities.SupportsRefreshTokens {
t.Error("Expected SupportsRefreshTokens to be true for Auth0")
}
if !capabilities.RequiresOfflineAccessScope {
t.Error("Expected RequiresOfflineAccessScope to be true for Auth0")
}
if capabilities.RequiresPromptConsent {
t.Error("Expected RequiresPromptConsent to be false for Auth0")
}
if capabilities.PreferredTokenValidation != "id" {
t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation)
}
}
// TestAuth0Provider_BuildAuthParams tests Auth0-specific auth params
func TestAuth0Provider_BuildAuthParams(t *testing.T) {
provider := NewAuth0Provider()
baseParams := url.Values{}
baseParams.Set("client_id", "test_client")
tests := []struct {
name string
scopes []string
expectedScopes []string
}{
{
name: "Add offline_access and openid scopes",
scopes: []string{"profile", "email"},
expectedScopes: []string{"profile", "email", "offline_access", "openid"},
},
{
name: "Keep existing offline_access and openid",
scopes: []string{"openid", "profile", "offline_access", "email"},
expectedScopes: []string{"openid", "profile", "offline_access", "email"},
},
{
name: "Add both scopes when none provided",
scopes: []string{},
expectedScopes: []string{"offline_access", "openid"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authParams, err := provider.BuildAuthParams(baseParams, tt.scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
// Check that response_type is set
if authParams.URLValues.Get("response_type") != "code" {
t.Errorf("Expected response_type 'code', got '%s'", authParams.URLValues.Get("response_type"))
}
if len(authParams.Scopes) != len(tt.expectedScopes) {
t.Errorf("Expected %d scopes, got %d. Expected: %v, Got: %v",
len(tt.expectedScopes), len(authParams.Scopes), tt.expectedScopes, authParams.Scopes)
return
}
// Check that all expected scopes are present
for _, expectedScope := range tt.expectedScopes {
found := false
for _, actualScope := range authParams.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes)
}
}
})
}
}
// TestAuth0Provider_ValidateConfig tests config validation
func TestAuth0Provider_ValidateConfig(t *testing.T) {
provider := NewAuth0Provider()
err := provider.ValidateConfig()
if err != nil {
t.Errorf("ValidateConfig failed: %v", err)
}
}
-74
View File
@@ -1,74 +0,0 @@
package providers
import (
"net/url"
"strings"
)
// AWSCognitoProvider encapsulates AWS Cognito-specific OIDC logic.
type AWSCognitoProvider struct {
*BaseProvider
}
// NewAWSCognitoProvider creates a new instance of the AWSCognitoProvider.
func NewAWSCognitoProvider() *AWSCognitoProvider {
return &AWSCognitoProvider{
BaseProvider: NewBaseProvider(),
}
}
// GetType returns the provider's type.
func (p *AWSCognitoProvider) GetType() ProviderType {
return ProviderTypeAWSCognito
}
// GetCapabilities returns the specific capabilities of the AWS Cognito provider.
func (p *AWSCognitoProvider) GetCapabilities() ProviderCapabilities {
return ProviderCapabilities{
SupportsRefreshTokens: true,
RequiresOfflineAccessScope: false, // Cognito doesn't use offline_access scope
RequiresPromptConsent: false,
PreferredTokenValidation: "id", // Cognito typically uses ID tokens
}
}
// BuildAuthParams configures AWS Cognito-specific authentication parameters.
func (p *AWSCognitoProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) {
// AWS Cognito supports standard OIDC parameters
baseParams.Set("response_type", "code")
// Remove offline_access scope as Cognito doesn't use it (case-insensitive)
var filteredScopes []string
for _, scope := range scopes {
if strings.ToLower(scope) != "offline_access" {
filteredScopes = append(filteredScopes, scope)
}
}
// Ensure openid scope is present
hasOpenID := false
for _, scope := range filteredScopes {
if scope == "openid" {
hasOpenID = true
break
}
}
if !hasOpenID {
filteredScopes = append(filteredScopes, "openid")
}
// Default Cognito scopes if none specified
if len(filteredScopes) == 1 && filteredScopes[0] == "openid" {
filteredScopes = append(filteredScopes, "email", "profile")
}
return &AuthParams{
URLValues: baseParams,
Scopes: deduplicateScopes(filteredScopes),
}, nil
}
// AWS Cognito requires user pool and domain configuration.
func (p *AWSCognitoProvider) ValidateConfig() error {
return p.BaseProvider.ValidateConfig()
}
-295
View File
@@ -1,295 +0,0 @@
package providers
import (
"net/url"
"testing"
)
// TestAWSCognitoProvider_NewAWSCognitoProvider tests the constructor
func TestAWSCognitoProvider_NewAWSCognitoProvider(t *testing.T) {
provider := NewAWSCognitoProvider()
if provider == nil {
t.Fatal("Expected provider to be created, got nil")
}
if provider.BaseProvider == nil {
t.Error("BaseProvider should be initialized")
}
}
// TestAWSCognitoProvider_GetType tests provider type
func TestAWSCognitoProvider_GetType(t *testing.T) {
provider := NewAWSCognitoProvider()
if provider.GetType() != ProviderTypeAWSCognito {
t.Errorf("Expected ProviderTypeAWSCognito, got %v", provider.GetType())
}
}
// TestAWSCognitoProvider_GetCapabilities tests AWS Cognito-specific capabilities
func TestAWSCognitoProvider_GetCapabilities(t *testing.T) {
provider := NewAWSCognitoProvider()
capabilities := provider.GetCapabilities()
if !capabilities.SupportsRefreshTokens {
t.Error("Expected SupportsRefreshTokens to be true for AWS Cognito")
}
if capabilities.RequiresOfflineAccessScope {
t.Error("Expected RequiresOfflineAccessScope to be false for AWS Cognito")
}
if capabilities.RequiresPromptConsent {
t.Error("Expected RequiresPromptConsent to be false for AWS Cognito")
}
if capabilities.PreferredTokenValidation != "id" {
t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation)
}
}
// TestAWSCognitoProvider_BuildAuthParams tests AWS Cognito-specific auth params
func TestAWSCognitoProvider_BuildAuthParams(t *testing.T) {
provider := NewAWSCognitoProvider()
baseParams := url.Values{}
baseParams.Set("client_id", "test_client")
tests := []struct {
name string
scopes []string
expectedScopes []string
}{
{
name: "Remove offline_access scope and ensure openid",
scopes: []string{"email", "profile", "offline_access"},
expectedScopes: []string{"email", "profile", "openid"},
},
{
name: "Keep existing openid, remove offline_access",
scopes: []string{"openid", "email", "offline_access", "profile"},
expectedScopes: []string{"openid", "email", "profile"},
},
{
name: "Add default scopes when only openid",
scopes: []string{"openid"},
expectedScopes: []string{"openid", "email", "profile"},
},
{
name: "Add openid and defaults when empty",
scopes: []string{},
expectedScopes: []string{"openid", "email", "profile"},
},
{
name: "Cognito-specific scopes",
scopes: []string{"aws.cognito.signin.user.admin", "phone"},
expectedScopes: []string{"aws.cognito.signin.user.admin", "phone", "openid"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authParams, err := provider.BuildAuthParams(baseParams, tt.scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
// Check that response_type is set
if authParams.URLValues.Get("response_type") != "code" {
t.Errorf("Expected response_type 'code', got '%s'", authParams.URLValues.Get("response_type"))
}
if len(authParams.Scopes) != len(tt.expectedScopes) {
t.Errorf("Expected %d scopes, got %d. Expected: %v, Got: %v",
len(tt.expectedScopes), len(authParams.Scopes), tt.expectedScopes, authParams.Scopes)
return
}
// Check that all expected scopes are present
for _, expectedScope := range tt.expectedScopes {
found := false
for _, actualScope := range authParams.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes)
}
}
// Ensure offline_access is NOT present
for _, actualScope := range authParams.Scopes {
if actualScope == "offline_access" {
t.Error("offline_access scope should be filtered out for AWS Cognito")
}
}
})
}
}
// TestAWSCognitoProvider_ValidateConfig tests config validation
func TestAWSCognitoProvider_ValidateConfig(t *testing.T) {
provider := NewAWSCognitoProvider()
err := provider.ValidateConfig()
if err != nil {
t.Errorf("ValidateConfig failed: %v", err)
}
}
// TestAWSCognitoProvider_InterfaceCompliance tests that AWS Cognito provider implements the OIDCProvider interface
func TestAWSCognitoProvider_InterfaceCompliance(t *testing.T) {
var _ OIDCProvider = NewAWSCognitoProvider()
}
// TestAWSCognitoProvider_BaseProviderInheritance tests that AWS Cognito provider inherits from BaseProvider correctly
func TestAWSCognitoProvider_BaseProviderInheritance(t *testing.T) {
provider := NewAWSCognitoProvider()
// Test that it has access to BaseProvider methods
if provider.BaseProvider == nil {
t.Error("Expected BaseProvider to be initialized")
}
// Test HandleTokenRefresh (inherited from BaseProvider)
err := provider.HandleTokenRefresh(&TokenResult{
IDToken: "test-id-token",
AccessToken: "test-access-token",
RefreshToken: "test-refresh-token",
})
if err != nil {
t.Errorf("HandleTokenRefresh failed: %v", err)
}
}
// TestAWSCognitoProvider_OfflineAccessFiltering tests that offline_access scope is always filtered out
func TestAWSCognitoProvider_OfflineAccessFiltering(t *testing.T) {
provider := NewAWSCognitoProvider()
baseParams := url.Values{}
tests := []struct {
name string
scopes []string
}{
{
name: "Single offline_access",
scopes: []string{"offline_access"},
},
{
name: "Multiple offline_access occurrences",
scopes: []string{"offline_access", "email", "offline_access", "profile"},
},
{
name: "Mixed case",
scopes: []string{"OFFLINE_ACCESS", "email"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authParams, err := provider.BuildAuthParams(baseParams, tt.scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
// Ensure offline_access is NOT present in any form
for _, actualScope := range authParams.Scopes {
if actualScope == "offline_access" || actualScope == "OFFLINE_ACCESS" {
t.Errorf("offline_access scope should be filtered out, but found: %s", actualScope)
}
}
})
}
}
// TestAWSCognitoProvider_CognitoSpecificScopes tests AWS Cognito-specific scopes
func TestAWSCognitoProvider_CognitoSpecificScopes(t *testing.T) {
provider := NewAWSCognitoProvider()
baseParams := url.Values{}
tests := []struct {
name string
scopes []string
checkFor []string
}{
{
name: "Cognito admin scope",
scopes: []string{"aws.cognito.signin.user.admin"},
checkFor: []string{"aws.cognito.signin.user.admin", "openid"},
},
{
name: "Phone scope",
scopes: []string{"phone"},
checkFor: []string{"phone", "openid"},
},
{
name: "Address scope",
scopes: []string{"address"},
checkFor: []string{"address", "openid"},
},
{
name: "Multiple Cognito scopes",
scopes: []string{"aws.cognito.signin.user.admin", "phone", "address"},
checkFor: []string{"aws.cognito.signin.user.admin", "phone", "address", "openid"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authParams, err := provider.BuildAuthParams(baseParams, tt.scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
for _, expectedScope := range tt.checkFor {
found := false
for _, actualScope := range authParams.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes)
}
}
})
}
}
// TestAWSCognitoProvider_DefaultScopeHandling tests default scope behavior
func TestAWSCognitoProvider_DefaultScopeHandling(t *testing.T) {
provider := NewAWSCognitoProvider()
baseParams := url.Values{}
// Test with only openid scope - should add defaults
authParams, err := provider.BuildAuthParams(baseParams, []string{"openid"})
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
expectedScopes := []string{"openid", "email", "profile"}
if len(authParams.Scopes) != len(expectedScopes) {
t.Errorf("Expected %d scopes, got %d", len(expectedScopes), len(authParams.Scopes))
return
}
for _, expectedScope := range expectedScopes {
found := false
for _, actualScope := range authParams.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected default scope '%s' not found in %v", expectedScope, authParams.Scopes)
}
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ func (p *AzureProvider) BuildAuthParams(baseParams url.Values, scopes []string)
return &AuthParams{
URLValues: baseParams,
Scopes: deduplicateScopes(scopes),
Scopes: scopes,
}, nil
}
-584
View File
@@ -1,584 +0,0 @@
package providers
import (
"errors"
"net/url"
"strings"
"testing"
"time"
)
// TestAzureProvider_NewAzureProvider tests the constructor
func TestAzureProvider_NewAzureProvider(t *testing.T) {
provider := NewAzureProvider()
if provider == nil {
t.Fatal("Expected provider to be created, got nil")
}
if provider.BaseProvider == nil {
t.Error("BaseProvider should be initialized")
}
}
// TestAzureProvider_GetType tests provider type
func TestAzureProvider_GetType(t *testing.T) {
provider := NewAzureProvider()
if provider.GetType() != ProviderTypeAzure {
t.Errorf("Expected ProviderTypeAzure, got %v", provider.GetType())
}
}
// TestAzureProvider_GetCapabilities tests Azure-specific capabilities
func TestAzureProvider_GetCapabilities(t *testing.T) {
provider := NewAzureProvider()
capabilities := provider.GetCapabilities()
if !capabilities.SupportsRefreshTokens {
t.Error("Expected SupportsRefreshTokens to be true")
}
if !capabilities.RequiresOfflineAccessScope {
t.Error("Expected RequiresOfflineAccessScope to be true for Azure")
}
if capabilities.RequiresPromptConsent {
t.Error("Expected RequiresPromptConsent to be false for Azure")
}
if capabilities.PreferredTokenValidation != "access" {
t.Errorf("Expected PreferredTokenValidation 'access', got '%s'", capabilities.PreferredTokenValidation)
}
}
// TestAzureProvider_BuildAuthParams tests Azure-specific auth parameters
func TestAzureProvider_BuildAuthParams(t *testing.T) {
provider := NewAzureProvider()
tests := []struct {
name string
inputScopes []string
expectedScopes []string
shouldHaveResponseMode bool
shouldAddOfflineAccess bool
}{
{
name: "Basic scopes without offline_access",
inputScopes: []string{"openid", "profile", "email"},
expectedScopes: []string{"openid", "profile", "email", "offline_access"},
shouldHaveResponseMode: true,
shouldAddOfflineAccess: true,
},
{
name: "Scopes with offline_access already present",
inputScopes: []string{"openid", "profile", "offline_access", "email"},
expectedScopes: []string{"openid", "profile", "offline_access", "email"},
shouldHaveResponseMode: true,
shouldAddOfflineAccess: false,
},
{
name: "Only offline_access scope",
inputScopes: []string{"offline_access"},
expectedScopes: []string{"offline_access"},
shouldHaveResponseMode: true,
shouldAddOfflineAccess: false,
},
{
name: "Empty scopes (should add offline_access)",
inputScopes: []string{},
expectedScopes: []string{"offline_access"},
shouldHaveResponseMode: true,
shouldAddOfflineAccess: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
baseParams := make(url.Values)
baseParams.Set("client_id", "test-client")
result, err := provider.BuildAuthParams(baseParams, tt.inputScopes)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
// Check Azure-specific parameters
if tt.shouldHaveResponseMode {
if result.URLValues.Get("response_mode") != "query" {
t.Errorf("Expected response_mode 'query', got '%s'", result.URLValues.Get("response_mode"))
}
}
// Check scopes
if len(result.Scopes) != len(tt.expectedScopes) {
t.Errorf("Expected %d scopes, got %d", len(tt.expectedScopes), len(result.Scopes))
}
for _, expectedScope := range tt.expectedScopes {
found := false
for _, actualScope := range result.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in result", expectedScope)
}
}
// Verify offline_access is present
hasOfflineAccess := false
for _, scope := range result.Scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
t.Error("Azure provider should always include offline_access scope")
}
// Verify original base parameters are preserved
if result.URLValues.Get("client_id") != "test-client" {
t.Errorf("Expected client_id 'test-client', got '%s'", result.URLValues.Get("client_id"))
}
})
}
}
// TestAzureProvider_ValidateTokens tests Azure-specific token validation logic
func TestAzureProvider_ValidateTokens(t *testing.T) {
provider := NewAzureProvider()
tests := []struct {
name string
session *mockSession
verifierError error
cacheData map[string]interface{}
expectedResult ValidationResult
}{
{
name: "Unauthenticated with refresh token",
session: &mockSession{
authenticated: false,
refreshToken: "refresh-token",
},
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: true,
IsExpired: false,
},
},
{
name: "Unauthenticated without refresh token",
session: &mockSession{
authenticated: false,
},
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: false,
IsExpired: true,
},
},
{
name: "JWT access token valid",
session: &mockSession{
authenticated: true,
accessToken: "valid.jwt.token",
refreshToken: "refresh-token",
},
verifierError: nil,
cacheData: map[string]interface{}{
"exp": float64(time.Now().Add(10 * time.Minute).Unix()),
"sub": "user123",
},
expectedResult: ValidationResult{
Authenticated: true,
NeedsRefresh: false,
IsExpired: false,
},
},
{
name: "JWT access token invalid, valid ID token",
session: &mockSession{
authenticated: true,
accessToken: "invalid.jwt.token",
idToken: "valid.id.token",
refreshToken: "refresh-token",
},
verifierError: errors.New("invalid token"),
cacheData: map[string]interface{}{
"exp": float64(time.Now().Add(10 * time.Minute).Unix()),
"sub": "user123",
},
expectedResult: ValidationResult{
Authenticated: true,
NeedsRefresh: false,
IsExpired: false,
},
},
{
name: "Opaque access token with valid ID token",
session: &mockSession{
authenticated: true,
accessToken: "opaque-token-no-dots",
idToken: "valid.id.token",
refreshToken: "refresh-token",
},
cacheData: map[string]interface{}{
"exp": float64(time.Now().Add(10 * time.Minute).Unix()),
"sub": "user123",
},
expectedResult: ValidationResult{
Authenticated: true,
NeedsRefresh: false,
IsExpired: false,
},
},
{
name: "Opaque access token without ID token",
session: &mockSession{
authenticated: true,
accessToken: "opaque-token-no-dots",
refreshToken: "refresh-token",
},
expectedResult: ValidationResult{
Authenticated: true,
NeedsRefresh: false,
IsExpired: false,
},
},
{
name: "No access token, valid ID token",
session: &mockSession{
authenticated: true,
idToken: "valid.id.token",
refreshToken: "refresh-token",
},
verifierError: nil,
cacheData: map[string]interface{}{
"exp": float64(time.Now().Add(10 * time.Minute).Unix()),
"sub": "user123",
},
expectedResult: ValidationResult{
Authenticated: true,
NeedsRefresh: false,
IsExpired: false,
},
},
{
name: "No access token, invalid ID token, with refresh token",
session: &mockSession{
authenticated: true,
idToken: "invalid.id.token",
refreshToken: "refresh-token",
},
verifierError: errors.New("invalid token"),
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: true,
IsExpired: false,
},
},
{
name: "No tokens, with refresh token",
session: &mockSession{
authenticated: true,
refreshToken: "refresh-token",
},
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: true,
IsExpired: false,
},
},
{
name: "No tokens, no refresh token",
session: &mockSession{
authenticated: true,
},
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: false,
IsExpired: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verifier := &mockTokenVerifier{error: tt.verifierError}
cache := &mockTokenCache{claims: make(map[string]map[string]interface{})}
// Set up cache data
if tt.cacheData != nil {
if tt.session.accessToken != "" && strings.Count(tt.session.accessToken, ".") == 2 {
cache.claims[tt.session.accessToken] = tt.cacheData
}
if tt.session.idToken != "" {
cache.claims[tt.session.idToken] = tt.cacheData
}
}
result, err := provider.ValidateTokens(tt.session, verifier, cache, time.Minute)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result.Authenticated != tt.expectedResult.Authenticated {
t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated)
}
if result.NeedsRefresh != tt.expectedResult.NeedsRefresh {
t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh)
}
if result.IsExpired != tt.expectedResult.IsExpired {
t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired)
}
})
}
}
// TestAzureProvider_ValidateConfig tests configuration validation
func TestAzureProvider_ValidateConfig(t *testing.T) {
provider := NewAzureProvider()
err := provider.ValidateConfig()
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
}
// TestAzureProvider_InterfaceCompliance tests that Azure provider implements OIDCProvider
func TestAzureProvider_InterfaceCompliance(t *testing.T) {
provider := NewAzureProvider()
// Verify it implements the OIDCProvider interface
var _ OIDCProvider = provider
}
// TestAzureProvider_OfflineAccessHandling tests comprehensive offline_access handling
func TestAzureProvider_OfflineAccessHandling(t *testing.T) {
provider := NewAzureProvider()
tests := []struct {
name string
inputScopes []string
expectedCount int // Expected number of offline_access scopes (should be 1)
description string
}{
{
name: "No offline_access - should add one",
inputScopes: []string{"openid", "profile", "email"},
expectedCount: 1,
description: "Should add offline_access when not present",
},
{
name: "One offline_access - should preserve",
inputScopes: []string{"openid", "offline_access", "profile"},
expectedCount: 1,
description: "Should preserve existing offline_access",
},
{
name: "Multiple offline_access - should deduplicate",
inputScopes: []string{"openid", "offline_access", "profile", "offline_access"},
expectedCount: 1,
description: "Should deduplicate multiple offline_access scopes",
},
{
name: "Only offline_access",
inputScopes: []string{"offline_access"},
expectedCount: 1,
description: "Should preserve when only offline_access is present",
},
{
name: "Empty scopes - should add offline_access",
inputScopes: []string{},
expectedCount: 1,
description: "Should add offline_access when no scopes provided",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
baseParams := make(url.Values)
result, err := provider.BuildAuthParams(baseParams, tt.inputScopes)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
// Count offline_access occurrences in result
offlineAccessCount := 0
for _, scope := range result.Scopes {
if scope == "offline_access" {
offlineAccessCount++
}
}
if offlineAccessCount != tt.expectedCount {
t.Errorf("Expected %d offline_access scopes in result, got %d", tt.expectedCount, offlineAccessCount)
}
// Ensure at least one offline_access is always present
if offlineAccessCount == 0 {
t.Error("Azure provider should always have at least one offline_access scope")
}
// Verify other scopes are preserved (except for the empty case)
if len(tt.inputScopes) > 0 {
for _, originalScope := range tt.inputScopes {
found := false
for _, resultScope := range result.Scopes {
if resultScope == originalScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' to be preserved", originalScope)
}
}
}
})
}
}
// TestAzureProvider_TokenValidationPriority tests access token vs ID token priority
func TestAzureProvider_TokenValidationPriority(t *testing.T) {
provider := NewAzureProvider()
// Test that Azure prefers access tokens over ID tokens when both are JWT
session := &mockSession{
authenticated: true,
accessToken: "valid.access.token",
idToken: "valid.id.token",
refreshToken: "refresh-token",
}
verifier := &mockTokenVerifier{} // Valid tokens
cache := &mockTokenCache{
claims: map[string]map[string]interface{}{
"valid.access.token": {
"exp": float64(time.Now().Add(10 * time.Minute).Unix()),
"sub": "user123",
},
"valid.id.token": {
"exp": float64(time.Now().Add(10 * time.Minute).Unix()),
"sub": "user123",
},
},
}
result, err := provider.ValidateTokens(session, verifier, cache, time.Minute)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !result.Authenticated {
t.Error("Should be authenticated with valid access token")
}
if result.NeedsRefresh {
t.Error("Should not need refresh with valid access token")
}
}
// TestAzureProvider_AuthParamsPreservation tests that base parameters are not overwritten
func TestAzureProvider_AuthParamsPreservation(t *testing.T) {
provider := NewAzureProvider()
baseParams := make(url.Values)
baseParams.Set("client_id", "test-client")
baseParams.Set("redirect_uri", "https://example.com/callback")
baseParams.Set("response_type", "code")
baseParams.Set("state", "test-state")
baseParams.Set("nonce", "test-nonce")
scopes := []string{"openid", "profile"}
result, err := provider.BuildAuthParams(baseParams, scopes)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
// Verify all original parameters are preserved
expectedParams := map[string]string{
"client_id": "test-client",
"redirect_uri": "https://example.com/callback",
"response_type": "code",
"state": "test-state",
"nonce": "test-nonce",
"response_mode": "query", // Added by Azure provider
}
for key, expectedValue := range expectedParams {
actualValue := result.URLValues.Get(key)
if actualValue != expectedValue {
t.Errorf("Expected %s '%s', got '%s'", key, expectedValue, actualValue)
}
}
// Verify scopes (should include offline_access)
if len(result.Scopes) != 3 {
t.Errorf("Expected 3 scopes (including offline_access), got %d", len(result.Scopes))
}
expectedScopes := []string{"openid", "profile", "offline_access"}
for _, expectedScope := range expectedScopes {
found := false
for _, actualScope := range result.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found", expectedScope)
}
}
}
// Benchmark tests
func BenchmarkAzureProvider_BuildAuthParams(b *testing.B) {
provider := NewAzureProvider()
baseParams := make(url.Values)
baseParams.Set("client_id", "test-client")
scopes := []string{"openid", "profile", "email"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
provider.BuildAuthParams(baseParams, scopes)
}
}
func BenchmarkAzureProvider_ValidateTokens(b *testing.B) {
provider := NewAzureProvider()
session := &mockSession{
authenticated: true,
accessToken: "valid.access.token",
idToken: "valid.id.token",
refreshToken: "refresh-token",
}
verifier := &mockTokenVerifier{}
cache := &mockTokenCache{
claims: map[string]map[string]interface{}{
"valid.access.token": {
"exp": float64(time.Now().Add(10 * time.Minute).Unix()),
"sub": "user123",
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
provider.ValidateTokens(session, verifier, cache, time.Minute)
}
}
+1 -16
View File
@@ -117,7 +117,7 @@ func (p *BaseProvider) BuildAuthParams(baseParams url.Values, scopes []string) (
return &AuthParams{
URLValues: baseParams,
Scopes: deduplicateScopes(scopes),
Scopes: scopes,
}, nil
}
@@ -127,21 +127,6 @@ func (p *BaseProvider) HandleTokenRefresh(tokenData *TokenResult) error {
return nil
}
// deduplicateScopes removes duplicate scopes from a slice while preserving order.
func deduplicateScopes(scopes []string) []string {
seen := make(map[string]bool)
result := make([]string, 0, len(scopes))
for _, scope := range scopes {
if !seen[scope] {
seen[scope] = true
result = append(result, scope)
}
}
return result
}
// ValidateConfig checks provider-specific configuration requirements.
// By default, it assumes the configuration is valid.
func (p *BaseProvider) ValidateConfig() error {
-652
View File
@@ -1,652 +0,0 @@
package providers
import (
"errors"
"testing"
"time"
)
// Mock implementations for testing
type mockSession struct {
authenticated bool
idToken string
accessToken string
refreshToken string
}
func (s *mockSession) GetIDToken() string { return s.idToken }
func (s *mockSession) GetAccessToken() string { return s.accessToken }
func (s *mockSession) GetRefreshToken() string { return s.refreshToken }
func (s *mockSession) GetAuthenticated() bool { return s.authenticated }
type mockTokenVerifier struct {
error error
}
func (v *mockTokenVerifier) VerifyToken(token string) error {
return v.error
}
type mockTokenCache struct {
claims map[string]map[string]interface{}
}
func (c *mockTokenCache) Get(key string) (map[string]interface{}, bool) {
claims, exists := c.claims[key]
return claims, exists
}
// TestBaseProvider_GetType tests the default provider type
func TestBaseProvider_GetType(t *testing.T) {
provider := NewBaseProvider()
if provider.GetType() != ProviderTypeGeneric {
t.Errorf("Expected ProviderTypeGeneric, got %v", provider.GetType())
}
}
// TestBaseProvider_GetCapabilities tests the default capabilities
func TestBaseProvider_GetCapabilities(t *testing.T) {
provider := NewBaseProvider()
capabilities := provider.GetCapabilities()
if !capabilities.SupportsRefreshTokens {
t.Error("Expected SupportsRefreshTokens to be true")
}
if !capabilities.RequiresOfflineAccessScope {
t.Error("Expected RequiresOfflineAccessScope to be true")
}
if capabilities.PreferredTokenValidation != "id" {
t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation)
}
if capabilities.RequiresPromptConsent {
t.Error("Expected RequiresPromptConsent to be false")
}
}
// TestBaseProvider_ValidateTokens_Unauthenticated tests validation when not authenticated
func TestBaseProvider_ValidateTokens_Unauthenticated(t *testing.T) {
provider := NewBaseProvider()
session := &mockSession{authenticated: false}
verifier := &mockTokenVerifier{}
cache := &mockTokenCache{}
tests := []struct {
name string
refreshToken string
expectedResult ValidationResult
}{
{
name: "No refresh token",
refreshToken: "",
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: false,
IsExpired: false,
},
},
{
name: "Has refresh token",
refreshToken: "refresh-token",
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: true,
IsExpired: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
session.refreshToken = tt.refreshToken
result, err := provider.ValidateTokens(session, verifier, cache, time.Minute)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result.Authenticated != tt.expectedResult.Authenticated {
t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated)
}
if result.NeedsRefresh != tt.expectedResult.NeedsRefresh {
t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh)
}
if result.IsExpired != tt.expectedResult.IsExpired {
t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired)
}
})
}
}
// TestBaseProvider_ValidateTokens_AuthenticatedNoAccessToken tests authenticated session without access token
func TestBaseProvider_ValidateTokens_AuthenticatedNoAccessToken(t *testing.T) {
provider := NewBaseProvider()
session := &mockSession{
authenticated: true,
accessToken: "", // No access token
}
verifier := &mockTokenVerifier{}
cache := &mockTokenCache{}
tests := []struct {
name string
refreshToken string
expectedResult ValidationResult
}{
{
name: "No access token, no refresh token",
refreshToken: "",
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: false,
IsExpired: true,
},
},
{
name: "No access token, has refresh token",
refreshToken: "refresh-token",
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: true,
IsExpired: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
session.refreshToken = tt.refreshToken
result, err := provider.ValidateTokens(session, verifier, cache, time.Minute)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result.Authenticated != tt.expectedResult.Authenticated {
t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated)
}
if result.NeedsRefresh != tt.expectedResult.NeedsRefresh {
t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh)
}
if result.IsExpired != tt.expectedResult.IsExpired {
t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired)
}
})
}
}
// TestBaseProvider_ValidateTokens_AuthenticatedNoIDToken tests authenticated session without ID token
func TestBaseProvider_ValidateTokens_AuthenticatedNoIDToken(t *testing.T) {
provider := NewBaseProvider()
session := &mockSession{
authenticated: true,
accessToken: "access-token",
idToken: "", // No ID token
}
verifier := &mockTokenVerifier{}
cache := &mockTokenCache{}
tests := []struct {
name string
refreshToken string
expectedResult ValidationResult
}{
{
name: "No ID token, no refresh token",
refreshToken: "",
expectedResult: ValidationResult{
Authenticated: true,
NeedsRefresh: false,
IsExpired: false,
},
},
{
name: "No ID token, has refresh token",
refreshToken: "refresh-token",
expectedResult: ValidationResult{
Authenticated: true,
NeedsRefresh: true,
IsExpired: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
session.refreshToken = tt.refreshToken
result, err := provider.ValidateTokens(session, verifier, cache, time.Minute)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result.Authenticated != tt.expectedResult.Authenticated {
t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated)
}
if result.NeedsRefresh != tt.expectedResult.NeedsRefresh {
t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh)
}
if result.IsExpired != tt.expectedResult.IsExpired {
t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired)
}
})
}
}
// TestBaseProvider_ValidateTokens_TokenVerificationFailure tests token verification failures
func TestBaseProvider_ValidateTokens_TokenVerificationFailure(t *testing.T) {
provider := NewBaseProvider()
session := &mockSession{
authenticated: true,
accessToken: "access-token",
idToken: "id-token",
}
cache := &mockTokenCache{}
tests := []struct {
name string
verifierError error
refreshToken string
expectedResult ValidationResult
}{
{
name: "Token expired, has refresh token",
verifierError: errors.New("token has expired"),
refreshToken: "refresh-token",
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: true,
IsExpired: false,
},
},
{
name: "Token expired, no refresh token",
verifierError: errors.New("token has expired"),
refreshToken: "",
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: false,
IsExpired: true,
},
},
{
name: "Other verification error, has refresh token",
verifierError: errors.New("invalid signature"),
refreshToken: "refresh-token",
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: true,
IsExpired: false,
},
},
{
name: "Other verification error, no refresh token",
verifierError: errors.New("invalid signature"),
refreshToken: "",
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: false,
IsExpired: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verifier := &mockTokenVerifier{error: tt.verifierError}
session.refreshToken = tt.refreshToken
result, err := provider.ValidateTokens(session, verifier, cache, time.Minute)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result.Authenticated != tt.expectedResult.Authenticated {
t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated)
}
if result.NeedsRefresh != tt.expectedResult.NeedsRefresh {
t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh)
}
if result.IsExpired != tt.expectedResult.IsExpired {
t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired)
}
})
}
}
// TestBaseProvider_ValidateTokenExpiry tests token expiry validation logic
func TestBaseProvider_ValidateTokenExpiry(t *testing.T) {
provider := NewBaseProvider()
session := &mockSession{refreshToken: "refresh-token"}
now := time.Now()
gracePeriod := 5 * time.Minute
tests := []struct {
name string
claims map[string]interface{}
cacheFound bool
expectedResult ValidationResult
}{
{
name: "Token not found in cache, has refresh token",
claims: nil,
cacheFound: false,
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: true,
IsExpired: false,
},
},
{
name: "Claims without exp, has refresh token",
claims: map[string]interface{}{"sub": "user123"},
cacheFound: true,
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: true,
IsExpired: false,
},
},
{
name: "Token expired (beyond grace period), has refresh token",
claims: map[string]interface{}{
"exp": float64(now.Add(-10 * time.Minute).Unix()),
},
cacheFound: true,
expectedResult: ValidationResult{
Authenticated: true,
NeedsRefresh: true,
IsExpired: false,
},
},
{
name: "Token expires within grace period, has refresh token",
claims: map[string]interface{}{
"exp": float64(now.Add(2 * time.Minute).Unix()),
},
cacheFound: true,
expectedResult: ValidationResult{
Authenticated: true,
NeedsRefresh: true,
IsExpired: false,
},
},
{
name: "Token valid (beyond grace period)",
claims: map[string]interface{}{
"exp": float64(now.Add(10 * time.Minute).Unix()),
},
cacheFound: true,
expectedResult: ValidationResult{
Authenticated: true,
NeedsRefresh: false,
IsExpired: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cache := &mockTokenCache{claims: make(map[string]map[string]interface{})}
if tt.cacheFound {
cache.claims["test-token"] = tt.claims
}
result, err := provider.ValidateTokenExpiry(session, "test-token", cache, gracePeriod)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result.Authenticated != tt.expectedResult.Authenticated {
t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated)
}
if result.NeedsRefresh != tt.expectedResult.NeedsRefresh {
t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh)
}
if result.IsExpired != tt.expectedResult.IsExpired {
t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired)
}
})
}
}
// TestBaseProvider_ValidateTokenExpiry_NoRefreshToken tests expiry validation without refresh token
func TestBaseProvider_ValidateTokenExpiry_NoRefreshToken(t *testing.T) {
provider := NewBaseProvider()
session := &mockSession{refreshToken: ""} // No refresh token
now := time.Now()
gracePeriod := 5 * time.Minute
tests := []struct {
name string
claims map[string]interface{}
cacheFound bool
expectedResult ValidationResult
}{
{
name: "Token not found in cache, no refresh token",
claims: nil,
cacheFound: false,
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: false,
IsExpired: true,
},
},
{
name: "Claims without exp, no refresh token",
claims: map[string]interface{}{"sub": "user123"},
cacheFound: true,
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: false,
IsExpired: true,
},
},
{
name: "Token expires within grace period, no refresh token",
claims: map[string]interface{}{
"exp": float64(now.Add(2 * time.Minute).Unix()),
},
cacheFound: true,
expectedResult: ValidationResult{
Authenticated: true,
NeedsRefresh: false,
IsExpired: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cache := &mockTokenCache{claims: make(map[string]map[string]interface{})}
if tt.cacheFound {
cache.claims["test-token"] = tt.claims
}
result, err := provider.ValidateTokenExpiry(session, "test-token", cache, gracePeriod)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result.Authenticated != tt.expectedResult.Authenticated {
t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated)
}
if result.NeedsRefresh != tt.expectedResult.NeedsRefresh {
t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh)
}
if result.IsExpired != tt.expectedResult.IsExpired {
t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired)
}
})
}
}
// TestBaseProvider_BuildAuthParams tests authorization parameter building
func TestBaseProvider_BuildAuthParams(t *testing.T) {
provider := NewBaseProvider()
tests := []struct {
name string
scopes []string
expectedScopes []string
}{
{
name: "No existing offline_access scope",
scopes: []string{"openid", "profile", "email"},
expectedScopes: []string{"openid", "profile", "email", "offline_access"},
},
{
name: "Existing offline_access scope",
scopes: []string{"openid", "profile", "offline_access", "email"},
expectedScopes: []string{"openid", "profile", "offline_access", "email"},
},
{
name: "Empty scopes",
scopes: []string{},
expectedScopes: []string{"offline_access"},
},
{
name: "Only offline_access",
scopes: []string{"offline_access"},
expectedScopes: []string{"offline_access"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
baseParams := make(map[string][]string)
baseParams["client_id"] = []string{"test-client"}
result, err := provider.BuildAuthParams(baseParams, tt.scopes)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if len(result.Scopes) != len(tt.expectedScopes) {
t.Errorf("Expected %d scopes, got %d", len(tt.expectedScopes), len(result.Scopes))
}
// Check that all expected scopes are present
for _, expectedScope := range tt.expectedScopes {
found := false
for _, actualScope := range result.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in result", expectedScope)
}
}
// Verify base parameters are preserved
if result.URLValues.Get("client_id") != "test-client" {
t.Errorf("Expected client_id 'test-client', got '%s'", result.URLValues.Get("client_id"))
}
})
}
}
// TestBaseProvider_HandleTokenRefresh tests token refresh handling
func TestBaseProvider_HandleTokenRefresh(t *testing.T) {
provider := NewBaseProvider()
tokenData := &TokenResult{
IDToken: "new-id-token",
AccessToken: "new-access-token",
RefreshToken: "new-refresh-token",
}
// Base provider should do nothing and return no error
err := provider.HandleTokenRefresh(tokenData)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
}
// TestBaseProvider_ValidateConfig tests configuration validation
func TestBaseProvider_ValidateConfig(t *testing.T) {
provider := NewBaseProvider()
// Base provider should always return valid configuration
err := provider.ValidateConfig()
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
}
// TestNewBaseProvider tests the constructor
func TestNewBaseProvider(t *testing.T) {
provider := NewBaseProvider()
if provider == nil {
t.Fatal("Expected provider to be created, got nil")
}
// Verify it implements the OIDCProvider interface
var _ OIDCProvider = provider
}
// Benchmark tests
func BenchmarkBaseProvider_ValidateTokens(b *testing.B) {
provider := NewBaseProvider()
session := &mockSession{
authenticated: true,
idToken: "test-token",
accessToken: "access-token",
refreshToken: "refresh-token",
}
verifier := &mockTokenVerifier{}
cache := &mockTokenCache{
claims: map[string]map[string]interface{}{
"test-token": {
"exp": float64(time.Now().Add(10 * time.Minute).Unix()),
"sub": "user123",
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
provider.ValidateTokens(session, verifier, cache, time.Minute)
}
}
func BenchmarkBaseProvider_BuildAuthParams(b *testing.B) {
provider := NewBaseProvider()
baseParams := make(map[string][]string)
baseParams["client_id"] = []string{"test-client"}
scopes := []string{"openid", "profile", "email"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
provider.BuildAuthParams(baseParams, scopes)
}
}
+4 -39
View File
@@ -18,12 +18,6 @@ func NewProviderFactory() *ProviderFactory {
registry.RegisterProvider(NewGenericProvider())
registry.RegisterProvider(NewGoogleProvider())
registry.RegisterProvider(NewAzureProvider())
registry.RegisterProvider(NewGitHubProvider())
registry.RegisterProvider(NewAuth0Provider())
registry.RegisterProvider(NewOktaProvider())
registry.RegisterProvider(NewKeycloakProvider())
registry.RegisterProvider(NewAWSCognitoProvider())
registry.RegisterProvider(NewGitLabProvider())
return &ProviderFactory{
registry: registry,
@@ -37,16 +31,10 @@ func (f *ProviderFactory) CreateProvider(issuerURL string) (OIDCProvider, error)
return nil, fmt.Errorf("issuer URL cannot be empty")
}
parsedURL, err := url.Parse(issuerURL)
if err != nil {
if _, err := url.Parse(issuerURL); err != nil {
return nil, fmt.Errorf("invalid issuer URL format: %w", err)
}
// Check if the URL has a valid scheme and host
if parsedURL.Scheme == "" || parsedURL.Host == "" {
return nil, fmt.Errorf("invalid issuer URL format: URL must have a valid scheme and host")
}
provider := f.registry.DetectProvider(issuerURL)
if provider == nil {
return nil, fmt.Errorf("unable to detect provider for issuer URL: %s", issuerURL)
@@ -71,18 +59,6 @@ func (f *ProviderFactory) CreateProviderByType(providerType ProviderType) (OIDCP
provider = NewGoogleProvider()
case ProviderTypeAzure:
provider = NewAzureProvider()
case ProviderTypeGitHub:
provider = NewGitHubProvider()
case ProviderTypeAuth0:
provider = NewAuth0Provider()
case ProviderTypeOkta:
provider = NewOktaProvider()
case ProviderTypeKeycloak:
provider = NewKeycloakProvider()
case ProviderTypeAWSCognito:
provider = NewAWSCognitoProvider()
case ProviderTypeGitLab:
provider = NewGitLabProvider()
default:
return nil, fmt.Errorf("unsupported provider type: %d", providerType)
}
@@ -97,15 +73,9 @@ func (f *ProviderFactory) CreateProviderByType(providerType ProviderType) (OIDCP
// GetSupportedProviders returns a list of all supported provider types and their detection patterns.
func (f *ProviderFactory) GetSupportedProviders() map[ProviderType][]string {
return map[ProviderType][]string{
ProviderTypeGeneric: {"*"},
ProviderTypeGoogle: {"accounts.google.com"},
ProviderTypeAzure: {"login.microsoftonline.com", "sts.windows.net"},
ProviderTypeGitHub: {"github.com"},
ProviderTypeAuth0: {".auth0.com"},
ProviderTypeOkta: {".okta.com", ".oktapreview.com", ".okta-emea.com"},
ProviderTypeKeycloak: {"keycloak"},
ProviderTypeAWSCognito: {"cognito-idp", ".amazonaws.com"},
ProviderTypeGitLab: {"gitlab.com"},
ProviderTypeGeneric: {"*"},
ProviderTypeGoogle: {"accounts.google.com"},
ProviderTypeAzure: {"login.microsoftonline.com", "sts.windows.net"},
}
}
@@ -130,11 +100,6 @@ func (f *ProviderFactory) IsProviderSupported(issuerURL string) bool {
return false
}
// Check if the URL has a valid scheme and host
if normalizedURL.Scheme == "" || normalizedURL.Host == "" {
return false
}
host := strings.ToLower(normalizedURL.Host)
supportedProviders := f.GetSupportedProviders()
-624
View File
@@ -1,624 +0,0 @@
package providers
import (
"strings"
"testing"
)
// TestProviderFactory_NewProviderFactory tests the factory constructor
func TestProviderFactory_NewProviderFactory(t *testing.T) {
factory := NewProviderFactory()
if factory == nil {
t.Fatal("Expected factory to be created, got nil")
}
if factory.registry == nil {
t.Error("Expected registry to be initialized")
}
}
// TestProviderFactory_CreateProvider tests provider creation by issuer URL
func TestProviderFactory_CreateProvider(t *testing.T) {
factory := NewProviderFactory()
tests := []struct {
name string
issuerURL string
expectedType ProviderType
wantErr bool
errMsg string
}{
{
name: "Google provider",
issuerURL: "https://accounts.google.com",
expectedType: ProviderTypeGoogle,
wantErr: false,
},
{
name: "Google provider with path",
issuerURL: "https://accounts.google.com/oauth2",
expectedType: ProviderTypeGoogle,
wantErr: false,
},
{
name: "Azure provider - login.microsoftonline.com",
issuerURL: "https://login.microsoftonline.com/tenant-id/v2.0",
expectedType: ProviderTypeAzure,
wantErr: false,
},
{
name: "Azure provider - sts.windows.net",
issuerURL: "https://sts.windows.net/tenant-id",
expectedType: ProviderTypeAzure,
wantErr: false,
},
{
name: "GitHub provider",
issuerURL: "https://github.com/login/oauth",
expectedType: ProviderTypeGitHub,
wantErr: false,
},
{
name: "Auth0 provider",
issuerURL: "https://tenant.auth0.com",
expectedType: ProviderTypeAuth0,
wantErr: false,
},
{
name: "Okta provider",
issuerURL: "https://tenant.okta.com",
expectedType: ProviderTypeOkta,
wantErr: false,
},
{
name: "Okta preview provider",
issuerURL: "https://tenant.oktapreview.com",
expectedType: ProviderTypeOkta,
wantErr: false,
},
{
name: "Keycloak provider",
issuerURL: "https://auth.example.com/auth/realms/master",
expectedType: ProviderTypeKeycloak,
wantErr: false,
},
{
name: "AWS Cognito provider",
issuerURL: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_example",
expectedType: ProviderTypeAWSCognito,
wantErr: false,
},
{
name: "GitLab provider",
issuerURL: "https://gitlab.com/oauth",
expectedType: ProviderTypeGitLab,
wantErr: false,
},
{
name: "Generic provider",
issuerURL: "https://auth.example.com",
expectedType: ProviderTypeGeneric,
wantErr: false,
},
{
name: "Empty issuer URL",
issuerURL: "",
wantErr: true,
errMsg: "issuer URL cannot be empty",
},
{
name: "Invalid URL format",
issuerURL: "not-a-url",
wantErr: true,
errMsg: "invalid issuer URL format",
},
{
name: "URL without scheme",
issuerURL: "example.com",
wantErr: true,
errMsg: "invalid issuer URL format",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider, err := factory.CreateProvider(tt.issuerURL)
if tt.wantErr {
if err == nil {
t.Error("Expected error but got none")
return
}
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("Expected error containing '%s', got '%s'", tt.errMsg, err.Error())
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if provider == nil {
t.Fatal("Expected provider to be created, got nil")
}
if provider.GetType() != tt.expectedType {
t.Errorf("Expected provider type %v, got %v", tt.expectedType, provider.GetType())
}
})
}
}
// TestProviderFactory_CreateProviderByType tests provider creation by type
func TestProviderFactory_CreateProviderByType(t *testing.T) {
factory := NewProviderFactory()
tests := []struct {
name string
providerType ProviderType
expectedType ProviderType
wantErr bool
errMsg string
}{
{
name: "Generic provider",
providerType: ProviderTypeGeneric,
expectedType: ProviderTypeGeneric,
wantErr: false,
},
{
name: "Google provider",
providerType: ProviderTypeGoogle,
expectedType: ProviderTypeGoogle,
wantErr: false,
},
{
name: "Azure provider",
providerType: ProviderTypeAzure,
expectedType: ProviderTypeAzure,
wantErr: false,
},
{
name: "GitHub provider",
providerType: ProviderTypeGitHub,
expectedType: ProviderTypeGitHub,
wantErr: false,
},
{
name: "Auth0 provider",
providerType: ProviderTypeAuth0,
expectedType: ProviderTypeAuth0,
wantErr: false,
},
{
name: "Okta provider",
providerType: ProviderTypeOkta,
expectedType: ProviderTypeOkta,
wantErr: false,
},
{
name: "Keycloak provider",
providerType: ProviderTypeKeycloak,
expectedType: ProviderTypeKeycloak,
wantErr: false,
},
{
name: "AWS Cognito provider",
providerType: ProviderTypeAWSCognito,
expectedType: ProviderTypeAWSCognito,
wantErr: false,
},
{
name: "GitLab provider",
providerType: ProviderTypeGitLab,
expectedType: ProviderTypeGitLab,
wantErr: false,
},
{
name: "Invalid provider type",
providerType: ProviderType(999),
wantErr: true,
errMsg: "unsupported provider type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider, err := factory.CreateProviderByType(tt.providerType)
if tt.wantErr {
if err == nil {
t.Error("Expected error but got none")
return
}
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("Expected error containing '%s', got '%s'", tt.errMsg, err.Error())
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if provider == nil {
t.Fatal("Expected provider to be created, got nil")
}
if provider.GetType() != tt.expectedType {
t.Errorf("Expected provider type %v, got %v", tt.expectedType, provider.GetType())
}
})
}
}
// TestProviderFactory_GetSupportedProviders tests listing supported providers
func TestProviderFactory_GetSupportedProviders(t *testing.T) {
factory := NewProviderFactory()
supported := factory.GetSupportedProviders()
// Verify expected provider types are present
expectedTypes := []ProviderType{
ProviderTypeGeneric,
ProviderTypeGoogle,
ProviderTypeAzure,
}
for _, expectedType := range expectedTypes {
if _, exists := supported[expectedType]; !exists {
t.Errorf("Expected provider type %v to be supported", expectedType)
}
}
// Verify Google patterns
googlePatterns := supported[ProviderTypeGoogle]
if len(googlePatterns) != 1 || googlePatterns[0] != "accounts.google.com" {
t.Errorf("Expected Google patterns ['accounts.google.com'], got %v", googlePatterns)
}
// Verify Azure patterns
azurePatterns := supported[ProviderTypeAzure]
expectedAzurePatterns := []string{"login.microsoftonline.com", "sts.windows.net"}
if len(azurePatterns) != len(expectedAzurePatterns) {
t.Errorf("Expected %d Azure patterns, got %d", len(expectedAzurePatterns), len(azurePatterns))
}
for _, expectedPattern := range expectedAzurePatterns {
found := false
for _, pattern := range azurePatterns {
if pattern == expectedPattern {
found = true
break
}
}
if !found {
t.Errorf("Expected Azure pattern '%s' not found", expectedPattern)
}
}
// Verify Generic patterns
genericPatterns := supported[ProviderTypeGeneric]
if len(genericPatterns) != 1 || genericPatterns[0] != "*" {
t.Errorf("Expected Generic patterns ['*'], got %v", genericPatterns)
}
}
// TestProviderFactory_DetectProviderType tests provider type detection
func TestProviderFactory_DetectProviderType(t *testing.T) {
factory := NewProviderFactory()
tests := []struct {
name string
issuerURL string
expectedType ProviderType
wantErr bool
}{
{
name: "Google provider detection",
issuerURL: "https://accounts.google.com",
expectedType: ProviderTypeGoogle,
wantErr: false,
},
{
name: "Azure provider detection",
issuerURL: "https://login.microsoftonline.com/tenant/v2.0",
expectedType: ProviderTypeAzure,
wantErr: false,
},
{
name: "Generic provider detection",
issuerURL: "https://auth.example.com",
expectedType: ProviderTypeGeneric,
wantErr: false,
},
{
name: "Invalid URL",
issuerURL: "not-a-url",
wantErr: true,
},
{
name: "Empty URL",
issuerURL: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
providerType, err := factory.DetectProviderType(tt.issuerURL)
if tt.wantErr {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if providerType != tt.expectedType {
t.Errorf("Expected provider type %v, got %v", tt.expectedType, providerType)
}
})
}
}
// TestProviderFactory_IsProviderSupported tests provider support checking
func TestProviderFactory_IsProviderSupported(t *testing.T) {
factory := NewProviderFactory()
tests := []struct {
name string
issuerURL string
expected bool
}{
{
name: "Google provider supported",
issuerURL: "https://accounts.google.com",
expected: true,
},
{
name: "Google provider with subdomain supported",
issuerURL: "https://accounts.google.com/oauth2",
expected: true,
},
{
name: "Azure login.microsoftonline.com supported",
issuerURL: "https://login.microsoftonline.com/tenant/v2.0",
expected: true,
},
{
name: "Azure sts.windows.net supported",
issuerURL: "https://sts.windows.net/tenant",
expected: true,
},
{
name: "Generic provider supported (wildcard)",
issuerURL: "https://auth.example.com",
expected: true,
},
{
name: "Any valid URL supported (wildcard)",
issuerURL: "https://custom-auth.company.org",
expected: true,
},
{
name: "Empty URL not supported",
issuerURL: "",
expected: false,
},
{
name: "Invalid URL format not supported",
issuerURL: "not-a-url",
expected: false,
},
{
name: "URL without scheme not supported",
issuerURL: "example.com",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := factory.IsProviderSupported(tt.issuerURL)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
// TestProviderFactory_IntegrationTest tests the full flow
func TestProviderFactory_IntegrationTest(t *testing.T) {
factory := NewProviderFactory()
// Test Google provider flow
t.Run("Google Provider Flow", func(t *testing.T) {
// Check if supported
if !factory.IsProviderSupported("https://accounts.google.com") {
t.Error("Google provider should be supported")
}
// Detect type
providerType, err := factory.DetectProviderType("https://accounts.google.com")
if err != nil {
t.Errorf("Unexpected error detecting Google provider: %v", err)
}
if providerType != ProviderTypeGoogle {
t.Errorf("Expected ProviderTypeGoogle, got %v", providerType)
}
// Create provider by URL
provider, err := factory.CreateProvider("https://accounts.google.com")
if err != nil {
t.Errorf("Unexpected error creating Google provider: %v", err)
}
if provider.GetType() != ProviderTypeGoogle {
t.Errorf("Expected ProviderTypeGoogle, got %v", provider.GetType())
}
// Create provider by type
provider2, err := factory.CreateProviderByType(ProviderTypeGoogle)
if err != nil {
t.Errorf("Unexpected error creating Google provider by type: %v", err)
}
if provider2.GetType() != ProviderTypeGoogle {
t.Errorf("Expected ProviderTypeGoogle, got %v", provider2.GetType())
}
})
// Test Azure provider flow
t.Run("Azure Provider Flow", func(t *testing.T) {
azureURL := "https://login.microsoftonline.com/tenant/v2.0"
// Check if supported
if !factory.IsProviderSupported(azureURL) {
t.Error("Azure provider should be supported")
}
// Detect type
providerType, err := factory.DetectProviderType(azureURL)
if err != nil {
t.Errorf("Unexpected error detecting Azure provider: %v", err)
}
if providerType != ProviderTypeAzure {
t.Errorf("Expected ProviderTypeAzure, got %v", providerType)
}
// Create provider
provider, err := factory.CreateProvider(azureURL)
if err != nil {
t.Errorf("Unexpected error creating Azure provider: %v", err)
}
if provider.GetType() != ProviderTypeAzure {
t.Errorf("Expected ProviderTypeAzure, got %v", provider.GetType())
}
})
// Test Generic provider flow
t.Run("Generic Provider Flow", func(t *testing.T) {
genericURL := "https://auth.custom-provider.com"
// Check if supported
if !factory.IsProviderSupported(genericURL) {
t.Error("Generic provider should be supported")
}
// Detect type
providerType, err := factory.DetectProviderType(genericURL)
if err != nil {
t.Errorf("Unexpected error detecting generic provider: %v", err)
}
if providerType != ProviderTypeGeneric {
t.Errorf("Expected ProviderTypeGeneric, got %v", providerType)
}
// Create provider
provider, err := factory.CreateProvider(genericURL)
if err != nil {
t.Errorf("Unexpected error creating generic provider: %v", err)
}
if provider.GetType() != ProviderTypeGeneric {
t.Errorf("Expected ProviderTypeGeneric, got %v", provider.GetType())
}
})
}
// TestProviderFactory_CaseInsensitiveHostMatching tests case insensitive host matching
func TestProviderFactory_CaseInsensitiveHostMatching(t *testing.T) {
factory := NewProviderFactory()
tests := []struct {
name string
issuerURL string
expectedType ProviderType
}{
{
name: "Google with uppercase",
issuerURL: "https://ACCOUNTS.GOOGLE.COM",
expectedType: ProviderTypeGoogle,
},
{
name: "Google with mixed case",
issuerURL: "https://Accounts.Google.Com",
expectedType: ProviderTypeGoogle,
},
{
name: "Azure with uppercase",
issuerURL: "https://LOGIN.MICROSOFTONLINE.COM/tenant",
expectedType: ProviderTypeAzure,
},
{
name: "Azure STS with mixed case",
issuerURL: "https://Sts.Windows.Net/tenant",
expectedType: ProviderTypeAzure,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Should be supported
if !factory.IsProviderSupported(tt.issuerURL) {
t.Errorf("URL %s should be supported", tt.issuerURL)
}
// Should detect correct type
providerType, err := factory.DetectProviderType(tt.issuerURL)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if providerType != tt.expectedType {
t.Errorf("Expected %v, got %v", tt.expectedType, providerType)
}
// Should create correct provider
provider, err := factory.CreateProvider(tt.issuerURL)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if provider.GetType() != tt.expectedType {
t.Errorf("Expected %v, got %v", tt.expectedType, provider.GetType())
}
})
}
}
// Benchmark tests
func BenchmarkProviderFactory_CreateProvider(b *testing.B) {
factory := NewProviderFactory()
issuerURL := "https://accounts.google.com"
b.ResetTimer()
for i := 0; i < b.N; i++ {
factory.CreateProvider(issuerURL)
}
}
func BenchmarkProviderFactory_IsProviderSupported(b *testing.B) {
factory := NewProviderFactory()
issuerURL := "https://auth.example.com"
b.ResetTimer()
for i := 0; i < b.N; i++ {
factory.IsProviderSupported(issuerURL)
}
}
func BenchmarkProviderFactory_DetectProviderType(b *testing.B) {
factory := NewProviderFactory()
issuerURL := "https://login.microsoftonline.com/tenant"
b.ResetTimer()
for i := 0; i < b.N; i++ {
factory.DetectProviderType(issuerURL)
}
}
-246
View File
@@ -1,246 +0,0 @@
package providers
import (
"testing"
)
// TestGenericProvider_NewGenericProvider tests the constructor
func TestGenericProvider_NewGenericProvider(t *testing.T) {
provider := NewGenericProvider()
if provider == nil {
t.Fatal("Expected provider to be created, got nil")
}
if provider.BaseProvider == nil {
t.Error("BaseProvider should be initialized")
}
}
// TestGenericProvider_GetType tests provider type
func TestGenericProvider_GetType(t *testing.T) {
provider := NewGenericProvider()
if provider.GetType() != ProviderTypeGeneric {
t.Errorf("Expected ProviderTypeGeneric, got %v", provider.GetType())
}
}
// TestGenericProvider_GetCapabilities tests that it inherits BaseProvider capabilities
func TestGenericProvider_GetCapabilities(t *testing.T) {
provider := NewGenericProvider()
capabilities := provider.GetCapabilities()
// Should have the same capabilities as BaseProvider
baseProvider := NewBaseProvider()
baseCapabilities := baseProvider.GetCapabilities()
if capabilities.SupportsRefreshTokens != baseCapabilities.SupportsRefreshTokens {
t.Errorf("Expected SupportsRefreshTokens %v, got %v",
baseCapabilities.SupportsRefreshTokens, capabilities.SupportsRefreshTokens)
}
if capabilities.RequiresOfflineAccessScope != baseCapabilities.RequiresOfflineAccessScope {
t.Errorf("Expected RequiresOfflineAccessScope %v, got %v",
baseCapabilities.RequiresOfflineAccessScope, capabilities.RequiresOfflineAccessScope)
}
if capabilities.PreferredTokenValidation != baseCapabilities.PreferredTokenValidation {
t.Errorf("Expected PreferredTokenValidation %v, got %v",
baseCapabilities.PreferredTokenValidation, capabilities.PreferredTokenValidation)
}
if capabilities.RequiresPromptConsent != baseCapabilities.RequiresPromptConsent {
t.Errorf("Expected RequiresPromptConsent %v, got %v",
baseCapabilities.RequiresPromptConsent, capabilities.RequiresPromptConsent)
}
}
// TestGenericProvider_InterfaceCompliance tests that Generic provider implements OIDCProvider
func TestGenericProvider_InterfaceCompliance(t *testing.T) {
provider := NewGenericProvider()
// Verify it implements the OIDCProvider interface
var _ OIDCProvider = provider
}
// TestGenericProvider_InheritsBaseProviderBehavior tests inherited functionality
func TestGenericProvider_InheritsBaseProviderBehavior(t *testing.T) {
provider := NewGenericProvider()
baseProvider := NewBaseProvider()
// Test BuildAuthParams behavior is the same
scopes := []string{"openid", "profile", "email"}
baseParams := make(map[string][]string)
baseParams["client_id"] = []string{"test-client"}
genericResult, genericErr := provider.BuildAuthParams(baseParams, scopes)
baseResult, baseErr := baseProvider.BuildAuthParams(baseParams, scopes)
if (genericErr == nil) != (baseErr == nil) {
t.Errorf("BuildAuthParams error mismatch: generic=%v, base=%v", genericErr, baseErr)
}
if genericErr == nil && baseErr == nil {
// Compare scopes length (offline_access should be added)
if len(genericResult.Scopes) != len(baseResult.Scopes) {
t.Errorf("BuildAuthParams scope count mismatch: generic=%d, base=%d",
len(genericResult.Scopes), len(baseResult.Scopes))
}
// Verify offline_access is added in both cases
genericHasOffline := false
baseHasOffline := false
for _, scope := range genericResult.Scopes {
if scope == "offline_access" {
genericHasOffline = true
break
}
}
for _, scope := range baseResult.Scopes {
if scope == "offline_access" {
baseHasOffline = true
break
}
}
if genericHasOffline != baseHasOffline {
t.Errorf("offline_access scope handling mismatch: generic=%v, base=%v",
genericHasOffline, baseHasOffline)
}
}
// Test ValidateConfig behavior is the same
genericConfigErr := provider.ValidateConfig()
baseConfigErr := baseProvider.ValidateConfig()
if (genericConfigErr == nil) != (baseConfigErr == nil) {
t.Errorf("ValidateConfig error mismatch: generic=%v, base=%v", genericConfigErr, baseConfigErr)
}
// Test HandleTokenRefresh behavior is the same
tokenData := &TokenResult{IDToken: "test-token"}
genericRefreshErr := provider.HandleTokenRefresh(tokenData)
baseRefreshErr := baseProvider.HandleTokenRefresh(tokenData)
if (genericRefreshErr == nil) != (baseRefreshErr == nil) {
t.Errorf("HandleTokenRefresh error mismatch: generic=%v, base=%v",
genericRefreshErr, baseRefreshErr)
}
}
// TestGenericProvider_ValidateTokens tests token validation inheritance
func TestGenericProvider_ValidateTokens(t *testing.T) {
provider := NewGenericProvider()
tests := []struct {
name string
session *mockSession
verifierError error
expectedResult ValidationResult
}{
{
name: "Unauthenticated with refresh token",
session: &mockSession{
authenticated: false,
refreshToken: "refresh-token",
},
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: true,
IsExpired: false,
},
},
{
name: "Authenticated with valid tokens",
session: &mockSession{
authenticated: true,
idToken: "valid-token",
accessToken: "access-token",
refreshToken: "refresh-token",
},
verifierError: nil,
expectedResult: ValidationResult{
Authenticated: true,
NeedsRefresh: false,
IsExpired: false,
},
},
{
name: "Authenticated with invalid token, has refresh",
session: &mockSession{
authenticated: true,
idToken: "invalid-token",
refreshToken: "refresh-token",
},
verifierError: &testError{"token expired"},
expectedResult: ValidationResult{
Authenticated: false,
NeedsRefresh: true,
IsExpired: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verifier := &mockTokenVerifier{error: tt.verifierError}
cache := &mockTokenCache{
claims: map[string]map[string]interface{}{
"valid-token": {
"exp": float64(9999999999), // Far future
"sub": "user123",
},
},
}
result, err := provider.ValidateTokens(tt.session, verifier, cache, 0)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result.Authenticated != tt.expectedResult.Authenticated {
t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated)
}
if result.NeedsRefresh != tt.expectedResult.NeedsRefresh {
t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh)
}
if result.IsExpired != tt.expectedResult.IsExpired {
t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired)
}
})
}
}
// Benchmark tests
func BenchmarkGenericProvider_GetType(b *testing.B) {
provider := NewGenericProvider()
b.ResetTimer()
for i := 0; i < b.N; i++ {
provider.GetType()
}
}
func BenchmarkGenericProvider_GetCapabilities(b *testing.B) {
provider := NewGenericProvider()
b.ResetTimer()
for i := 0; i < b.N; i++ {
provider.GetCapabilities()
}
}
// Test error type for testing
type testError struct {
message string
}
func (e *testError) Error() string {
return e.message
}
-61
View File
@@ -1,61 +0,0 @@
package providers
import (
"net/url"
)
// GitHubProvider encapsulates GitHub-specific OIDC logic.
type GitHubProvider struct {
*BaseProvider
}
// NewGitHubProvider creates a new instance of the GitHubProvider.
func NewGitHubProvider() *GitHubProvider {
return &GitHubProvider{
BaseProvider: NewBaseProvider(),
}
}
// GetType returns the provider's type.
func (p *GitHubProvider) GetType() ProviderType {
return ProviderTypeGitHub
}
// GetCapabilities returns the specific capabilities of the GitHub provider.
// WARNING: GitHub does NOT support OpenID Connect - it's OAuth 2.0 only.
// This provider should only be used for OAuth flows, not OIDC authentication.
func (p *GitHubProvider) GetCapabilities() ProviderCapabilities {
return ProviderCapabilities{
SupportsRefreshTokens: false, // GitHub OAuth apps don't support refresh tokens
RequiresOfflineAccessScope: false, // GitHub doesn't use offline_access
RequiresPromptConsent: false,
PreferredTokenValidation: "access", // GitHub only provides access tokens, no ID tokens
}
}
// BuildAuthParams configures GitHub-specific authentication parameters.
func (p *GitHubProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) {
// GitHub doesn't use offline_access scope, so remove it if present
var filteredScopes []string
for _, scope := range scopes {
if scope != "offline_access" {
filteredScopes = append(filteredScopes, scope)
}
}
// If no scopes specified, use default GitHub scopes for OAuth
// Note: GitHub doesn't support 'openid' scope as it's not an OIDC provider
if len(filteredScopes) == 0 {
filteredScopes = []string{"user:email", "read:user"}
}
return &AuthParams{
URLValues: baseParams,
Scopes: deduplicateScopes(filteredScopes),
}, nil
}
// GitHub requires specific configuration for proper operation.
func (p *GitHubProvider) ValidateConfig() error {
return p.BaseProvider.ValidateConfig()
}
-110
View File
@@ -1,110 +0,0 @@
package providers
import (
"net/url"
"testing"
)
// TestGitHubProvider_NewGitHubProvider tests the constructor
func TestGitHubProvider_NewGitHubProvider(t *testing.T) {
provider := NewGitHubProvider()
if provider == nil {
t.Fatal("Expected provider to be created, got nil")
}
if provider.BaseProvider == nil {
t.Error("BaseProvider should be initialized")
}
}
// TestGitHubProvider_GetType tests provider type
func TestGitHubProvider_GetType(t *testing.T) {
provider := NewGitHubProvider()
if provider.GetType() != ProviderTypeGitHub {
t.Errorf("Expected ProviderTypeGitHub, got %v", provider.GetType())
}
}
// TestGitHubProvider_GetCapabilities tests GitHub-specific capabilities
func TestGitHubProvider_GetCapabilities(t *testing.T) {
provider := NewGitHubProvider()
capabilities := provider.GetCapabilities()
if capabilities.SupportsRefreshTokens {
t.Error("Expected SupportsRefreshTokens to be false for GitHub")
}
if capabilities.RequiresOfflineAccessScope {
t.Error("Expected RequiresOfflineAccessScope to be false for GitHub")
}
if capabilities.RequiresPromptConsent {
t.Error("Expected RequiresPromptConsent to be false for GitHub")
}
if capabilities.PreferredTokenValidation != "access" {
t.Errorf("Expected PreferredTokenValidation 'access', got '%s'", capabilities.PreferredTokenValidation)
}
}
// TestGitHubProvider_BuildAuthParams tests GitHub-specific auth params
func TestGitHubProvider_BuildAuthParams(t *testing.T) {
provider := NewGitHubProvider()
baseParams := url.Values{}
baseParams.Set("client_id", "test_client")
tests := []struct {
name string
scopes []string
expectedScopes []string
}{
{
name: "Remove offline_access scope",
scopes: []string{"user:email", "offline_access", "read:user"},
expectedScopes: []string{"user:email", "read:user"},
},
{
name: "Default scopes when none provided",
scopes: []string{},
expectedScopes: []string{"user:email", "read:user"},
},
{
name: "Keep other scopes",
scopes: []string{"user", "repo"},
expectedScopes: []string{"user", "repo"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authParams, err := provider.BuildAuthParams(baseParams, tt.scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
if len(authParams.Scopes) != len(tt.expectedScopes) {
t.Errorf("Expected %d scopes, got %d", len(tt.expectedScopes), len(authParams.Scopes))
return
}
for i, scope := range tt.expectedScopes {
if authParams.Scopes[i] != scope {
t.Errorf("Expected scope '%s', got '%s'", scope, authParams.Scopes[i])
}
}
})
}
}
// TestGitHubProvider_ValidateConfig tests config validation
func TestGitHubProvider_ValidateConfig(t *testing.T) {
provider := NewGitHubProvider()
err := provider.ValidateConfig()
if err != nil {
t.Errorf("ValidateConfig failed: %v", err)
}
}
-73
View File
@@ -1,73 +0,0 @@
package providers
import (
"net/url"
)
// GitLabProvider encapsulates GitLab-specific OIDC logic.
type GitLabProvider struct {
*BaseProvider
}
// NewGitLabProvider creates a new instance of the GitLabProvider.
func NewGitLabProvider() *GitLabProvider {
return &GitLabProvider{
BaseProvider: NewBaseProvider(),
}
}
// GetType returns the provider's type.
func (p *GitLabProvider) GetType() ProviderType {
return ProviderTypeGitLab
}
// GetCapabilities returns the specific capabilities of the GitLab provider.
func (p *GitLabProvider) GetCapabilities() ProviderCapabilities {
return ProviderCapabilities{
SupportsRefreshTokens: true,
RequiresOfflineAccessScope: false, // GitLab doesn't use offline_access scope
RequiresPromptConsent: false,
PreferredTokenValidation: "id", // GitLab typically uses ID tokens
}
}
// BuildAuthParams configures GitLab-specific authentication parameters.
func (p *GitLabProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) {
// GitLab supports standard OAuth 2.0 parameters
baseParams.Set("response_type", "code")
// Remove offline_access scope as GitLab doesn't use it
var filteredScopes []string
for _, scope := range scopes {
if scope != "offline_access" {
filteredScopes = append(filteredScopes, scope)
}
}
// Ensure openid scope is present for OIDC
hasOpenID := false
for _, scope := range filteredScopes {
if scope == "openid" {
hasOpenID = true
break
}
}
if !hasOpenID {
filteredScopes = append(filteredScopes, "openid")
}
// Default GitLab scopes if none specified
if len(filteredScopes) == 1 && filteredScopes[0] == "openid" {
filteredScopes = append(filteredScopes, "profile", "email")
}
return &AuthParams{
URLValues: baseParams,
Scopes: deduplicateScopes(filteredScopes),
}, nil
}
// GitLab requires application configuration and proper redirect URIs.
func (p *GitLabProvider) ValidateConfig() error {
return p.BaseProvider.ValidateConfig()
}
-322
View File
@@ -1,322 +0,0 @@
package providers
import (
"net/url"
"testing"
)
// TestGitLabProvider_NewGitLabProvider tests the constructor
func TestGitLabProvider_NewGitLabProvider(t *testing.T) {
provider := NewGitLabProvider()
if provider == nil {
t.Fatal("Expected provider to be created, got nil")
}
if provider.BaseProvider == nil {
t.Error("BaseProvider should be initialized")
}
}
// TestGitLabProvider_GetType tests provider type
func TestGitLabProvider_GetType(t *testing.T) {
provider := NewGitLabProvider()
if provider.GetType() != ProviderTypeGitLab {
t.Errorf("Expected ProviderTypeGitLab, got %v", provider.GetType())
}
}
// TestGitLabProvider_GetCapabilities tests GitLab-specific capabilities
func TestGitLabProvider_GetCapabilities(t *testing.T) {
provider := NewGitLabProvider()
capabilities := provider.GetCapabilities()
if !capabilities.SupportsRefreshTokens {
t.Error("Expected SupportsRefreshTokens to be true for GitLab")
}
if capabilities.RequiresOfflineAccessScope {
t.Error("Expected RequiresOfflineAccessScope to be false for GitLab")
}
if capabilities.RequiresPromptConsent {
t.Error("Expected RequiresPromptConsent to be false for GitLab")
}
if capabilities.PreferredTokenValidation != "id" {
t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation)
}
}
// TestGitLabProvider_BuildAuthParams tests GitLab-specific auth params
func TestGitLabProvider_BuildAuthParams(t *testing.T) {
provider := NewGitLabProvider()
baseParams := url.Values{}
baseParams.Set("client_id", "test_client")
tests := []struct {
name string
scopes []string
expectedScopes []string
}{
{
name: "Remove offline_access scope and ensure openid",
scopes: []string{"read_user", "read_api", "offline_access"},
expectedScopes: []string{"read_user", "read_api", "openid"},
},
{
name: "Keep existing openid, remove offline_access",
scopes: []string{"openid", "read_user", "offline_access", "profile"},
expectedScopes: []string{"openid", "read_user", "profile"},
},
{
name: "Add default scopes when only openid",
scopes: []string{"openid"},
expectedScopes: []string{"openid", "profile", "email"},
},
{
name: "Add openid and defaults when empty",
scopes: []string{},
expectedScopes: []string{"openid", "profile", "email"},
},
{
name: "GitLab-specific scopes",
scopes: []string{"read_user", "read_api", "read_repository"},
expectedScopes: []string{"read_user", "read_api", "read_repository", "openid"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authParams, err := provider.BuildAuthParams(baseParams, tt.scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
// Check that response_type is set
if authParams.URLValues.Get("response_type") != "code" {
t.Errorf("Expected response_type 'code', got '%s'", authParams.URLValues.Get("response_type"))
}
if len(authParams.Scopes) != len(tt.expectedScopes) {
t.Errorf("Expected %d scopes, got %d. Expected: %v, Got: %v",
len(tt.expectedScopes), len(authParams.Scopes), tt.expectedScopes, authParams.Scopes)
return
}
// Check that all expected scopes are present
for _, expectedScope := range tt.expectedScopes {
found := false
for _, actualScope := range authParams.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes)
}
}
// Ensure offline_access is NOT present
for _, actualScope := range authParams.Scopes {
if actualScope == "offline_access" {
t.Error("offline_access scope should be filtered out for GitLab")
}
}
})
}
}
// TestGitLabProvider_ValidateConfig tests config validation
func TestGitLabProvider_ValidateConfig(t *testing.T) {
provider := NewGitLabProvider()
err := provider.ValidateConfig()
if err != nil {
t.Errorf("ValidateConfig failed: %v", err)
}
}
// TestGitLabProvider_InterfaceCompliance tests that GitLab provider implements the OIDCProvider interface
func TestGitLabProvider_InterfaceCompliance(t *testing.T) {
var _ OIDCProvider = NewGitLabProvider()
}
// TestGitLabProvider_BaseProviderInheritance tests that GitLab provider inherits from BaseProvider correctly
func TestGitLabProvider_BaseProviderInheritance(t *testing.T) {
provider := NewGitLabProvider()
// Test that it has access to BaseProvider methods
if provider.BaseProvider == nil {
t.Error("Expected BaseProvider to be initialized")
}
// Test HandleTokenRefresh (inherited from BaseProvider)
err := provider.HandleTokenRefresh(&TokenResult{
IDToken: "test-id-token",
AccessToken: "test-access-token",
RefreshToken: "test-refresh-token",
})
if err != nil {
t.Errorf("HandleTokenRefresh failed: %v", err)
}
}
// TestGitLabProvider_OfflineAccessFiltering tests that offline_access scope is always filtered out
func TestGitLabProvider_OfflineAccessFiltering(t *testing.T) {
provider := NewGitLabProvider()
baseParams := url.Values{}
tests := []struct {
name string
scopes []string
}{
{
name: "Single offline_access",
scopes: []string{"offline_access"},
},
{
name: "Multiple offline_access occurrences",
scopes: []string{"offline_access", "read_user", "offline_access", "profile"},
},
{
name: "Mixed with other scopes",
scopes: []string{"read_api", "offline_access", "read_user"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authParams, err := provider.BuildAuthParams(baseParams, tt.scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
// Ensure offline_access is NOT present
for _, actualScope := range authParams.Scopes {
if actualScope == "offline_access" {
t.Error("offline_access scope should be filtered out for GitLab")
}
}
})
}
}
// TestGitLabProvider_GitLabSpecificScopes tests GitLab-specific scopes
func TestGitLabProvider_GitLabSpecificScopes(t *testing.T) {
provider := NewGitLabProvider()
baseParams := url.Values{}
tests := []struct {
name string
scopes []string
checkFor []string
}{
{
name: "GitLab API scopes",
scopes: []string{"read_api", "read_user"},
checkFor: []string{"read_api", "read_user", "openid"},
},
{
name: "GitLab repository scopes",
scopes: []string{"read_repository", "write_repository"},
checkFor: []string{"read_repository", "write_repository", "openid"},
},
{
name: "GitLab admin scopes",
scopes: []string{"api", "sudo"},
checkFor: []string{"api", "sudo", "openid"},
},
{
name: "GitLab registry scopes",
scopes: []string{"read_registry", "write_registry"},
checkFor: []string{"read_registry", "write_registry", "openid"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authParams, err := provider.BuildAuthParams(baseParams, tt.scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
for _, expectedScope := range tt.checkFor {
found := false
for _, actualScope := range authParams.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes)
}
}
})
}
}
// TestGitLabProvider_DefaultScopeHandling tests default scope behavior
func TestGitLabProvider_DefaultScopeHandling(t *testing.T) {
provider := NewGitLabProvider()
baseParams := url.Values{}
// Test with only openid scope - should add defaults
authParams, err := provider.BuildAuthParams(baseParams, []string{"openid"})
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
expectedScopes := []string{"openid", "profile", "email"}
if len(authParams.Scopes) != len(expectedScopes) {
t.Errorf("Expected %d scopes, got %d", len(expectedScopes), len(authParams.Scopes))
return
}
for _, expectedScope := range expectedScopes {
found := false
for _, actualScope := range authParams.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected default scope '%s' not found in %v", expectedScope, authParams.Scopes)
}
}
}
// TestGitLabProvider_ScopeDeduplication tests that duplicate scopes are handled correctly
func TestGitLabProvider_ScopeDeduplication(t *testing.T) {
provider := NewGitLabProvider()
baseParams := url.Values{}
// Test with duplicate scopes
scopes := []string{"openid", "read_user", "openid", "profile", "read_user"}
authParams, err := provider.BuildAuthParams(baseParams, scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
// Count occurrences of each scope
scopeCounts := make(map[string]int)
for _, scope := range authParams.Scopes {
scopeCounts[scope]++
}
// Check that no scope appears more than once
for scope, count := range scopeCounts {
if count > 1 {
t.Errorf("Scope '%s' appears %d times, expected 1", scope, count)
}
}
}
+3 -3
View File
@@ -24,8 +24,8 @@ func (p *GoogleProvider) GetType() ProviderType {
// GetCapabilities returns the specific capabilities of the Google provider.
func (p *GoogleProvider) GetCapabilities() ProviderCapabilities {
return ProviderCapabilities{
SupportsRefreshTokens: true, // Google DOES support refresh tokens
RequiresOfflineAccessScope: false, // Google uses access_type=offline instead
SupportsRefreshTokens: true,
RequiresOfflineAccessScope: false,
RequiresPromptConsent: true,
PreferredTokenValidation: "id",
}
@@ -46,7 +46,7 @@ func (p *GoogleProvider) BuildAuthParams(baseParams url.Values, scopes []string)
return &AuthParams{
URLValues: baseParams,
Scopes: deduplicateScopes(filteredScopes),
Scopes: filteredScopes,
}, nil
}
-350
View File
@@ -1,350 +0,0 @@
package providers
import (
"net/url"
"testing"
)
// TestGoogleProvider_NewGoogleProvider tests the constructor
func TestGoogleProvider_NewGoogleProvider(t *testing.T) {
provider := NewGoogleProvider()
if provider == nil {
t.Fatal("Expected provider to be created, got nil")
}
if provider.BaseProvider == nil {
t.Error("BaseProvider should be initialized")
}
}
// TestGoogleProvider_GetType tests provider type
func TestGoogleProvider_GetType(t *testing.T) {
provider := NewGoogleProvider()
if provider.GetType() != ProviderTypeGoogle {
t.Errorf("Expected ProviderTypeGoogle, got %v", provider.GetType())
}
}
// TestGoogleProvider_GetCapabilities tests Google-specific capabilities
func TestGoogleProvider_GetCapabilities(t *testing.T) {
provider := NewGoogleProvider()
capabilities := provider.GetCapabilities()
if !capabilities.SupportsRefreshTokens {
t.Error("Expected SupportsRefreshTokens to be true")
}
if capabilities.RequiresOfflineAccessScope {
t.Error("Expected RequiresOfflineAccessScope to be false for Google")
}
if !capabilities.RequiresPromptConsent {
t.Error("Expected RequiresPromptConsent to be true for Google")
}
if capabilities.PreferredTokenValidation != "id" {
t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation)
}
}
// TestGoogleProvider_BuildAuthParams tests Google-specific auth parameters
func TestGoogleProvider_BuildAuthParams(t *testing.T) {
provider := NewGoogleProvider()
tests := []struct {
name string
inputScopes []string
expectedScopes []string
shouldHaveAccessType bool
shouldHavePrompt bool
}{
{
name: "Basic scopes without offline_access",
inputScopes: []string{"openid", "profile", "email"},
expectedScopes: []string{"openid", "profile", "email"},
shouldHaveAccessType: true,
shouldHavePrompt: true,
},
{
name: "Scopes with offline_access (should be filtered out)",
inputScopes: []string{"openid", "profile", "offline_access", "email"},
expectedScopes: []string{"openid", "profile", "email"},
shouldHaveAccessType: true,
shouldHavePrompt: true,
},
{
name: "Only offline_access scope (should be filtered out)",
inputScopes: []string{"offline_access"},
expectedScopes: []string{},
shouldHaveAccessType: true,
shouldHavePrompt: true,
},
{
name: "Empty scopes",
inputScopes: []string{},
expectedScopes: []string{},
shouldHaveAccessType: true,
shouldHavePrompt: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
baseParams := make(url.Values)
baseParams.Set("client_id", "test-client")
result, err := provider.BuildAuthParams(baseParams, tt.inputScopes)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
// Check Google-specific parameters
if tt.shouldHaveAccessType {
if result.URLValues.Get("access_type") != "offline" {
t.Errorf("Expected access_type 'offline', got '%s'", result.URLValues.Get("access_type"))
}
}
if tt.shouldHavePrompt {
if result.URLValues.Get("prompt") != "consent" {
t.Errorf("Expected prompt 'consent', got '%s'", result.URLValues.Get("prompt"))
}
}
// Check filtered scopes
if len(result.Scopes) != len(tt.expectedScopes) {
t.Errorf("Expected %d scopes, got %d", len(tt.expectedScopes), len(result.Scopes))
}
for _, expectedScope := range tt.expectedScopes {
found := false
for _, actualScope := range result.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in result", expectedScope)
}
}
// Ensure offline_access is not in the result scopes
for _, scope := range result.Scopes {
if scope == "offline_access" {
t.Error("offline_access scope should be filtered out for Google")
}
}
// Verify original base parameters are preserved
if result.URLValues.Get("client_id") != "test-client" {
t.Errorf("Expected client_id 'test-client', got '%s'", result.URLValues.Get("client_id"))
}
})
}
}
// TestGoogleProvider_ValidateConfig tests configuration validation
func TestGoogleProvider_ValidateConfig(t *testing.T) {
provider := NewGoogleProvider()
err := provider.ValidateConfig()
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
}
// TestGoogleProvider_InterfaceCompliance tests that Google provider implements OIDCProvider
func TestGoogleProvider_InterfaceCompliance(t *testing.T) {
provider := NewGoogleProvider()
// Verify it implements the OIDCProvider interface
var _ OIDCProvider = provider
}
// TestGoogleProvider_OfflineAccessFiltering tests comprehensive offline_access filtering
func TestGoogleProvider_OfflineAccessFiltering(t *testing.T) {
provider := NewGoogleProvider()
tests := []struct {
name string
inputScopes []string
description string
}{
{
name: "Multiple offline_access occurrences",
inputScopes: []string{"openid", "offline_access", "profile", "offline_access", "email"},
description: "Should remove all instances of offline_access",
},
{
name: "Case sensitive filtering",
inputScopes: []string{"openid", "OFFLINE_ACCESS", "profile", "offline_access"},
description: "Should only remove exact case matches",
},
{
name: "Similar but different scopes",
inputScopes: []string{"openid", "offline_access_extended", "profile", "offline_access"},
description: "Should only remove exact offline_access matches",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
baseParams := make(url.Values)
result, err := provider.BuildAuthParams(baseParams, tt.inputScopes)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
// Count offline_access occurrences in result
offlineAccessCount := 0
for _, scope := range result.Scopes {
if scope == "offline_access" {
offlineAccessCount++
}
}
if offlineAccessCount != 0 {
t.Errorf("Expected 0 offline_access scopes in result, got %d", offlineAccessCount)
}
// Verify other scopes are preserved
for _, originalScope := range tt.inputScopes {
if originalScope == "offline_access" {
continue // Skip the filtered scope
}
found := false
for _, resultScope := range result.Scopes {
if resultScope == originalScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' to be preserved", originalScope)
}
}
})
}
}
// TestGoogleProvider_BaseProviderInheritance tests inherited functionality from BaseProvider
func TestGoogleProvider_BaseProviderInheritance(t *testing.T) {
provider := NewGoogleProvider()
// Test ValidateTokens (inherited from BaseProvider)
session := &mockSession{
authenticated: true,
idToken: "test-token",
accessToken: "access-token", // Add access token for proper validation
}
verifier := &mockTokenVerifier{}
cache := &mockTokenCache{
claims: map[string]map[string]interface{}{
"test-token": {
"exp": float64(9999999999), // Far future
"sub": "user123",
},
},
}
result, err := provider.ValidateTokens(session, verifier, cache, 0)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !result.Authenticated {
t.Error("Expected result to be authenticated")
}
// Test HandleTokenRefresh (inherited from BaseProvider)
tokenData := &TokenResult{IDToken: "new-token"}
err = provider.HandleTokenRefresh(tokenData)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
// TestGoogleProvider_AuthParamsPreservation tests that base parameters are not overwritten
func TestGoogleProvider_AuthParamsPreservation(t *testing.T) {
provider := NewGoogleProvider()
baseParams := make(url.Values)
baseParams.Set("client_id", "test-client")
baseParams.Set("redirect_uri", "https://example.com/callback")
baseParams.Set("response_type", "code")
baseParams.Set("state", "test-state")
baseParams.Set("nonce", "test-nonce")
scopes := []string{"openid", "profile"}
result, err := provider.BuildAuthParams(baseParams, scopes)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
// Verify all original parameters are preserved
expectedParams := map[string]string{
"client_id": "test-client",
"redirect_uri": "https://example.com/callback",
"response_type": "code",
"state": "test-state",
"nonce": "test-nonce",
"access_type": "offline", // Added by Google provider
"prompt": "consent", // Added by Google provider
}
for key, expectedValue := range expectedParams {
actualValue := result.URLValues.Get(key)
if actualValue != expectedValue {
t.Errorf("Expected %s '%s', got '%s'", key, expectedValue, actualValue)
}
}
// Verify scopes
if len(result.Scopes) != 2 {
t.Errorf("Expected 2 scopes, got %d", len(result.Scopes))
}
expectedScopes := []string{"openid", "profile"}
for _, expectedScope := range expectedScopes {
found := false
for _, actualScope := range result.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found", expectedScope)
}
}
}
// Benchmark tests
func BenchmarkGoogleProvider_BuildAuthParams(b *testing.B) {
provider := NewGoogleProvider()
baseParams := make(url.Values)
baseParams.Set("client_id", "test-client")
scopes := []string{"openid", "profile", "email", "offline_access"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
provider.BuildAuthParams(baseParams, scopes)
}
}
func BenchmarkGoogleProvider_GetCapabilities(b *testing.B) {
provider := NewGoogleProvider()
b.ResetTimer()
for i := 0; i < b.N; i++ {
provider.GetCapabilities()
}
}
-6
View File
@@ -25,12 +25,6 @@ const (
ProviderTypeGeneric ProviderType = iota
ProviderTypeGoogle
ProviderTypeAzure
ProviderTypeGitHub
ProviderTypeAuth0
ProviderTypeOkta
ProviderTypeKeycloak
ProviderTypeAWSCognito
ProviderTypeGitLab
)
// ProviderCapabilities defines the specific features and behaviors of an OIDC provider.
-72
View File
@@ -1,72 +0,0 @@
package providers
import (
"net/url"
)
// KeycloakProvider encapsulates Keycloak-specific OIDC logic.
type KeycloakProvider struct {
*BaseProvider
}
// NewKeycloakProvider creates a new instance of the KeycloakProvider.
func NewKeycloakProvider() *KeycloakProvider {
return &KeycloakProvider{
BaseProvider: NewBaseProvider(),
}
}
// GetType returns the provider's type.
func (p *KeycloakProvider) GetType() ProviderType {
return ProviderTypeKeycloak
}
// GetCapabilities returns the specific capabilities of the Keycloak provider.
func (p *KeycloakProvider) GetCapabilities() ProviderCapabilities {
return ProviderCapabilities{
SupportsRefreshTokens: true,
RequiresOfflineAccessScope: true,
RequiresPromptConsent: false,
PreferredTokenValidation: "id", // Keycloak typically uses ID tokens
}
}
// BuildAuthParams configures Keycloak-specific authentication parameters.
func (p *KeycloakProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) {
// Keycloak supports standard OIDC parameters
baseParams.Set("response_type", "code")
// Ensure offline_access scope is present for refresh tokens
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
}
// Ensure openid scope is present
hasOpenID := false
for _, scope := range scopes {
if scope == "openid" {
hasOpenID = true
break
}
}
if !hasOpenID {
scopes = append(scopes, "openid")
}
return &AuthParams{
URLValues: baseParams,
Scopes: deduplicateScopes(scopes),
}, nil
}
// Keycloak requires realm and server configuration.
func (p *KeycloakProvider) ValidateConfig() error {
return p.BaseProvider.ValidateConfig()
}
-232
View File
@@ -1,232 +0,0 @@
package providers
import (
"net/url"
"testing"
)
// TestKeycloakProvider_NewKeycloakProvider tests the constructor
func TestKeycloakProvider_NewKeycloakProvider(t *testing.T) {
provider := NewKeycloakProvider()
if provider == nil {
t.Fatal("Expected provider to be created, got nil")
}
if provider.BaseProvider == nil {
t.Error("BaseProvider should be initialized")
}
}
// TestKeycloakProvider_GetType tests provider type
func TestKeycloakProvider_GetType(t *testing.T) {
provider := NewKeycloakProvider()
if provider.GetType() != ProviderTypeKeycloak {
t.Errorf("Expected ProviderTypeKeycloak, got %v", provider.GetType())
}
}
// TestKeycloakProvider_GetCapabilities tests Keycloak-specific capabilities
func TestKeycloakProvider_GetCapabilities(t *testing.T) {
provider := NewKeycloakProvider()
capabilities := provider.GetCapabilities()
if !capabilities.SupportsRefreshTokens {
t.Error("Expected SupportsRefreshTokens to be true for Keycloak")
}
if !capabilities.RequiresOfflineAccessScope {
t.Error("Expected RequiresOfflineAccessScope to be true for Keycloak")
}
if capabilities.RequiresPromptConsent {
t.Error("Expected RequiresPromptConsent to be false for Keycloak")
}
if capabilities.PreferredTokenValidation != "id" {
t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation)
}
}
// TestKeycloakProvider_BuildAuthParams tests Keycloak-specific auth params
func TestKeycloakProvider_BuildAuthParams(t *testing.T) {
provider := NewKeycloakProvider()
baseParams := url.Values{}
baseParams.Set("client_id", "test_client")
tests := []struct {
name string
scopes []string
expectedScopes []string
}{
{
name: "Add offline_access and openid scopes",
scopes: []string{"roles", "groups"},
expectedScopes: []string{"roles", "groups", "offline_access", "openid"},
},
{
name: "Keep existing offline_access and openid",
scopes: []string{"openid", "roles", "offline_access", "groups"},
expectedScopes: []string{"openid", "roles", "offline_access", "groups"},
},
{
name: "Add both scopes when none provided",
scopes: []string{},
expectedScopes: []string{"offline_access", "openid"},
},
{
name: "Keycloak custom scopes",
scopes: []string{"realm-roles", "account"},
expectedScopes: []string{"realm-roles", "account", "offline_access", "openid"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authParams, err := provider.BuildAuthParams(baseParams, tt.scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
// Check that response_type is set
if authParams.URLValues.Get("response_type") != "code" {
t.Errorf("Expected response_type 'code', got '%s'", authParams.URLValues.Get("response_type"))
}
if len(authParams.Scopes) != len(tt.expectedScopes) {
t.Errorf("Expected %d scopes, got %d. Expected: %v, Got: %v",
len(tt.expectedScopes), len(authParams.Scopes), tt.expectedScopes, authParams.Scopes)
return
}
// Check that all expected scopes are present
for _, expectedScope := range tt.expectedScopes {
found := false
for _, actualScope := range authParams.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes)
}
}
})
}
}
// TestKeycloakProvider_ValidateConfig tests config validation
func TestKeycloakProvider_ValidateConfig(t *testing.T) {
provider := NewKeycloakProvider()
err := provider.ValidateConfig()
if err != nil {
t.Errorf("ValidateConfig failed: %v", err)
}
}
// TestKeycloakProvider_InterfaceCompliance tests that Keycloak provider implements the OIDCProvider interface
func TestKeycloakProvider_InterfaceCompliance(t *testing.T) {
var _ OIDCProvider = NewKeycloakProvider()
}
// TestKeycloakProvider_BaseProviderInheritance tests that Keycloak provider inherits from BaseProvider correctly
func TestKeycloakProvider_BaseProviderInheritance(t *testing.T) {
provider := NewKeycloakProvider()
// Test that it has access to BaseProvider methods
if provider.BaseProvider == nil {
t.Error("Expected BaseProvider to be initialized")
}
// Test HandleTokenRefresh (inherited from BaseProvider)
err := provider.HandleTokenRefresh(&TokenResult{
IDToken: "test-id-token",
AccessToken: "test-access-token",
RefreshToken: "test-refresh-token",
})
if err != nil {
t.Errorf("HandleTokenRefresh failed: %v", err)
}
}
// TestKeycloakProvider_RealmSpecificScopes tests Keycloak realm-specific scopes
func TestKeycloakProvider_RealmSpecificScopes(t *testing.T) {
provider := NewKeycloakProvider()
baseParams := url.Values{}
tests := []struct {
name string
scopes []string
checkFor []string
}{
{
name: "Keycloak standard scopes",
scopes: []string{"roles", "groups", "profile", "email"},
checkFor: []string{"roles", "groups", "profile", "email", "offline_access", "openid"},
},
{
name: "Keycloak realm roles",
scopes: []string{"realm-roles", "client-roles"},
checkFor: []string{"realm-roles", "client-roles", "offline_access", "openid"},
},
{
name: "Keycloak account service",
scopes: []string{"account"},
checkFor: []string{"account", "offline_access", "openid"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authParams, err := provider.BuildAuthParams(baseParams, tt.scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
for _, expectedScope := range tt.checkFor {
found := false
for _, actualScope := range authParams.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes)
}
}
})
}
}
// TestKeycloakProvider_ScopeDeduplication tests that duplicate scopes are handled correctly
func TestKeycloakProvider_ScopeDeduplication(t *testing.T) {
provider := NewKeycloakProvider()
baseParams := url.Values{}
// Test with duplicate scopes
scopes := []string{"openid", "profile", "offline_access", "roles", "openid", "profile"}
authParams, err := provider.BuildAuthParams(baseParams, scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
// Count occurrences of each scope
scopeCounts := make(map[string]int)
for _, scope := range authParams.Scopes {
scopeCounts[scope]++
}
// Check that no scope appears more than once
for scope, count := range scopeCounts {
if count > 1 {
t.Errorf("Scope '%s' appears %d times, expected 1", scope, count)
}
}
}
-72
View File
@@ -1,72 +0,0 @@
package providers
import (
"net/url"
)
// OktaProvider encapsulates Okta-specific OIDC logic.
type OktaProvider struct {
*BaseProvider
}
// NewOktaProvider creates a new instance of the OktaProvider.
func NewOktaProvider() *OktaProvider {
return &OktaProvider{
BaseProvider: NewBaseProvider(),
}
}
// GetType returns the provider's type.
func (p *OktaProvider) GetType() ProviderType {
return ProviderTypeOkta
}
// GetCapabilities returns the specific capabilities of the Okta provider.
func (p *OktaProvider) GetCapabilities() ProviderCapabilities {
return ProviderCapabilities{
SupportsRefreshTokens: true,
RequiresOfflineAccessScope: true,
RequiresPromptConsent: false,
PreferredTokenValidation: "id", // Okta primarily uses ID tokens
}
}
// BuildAuthParams configures Okta-specific authentication parameters.
func (p *OktaProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) {
// Okta supports various response types
baseParams.Set("response_type", "code")
// Ensure offline_access scope is present for refresh tokens
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
}
// Ensure openid scope is present
hasOpenID := false
for _, scope := range scopes {
if scope == "openid" {
hasOpenID = true
break
}
}
if !hasOpenID {
scopes = append(scopes, "openid")
}
return &AuthParams{
URLValues: baseParams,
Scopes: deduplicateScopes(scopes),
}, nil
}
// Okta requires specific domain configuration and application setup.
func (p *OktaProvider) ValidateConfig() error {
return p.BaseProvider.ValidateConfig()
}
-200
View File
@@ -1,200 +0,0 @@
package providers
import (
"net/url"
"testing"
)
// TestOktaProvider_NewOktaProvider tests the constructor
func TestOktaProvider_NewOktaProvider(t *testing.T) {
provider := NewOktaProvider()
if provider == nil {
t.Fatal("Expected provider to be created, got nil")
}
if provider.BaseProvider == nil {
t.Error("BaseProvider should be initialized")
}
}
// TestOktaProvider_GetType tests provider type
func TestOktaProvider_GetType(t *testing.T) {
provider := NewOktaProvider()
if provider.GetType() != ProviderTypeOkta {
t.Errorf("Expected ProviderTypeOkta, got %v", provider.GetType())
}
}
// TestOktaProvider_GetCapabilities tests Okta-specific capabilities
func TestOktaProvider_GetCapabilities(t *testing.T) {
provider := NewOktaProvider()
capabilities := provider.GetCapabilities()
if !capabilities.SupportsRefreshTokens {
t.Error("Expected SupportsRefreshTokens to be true for Okta")
}
if !capabilities.RequiresOfflineAccessScope {
t.Error("Expected RequiresOfflineAccessScope to be true for Okta")
}
if capabilities.RequiresPromptConsent {
t.Error("Expected RequiresPromptConsent to be false for Okta")
}
if capabilities.PreferredTokenValidation != "id" {
t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation)
}
}
// TestOktaProvider_BuildAuthParams tests Okta-specific auth params
func TestOktaProvider_BuildAuthParams(t *testing.T) {
provider := NewOktaProvider()
baseParams := url.Values{}
baseParams.Set("client_id", "test_client")
tests := []struct {
name string
scopes []string
expectedScopes []string
}{
{
name: "Add offline_access and openid scopes",
scopes: []string{"groups", "profile"},
expectedScopes: []string{"groups", "profile", "offline_access", "openid"},
},
{
name: "Keep existing offline_access and openid",
scopes: []string{"openid", "groups", "offline_access", "profile"},
expectedScopes: []string{"openid", "groups", "offline_access", "profile"},
},
{
name: "Add both scopes when none provided",
scopes: []string{},
expectedScopes: []string{"offline_access", "openid"},
},
{
name: "Add openid when only offline_access present",
scopes: []string{"offline_access"},
expectedScopes: []string{"offline_access", "openid"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authParams, err := provider.BuildAuthParams(baseParams, tt.scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
// Check that response_type is set
if authParams.URLValues.Get("response_type") != "code" {
t.Errorf("Expected response_type 'code', got '%s'", authParams.URLValues.Get("response_type"))
}
if len(authParams.Scopes) != len(tt.expectedScopes) {
t.Errorf("Expected %d scopes, got %d. Expected: %v, Got: %v",
len(tt.expectedScopes), len(authParams.Scopes), tt.expectedScopes, authParams.Scopes)
return
}
// Check that all expected scopes are present
for _, expectedScope := range tt.expectedScopes {
found := false
for _, actualScope := range authParams.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes)
}
}
})
}
}
// TestOktaProvider_ValidateConfig tests config validation
func TestOktaProvider_ValidateConfig(t *testing.T) {
provider := NewOktaProvider()
err := provider.ValidateConfig()
if err != nil {
t.Errorf("ValidateConfig failed: %v", err)
}
}
// TestOktaProvider_InterfaceCompliance tests that Okta provider implements the OIDCProvider interface
func TestOktaProvider_InterfaceCompliance(t *testing.T) {
var _ OIDCProvider = NewOktaProvider()
}
// TestOktaProvider_BaseProviderInheritance tests that Okta provider inherits from BaseProvider correctly
func TestOktaProvider_BaseProviderInheritance(t *testing.T) {
provider := NewOktaProvider()
// Test that it has access to BaseProvider methods
if provider.BaseProvider == nil {
t.Error("Expected BaseProvider to be initialized")
}
// Test HandleTokenRefresh (inherited from BaseProvider)
err := provider.HandleTokenRefresh(&TokenResult{
IDToken: "test-id-token",
AccessToken: "test-access-token",
RefreshToken: "test-refresh-token",
})
if err != nil {
t.Errorf("HandleTokenRefresh failed: %v", err)
}
}
// TestOktaProvider_ScopeHandling tests Okta-specific scope handling
func TestOktaProvider_ScopeHandling(t *testing.T) {
provider := NewOktaProvider()
baseParams := url.Values{}
tests := []struct {
name string
scopes []string
checkFor []string
}{
{
name: "Groups scope handling",
scopes: []string{"groups", "profile"},
checkFor: []string{"groups", "profile", "offline_access", "openid"},
},
{
name: "Custom Okta scopes",
scopes: []string{"okta.users.read", "okta.groups.read"},
checkFor: []string{"okta.users.read", "okta.groups.read", "offline_access", "openid"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authParams, err := provider.BuildAuthParams(baseParams, tt.scopes)
if err != nil {
t.Errorf("BuildAuthParams failed: %v", err)
return
}
for _, expectedScope := range tt.checkFor {
found := false
for _, actualScope := range authParams.Scopes {
if actualScope == expectedScope {
found = true
break
}
}
if !found {
t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes)
}
}
})
}
}
+1 -34
View File
@@ -115,14 +115,7 @@ func (r *ProviderRegistry) detectProviderUnsafe(issuerURL string) OIDCProvider {
if err != nil {
return nil
}
// Check if the URL has a valid scheme and host
if normalizedURL.Scheme == "" || normalizedURL.Host == "" {
return nil
}
// Convert host to lowercase for case-insensitive matching
host := strings.ToLower(normalizedURL.Host)
host := normalizedURL.Host
for _, p := range r.providers {
switch p.GetType() {
@@ -134,32 +127,6 @@ func (r *ProviderRegistry) detectProviderUnsafe(issuerURL string) OIDCProvider {
if strings.Contains(host, "login.microsoftonline.com") || strings.Contains(host, "sts.windows.net") {
return p
}
case ProviderTypeGitHub:
if strings.Contains(host, "github.com") {
return p
}
case ProviderTypeAuth0:
if strings.Contains(host, ".auth0.com") {
return p
}
case ProviderTypeOkta:
if strings.Contains(host, ".okta.com") || strings.Contains(host, ".oktapreview.com") || strings.Contains(host, ".okta-emea.com") {
return p
}
case ProviderTypeKeycloak:
if strings.Contains(host, "keycloak") || strings.Contains(normalizedURL.Path, "/auth/realms/") {
return p
}
case ProviderTypeAWSCognito:
if strings.Contains(host, "cognito-idp") && strings.Contains(host, ".amazonaws.com") {
return p
}
case ProviderTypeGitLab:
// Match gitlab.com, self-hosted (gitlab.*), and instances with gitlab in subdomain
if strings.Contains(host, "gitlab.com") ||
strings.Contains(host, "gitlab") {
return p
}
}
}
-742
View File
@@ -1,742 +0,0 @@
package providers
import (
"fmt"
"sync"
"testing"
)
// TestProviderRegistry_NewProviderRegistry tests registry constructor
func TestProviderRegistry_NewProviderRegistry(t *testing.T) {
registry := NewProviderRegistry()
if registry == nil {
t.Fatal("Expected registry to be created, got nil")
}
if registry.providers == nil {
t.Error("Providers slice should be initialized")
}
if registry.cache == nil {
t.Error("Cache map should be initialized")
}
if registry.typeMap == nil {
t.Error("TypeMap should be initialized")
}
if registry.maxCacheSize != 1000 {
t.Errorf("Expected maxCacheSize 1000, got %d", registry.maxCacheSize)
}
if registry.cacheCount != 0 {
t.Errorf("Expected initial cacheCount 0, got %d", registry.cacheCount)
}
}
// TestProviderRegistry_RegisterProvider tests provider registration
func TestProviderRegistry_RegisterProvider(t *testing.T) {
registry := NewProviderRegistry()
genericProvider := NewGenericProvider()
googleProvider := NewGoogleProvider()
azureProvider := NewAzureProvider()
// Register providers
registry.RegisterProvider(genericProvider)
registry.RegisterProvider(googleProvider)
registry.RegisterProvider(azureProvider)
// Verify providers are registered
if len(registry.providers) != 3 {
t.Errorf("Expected 3 providers, got %d", len(registry.providers))
}
if len(registry.typeMap) != 3 {
t.Errorf("Expected 3 type mappings, got %d", len(registry.typeMap))
}
// Verify type mappings
if registry.typeMap[ProviderTypeGeneric] != genericProvider {
t.Error("Generic provider not mapped correctly")
}
if registry.typeMap[ProviderTypeGoogle] != googleProvider {
t.Error("Google provider not mapped correctly")
}
if registry.typeMap[ProviderTypeAzure] != azureProvider {
t.Error("Azure provider not mapped correctly")
}
}
// TestProviderRegistry_GetProviderByType tests provider retrieval by type
func TestProviderRegistry_GetProviderByType(t *testing.T) {
registry := NewProviderRegistry()
genericProvider := NewGenericProvider()
googleProvider := NewGoogleProvider()
registry.RegisterProvider(genericProvider)
registry.RegisterProvider(googleProvider)
tests := []struct {
name string
providerType ProviderType
expected OIDCProvider
}{
{
name: "Get Generic provider",
providerType: ProviderTypeGeneric,
expected: genericProvider,
},
{
name: "Get Google provider",
providerType: ProviderTypeGoogle,
expected: googleProvider,
},
{
name: "Get unregistered provider",
providerType: ProviderTypeAzure,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := registry.GetProviderByType(tt.providerType)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
// TestProviderRegistry_GetRegisteredProviders tests listing registered provider types
func TestProviderRegistry_GetRegisteredProviders(t *testing.T) {
registry := NewProviderRegistry()
// Initially empty
types := registry.GetRegisteredProviders()
if len(types) != 0 {
t.Errorf("Expected 0 registered providers, got %d", len(types))
}
// Register some providers
registry.RegisterProvider(NewGenericProvider())
registry.RegisterProvider(NewGoogleProvider())
types = registry.GetRegisteredProviders()
if len(types) != 2 {
t.Errorf("Expected 2 registered providers, got %d", len(types))
}
// Verify types are correct
expectedTypes := map[ProviderType]bool{
ProviderTypeGeneric: false,
ProviderTypeGoogle: false,
}
for _, providerType := range types {
if _, exists := expectedTypes[providerType]; exists {
expectedTypes[providerType] = true
} else {
t.Errorf("Unexpected provider type: %v", providerType)
}
}
for providerType, found := range expectedTypes {
if !found {
t.Errorf("Provider type %v not found in results", providerType)
}
}
}
// TestProviderRegistry_DetectProvider tests provider detection
func TestProviderRegistry_DetectProvider(t *testing.T) {
registry := NewProviderRegistry()
// Register providers
genericProvider := NewGenericProvider()
googleProvider := NewGoogleProvider()
azureProvider := NewAzureProvider()
githubProvider := NewGitHubProvider()
auth0Provider := NewAuth0Provider()
oktaProvider := NewOktaProvider()
keycloakProvider := NewKeycloakProvider()
cognitoProvider := NewAWSCognitoProvider()
gitlabProvider := NewGitLabProvider()
registry.RegisterProvider(genericProvider)
registry.RegisterProvider(googleProvider)
registry.RegisterProvider(azureProvider)
registry.RegisterProvider(githubProvider)
registry.RegisterProvider(auth0Provider)
registry.RegisterProvider(oktaProvider)
registry.RegisterProvider(keycloakProvider)
registry.RegisterProvider(cognitoProvider)
registry.RegisterProvider(gitlabProvider)
tests := []struct {
name string
issuerURL string
expected OIDCProvider
}{
{
name: "Google provider detection",
issuerURL: "https://accounts.google.com",
expected: googleProvider,
},
{
name: "Google provider with path",
issuerURL: "https://accounts.google.com/oauth2",
expected: googleProvider,
},
{
name: "Azure provider detection - login.microsoftonline.com",
issuerURL: "https://login.microsoftonline.com/tenant/v2.0",
expected: azureProvider,
},
{
name: "Azure provider detection - sts.windows.net",
issuerURL: "https://sts.windows.net/tenant",
expected: azureProvider,
},
{
name: "GitHub provider detection",
issuerURL: "https://github.com/login/oauth",
expected: githubProvider,
},
{
name: "Auth0 provider detection",
issuerURL: "https://tenant.auth0.com",
expected: auth0Provider,
},
{
name: "Okta provider detection",
issuerURL: "https://tenant.okta.com",
expected: oktaProvider,
},
{
name: "Okta preview provider detection",
issuerURL: "https://tenant.oktapreview.com",
expected: oktaProvider,
},
{
name: "Keycloak provider detection",
issuerURL: "https://auth.example.com/auth/realms/master",
expected: keycloakProvider,
},
{
name: "AWS Cognito provider detection",
issuerURL: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_example",
expected: cognitoProvider,
},
{
name: "GitLab provider detection",
issuerURL: "https://gitlab.com/oauth",
expected: gitlabProvider,
},
{
name: "GitLab self-hosted detection - gitlab subdomain",
issuerURL: "https://gitlab.example.com",
expected: gitlabProvider,
},
{
name: "GitLab self-hosted detection - gitlab in domain",
issuerURL: "https://my-gitlab.company.io",
expected: gitlabProvider,
},
{
name: "GitLab self-hosted detection - gitlab prefix",
issuerURL: "https://gitlab-prod.internal.net",
expected: gitlabProvider,
},
{
name: "GitLab self-hosted detection - gitlab suffix",
issuerURL: "https://company-gitlab.net",
expected: gitlabProvider,
},
{
name: "Generic provider fallback",
issuerURL: "https://auth.example.com",
expected: genericProvider,
},
{
name: "Invalid URL",
issuerURL: "not-a-url",
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := registry.DetectProvider(tt.issuerURL)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
// TestProviderRegistry_DetectProvider_Caching tests cache behavior
func TestProviderRegistry_DetectProvider_Caching(t *testing.T) {
registry := NewProviderRegistry()
genericProvider := NewGenericProvider()
registry.RegisterProvider(genericProvider)
issuerURL := "https://auth.example.com"
// First call should detect and cache
result1 := registry.DetectProvider(issuerURL)
if result1 != genericProvider {
t.Errorf("Expected generic provider, got %v", result1)
}
// Verify it's cached
registry.mu.RLock()
cachedResult, found := registry.cache[issuerURL]
registry.mu.RUnlock()
if !found {
t.Error("Expected result to be cached")
}
if cachedResult != genericProvider {
t.Errorf("Expected cached generic provider, got %v", cachedResult)
}
// Second call should return cached result
result2 := registry.DetectProvider(issuerURL)
if result2 != genericProvider {
t.Errorf("Expected cached generic provider, got %v", result2)
}
// Should be same instance (from cache)
if result1 != result2 {
t.Error("Expected same instance from cache")
}
}
// TestProviderRegistry_ClearCache tests cache clearing
func TestProviderRegistry_ClearCache(t *testing.T) {
registry := NewProviderRegistry()
genericProvider := NewGenericProvider()
registry.RegisterProvider(genericProvider)
// Populate cache
registry.DetectProvider("https://auth1.example.com")
registry.DetectProvider("https://auth2.example.com")
// Verify cache has entries
registry.mu.RLock()
cacheSize := len(registry.cache)
registry.mu.RUnlock()
if cacheSize != 2 {
t.Errorf("Expected 2 cache entries, got %d", cacheSize)
}
// Clear cache
registry.ClearCache()
// Verify cache is empty
registry.mu.RLock()
cacheSize = len(registry.cache)
cacheCount := registry.cacheCount
registry.mu.RUnlock()
if cacheSize != 0 {
t.Errorf("Expected 0 cache entries after clear, got %d", cacheSize)
}
if cacheCount != 0 {
t.Errorf("Expected 0 cache count after clear, got %d", cacheCount)
}
}
// TestProviderRegistry_CacheEviction tests cache size limits and eviction
func TestProviderRegistry_CacheEviction(t *testing.T) {
registry := NewProviderRegistry()
registry.maxCacheSize = 2 // Set small cache size for testing
genericProvider := NewGenericProvider()
registry.RegisterProvider(genericProvider)
// Fill cache to capacity
registry.DetectProvider("https://auth1.example.com")
registry.DetectProvider("https://auth2.example.com")
// Verify cache is at capacity
registry.mu.RLock()
cacheSize := len(registry.cache)
registry.mu.RUnlock()
if cacheSize != 2 {
t.Errorf("Expected 2 cache entries, got %d", cacheSize)
}
// Add one more entry (should trigger eviction)
registry.DetectProvider("https://auth3.example.com")
// Cache size should still be at max
registry.mu.RLock()
cacheSize = len(registry.cache)
registry.mu.RUnlock()
if cacheSize != 2 {
t.Errorf("Expected 2 cache entries after eviction, got %d", cacheSize)
}
// Verify the new entry is cached
registry.mu.RLock()
_, found := registry.cache["https://auth3.example.com"]
registry.mu.RUnlock()
if !found {
t.Error("Expected new entry to be cached")
}
}
// TestProviderRegistry_ConcurrentAccess tests thread safety
func TestProviderRegistry_ConcurrentAccess(t *testing.T) {
registry := NewProviderRegistry()
genericProvider := NewGenericProvider()
googleProvider := NewGoogleProvider()
azureProvider := NewAzureProvider()
registry.RegisterProvider(genericProvider)
registry.RegisterProvider(googleProvider)
registry.RegisterProvider(azureProvider)
var wg sync.WaitGroup
goroutines := 10
iterations := 100
// Test concurrent detection
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
issuerURL := "https://accounts.google.com"
if id%2 == 0 {
issuerURL = "https://login.microsoftonline.com/tenant"
} else if id%3 == 0 {
issuerURL = "https://auth.example.com"
}
result := registry.DetectProvider(issuerURL)
if result == nil {
t.Errorf("Expected provider for URL %s", issuerURL)
}
}
}(i)
}
// Test concurrent registration
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 10; i++ {
newProvider := NewGenericProvider()
registry.RegisterProvider(newProvider)
}
}()
// Test concurrent cache clearing
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 10; i++ {
registry.ClearCache()
}
}()
wg.Wait()
// Verify final state is consistent
types := registry.GetRegisteredProviders()
if len(types) < 3 { // Should have at least the original 3
t.Errorf("Expected at least 3 provider types, got %d", len(types))
}
}
// TestProviderRegistry_DoubleCheckedLocking tests the double-checked locking pattern
func TestProviderRegistry_DoubleCheckedLocking(t *testing.T) {
registry := NewProviderRegistry()
genericProvider := NewGenericProvider()
registry.RegisterProvider(genericProvider)
var wg sync.WaitGroup
goroutines := 100
issuerURL := "https://auth.example.com"
// Multiple goroutines trying to detect the same provider simultaneously
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
result := registry.DetectProvider(issuerURL)
if result != genericProvider {
t.Errorf("Expected generic provider, got %v", result)
}
}()
}
wg.Wait()
// Verify only one cache entry was created
registry.mu.RLock()
cacheSize := len(registry.cache)
registry.mu.RUnlock()
if cacheSize != 1 {
t.Errorf("Expected 1 cache entry, got %d", cacheSize)
}
}
// TestProviderRegistry_DetectGitLabSelfHosted tests improved GitLab detection for issue #61
func TestProviderRegistry_DetectGitLabSelfHosted(t *testing.T) {
registry := NewProviderRegistry()
genericProvider := NewGenericProvider()
gitlabProvider := NewGitLabProvider()
githubProvider := NewGitHubProvider()
registry.RegisterProvider(genericProvider)
registry.RegisterProvider(gitlabProvider)
registry.RegisterProvider(githubProvider)
tests := []struct {
name string
issuerURL string
expected OIDCProvider
description string
}{
{
name: "GitLab.com official",
issuerURL: "https://gitlab.com",
expected: gitlabProvider,
description: "Should detect official GitLab.com",
},
{
name: "GitLab.com with path",
issuerURL: "https://gitlab.com/oauth/authorize",
expected: gitlabProvider,
description: "Should detect GitLab.com with path",
},
{
name: "Self-hosted gitlab.example.com",
issuerURL: "https://gitlab.example.com",
expected: gitlabProvider,
description: "Should detect gitlab as subdomain",
},
{
name: "Self-hosted my.gitlab.io",
issuerURL: "https://my.gitlab.io",
expected: gitlabProvider,
description: "Should detect gitlab in domain",
},
{
name: "Self-hosted example-gitlab.com",
issuerURL: "https://example-gitlab.com",
expected: gitlabProvider,
description: "Should detect gitlab as suffix",
},
{
name: "Self-hosted gitlab-prod.company.net",
issuerURL: "https://gitlab-prod.company.net",
expected: gitlabProvider,
description: "Should detect gitlab as prefix",
},
{
name: "Self-hosted my-gitlab.internal",
issuerURL: "https://my-gitlab.internal",
expected: gitlabProvider,
description: "Should detect gitlab in middle of host",
},
{
name: "Self-hosted company.gitlab.services",
issuerURL: "https://company.gitlab.services",
expected: gitlabProvider,
description: "Should detect gitlab in middle of domain",
},
{
name: "Self-hosted with port",
issuerURL: "https://gitlab.example.com:8443",
expected: gitlabProvider,
description: "Should detect GitLab with custom port",
},
{
name: "Self-hosted with path and query",
issuerURL: "https://gitlab.example.com/oauth?param=value",
expected: gitlabProvider,
description: "Should detect GitLab with complex URL",
},
{
name: "Case insensitive - GITLAB",
issuerURL: "https://GITLAB.example.com",
expected: gitlabProvider,
description: "Should detect GitLab case-insensitively",
},
{
name: "Case insensitive - GitLab",
issuerURL: "https://GitLab.example.com",
expected: gitlabProvider,
description: "Should detect GitLab with mixed case",
},
{
name: "Not GitLab - git prefix only",
issuerURL: "https://github.com",
expected: githubProvider, // Should match GitHub provider, not GitLab
description: "Should not match github.com as GitLab",
},
{
name: "Not GitLab - lab suffix only",
issuerURL: "https://mylab.example.com",
expected: genericProvider,
description: "Should not match partial gitlab string",
},
{
name: "Not GitLab - git and lab separate",
issuerURL: "https://git.mylab.example.com",
expected: genericProvider,
description: "Should not match git and lab when not together",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clear cache to ensure fresh detection
registry.ClearCache()
result := registry.DetectProvider(tt.issuerURL)
if result != tt.expected {
t.Errorf("%s: Expected %v, got %v", tt.description, tt.expected, result)
}
})
}
}
// TestProviderRegistry_GitLabDetection_RealWorldURLs tests real-world GitLab URLs
func TestProviderRegistry_GitLabDetection_RealWorldURLs(t *testing.T) {
registry := NewProviderRegistry()
genericProvider := NewGenericProvider()
gitlabProvider := NewGitLabProvider()
githubProvider := NewGitHubProvider()
registry.RegisterProvider(genericProvider)
registry.RegisterProvider(gitlabProvider)
registry.RegisterProvider(githubProvider)
realWorldTests := []struct {
name string
issuerURL string
expected OIDCProvider
}{
// Actual self-hosted GitLab examples from issue #61
{
name: "Company self-hosted GitLab",
issuerURL: "https://gitlab.company.com",
expected: gitlabProvider,
},
{
name: "Organization GitLab instance with gitlab in subdomain",
issuerURL: "https://gitlab.organization.org",
expected: gitlabProvider,
},
{
name: "Internal GitLab server",
issuerURL: "https://gitlab.internal.corp",
expected: gitlabProvider,
},
{
name: "GitLab with custom subdomain",
issuerURL: "https://code.gitlab.mycompany.com",
expected: gitlabProvider,
},
// Negative cases to ensure we don't over-match
{
name: "GitHub should not match GitLab",
issuerURL: "https://github.com",
expected: githubProvider,
},
{
name: "Generic git server",
issuerURL: "https://git.example.com",
expected: genericProvider,
},
}
for _, tt := range realWorldTests {
t.Run(tt.name, func(t *testing.T) {
registry.ClearCache()
result := registry.DetectProvider(tt.issuerURL)
if result != tt.expected {
var expectedType, resultType string
if tt.expected != nil {
expectedType = fmt.Sprintf("%v", tt.expected.GetType())
} else {
expectedType = "nil"
}
if result != nil {
resultType = fmt.Sprintf("%v", result.GetType())
} else {
resultType = "nil"
}
t.Errorf("Expected provider type %s, got %s for URL %s",
expectedType, resultType, tt.issuerURL)
}
})
}
}
// Benchmark tests
func BenchmarkProviderRegistry_DetectProvider_Cached(b *testing.B) {
registry := NewProviderRegistry()
genericProvider := NewGenericProvider()
registry.RegisterProvider(genericProvider)
issuerURL := "https://auth.example.com"
// Warm up cache
registry.DetectProvider(issuerURL)
b.ResetTimer()
for i := 0; i < b.N; i++ {
registry.DetectProvider(issuerURL)
}
}
func BenchmarkProviderRegistry_DetectProvider_Uncached(b *testing.B) {
registry := NewProviderRegistry()
genericProvider := NewGenericProvider()
registry.RegisterProvider(genericProvider)
b.ResetTimer()
for i := 0; i < b.N; i++ {
registry.ClearCache() // Clear cache for each iteration
registry.DetectProvider("https://auth.example.com")
}
}
func BenchmarkProviderRegistry_RegisterProvider(b *testing.B) {
registry := NewProviderRegistry()
b.ResetTimer()
for i := 0; i < b.N; i++ {
provider := NewGenericProvider()
registry.RegisterProvider(provider)
}
}
-563
View File
@@ -1,563 +0,0 @@
package providers
import (
"net/url"
"strings"
"testing"
"time"
)
// TestNewConfigValidator tests the creation of a ConfigValidator
func TestNewConfigValidator(t *testing.T) {
validator := NewConfigValidator()
if validator == nil {
t.Error("expected non-nil validator")
}
}
// TestValidateIssuerURL tests the ValidateIssuerURL function
func TestValidateIssuerURL(t *testing.T) {
tests := []struct {
name string
issuerURL string
wantErr bool
errMsg string
}{
{
name: "valid https URL",
issuerURL: "https://accounts.google.com",
wantErr: false,
},
{
name: "valid http URL",
issuerURL: "http://localhost:8080",
wantErr: false,
},
{
name: "valid URL with path",
issuerURL: "https://login.microsoftonline.com/tenant-id/v2.0",
wantErr: false,
},
{
name: "empty URL",
issuerURL: "",
wantErr: true,
errMsg: "issuer URL cannot be empty",
},
{
name: "URL without scheme",
issuerURL: "accounts.google.com",
wantErr: true,
errMsg: "issuer URL must include scheme",
},
{
name: "URL with invalid scheme",
issuerURL: "ftp://example.com",
wantErr: true,
errMsg: "issuer URL scheme must be http or https",
},
{
name: "URL without host",
issuerURL: "https://",
wantErr: true,
errMsg: "issuer URL must include host",
},
{
name: "malformed URL",
issuerURL: "ht!tp://[invalid",
wantErr: true,
errMsg: "invalid issuer URL format",
},
{
name: "URL with port",
issuerURL: "https://auth.example.com:443/oauth",
wantErr: false,
},
{
name: "URL with query parameters",
issuerURL: "https://auth.example.com?tenant=123",
wantErr: false,
},
}
validator := NewConfigValidator()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateIssuerURL(tt.issuerURL)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
// TestValidateClientID tests the ValidateClientID function
func TestValidateClientID(t *testing.T) {
tests := []struct {
name string
clientID string
wantErr bool
errMsg string
}{
{
name: "valid client ID",
clientID: "my-application-client",
wantErr: false,
},
{
name: "valid UUID client ID",
clientID: "123e4567-e89b-12d3-a456-426614174000",
wantErr: false,
},
{
name: "empty client ID",
clientID: "",
wantErr: true,
errMsg: "client ID cannot be empty",
},
{
name: "too short client ID",
clientID: "ab",
wantErr: true,
errMsg: "client ID appears to be too short",
},
{
name: "minimum length client ID",
clientID: "abc",
wantErr: false,
},
{
name: "client ID with special characters",
clientID: "client-id_123.app",
wantErr: false,
},
{
name: "long client ID",
clientID: strings.Repeat("a", 255),
wantErr: false,
},
}
validator := NewConfigValidator()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateClientID(tt.clientID)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
// TestValidateScopes tests the ValidateScopes function
func TestValidateScopes(t *testing.T) {
tests := []struct {
name string
scopes []string
wantErr bool
errMsg string
}{
{
name: "valid scopes with openid",
scopes: []string{"openid", "email", "profile"},
wantErr: false,
},
{
name: "only openid scope",
scopes: []string{"openid"},
wantErr: false,
},
{
name: "openid with whitespace",
scopes: []string{" openid ", "email"},
wantErr: false,
},
{
name: "empty scopes",
scopes: []string{},
wantErr: true,
errMsg: "at least one scope must be provided",
},
{
name: "nil scopes",
scopes: nil,
wantErr: true,
errMsg: "at least one scope must be provided",
},
{
name: "missing openid scope",
scopes: []string{"email", "profile"},
wantErr: true,
errMsg: "'openid' scope is required",
},
{
name: "duplicate openid scope",
scopes: []string{"openid", "openid", "email"},
wantErr: false,
},
{
name: "custom scopes with openid",
scopes: []string{"openid", "api:read", "api:write"},
wantErr: false,
},
}
validator := NewConfigValidator()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateScopes(tt.scopes)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
// TestValidateRedirectURL tests the ValidateRedirectURL function
func TestValidateRedirectURL(t *testing.T) {
tests := []struct {
name string
redirectURL string
wantErr bool
errMsg string
}{
{
name: "valid https redirect URL",
redirectURL: "https://example.com/callback",
wantErr: false,
},
{
name: "valid http redirect URL",
redirectURL: "http://localhost:3000/auth/callback",
wantErr: false,
},
{
name: "empty redirect URL",
redirectURL: "",
wantErr: true,
errMsg: "redirect URL cannot be empty",
},
{
name: "redirect URL without scheme",
redirectURL: "example.com/callback",
wantErr: true,
errMsg: "redirect URL must include scheme",
},
{
name: "malformed redirect URL",
redirectURL: "ht!tp://[invalid",
wantErr: true,
errMsg: "invalid redirect URL format",
},
{
name: "redirect URL with query parameters",
redirectURL: "https://example.com/callback?state=abc",
wantErr: false,
},
{
name: "redirect URL with fragment",
redirectURL: "https://example.com/callback#section",
wantErr: false,
},
}
validator := NewConfigValidator()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateRedirectURL(tt.redirectURL)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
// TestValidateProviderSpecificConfig tests provider-specific configuration validation
func TestValidateProviderSpecificConfig(t *testing.T) {
tests := []struct {
name string
provider OIDCProvider
config map[string]interface{}
wantErr bool
errMsg string
}{
{
name: "valid Google config",
provider: NewGoogleProvider(),
config: map[string]interface{}{
"issuer_url": "https://accounts.google.com",
},
wantErr: false,
},
{
name: "invalid Google config - wrong issuer",
provider: NewGoogleProvider(),
config: map[string]interface{}{
"issuer_url": "https://example.com",
},
wantErr: true,
errMsg: "google provider requires issuer URL to contain accounts.google.com",
},
{
name: "valid Azure config with tenant ID",
provider: NewAzureProvider(),
config: map[string]interface{}{
"issuer_url": "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0",
},
wantErr: false,
},
{
name: "invalid Azure config - wrong domain",
provider: NewAzureProvider(),
config: map[string]interface{}{
"issuer_url": "https://example.com/tenant",
},
wantErr: true,
errMsg: "azure provider requires issuer URL to contain login.microsoftonline.com",
},
{
name: "Azure config with sts.windows.net",
provider: NewAzureProvider(),
config: map[string]interface{}{
"issuer_url": "https://sts.windows.net/12345678-1234-1234-1234-123456789012",
},
wantErr: false,
},
{
name: "Azure config without tenant ID",
provider: NewAzureProvider(),
config: map[string]interface{}{
"issuer_url": "https://login.microsoftonline.com/common",
},
wantErr: true,
errMsg: "azure issuer URL should include tenant ID",
},
{
name: "valid generic provider config",
provider: NewGenericProvider(),
config: map[string]interface{}{
"issuer_url": "https://auth.example.com",
},
wantErr: false,
},
{
name: "empty config for generic provider",
provider: NewGenericProvider(),
config: map[string]interface{}{},
wantErr: false,
},
}
validator := NewConfigValidator()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateProviderSpecificConfig(tt.provider, tt.config)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
// TestValidateProviderSpecificConfig_UnknownProvider tests handling of unknown provider types
func TestValidateProviderSpecificConfig_UnknownProvider(t *testing.T) {
validator := NewConfigValidator()
// Create a mock provider with invalid type
mockProvider := &mockUnknownProvider{}
err := validator.ValidateProviderSpecificConfig(mockProvider, map[string]interface{}{})
if err == nil {
t.Error("expected error for unknown provider type")
}
if !strings.Contains(err.Error(), "unknown provider type") {
t.Errorf("expected 'unknown provider type' error, got: %v", err)
}
}
// mockUnknownProvider is a test provider with an invalid type
type mockUnknownProvider struct{}
func (m *mockUnknownProvider) GetType() ProviderType {
return ProviderType(999) // Invalid type
}
func (m *mockUnknownProvider) GetCapabilities() ProviderCapabilities {
return ProviderCapabilities{}
}
func (m *mockUnknownProvider) ValidateTokens(session Session, verifier TokenVerifier, tokenCache TokenCache, refreshGracePeriod time.Duration) (*ValidationResult, error) {
return &ValidationResult{}, nil
}
func (m *mockUnknownProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) {
return &AuthParams{}, nil
}
func (m *mockUnknownProvider) HandleTokenRefresh(tokenData *TokenResult) error {
return nil
}
func (m *mockUnknownProvider) ValidateConfig() error {
return nil
}
// TestValidateGoogleConfig_EdgeCases tests edge cases for Google config validation
func TestValidateGoogleConfig_EdgeCases(t *testing.T) {
validator := NewConfigValidator()
googleProvider := NewGoogleProvider()
tests := []struct {
name string
config map[string]interface{}
wantErr bool
}{
{
name: "config without issuer_url",
config: map[string]interface{}{},
wantErr: false, // Should pass as issuer_url is not present
},
{
name: "config with non-string issuer_url",
config: map[string]interface{}{
"issuer_url": 123,
},
wantErr: false, // Should pass as type assertion fails
},
{
name: "config with accounts.google.com in path",
config: map[string]interface{}{
"issuer_url": "https://example.com/accounts.google.com",
},
wantErr: false, // Should pass as it contains the required string
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateProviderSpecificConfig(googleProvider, tt.config)
if tt.wantErr && err == nil {
t.Error("expected error, got nil")
} else if !tt.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
// TestValidateAzureConfig_EdgeCases tests edge cases for Azure config validation
func TestValidateAzureConfig_EdgeCases(t *testing.T) {
validator := NewConfigValidator()
azureProvider := NewAzureProvider()
tests := []struct {
name string
config map[string]interface{}
wantErr bool
errMsg string
}{
{
name: "valid tenant ID format",
config: map[string]interface{}{
"issuer_url": "https://login.microsoftonline.com/a1b2c3d4-e5f6-7890-abcd-ef1234567890/v2.0",
},
wantErr: false,
},
{
name: "tenant ID in different position",
config: map[string]interface{}{
"issuer_url": "https://login.microsoftonline.com/v2.0/a1b2c3d4-e5f6-7890-abcd-ef1234567890/oauth",
},
wantErr: false,
},
{
name: "malformed URL for parsing",
config: map[string]interface{}{
"issuer_url": "https://login.microsoftonline.com/[invalid",
},
wantErr: true,
errMsg: "azure issuer URL should include tenant ID",
},
{
name: "config without issuer_url",
config: map[string]interface{}{},
wantErr: false,
},
{
name: "config with non-string issuer_url",
config: map[string]interface{}{
"issuer_url": []string{"https://login.microsoftonline.com"},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateProviderSpecificConfig(azureProvider, tt.config)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
-151
View File
@@ -1,151 +0,0 @@
package providers
import (
"fmt"
"strings"
)
// ProviderWarning represents a warning about provider limitations or requirements.
type ProviderWarning struct {
ProviderType ProviderType
Level string // "info", "warning", "error"
Message string
}
// GetProviderWarnings returns warnings about provider-specific limitations.
func GetProviderWarnings(providerType ProviderType) []ProviderWarning {
var warnings []ProviderWarning
switch providerType {
case ProviderTypeGitHub:
warnings = append(warnings, ProviderWarning{
ProviderType: ProviderTypeGitHub,
Level: "warning",
Message: "GitHub uses OAuth 2.0, not OpenID Connect. ID tokens are not available. Use access tokens for API calls only.",
})
warnings = append(warnings, ProviderWarning{
ProviderType: ProviderTypeGitHub,
Level: "info",
Message: "GitHub OAuth apps do not support refresh tokens. Users will need to re-authenticate when tokens expire.",
})
case ProviderTypeAuth0:
warnings = append(warnings, ProviderWarning{
ProviderType: ProviderTypeAuth0,
Level: "info",
Message: "Auth0 requires 'offline_access' scope for refresh tokens. This will be automatically added.",
})
case ProviderTypeOkta:
warnings = append(warnings, ProviderWarning{
ProviderType: ProviderTypeOkta,
Level: "info",
Message: "Okta requires proper application configuration in your Okta admin console for OIDC to work.",
})
case ProviderTypeKeycloak:
warnings = append(warnings, ProviderWarning{
ProviderType: ProviderTypeKeycloak,
Level: "info",
Message: "Keycloak detection is based on URL path '/auth/realms/'. Ensure your issuer URL follows this pattern.",
})
case ProviderTypeAWSCognito:
warnings = append(warnings, ProviderWarning{
ProviderType: ProviderTypeAWSCognito,
Level: "info",
Message: "AWS Cognito uses regional endpoints. Ensure your issuer URL includes the correct region (e.g., cognito-idp.us-east-1.amazonaws.com).",
})
case ProviderTypeGitLab:
warnings = append(warnings, ProviderWarning{
ProviderType: ProviderTypeGitLab,
Level: "info",
Message: "GitLab supports OIDC but requires application registration in GitLab admin settings.",
})
}
return warnings
}
// ValidateProviderCompatibility checks if a provider is suitable for OIDC authentication.
func ValidateProviderCompatibility(providerType ProviderType, requiresOIDC bool) error {
switch providerType {
case ProviderTypeGitHub:
if requiresOIDC {
return fmt.Errorf("GitHub does not support OpenID Connect. It only supports OAuth 2.0. Consider using a different provider for OIDC authentication")
}
return nil
default:
return nil
}
}
// GetProviderRecommendations returns setup recommendations for each provider.
func GetProviderRecommendations(providerType ProviderType) []string {
switch providerType {
case ProviderTypeGitHub:
return []string{
"Register an OAuth App in GitHub Settings > Developer settings > OAuth Apps",
"Use scopes: 'user:email', 'read:user' for basic profile access",
"GitHub tokens expire, plan for re-authentication flow",
}
case ProviderTypeAuth0:
return []string{
"Create an Application in Auth0 Dashboard",
"Set Application Type to 'Regular Web Application'",
"Configure Allowed Callback URLs with your redirect URI",
"Enable 'offline_access' scope for refresh tokens",
}
case ProviderTypeOkta:
return []string{
"Create an App Integration in Okta Admin Console",
"Choose 'OIDC - OpenID Connect' as sign-in method",
"Select 'Web Application' as application type",
"Configure redirect URIs and assign users/groups",
}
case ProviderTypeKeycloak:
return []string{
"Create a Client in your Keycloak realm",
"Set Client Protocol to 'openid-connect'",
"Configure Valid Redirect URIs",
"Ensure issuer URL format: https://your-keycloak/auth/realms/your-realm",
}
case ProviderTypeAWSCognito:
return []string{
"Create a User Pool in AWS Cognito",
"Create an App Client with 'Authorization code grant' enabled",
"Configure App Client settings and callback URLs",
"Use issuer URL format: https://cognito-idp.{region}.amazonaws.com/{userPoolId}",
}
case ProviderTypeGitLab:
return []string{
"Create an Application in GitLab (Admin Area > Applications)",
"Select 'openid', 'profile', 'email' scopes",
"Configure Redirect URI",
"Use issuer URL: https://gitlab.com (for GitLab.com)",
}
default:
return []string{}
}
}
// FormatProviderWarnings formats warnings for display.
func FormatProviderWarnings(warnings []ProviderWarning) string {
if len(warnings) == 0 {
return ""
}
var result strings.Builder
for _, warning := range warnings {
result.WriteString(fmt.Sprintf("[%s] %s\n", strings.ToUpper(warning.Level), warning.Message))
}
return result.String()
}
-195
View File
@@ -1,195 +0,0 @@
package providers
import (
"strings"
"testing"
)
// TestGetProviderWarnings tests that warnings are provided for providers with limitations
func TestGetProviderWarnings(t *testing.T) {
tests := []struct {
name string
providerType ProviderType
expectCount int
checkContent string
}{
{
name: "GitHub has OAuth 2.0 warning",
providerType: ProviderTypeGitHub,
expectCount: 2,
checkContent: "OAuth 2.0",
},
{
name: "Auth0 has offline_access info",
providerType: ProviderTypeAuth0,
expectCount: 1,
checkContent: "offline_access",
},
{
name: "Okta has configuration info",
providerType: ProviderTypeOkta,
expectCount: 1,
checkContent: "admin console",
},
{
name: "AWS Cognito has regional endpoint info",
providerType: ProviderTypeAWSCognito,
expectCount: 1,
checkContent: "regional endpoints",
},
{
name: "Generic provider has no warnings",
providerType: ProviderTypeGeneric,
expectCount: 0,
checkContent: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
warnings := GetProviderWarnings(tt.providerType)
if len(warnings) != tt.expectCount {
t.Errorf("Expected %d warnings, got %d", tt.expectCount, len(warnings))
}
if tt.checkContent != "" {
found := false
for _, warning := range warnings {
if strings.Contains(strings.ToLower(warning.Message), strings.ToLower(tt.checkContent)) {
found = true
break
}
}
if !found {
t.Errorf("Expected warning content '%s' not found", tt.checkContent)
}
}
})
}
}
// TestValidateProviderCompatibility tests OIDC compatibility validation
func TestValidateProviderCompatibility(t *testing.T) {
tests := []struct {
name string
providerType ProviderType
requiresOIDC bool
expectError bool
}{
{
name: "GitHub with OIDC requirement should error",
providerType: ProviderTypeGitHub,
requiresOIDC: true,
expectError: true,
},
{
name: "GitHub without OIDC requirement should pass",
providerType: ProviderTypeGitHub,
requiresOIDC: false,
expectError: false,
},
{
name: "Auth0 with OIDC requirement should pass",
providerType: ProviderTypeAuth0,
requiresOIDC: true,
expectError: false,
},
{
name: "Google with OIDC requirement should pass",
providerType: ProviderTypeGoogle,
requiresOIDC: true,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateProviderCompatibility(tt.providerType, tt.requiresOIDC)
if tt.expectError && err == nil {
t.Error("Expected error but got none")
}
if !tt.expectError && err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
}
}
// TestGetProviderRecommendations tests that recommendations are provided
func TestGetProviderRecommendations(t *testing.T) {
tests := []struct {
name string
providerType ProviderType
expectMin int
}{
{
name: "GitHub recommendations",
providerType: ProviderTypeGitHub,
expectMin: 3,
},
{
name: "Auth0 recommendations",
providerType: ProviderTypeAuth0,
expectMin: 3,
},
{
name: "AWS Cognito recommendations",
providerType: ProviderTypeAWSCognito,
expectMin: 3,
},
{
name: "Generic provider no recommendations",
providerType: ProviderTypeGeneric,
expectMin: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recommendations := GetProviderRecommendations(tt.providerType)
if len(recommendations) < tt.expectMin {
t.Errorf("Expected at least %d recommendations, got %d", tt.expectMin, len(recommendations))
}
})
}
}
// TestFormatProviderWarnings tests warning formatting
func TestFormatProviderWarnings(t *testing.T) {
warnings := []ProviderWarning{
{
ProviderType: ProviderTypeGitHub,
Level: "warning",
Message: "Test warning message",
},
{
ProviderType: ProviderTypeGitHub,
Level: "info",
Message: "Test info message",
},
}
formatted := FormatProviderWarnings(warnings)
if !strings.Contains(formatted, "[WARNING]") {
t.Error("Expected formatted output to contain [WARNING]")
}
if !strings.Contains(formatted, "[INFO]") {
t.Error("Expected formatted output to contain [INFO]")
}
if !strings.Contains(formatted, "Test warning message") {
t.Error("Expected formatted output to contain warning message")
}
// Test empty warnings
emptyFormatted := FormatProviderWarnings([]ProviderWarning{})
if emptyFormatted != "" {
t.Error("Expected empty string for no warnings")
}
}
-403
View File
@@ -1,403 +0,0 @@
// Package security provides security-related middleware and utilities
package security
import (
"net/http"
"strings"
"time"
)
// SecurityHeadersConfig configures security headers
type SecurityHeadersConfig struct {
// Content Security Policy
ContentSecurityPolicy string
// HSTS settings
StrictTransportSecurity string
StrictTransportSecurityMaxAge int // seconds
StrictTransportSecuritySubdomains bool
StrictTransportSecurityPreload bool
// Frame options
FrameOptions string // DENY, SAMEORIGIN, or ALLOW-FROM uri
// Content type options
ContentTypeOptions string // nosniff
// XSS protection
XSSProtection string // 1; mode=block
// Referrer policy
ReferrerPolicy string
// Permissions policy
PermissionsPolicy string
// Cross-origin settings
CrossOriginEmbedderPolicy string
CrossOriginOpenerPolicy string
CrossOriginResourcePolicy string
// CORS settings
CORSEnabled bool
CORSAllowedOrigins []string
CORSAllowedMethods []string
CORSAllowedHeaders []string
CORSAllowCredentials bool
CORSMaxAge int // seconds
// Custom headers
CustomHeaders map[string]string
// Security features
DisableServerHeader bool
DisablePoweredByHeader bool
// Development mode (less strict for local development)
DevelopmentMode bool
}
// DefaultSecurityConfig returns a secure default configuration
func DefaultSecurityConfig() *SecurityHeadersConfig {
return &SecurityHeadersConfig{
ContentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';",
StrictTransportSecurityMaxAge: 31536000, // 1 year
StrictTransportSecuritySubdomains: true,
StrictTransportSecurityPreload: true,
FrameOptions: "DENY",
ContentTypeOptions: "nosniff",
XSSProtection: "1; mode=block",
ReferrerPolicy: "strict-origin-when-cross-origin",
PermissionsPolicy: "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), speaker=()",
CrossOriginEmbedderPolicy: "require-corp",
CrossOriginOpenerPolicy: "same-origin",
CrossOriginResourcePolicy: "same-origin",
CORSEnabled: false,
CORSAllowedMethods: []string{"GET", "POST", "OPTIONS"},
CORSAllowedHeaders: []string{"Authorization", "Content-Type", "X-Requested-With"},
CORSMaxAge: 86400, // 24 hours
DisableServerHeader: true,
DisablePoweredByHeader: true,
DevelopmentMode: false,
}
}
// DevelopmentSecurityConfig returns a configuration suitable for development
func DevelopmentSecurityConfig() *SecurityHeadersConfig {
config := DefaultSecurityConfig()
// Relax CSP for development
config.ContentSecurityPolicy = "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data: https: http:; connect-src 'self' ws: wss:;"
// Allow framing for development tools
config.FrameOptions = "SAMEORIGIN"
// Enable CORS for local development
config.CORSEnabled = true
config.CORSAllowedOrigins = []string{"http://localhost:*", "http://127.0.0.1:*"}
config.CORSAllowCredentials = true
// Relax cross-origin policies
config.CrossOriginEmbedderPolicy = ""
config.CrossOriginOpenerPolicy = "unsafe-none"
config.CrossOriginResourcePolicy = "cross-origin"
config.DevelopmentMode = true
return config
}
// SecurityHeadersMiddleware applies security headers to HTTP responses
type SecurityHeadersMiddleware struct {
config *SecurityHeadersConfig
}
// NewSecurityHeadersMiddleware creates a new security headers middleware
func NewSecurityHeadersMiddleware(config *SecurityHeadersConfig) *SecurityHeadersMiddleware {
if config == nil {
config = DefaultSecurityConfig()
}
return &SecurityHeadersMiddleware{
config: config,
}
}
// Apply applies security headers to the response
func (m *SecurityHeadersMiddleware) Apply(rw http.ResponseWriter, req *http.Request) {
headers := rw.Header()
// Content Security Policy
if m.config.ContentSecurityPolicy != "" {
headers.Set("Content-Security-Policy", m.config.ContentSecurityPolicy)
}
// HSTS (only for HTTPS)
if req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https" {
hstsValue := m.buildHSTSHeader()
if hstsValue != "" {
headers.Set("Strict-Transport-Security", hstsValue)
}
}
// Frame options
if m.config.FrameOptions != "" {
headers.Set("X-Frame-Options", m.config.FrameOptions)
}
// Content type options
if m.config.ContentTypeOptions != "" {
headers.Set("X-Content-Type-Options", m.config.ContentTypeOptions)
}
// XSS protection
if m.config.XSSProtection != "" {
headers.Set("X-XSS-Protection", m.config.XSSProtection)
}
// Referrer policy
if m.config.ReferrerPolicy != "" {
headers.Set("Referrer-Policy", m.config.ReferrerPolicy)
}
// Permissions policy
if m.config.PermissionsPolicy != "" {
headers.Set("Permissions-Policy", m.config.PermissionsPolicy)
}
// Cross-origin policies
if m.config.CrossOriginEmbedderPolicy != "" {
headers.Set("Cross-Origin-Embedder-Policy", m.config.CrossOriginEmbedderPolicy)
}
if m.config.CrossOriginOpenerPolicy != "" {
headers.Set("Cross-Origin-Opener-Policy", m.config.CrossOriginOpenerPolicy)
}
if m.config.CrossOriginResourcePolicy != "" {
headers.Set("Cross-Origin-Resource-Policy", m.config.CrossOriginResourcePolicy)
}
// CORS headers
if m.config.CORSEnabled {
m.applyCORSHeaders(rw, req)
}
// Custom headers
for name, value := range m.config.CustomHeaders {
headers.Set(name, value)
}
// Remove server identification headers
if m.config.DisableServerHeader {
headers.Del("Server")
}
if m.config.DisablePoweredByHeader {
headers.Del("X-Powered-By")
}
// Add security timestamp for debugging
if m.config.DevelopmentMode {
headers.Set("X-Security-Headers-Applied", time.Now().UTC().Format(time.RFC3339))
}
}
// buildHSTSHeader constructs the HSTS header value
func (m *SecurityHeadersMiddleware) buildHSTSHeader() string {
if m.config.StrictTransportSecurityMaxAge <= 0 {
return ""
}
parts := []string{
"max-age=" + string(rune(m.config.StrictTransportSecurityMaxAge)),
}
if m.config.StrictTransportSecuritySubdomains {
parts = append(parts, "includeSubDomains")
}
if m.config.StrictTransportSecurityPreload {
parts = append(parts, "preload")
}
return strings.Join(parts, "; ")
}
// applyCORSHeaders applies CORS headers based on the request
func (m *SecurityHeadersMiddleware) applyCORSHeaders(rw http.ResponseWriter, req *http.Request) {
headers := rw.Header()
origin := req.Header.Get("Origin")
// Check if origin is allowed
if origin != "" && m.isOriginAllowed(origin) {
headers.Set("Access-Control-Allow-Origin", origin)
} else if len(m.config.CORSAllowedOrigins) == 1 && m.config.CORSAllowedOrigins[0] == "*" {
headers.Set("Access-Control-Allow-Origin", "*")
}
// Set other CORS headers
if len(m.config.CORSAllowedMethods) > 0 {
headers.Set("Access-Control-Allow-Methods", strings.Join(m.config.CORSAllowedMethods, ", "))
}
if len(m.config.CORSAllowedHeaders) > 0 {
headers.Set("Access-Control-Allow-Headers", strings.Join(m.config.CORSAllowedHeaders, ", "))
}
if m.config.CORSAllowCredentials {
headers.Set("Access-Control-Allow-Credentials", "true")
}
if m.config.CORSMaxAge > 0 {
headers.Set("Access-Control-Max-Age", string(rune(m.config.CORSMaxAge)))
}
// Handle preflight requests
if req.Method == "OPTIONS" {
headers.Set("Access-Control-Allow-Methods", strings.Join(m.config.CORSAllowedMethods, ", "))
headers.Set("Access-Control-Allow-Headers", strings.Join(m.config.CORSAllowedHeaders, ", "))
rw.WriteHeader(http.StatusOK)
}
}
// isOriginAllowed checks if the origin is in the allowed list
func (m *SecurityHeadersMiddleware) isOriginAllowed(origin string) bool {
for _, allowed := range m.config.CORSAllowedOrigins {
if m.matchOrigin(origin, allowed) {
return true
}
}
return false
}
// matchOrigin checks if an origin matches an allowed pattern
func (m *SecurityHeadersMiddleware) matchOrigin(origin, pattern string) bool {
// Exact match
if origin == pattern {
return true
}
// Wildcard subdomain match (e.g., "https://*.example.com")
if strings.Contains(pattern, "*") {
// Simple wildcard matching for subdomains
if strings.HasPrefix(pattern, "https://*.") {
domain := strings.TrimPrefix(pattern, "https://*.")
if strings.HasSuffix(origin, "."+domain) || origin == "https://"+domain {
return true
}
}
if strings.HasPrefix(pattern, "http://*.") {
domain := strings.TrimPrefix(pattern, "http://*.")
if strings.HasSuffix(origin, "."+domain) || origin == "http://"+domain {
return true
}
}
}
// Port wildcard match (e.g., "http://localhost:*")
if strings.HasSuffix(pattern, ":*") {
prefix := strings.TrimSuffix(pattern, ":*")
if strings.HasPrefix(origin, prefix+":") {
return true
}
}
return false
}
// Wrap wraps an HTTP handler with security headers
func (m *SecurityHeadersMiddleware) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
m.Apply(rw, req)
next.ServeHTTP(rw, req)
})
}
// SecurityHeadersHandler is a convenience function that creates and applies security headers
func SecurityHeadersHandler(config *SecurityHeadersConfig) func(http.ResponseWriter, *http.Request) {
middleware := NewSecurityHeadersMiddleware(config)
return middleware.Apply
}
// Common security header presets
// StrictSecurityConfig returns a very strict security configuration
func StrictSecurityConfig() *SecurityHeadersConfig {
config := DefaultSecurityConfig()
// Very strict CSP
config.ContentSecurityPolicy = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
// Stricter frame options
config.FrameOptions = "DENY"
// Disable CORS entirely
config.CORSEnabled = false
// Very strict cross-origin policies
config.CrossOriginEmbedderPolicy = "require-corp"
config.CrossOriginOpenerPolicy = "same-origin"
config.CrossOriginResourcePolicy = "same-site"
return config
}
// APISecurityConfig returns a configuration suitable for APIs
func APISecurityConfig() *SecurityHeadersConfig {
config := DefaultSecurityConfig()
// API-friendly CSP
config.ContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none';"
// Enable CORS for APIs
config.CORSEnabled = true
config.CORSAllowedMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}
config.CORSAllowedHeaders = []string{"Authorization", "Content-Type", "X-Requested-With", "X-API-Key"}
// API-appropriate policies
config.CrossOriginResourcePolicy = "cross-origin"
return config
}
// ValidateConfig validates the security configuration
func (c *SecurityHeadersConfig) Validate() error {
// Validate HSTS max age
if c.StrictTransportSecurityMaxAge < 0 {
c.StrictTransportSecurityMaxAge = 0
}
// Validate CORS max age
if c.CORSMaxAge < 0 {
c.CORSMaxAge = 0
}
// Validate frame options
validFrameOptions := []string{"DENY", "SAMEORIGIN", ""}
isValidFrameOption := false
for _, valid := range validFrameOptions {
if c.FrameOptions == valid || strings.HasPrefix(c.FrameOptions, "ALLOW-FROM ") {
isValidFrameOption = true
break
}
}
if !isValidFrameOption {
c.FrameOptions = "DENY"
}
return nil
}
// ApplyToResponseWriter is a helper function to quickly apply security headers
func ApplySecurityHeaders(rw http.ResponseWriter, req *http.Request, config *SecurityHeadersConfig) {
middleware := NewSecurityHeadersMiddleware(config)
middleware.Apply(rw, req)
}
-350
View File
@@ -1,350 +0,0 @@
package security
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestDefaultSecurityConfig(t *testing.T) {
config := DefaultSecurityConfig()
if config.ContentSecurityPolicy == "" {
t.Error("Expected default CSP to be set")
}
if config.FrameOptions != "DENY" {
t.Errorf("Expected frame options to be DENY, got %s", config.FrameOptions)
}
if !config.DisableServerHeader {
t.Error("Expected server header to be disabled by default")
}
}
func TestSecurityHeadersMiddleware_Apply(t *testing.T) {
config := DefaultSecurityConfig()
middleware := NewSecurityHeadersMiddleware(config)
// Create a mock request (HTTPS)
req := httptest.NewRequest("GET", "https://example.com/test", nil)
req.TLS = &tls.ConnectionState{} // Mock TLS
// Create a response recorder
rr := httptest.NewRecorder()
// Apply security headers
middleware.Apply(rr, req)
headers := rr.Header()
// Check that security headers are set
if headers.Get("Content-Security-Policy") == "" {
t.Error("Expected CSP header to be set")
}
if headers.Get("X-Frame-Options") != "DENY" {
t.Errorf("Expected X-Frame-Options to be DENY, got %s", headers.Get("X-Frame-Options"))
}
if headers.Get("X-Content-Type-Options") != "nosniff" {
t.Errorf("Expected X-Content-Type-Options to be nosniff, got %s", headers.Get("X-Content-Type-Options"))
}
if headers.Get("X-XSS-Protection") != "1; mode=block" {
t.Errorf("Expected X-XSS-Protection to be '1; mode=block', got %s", headers.Get("X-XSS-Protection"))
}
// Check HSTS for HTTPS requests
hsts := headers.Get("Strict-Transport-Security")
if hsts == "" {
t.Error("Expected HSTS header for HTTPS request")
}
if !strings.Contains(hsts, "max-age=") {
t.Error("Expected HSTS header to contain max-age")
}
}
func TestSecurityHeadersMiddleware_HTTPSOnly(t *testing.T) {
config := DefaultSecurityConfig()
middleware := NewSecurityHeadersMiddleware(config)
// Test HTTP request (no HSTS)
req := httptest.NewRequest("GET", "http://example.com/test", nil)
rr := httptest.NewRecorder()
middleware.Apply(rr, req)
if rr.Header().Get("Strict-Transport-Security") != "" {
t.Error("Expected no HSTS header for HTTP request")
}
// Test HTTPS request (with HSTS)
req = httptest.NewRequest("GET", "https://example.com/test", nil)
req.TLS = &tls.ConnectionState{}
rr = httptest.NewRecorder()
middleware.Apply(rr, req)
if rr.Header().Get("Strict-Transport-Security") == "" {
t.Error("Expected HSTS header for HTTPS request")
}
}
func TestCORSHeaders(t *testing.T) {
config := DefaultSecurityConfig()
config.CORSEnabled = true
config.CORSAllowedOrigins = []string{"https://example.com", "https://*.test.com"}
config.CORSAllowCredentials = true
middleware := NewSecurityHeadersMiddleware(config)
tests := []struct {
name string
origin string
expectedOrigin string
}{
{
name: "exact match",
origin: "https://example.com",
expectedOrigin: "https://example.com",
},
{
name: "wildcard subdomain match",
origin: "https://api.test.com",
expectedOrigin: "https://api.test.com",
},
{
name: "no match",
origin: "https://malicious.com",
expectedOrigin: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com/test", nil)
if tt.origin != "" {
req.Header.Set("Origin", tt.origin)
}
rr := httptest.NewRecorder()
middleware.Apply(rr, req)
actualOrigin := rr.Header().Get("Access-Control-Allow-Origin")
if actualOrigin != tt.expectedOrigin {
t.Errorf("Expected origin %s, got %s", tt.expectedOrigin, actualOrigin)
}
if tt.expectedOrigin != "" {
// Should have credentials header
if rr.Header().Get("Access-Control-Allow-Credentials") != "true" {
t.Error("Expected credentials header for allowed origin")
}
}
})
}
}
func TestCORSPreflight(t *testing.T) {
config := DefaultSecurityConfig()
config.CORSEnabled = true
config.CORSAllowedOrigins = []string{"*"}
config.CORSAllowedMethods = []string{"GET", "POST", "OPTIONS"}
middleware := NewSecurityHeadersMiddleware(config)
req := httptest.NewRequest("OPTIONS", "https://example.com/test", nil)
req.Header.Set("Origin", "https://other.com")
rr := httptest.NewRecorder()
middleware.Apply(rr, req)
if rr.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Error("Expected wildcard origin for preflight request")
}
if rr.Header().Get("Access-Control-Allow-Methods") == "" {
t.Error("Expected methods header for preflight request")
}
if rr.Code != http.StatusOK {
t.Errorf("Expected status 200 for preflight, got %d", rr.Code)
}
}
func TestOriginMatching(t *testing.T) {
config := &SecurityHeadersConfig{
CORSEnabled: true,
CORSAllowedOrigins: []string{
"https://example.com",
"https://*.example.com",
"http://localhost:*",
},
}
middleware := NewSecurityHeadersMiddleware(config)
tests := []struct {
origin string
expected bool
}{
{"https://example.com", true},
{"https://api.example.com", true},
{"https://sub.api.example.com", true},
{"http://localhost:3000", true},
{"http://localhost:8080", true},
{"https://malicious.com", false},
{"http://example.com", false}, // Different scheme
{"https://example.com.evil.com", false}, // Domain suffix attack
}
for _, tt := range tests {
t.Run(tt.origin, func(t *testing.T) {
result := middleware.isOriginAllowed(tt.origin)
if result != tt.expected {
t.Errorf("Origin %s: expected %v, got %v", tt.origin, tt.expected, result)
}
})
}
}
func TestDevelopmentMode(t *testing.T) {
config := DevelopmentSecurityConfig()
if !config.DevelopmentMode {
t.Error("Expected development mode to be enabled")
}
if !config.CORSEnabled {
t.Error("Expected CORS to be enabled in development mode")
}
if config.FrameOptions != "SAMEORIGIN" {
t.Errorf("Expected frame options to be SAMEORIGIN in dev mode, got %s", config.FrameOptions)
}
// Should be less strict CSP
if strings.Contains(config.ContentSecurityPolicy, "'none'") {
t.Error("Expected less strict CSP in development mode")
}
}
func TestStrictSecurityConfig(t *testing.T) {
config := StrictSecurityConfig()
if !strings.Contains(config.ContentSecurityPolicy, "'none'") {
t.Error("Expected very strict CSP with 'none' defaults")
}
if config.CORSEnabled {
t.Error("Expected CORS to be disabled in strict mode")
}
if config.FrameOptions != "DENY" {
t.Error("Expected frame options to be DENY in strict mode")
}
}
func TestAPISecurityConfig(t *testing.T) {
config := APISecurityConfig()
if !config.CORSEnabled {
t.Error("Expected CORS to be enabled for API config")
}
methods := config.CORSAllowedMethods
expectedMethods := []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}
for _, method := range expectedMethods {
found := false
for _, allowed := range methods {
if allowed == method {
found = true
break
}
}
if !found {
t.Errorf("Expected method %s to be allowed in API config", method)
}
}
}
func TestMiddlewareWrap(t *testing.T) {
config := DefaultSecurityConfig()
middleware := NewSecurityHeadersMiddleware(config)
// Create a simple handler
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
// Wrap with security middleware
wrappedHandler := middleware.Wrap(handler)
req := httptest.NewRequest("GET", "https://example.com/test", nil)
req.TLS = &tls.ConnectionState{}
rr := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rr, req)
// Check response
if rr.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", rr.Code)
}
if rr.Body.String() != "OK" {
t.Errorf("Expected body 'OK', got %s", rr.Body.String())
}
// Check security headers were applied
if rr.Header().Get("X-Frame-Options") == "" {
t.Error("Expected security headers to be applied by wrapper")
}
}
func TestConfigValidation(t *testing.T) {
config := &SecurityHeadersConfig{
StrictTransportSecurityMaxAge: -1,
CORSMaxAge: -1,
FrameOptions: "INVALID",
}
err := config.Validate()
if err != nil {
t.Errorf("Unexpected validation error: %v", err)
}
// Should fix invalid values
if config.StrictTransportSecurityMaxAge != 0 {
t.Error("Expected negative HSTS max age to be reset to 0")
}
if config.CORSMaxAge != 0 {
t.Error("Expected negative CORS max age to be reset to 0")
}
if config.FrameOptions != "DENY" {
t.Error("Expected invalid frame options to be reset to DENY")
}
}
func BenchmarkSecurityHeadersApply(b *testing.B) {
config := DefaultSecurityConfig()
middleware := NewSecurityHeadersMiddleware(config)
req := httptest.NewRequest("GET", "https://example.com/test", nil)
req.TLS = &tls.ConnectionState{}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
rr := httptest.NewRecorder()
middleware.Apply(rr, req)
}
})
}
-393
View File
@@ -1,393 +0,0 @@
// Package testing provides unified mock implementations for tests
package testing
import (
"fmt"
"net/http"
"sync"
"time"
)
// UnifiedMockLogger provides a standard mock logger for all tests
type UnifiedMockLogger struct {
LoggedMessages []string
mu sync.RWMutex
}
func NewUnifiedMockLogger() *UnifiedMockLogger {
return &UnifiedMockLogger{
LoggedMessages: make([]string, 0),
}
}
func (l *UnifiedMockLogger) Debug(msg string) {
l.mu.Lock()
defer l.mu.Unlock()
l.LoggedMessages = append(l.LoggedMessages, fmt.Sprintf("DEBUG: %s", msg))
}
func (l *UnifiedMockLogger) Debugf(format string, args ...interface{}) {
l.Debug(fmt.Sprintf(format, args...))
}
func (l *UnifiedMockLogger) Info(msg string) {
l.mu.Lock()
defer l.mu.Unlock()
l.LoggedMessages = append(l.LoggedMessages, fmt.Sprintf("INFO: %s", msg))
}
func (l *UnifiedMockLogger) Infof(format string, args ...interface{}) {
l.Info(fmt.Sprintf(format, args...))
}
func (l *UnifiedMockLogger) Error(msg string) {
l.mu.Lock()
defer l.mu.Unlock()
l.LoggedMessages = append(l.LoggedMessages, fmt.Sprintf("ERROR: %s", msg))
}
func (l *UnifiedMockLogger) Errorf(format string, args ...interface{}) {
l.Error(fmt.Sprintf(format, args...))
}
func (l *UnifiedMockLogger) GetMessages() []string {
l.mu.RLock()
defer l.mu.RUnlock()
result := make([]string, len(l.LoggedMessages))
copy(result, l.LoggedMessages)
return result
}
func (l *UnifiedMockLogger) Clear() {
l.mu.Lock()
defer l.mu.Unlock()
l.LoggedMessages = l.LoggedMessages[:0]
}
// UnifiedMockSession provides a standard mock session for all tests
type UnifiedMockSession struct {
authenticated bool
idToken string
accessToken string
refreshToken string
email string
csrf string
nonce string
codeVerifier string
incomingPath string
redirectCount int
mu sync.RWMutex
}
func NewUnifiedMockSession() *UnifiedMockSession {
return &UnifiedMockSession{}
}
func (s *UnifiedMockSession) GetAuthenticated() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.authenticated
}
func (s *UnifiedMockSession) SetAuthenticated(auth bool) error {
s.mu.Lock()
defer s.mu.Unlock()
s.authenticated = auth
return nil
}
func (s *UnifiedMockSession) GetIDToken() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.idToken
}
func (s *UnifiedMockSession) SetIDToken(token string) {
s.mu.Lock()
defer s.mu.Unlock()
s.idToken = token
}
func (s *UnifiedMockSession) GetAccessToken() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.accessToken
}
func (s *UnifiedMockSession) SetAccessToken(token string) {
s.mu.Lock()
defer s.mu.Unlock()
s.accessToken = token
}
func (s *UnifiedMockSession) GetRefreshToken() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.refreshToken
}
func (s *UnifiedMockSession) SetRefreshToken(token string) {
s.mu.Lock()
defer s.mu.Unlock()
s.refreshToken = token
}
func (s *UnifiedMockSession) GetEmail() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.email
}
func (s *UnifiedMockSession) SetEmail(email string) {
s.mu.Lock()
defer s.mu.Unlock()
s.email = email
}
func (s *UnifiedMockSession) GetCSRF() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.csrf
}
func (s *UnifiedMockSession) SetCSRF(csrf string) {
s.mu.Lock()
defer s.mu.Unlock()
s.csrf = csrf
}
func (s *UnifiedMockSession) GetNonce() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.nonce
}
func (s *UnifiedMockSession) SetNonce(nonce string) {
s.mu.Lock()
defer s.mu.Unlock()
s.nonce = nonce
}
func (s *UnifiedMockSession) GetCodeVerifier() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.codeVerifier
}
func (s *UnifiedMockSession) SetCodeVerifier(verifier string) {
s.mu.Lock()
defer s.mu.Unlock()
s.codeVerifier = verifier
}
func (s *UnifiedMockSession) GetIncomingPath() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.incomingPath
}
func (s *UnifiedMockSession) SetIncomingPath(path string) {
s.mu.Lock()
defer s.mu.Unlock()
s.incomingPath = path
}
func (s *UnifiedMockSession) GetRedirectCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.redirectCount
}
func (s *UnifiedMockSession) IncrementRedirectCount() {
s.mu.Lock()
defer s.mu.Unlock()
s.redirectCount++
}
func (s *UnifiedMockSession) ResetRedirectCount() {
s.mu.Lock()
defer s.mu.Unlock()
s.redirectCount = 0
}
func (s *UnifiedMockSession) Save(req *http.Request, rw http.ResponseWriter) error {
return nil
}
func (s *UnifiedMockSession) Clear(req *http.Request, rw http.ResponseWriter) error {
s.mu.Lock()
defer s.mu.Unlock()
s.authenticated = false
s.idToken = ""
s.accessToken = ""
s.refreshToken = ""
s.email = ""
s.csrf = ""
s.nonce = ""
s.codeVerifier = ""
s.incomingPath = ""
s.redirectCount = 0
return nil
}
func (s *UnifiedMockSession) MarkDirty() {}
func (s *UnifiedMockSession) IsDirty() bool {
return false
}
func (s *UnifiedMockSession) ReturnToPoolSafely() {}
// UnifiedMockTokenVerifier provides a standard mock token verifier
type UnifiedMockTokenVerifier struct {
ShouldFail bool
Error error
}
func NewUnifiedMockTokenVerifier() *UnifiedMockTokenVerifier {
return &UnifiedMockTokenVerifier{}
}
func (v *UnifiedMockTokenVerifier) VerifyToken(token string) error {
if v.ShouldFail {
if v.Error != nil {
return v.Error
}
return fmt.Errorf("mock verification failed")
}
return nil
}
// UnifiedMockTokenCache provides a standard mock token cache
type UnifiedMockTokenCache struct {
data map[string]map[string]interface{}
mu sync.RWMutex
}
func NewUnifiedMockTokenCache() *UnifiedMockTokenCache {
return &UnifiedMockTokenCache{
data: make(map[string]map[string]interface{}),
}
}
func (c *UnifiedMockTokenCache) Get(key string) (map[string]interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
value, exists := c.data[key]
return value, exists
}
func (c *UnifiedMockTokenCache) Set(key string, claims map[string]interface{}, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = claims
}
func (c *UnifiedMockTokenCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}
func (c *UnifiedMockTokenCache) SetMaxSize(size int) {}
func (c *UnifiedMockTokenCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.data)
}
func (c *UnifiedMockTokenCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.data = make(map[string]map[string]interface{})
}
func (c *UnifiedMockTokenCache) Cleanup() {}
func (c *UnifiedMockTokenCache) Close() {}
func (c *UnifiedMockTokenCache) GetStats() map[string]interface{} {
return map[string]interface{}{
"size": c.Size(),
}
}
// UnifiedMockHTTPClient provides a mock HTTP client for tests
type UnifiedMockHTTPClient struct {
Responses map[string]*http.Response
Errors map[string]error
mu sync.RWMutex
}
func NewUnifiedMockHTTPClient() *UnifiedMockHTTPClient {
return &UnifiedMockHTTPClient{
Responses: make(map[string]*http.Response),
Errors: make(map[string]error),
}
}
func (c *UnifiedMockHTTPClient) Do(req *http.Request) (*http.Response, error) {
c.mu.RLock()
defer c.mu.RUnlock()
url := req.URL.String()
if err, exists := c.Errors[url]; exists {
return nil, err
}
if resp, exists := c.Responses[url]; exists {
return resp, nil
}
// Default response
return &http.Response{
StatusCode: 200,
Body: http.NoBody,
Header: make(http.Header),
}, nil
}
func (c *UnifiedMockHTTPClient) SetResponse(url string, response *http.Response) {
c.mu.Lock()
defer c.mu.Unlock()
c.Responses[url] = response
}
func (c *UnifiedMockHTTPClient) SetError(url string, err error) {
c.mu.Lock()
defer c.mu.Unlock()
c.Errors[url] = err
}
// TestSuite provides a unified test setup and teardown
type TestSuite struct {
Logger *UnifiedMockLogger
Session *UnifiedMockSession
TokenVerifier *UnifiedMockTokenVerifier
TokenCache *UnifiedMockTokenCache
HTTPClient *UnifiedMockHTTPClient
}
func NewTestSuite() *TestSuite {
return &TestSuite{
Logger: NewUnifiedMockLogger(),
Session: NewUnifiedMockSession(),
TokenVerifier: NewUnifiedMockTokenVerifier(),
TokenCache: NewUnifiedMockTokenCache(),
HTTPClient: NewUnifiedMockHTTPClient(),
}
}
func (ts *TestSuite) Setup() {
// Common test setup
ts.Logger.Clear()
_ = ts.Session.Clear(nil, nil) // Safe to ignore: test helper function
ts.TokenCache.Clear()
ts.TokenVerifier.ShouldFail = false
ts.TokenVerifier.Error = nil
}
func (ts *TestSuite) Teardown() {
// Common test teardown
ts.TokenCache.Close()
}
-139
View File
@@ -1,139 +0,0 @@
// Package token provides token verification and management functionality
package token
import (
"fmt"
"strings"
"time"
traefikoidc "github.com/lukaszraczylo/traefikoidc"
)
// Verifier handles token verification operations
type Verifier struct {
tokenCache TokenCache
tokenBlacklist Cache
jwkCache JWKCache
limiter RateLimiter
logger Logger
}
// Cache interface for token operations
type Cache interface {
Get(key string) (interface{}, bool)
Set(key string, value interface{}, ttl time.Duration)
}
// TokenCache interface for verified token storage
type TokenCache interface {
Get(key string) (map[string]interface{}, bool)
Set(key string, claims map[string]interface{}, ttl time.Duration)
}
// JWKCache interface for key management
type JWKCache interface {
GetJWKS(providerURL string) (*traefikoidc.JWKSet, error)
}
// RateLimiter interface for request limiting
type RateLimiter interface {
Allow() bool
}
// Logger interface for logging
type Logger interface {
Debugf(format string, args ...interface{})
Errorf(format string, args ...interface{})
}
// JWT represents a parsed JWT token
type JWT struct {
Header map[string]interface{}
Claims map[string]interface{}
}
// NewVerifier creates a new token verifier
func NewVerifier(tokenCache TokenCache, tokenBlacklist Cache, jwkCache JWKCache, limiter RateLimiter, logger Logger) *Verifier {
return &Verifier{
tokenCache: tokenCache,
tokenBlacklist: tokenBlacklist,
jwkCache: jwkCache,
limiter: limiter,
logger: logger,
}
}
// VerifyToken verifies the validity of an ID token or access token
func (v *Verifier) VerifyToken(token string, clientID string, jwksURL string, issuerURL string) error {
if token == "" {
return fmt.Errorf("invalid JWT format: token is empty")
}
if strings.Count(token, ".") != 2 {
return fmt.Errorf("invalid JWT format: expected JWT with 3 parts, got %d parts", strings.Count(token, ".")+1)
}
if len(token) < 10 {
return fmt.Errorf("token too short to be valid JWT")
}
// Check blacklist
if v.tokenBlacklist != nil {
if blacklisted, exists := v.tokenBlacklist.Get(token); exists && blacklisted != nil {
return fmt.Errorf("token is blacklisted")
}
}
// Check cache first
if claims, exists := v.tokenCache.Get(token); exists && len(claims) > 0 {
return nil
}
// Rate limiting
if !v.limiter.Allow() {
return fmt.Errorf("rate limit exceeded")
}
// Parse and verify JWT
jwt, err := v.parseJWT(token)
if err != nil {
return fmt.Errorf("failed to parse JWT: %w", err)
}
if err := v.verifyJWTSignatureAndClaims(jwt, token, clientID, jwksURL, issuerURL); err != nil {
return err
}
// Cache successful verification
v.cacheVerifiedToken(token, jwt.Claims)
return nil
}
// parseJWT parses a JWT token into its components
func (v *Verifier) parseJWT(token string) (*JWT, error) {
// This would contain the actual JWT parsing logic
// For now, return a placeholder
return &JWT{
Header: make(map[string]interface{}),
Claims: make(map[string]interface{}),
}, nil
}
// verifyJWTSignatureAndClaims verifies JWT signature and claims
func (v *Verifier) verifyJWTSignatureAndClaims(jwt *JWT, token string, clientID string, jwksURL string, issuerURL string) error {
// This would contain the actual signature verification logic
// For now, return nil (placeholder)
return nil
}
// cacheVerifiedToken stores a successfully verified token
func (v *Verifier) cacheVerifiedToken(token string, claims map[string]interface{}) {
if expClaim, ok := claims["exp"].(float64); ok {
expirationTime := time.Unix(int64(expClaim), 0)
duration := time.Until(expirationTime)
if duration > 0 {
v.tokenCache.Set(token, claims, duration)
}
}
}
-457
View File
@@ -1,457 +0,0 @@
package token
import (
"strings"
"testing"
"time"
traefikoidc "github.com/lukaszraczylo/traefikoidc"
)
// Mock implementations for testing
type MockTokenCache struct {
data map[string]map[string]interface{}
}
func (m *MockTokenCache) Get(key string) (map[string]interface{}, bool) {
if m.data == nil {
return nil, false
}
value, exists := m.data[key]
return value, exists
}
func (m *MockTokenCache) Set(key string, claims map[string]interface{}, ttl time.Duration) {
if m.data == nil {
m.data = make(map[string]map[string]interface{})
}
m.data[key] = claims
}
type MockCache struct {
data map[string]interface{}
}
func (m *MockCache) Get(key string) (interface{}, bool) {
if m.data == nil {
return nil, false
}
value, exists := m.data[key]
return value, exists
}
func (m *MockCache) Set(key string, value interface{}, ttl time.Duration) {
if m.data == nil {
m.data = make(map[string]interface{})
}
m.data[key] = value
}
type MockJWKCache struct{}
func (m *MockJWKCache) GetJWKS(providerURL string) (*traefikoidc.JWKSet, error) {
return &traefikoidc.JWKSet{
Keys: []traefikoidc.JWK{
{
Kid: "test-key",
Kty: "RSA",
Use: "sig",
Alg: "RS256",
},
},
}, nil
}
type MockRateLimiter struct {
allow bool
}
func (m *MockRateLimiter) Allow() bool {
return m.allow
}
type MockLogger struct {
debugMessages []string
errorMessages []string
}
func (m *MockLogger) Debugf(format string, args ...interface{}) {
m.debugMessages = append(m.debugMessages, format)
}
func (m *MockLogger) Errorf(format string, args ...interface{}) {
m.errorMessages = append(m.errorMessages, format)
}
func TestNewVerifier(t *testing.T) {
tokenCache := &MockTokenCache{}
tokenBlacklist := &MockCache{}
jwkCache := &MockJWKCache{}
limiter := &MockRateLimiter{allow: true}
logger := &MockLogger{}
verifier := NewVerifier(tokenCache, tokenBlacklist, jwkCache, limiter, logger)
if verifier == nil {
t.Fatal("NewVerifier returned nil")
}
if verifier.tokenCache != tokenCache {
t.Error("TokenCache not set correctly")
}
if verifier.tokenBlacklist != tokenBlacklist {
t.Error("TokenBlacklist not set correctly")
}
// Note: Interface comparison would require reflecting on the actual implementation
// For now, we just check that the field was set to something non-nil
if verifier.jwkCache == nil {
t.Error("JWKCache not set correctly")
}
if verifier.limiter != limiter {
t.Error("RateLimiter not set correctly")
}
if verifier.logger != logger {
t.Error("Logger not set correctly")
}
}
func TestVerifierBasicFunctionality(t *testing.T) {
tokenCache := &MockTokenCache{}
tokenBlacklist := &MockCache{}
jwkCache := &MockJWKCache{}
limiter := &MockRateLimiter{allow: true}
logger := &MockLogger{}
verifier := NewVerifier(tokenCache, tokenBlacklist, jwkCache, limiter, logger)
// Test that the verifier was created successfully
if verifier == nil {
t.Fatal("Expected non-nil verifier")
}
}
func TestJWKSStructure(t *testing.T) {
jwks := &traefikoidc.JWKSet{
Keys: []traefikoidc.JWK{
{
Kid: "test-key-1",
Kty: "RSA",
Use: "sig",
Alg: "RS256",
},
{
Kid: "test-key-2",
Kty: "RSA",
Use: "sig",
Alg: "RS256",
},
},
}
if len(jwks.Keys) != 2 {
t.Errorf("Expected 2 keys, got %d", len(jwks.Keys))
}
if jwks.Keys[0].Kid != "test-key-1" {
t.Errorf("Expected Kid 'test-key-1', got '%s'", jwks.Keys[0].Kid)
}
if jwks.Keys[1].Kid != "test-key-2" {
t.Errorf("Expected Kid 'test-key-2', got '%s'", jwks.Keys[1].Kid)
}
}
func TestJWKStructure(t *testing.T) {
jwk := traefikoidc.JWK{
Kid: "test-key",
Kty: "RSA",
Use: "sig",
Alg: "RS256",
N: "test-modulus",
E: "test-exponent",
}
if jwk.Kid != "test-key" {
t.Errorf("Expected Kid 'test-key', got '%s'", jwk.Kid)
}
if jwk.Kty != "RSA" {
t.Errorf("Expected Kty 'RSA', got '%s'", jwk.Kty)
}
if jwk.Use != "sig" {
t.Errorf("Expected Use 'sig', got '%s'", jwk.Use)
}
if jwk.Alg != "RS256" {
t.Errorf("Expected Alg 'RS256', got '%s'", jwk.Alg)
}
}
func TestVerifyToken(t *testing.T) {
tests := []struct {
name string
token string
clientID string
jwksURL string
issuerURL string
rateLimitAllow bool
cacheData map[string]map[string]interface{}
blacklistData map[string]interface{}
expectedError string
}{
{
name: "Empty token",
token: "",
clientID: "test-client",
jwksURL: "https://example.com/jwks",
issuerURL: "https://example.com",
rateLimitAllow: true,
expectedError: "invalid JWT format: token is empty",
},
{
name: "Invalid JWT format - too few parts",
token: "header.payload",
clientID: "test-client",
jwksURL: "https://example.com/jwks",
issuerURL: "https://example.com",
rateLimitAllow: true,
expectedError: "invalid JWT format: expected JWT with 3 parts, got 2 parts",
},
{
name: "Invalid JWT format - too many parts",
token: "header.payload.signature.extra",
clientID: "test-client",
jwksURL: "https://example.com/jwks",
issuerURL: "https://example.com",
rateLimitAllow: true,
expectedError: "invalid JWT format: expected JWT with 3 parts, got 4 parts",
},
{
name: "Token too short",
token: "a.b.c",
clientID: "test-client",
jwksURL: "https://example.com/jwks",
issuerURL: "https://example.com",
rateLimitAllow: true,
expectedError: "token too short to be valid JWT",
},
{
name: "Blacklisted token",
token: "valid.format.token",
clientID: "test-client",
jwksURL: "https://example.com/jwks",
issuerURL: "https://example.com",
rateLimitAllow: true,
blacklistData: map[string]interface{}{"valid.format.token": true},
expectedError: "token is blacklisted",
},
{
name: "Cached token - success",
token: "valid.format.token",
clientID: "test-client",
jwksURL: "https://example.com/jwks",
issuerURL: "https://example.com",
rateLimitAllow: true,
cacheData: map[string]map[string]interface{}{"valid.format.token": {"sub": "user123"}},
expectedError: "",
},
{
name: "Rate limit exceeded",
token: "valid.format.token",
clientID: "test-client",
jwksURL: "https://example.com/jwks",
issuerURL: "https://example.com",
rateLimitAllow: false,
expectedError: "rate limit exceeded",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tokenCache := &MockTokenCache{data: tt.cacheData}
tokenBlacklist := &MockCache{data: tt.blacklistData}
jwkCache := &MockJWKCache{}
limiter := &MockRateLimiter{allow: tt.rateLimitAllow}
logger := &MockLogger{}
verifier := NewVerifier(tokenCache, tokenBlacklist, jwkCache, limiter, logger)
err := verifier.VerifyToken(tt.token, tt.clientID, tt.jwksURL, tt.issuerURL)
if tt.expectedError == "" {
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
} else {
if err == nil {
t.Errorf("Expected error containing '%s', got nil", tt.expectedError)
} else if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got: %v", tt.expectedError, err)
}
}
})
}
}
func TestParseJWT(t *testing.T) {
tokenCache := &MockTokenCache{}
tokenBlacklist := &MockCache{}
jwkCache := &MockJWKCache{}
limiter := &MockRateLimiter{allow: true}
logger := &MockLogger{}
verifier := NewVerifier(tokenCache, tokenBlacklist, jwkCache, limiter, logger)
// Test parseJWT with a valid format token
jwt, err := verifier.parseJWT("header.payload.signature")
if err != nil {
t.Errorf("Expected no error parsing JWT, got: %v", err)
}
if jwt == nil {
t.Error("Expected non-nil JWT object")
return
}
if jwt.Header == nil {
t.Error("Expected non-nil Header map")
}
if jwt.Claims == nil {
t.Error("Expected non-nil Claims map")
}
}
func TestVerifyJWTSignatureAndClaims(t *testing.T) {
tokenCache := &MockTokenCache{}
tokenBlacklist := &MockCache{}
jwkCache := &MockJWKCache{}
limiter := &MockRateLimiter{allow: true}
logger := &MockLogger{}
verifier := NewVerifier(tokenCache, tokenBlacklist, jwkCache, limiter, logger)
jwt := &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{"sub": "user123", "exp": float64(time.Now().Add(time.Hour).Unix())},
}
// Test signature verification (currently returns nil - placeholder)
err := verifier.verifyJWTSignatureAndClaims(jwt, "test.token.here", "client-id", "https://example.com/jwks", "https://example.com")
if err != nil {
t.Errorf("Expected no error from placeholder verification, got: %v", err)
}
}
func TestCacheVerifiedToken(t *testing.T) {
tokenCache := &MockTokenCache{}
tokenBlacklist := &MockCache{}
jwkCache := &MockJWKCache{}
limiter := &MockRateLimiter{allow: true}
logger := &MockLogger{}
verifier := NewVerifier(tokenCache, tokenBlacklist, jwkCache, limiter, logger)
tests := []struct {
name string
token string
claims map[string]interface{}
expected bool
}{
{
name: "Valid expiration time",
token: "test-token-1",
claims: map[string]interface{}{"exp": float64(time.Now().Add(time.Hour).Unix())},
expected: true,
},
{
name: "Expired token",
token: "test-token-2",
claims: map[string]interface{}{"exp": float64(time.Now().Add(-time.Hour).Unix())},
expected: false,
},
{
name: "No expiration claim",
token: "test-token-3",
claims: map[string]interface{}{"sub": "user123"},
expected: false,
},
{
name: "Invalid expiration type",
token: "test-token-4",
claims: map[string]interface{}{"exp": "invalid"},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clear cache before test
tokenCache.data = make(map[string]map[string]interface{})
verifier.cacheVerifiedToken(tt.token, tt.claims)
_, exists := tokenCache.Get(tt.token)
if exists != tt.expected {
t.Errorf("Expected cache existence: %v, got: %v", tt.expected, exists)
}
})
}
}
func TestMockInterfaces(t *testing.T) {
// Test MockTokenCache
tokenCache := &MockTokenCache{}
claims := map[string]interface{}{"sub": "user123", "exp": 1234567890}
tokenCache.Set("test-token", claims, time.Hour)
retrieved, exists := tokenCache.Get("test-token")
if !exists {
t.Error("Expected token to exist in cache")
}
if retrieved["sub"] != "user123" {
t.Errorf("Expected sub 'user123', got '%v'", retrieved["sub"])
}
// Test MockCache
cache := &MockCache{}
cache.Set("test-key", "test-value", time.Hour)
value, exists := cache.Get("test-key")
if !exists {
t.Error("Expected key to exist in cache")
}
if value != "test-value" {
t.Errorf("Expected 'test-value', got '%v'", value)
}
// Test MockRateLimiter
limiter := &MockRateLimiter{allow: true}
if !limiter.Allow() {
t.Error("Expected rate limiter to allow request")
}
limiter.allow = false
if limiter.Allow() {
t.Error("Expected rate limiter to deny request")
}
// Test MockLogger
logger := &MockLogger{}
logger.Debugf("test debug message")
logger.Errorf("test error message")
if len(logger.debugMessages) != 1 {
t.Errorf("Expected 1 debug message, got %d", len(logger.debugMessages))
}
if len(logger.errorMessages) != 1 {
t.Errorf("Expected 1 error message, got %d", len(logger.errorMessages))
}
}

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