Files
traefikoidc/token_test.go
T
lukaszraczylo 546ceb949c security: remediate audit findings (ranks 1–16 + 22 Lows) + yaegi load validation (#144)
* fix(security): encrypt session cookies + fail closed on invalid config

Batch 1 of security audit remediation (ranks 1, 2, 6).

- session.go: derive independent HMAC + AES-256 keys via stdlib HKDF-SHA256
  and build the gorilla cookie store with both, so session cookies are now
  encrypted, not merely signed. The single-key store previously left OIDC
  access/refresh/ID tokens recoverable from raw cookie bytes. Cookie format
  changes, so existing sessions are invalidated on deploy (one-time re-login).
- main.go: call config.Validate() at construction and error out on failure,
  instead of silently substituting a public hardcoded encryption key for
  empty/short keys (which allowed session forgery). The yaegi analyzer
  passes via .traefik.yml testData.
- settings.go: isValidSecureURL permits plaintext HTTP for loopback hosts
  only (RFC 8252); remote providers must still use HTTPS.
- tests: complete configs that did not satisfy Validate(); add regression
  tests in security_audit_fixes_test.go.

Configs below documented minimums (rateLimit < 10, key < 32 chars) are now
rejected at startup (fail closed).

* fix(security): validate discovered OIDC endpoints + pin introspection host

Batch 2 of security audit remediation (ranks 3, 4).

- url_helpers.go: add validateDiscoveredEndpoint, an SSRF screen for endpoints
  taken from the provider discovery document (jwks_uri, token, authorization,
  revocation, end_session, introspection, registration). Blocks link-local
  (cloud metadata 169.254.169.254), multicast, unspecified and private
  addresses (unless allowPrivateIPAddresses); blocks loopback unless the
  configured providerURL is itself loopback (dev/test). Cross-domain JWKS
  hosts (e.g. Google) stay allowed. Add sameHost helper.
- main.go: updateMetadataEndpoints screens every discovered endpoint and
  blanks any that fail (fail closed downstream). The introspection endpoint
  carries the client secret via HTTP Basic, so it is additionally pinned to
  the providerURL host to stop a poisoned discovery document exfiltrating the
  secret to an attacker-controlled host.
- tests: regression tests for the SSRF guard and the host pin.

* fix(security): close open redirects + anchor excluded-URL matching

Batch 3 of security audit remediation (ranks 5, 14, 15).

- auth_flow.go: run the stored incoming path through normalizeLogoutPath
  before using it as the post-login redirect, so //evil.com and /\evil.com
  payloads become host-relative (open-redirect, rank 5).
- url_helpers.go: excluded-URL matching is anchored at a natural boundary
  (exact, sub-path "/", or file extension "."), so excluding "/public" no
  longer also bypasses auth on "/publicsecret"; "/favicon" still matches
  "/favicon.ico" (rank 14).
- internal/utils: X-Forwarded-Host is sanitized (first value only; reject
  CRLF/whitespace/multi-value) before building redirect URLs (rank 15).
- helpers.go: the logout redirect used when there is no provider end-session
  endpoint is host-relative, never an absolute URL derived from the
  client-controllable request host (logout open-redirect, rank 15).
- tests: update two logout cases that asserted the old absolute redirect;
  add regression tests.

* fix(security): reject unverified Azure tokens; fix transport TLS reuse

Batch 4 of security audit remediation (ranks 7, 11).

- token_validation_rs.go: an Azure nonce-bearing access token that cannot be
  cryptographically verified no longer returns "authenticated" when there is
  no ID token to corroborate it; it refreshes (if possible) or forces
  re-authentication instead of failing open (rank 7).
- http_client_pool.go: the at-limit transport-reuse path now takes the write
  lock before mutating refCount (fixes a data race) and only reuses a
  transport whose TLS settings (CA pool + InsecureSkipVerify) match the
  caller's, never one with a different trust store; if none matches it returns
  nil so the caller falls back to a verifying default transport (rank 11).
- tests: add a transport-pool TLS-isolation regression test.

* fix(security): stop logging templated header values (token leak)

Batch 5 of security audit remediation (rank 16).

middleware.go: templated downstream headers commonly carry the access token
(e.g. "Authorization: Bearer {{.AccessToken}}"). The debug log line printed
the full header value, leaking credentials into logs. Log the header name and
byte length instead.

* fix(security): cache-key collision, cache-config divergence, fleet cleanup

Batch 6 of security audit remediation (ranks 9, 10, 12).

- token_manager.go: detectTokenType keys its cache on a SHA-256 hash of the
  full token instead of the first 32 chars (which are only the base64url JWT
  header). Distinct tokens sharing alg+kid no longer collide and get
  mis-classified (rank 10).
- cache_manager.go: the process-global cache manager is initialized once and
  shared across plugin instances; it now logs a loud warning when a later
  instance requests a different explicit Redis backend that is silently
  ignored, surfacing the cross-instance state-isolation hazard (rank 9).
- singleton_resources.go / main.go / utilities.go: track a process-global live
  instance count; the shared singleton-token-cleanup task is stopped only when
  the LAST instance shuts down, so one instance's Close() (e.g. a config reload)
  no longer kills cleanup for surviving instances (rank 12).
- tests: update TestDetectTokenTypeCaching for the new key; add regression tests.

* fix(security): bound introspection cache + cookie lifetime to config

Batch 7 of security audit remediation (ranks 8, 13).

- token_introspection.go: when requireTokenIntrospection is enabled, cap the
  positive introspection-result cache at 30s (instead of 5m) so a token
  revoked at the provider stops passing within ~30s, matching the operator's
  near-real-time revocation expectation (rank 8).
- session.go: bind the cookie store's MaxAge to the configured sessionMaxAge,
  so the cookie codec's cryptographic timestamp validity is no longer fixed at
  gorilla's 30-day default; a stolen cookie is valid only for the configured
  session lifetime (rank 13).
- tests: add a cookie-lifetime regression test.

* fix(security): low-severity hardening (cache, DoS caps, PKCE, throttle)

Batch 8 of security audit remediation — low severity
(ranks 24, 25, 27, 29, 31, 36, 37, 41, 45, 46, 49).

- universal_cache.go: updateLocalCache updates an existing key in place instead
  of orphaning its LRU element and double-counting currentSize/currentMemory
  (rank 36 — the only production-reachable bug in this batch).
- jwk.go / metadata_cache.go / token_introspection.go: bound response bodies
  with io.LimitReader (1 MiB) to prevent memory exhaustion from a hostile or
  buggy provider (ranks 24, 25).
- jwk.go: skip JWKs not usable for signature verification (use != sig, or
  key_ops without "verify") when building the key set (rank 49).
- auth_flow.go: fail closed at the callback when PKCE is enabled but the code
  verifier is missing, instead of silently dropping it (rank 27).
- utilities.go / main.go: match allowedUserDomains case-insensitively (rank 31).
- bearer_auth.go: a single success no longer wipes an active per-IP penalty;
  the counter resets only when no penalty is in effect (rank 29).
- main.go: handle (not discard) the NewSessionManager error (rank 37).
- error_recovery.go: take a write lock in isServiceDegraded (it deletes from a
  map); compare retryable-error substrings case-insensitively (ranks 45, 46).
- singleton_resources.go: bind the generic-cache cleanup goroutine to the
  resource-manager shutdown channel so it cannot outlive its owner (rank 41).
- tests: update the bearer throttle test to the corrected penalty semantics.

* fix(security): header sanitization, issuer pinning, fail-closed paths

Batch 9 of security audit remediation (ranks 18, 19, 20, 21, 22, 30, 33, 34).

- middleware.go / bearer_auth.go: sanitize claim-derived values on the cookie
  auth path before injecting them into downstream headers. Drop group/role and
  identifier values containing control chars, bidi-override runes, or the
  , ; = delimiters (a comma would inject phantom entries into X-User-Groups);
  reject control/bidi/over-length in rendered templated header output (but
  permit , ; = in free-form values such as a bearer token). The bearer path
  already sanitized; the cookie path did not (ranks 33, 34).
- main.go / metadata_cache.go: pin the discovered issuer to the configured
  provider host (sameHost) and refuse/never-cache a mismatch, so a poisoned
  discovery document cannot redefine the JWT trust anchor (ranks 21, 22).
- token_introspection.go: when a distinct API audience is configured, fail
  closed on a missing or mismatched introspection audience; aud parsed as
  string-or-array per RFC 7662 (rank 19).
- logout.go: front-channel logout requires a matching issuer; an empty iss is
  rejected (blocks unauthenticated forced-logout via a known sid) (rank 30).
- token_validation_rs.go: an opaque access token with no ID token and no
  successful introspection fails closed (re-auth) instead of authenticating
  (ranks 18, 20).
- tests: realistic same-host provider mocks; regression tests for the header
  sanitization distinction and the fail-closed paths.

* chore(security): remove unwired dead code with latent footguns

Batch 10 of security audit remediation — delete confirmed-dead, unwired
subsystems (ranks 26, 35, 50). None had a production caller (grep-verified);
removal eliminates the latent footguns and ~2.1k lines of dead code.

- token_validator.go (deleted): an unused *TokenValidator whose validateJWT set
  Valid=true with NO signature verification — a severe footgun if ever wired
  (rank 50). The wired RS-aware validators are unaffected.
- security_monitoring.go (deleted): an unused *SecurityMonitor / ExtractClientIP
  that trusted spoofable X-Forwarded-For / X-Real-IP. The live bearer throttle
  uses clientIPForBearer (RemoteAddr-only), unchanged (rank 35).
- dynamic_client_registration.go: removed the RFC 7592 management methods
  (Update/Read/DeleteClientRegistration) that dereferenced an attacker-
  influenced RegistrationClientURI with the registration token attached and no
  HTTPS/SSRF gate, and had no callers. The wired RFC 7591 RegisterClient and
  credential-store helpers are kept (rank 26).
- tests: removed the tests covering the deleted code.

* chore: add Makefile with yaegi load validation

No Makefile existed. The new `yaegi-validate` target interprets the plugin
under the yaegi interpreter the same way Traefik loads it, catching yaegi-only
incompatibilities (unsupported stdlib symbols, reflection edge cases) that the
native `go build` / `go test` toolchain does not. Importing the plugin forces
yaegi to interpret every file plus its vendored deps; CreateConfig + New
exercise the instantiation path.

- cmd/yaegicheck/main.go: the load driver, marked //go:build ignore so it is
  excluded from `go build ./...` (avoids VCS-stamping a main binary, which
  fails in git-worktree layouts) yet is run explicitly by yaegi.
- Makefile: build / fmt / vet / lint / test / vendor / yaegi-validate / check
  targets; `make check` runs vet + tests + yaegi-validate.

Verified: `make yaegi-validate` passes on this branch — the HKDF cookie
encryption, net-based endpoint validation, and claim sanitizers all interpret
and instantiate cleanly under yaegi.

* ci: bump workflow Go toolchain to 1.25; pin yaegi-validate to v0.16.1

Traefik v3.7.1 (the deployed version) is built with `go 1.25.0`, so the PR and
release workflows now use Go 1.25.x to match the toolchain Traefik uses.

Important distinction: the CI Go version is the build TOOLCHAIN. The plugin's
actual interpreter-compatibility ceiling is the yaegi version Traefik bundles
(v0.16.1, which declares go 1.21 and ships a ~Go 1.22 stdlib symbol surface),
NOT the CI Go version. That ceiling is enforced by `make yaegi-validate` plus
the go.mod language directive — e.g. it is why HKDF is hand-rolled with
hmac+sha256 rather than Go 1.24's crypto/hkdf, which yaegi v0.16.1 lacks.

Also pin Makefile YAEGI_VERSION to v0.16.1 (what Traefik v3.7.1 vendors) so
yaegi-validate exercises the real deployed interpreter instead of @latest,
which could pass on a newer yaegi that supports symbols the deployed one does
not.

* docs: align README/CONFIGURATION with branch behavior changes

- excludedURLs: documented as segment/extension-boundary matching (was
  "prefix-matched") — "/public" no longer also matches "/publicsecret" (rank 14).
- Front-channel logout now requires a matching `iss`; requests without one are
  rejected with 400 (rank 30).
- Add an "Upgrading from an earlier release" note: session cookies are now
  AES-256 encrypted with lifetime tracking sessionMaxAge (one-time re-login on
  upgrade), and invalid configuration (rateLimit < 10, key < 32 bytes, missing
  callbackURL, non-HTTPS remote providerURL) now fails closed at startup.

* fix: remove staticcheck-flagged unused functions; wire staticcheck into make check

CI Static Analysis (standalone staticcheck) failed with U1000 "unused":
- dynamic_client_registration.go: deleteCredentialsFromStore — its only caller
  was the RFC 7592 DeleteClientRegistration removed in the dead-code batch.
- token_test.go: createTestJWTSimple — its only callers were the TokenValidator
  tests removed in the same batch.
Both confirmed to have zero remaining callers and removed. build / vet /
go test ./... / staticcheck ./... all green.

The pre-commit hook runs golangci-lint, but CI runs standalone staticcheck
(which flags U1000). Add a `staticcheck` Makefile target and include it in
`make check` so this class of finding is caught locally before push.

* fix(test): stabilize flaky TestWorkerPool_TaskPanic

tasksFailed is incremented in the worker's deferred recover(), which runs after the panicking task's own defer wg.Done(). wg.Wait() could therefore return before the failure was recorded, so reading the counter immediately raced and flaked on slow CI runners. Poll until the failure lands (2s budget) instead. Verified 200x plain + 50x under -race/GOMAXPROCS=1.
2026-05-30 14:10:32 +01:00

1587 lines
44 KiB
Go

package traefikoidc
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"text/template"
"time"
"golang.org/x/time/rate"
)
// =============================================================================
// TOKEN TEST CONSTANTS AND TYPES
// =============================================================================
// Test tokens used across multiple test files
var (
ValidAccessToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIn0.eyJpc3MiOiJodHRwczovL3Rlc3QtaXNzdWVyLmNvbSIsImF1ZCI6InRlc3QtY2xpZW50LWlkIiwiZXhwIjozMDAwMDAwMDAwLCJzdWIiOiJ0ZXN0LXN1YmplY3QiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.dGVzdC1zaWduYXR1cmU" // trufflehog:ignore
ValidIDToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIn0.eyJpc3MiOiJodHRwczovL3Rlc3QtaXNzdWVyLmNvbSIsImF1ZCI6InRlc3QtY2xpZW50LWlkIiwiZXhwIjozMDAwMDAwMDAwLCJzdWIiOiJ0ZXN0LXN1YmplY3QiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.dGVzdC1zaWduYXR1cmU" // trufflehog:ignore
ValidRefreshToken = "refresh_token_abc123"
MinimalValidJWT = "eyJhbGciOiJub25lIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0." // trufflehog:ignore
InvalidTokenOneDot = "invalid.token"
InvalidTokenNoDots = "invalidtoken"
InvalidTokenThreeDots = "invalid..token"
)
// TestTokens provides test JWT tokens
type TestTokens struct {
validJWT string
expiredJWT string
}
func NewTestTokens() *TestTokens {
return &TestTokens{
validJWT: "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIn0.eyJpc3MiOiJodHRwczovL3Rlc3QtaXNzdWVyLmNvbSIsImF1ZCI6InRlc3QtY2xpZW50LWlkIiwiZXhwIjozMDAwMDAwMDAwLCJzdWIiOiJ0ZXN0LXN1YmplY3QiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.dGVzdC1zaWduYXR1cmU", // trufflehog:ignore
expiredJWT: "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIn0.eyJpc3MiOiJodHRwczovL3Rlc3QtaXNzdWVyLmNvbSIsImF1ZCI6InRlc3QtY2xpZW50LWlkIiwiZXhwIjoxMDAwMDAwMDAwLCJzdWIiOiJ0ZXN0LXN1YmplY3QiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.dGVzdC1zaWduYXR1cmU", // trufflehog:ignore
}
}
func (tt *TestTokens) CreateValidJWT() string {
return tt.validJWT
}
// TokenSet represents a complete set of tokens with proper field names
type TokenSet struct {
AccessToken string
IDToken string
RefreshToken string
}
func (tt *TestTokens) GetValidTokenSet() *TokenSet {
return &TokenSet{
AccessToken: tt.validJWT,
IDToken: tt.validJWT,
RefreshToken: ValidRefreshToken,
}
}
func (tt *TestTokens) CreateIncompressibleToken(size int) string {
return "incompressible." + generateRandomString(size) + ".signature"
}
func (tt *TestTokens) CreateUniqueValidJWT(suffix string) string {
return tt.validJWT + "_" + suffix
}
func (tt *TestTokens) GetLargeTokenSet() *TokenSet {
return &TokenSet{
AccessToken: tt.CreateIncompressibleToken(2000),
IDToken: tt.CreateIncompressibleToken(2000),
RefreshToken: ValidRefreshToken,
}
}
func (tt *TestTokens) CreateExpiredJWT() string {
return tt.expiredJWT
}
func (tt *TestTokens) CreateLargeValidJWT(claimSize int) string {
largeClaim := generateRandomString(claimSize)
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"test-key-id"}`))
payload := fmt.Sprintf(`{"iss":"https://test-issuer.com","aud":"test-client-id","exp":3000000000,"sub":"test-subject","email":"test@example.com","large_claim":"%s"}`, largeClaim)
encodedPayload := base64.RawURLEncoding.EncodeToString([]byte(payload))
signature := base64.RawURLEncoding.EncodeToString([]byte("test-signature"))
return fmt.Sprintf("%s.%s.%s", header, encodedPayload, signature)
}
// TestCache is a simple in-memory cache for testing
type TestCache struct {
data map[string]interface{}
}
func NewTestCache() *TestCache {
return &TestCache{
data: make(map[string]interface{}),
}
}
func (c *TestCache) Set(key string, value interface{}, ttl time.Duration) {
c.data[key] = value
}
func (c *TestCache) Get(key string) (interface{}, bool) {
val, ok := c.data[key]
return val, ok
}
func (c *TestCache) Delete(key string) {
delete(c.data, key)
}
func (c *TestCache) SetMaxSize(size int) {}
func (c *TestCache) Size() int { return len(c.data) }
func (c *TestCache) Clear() { c.data = make(map[string]interface{}) }
func (c *TestCache) Cleanup() {}
func (c *TestCache) Close() {}
func (c *TestCache) GetStats() map[string]interface{} {
return map[string]interface{}{"size": len(c.data)}
}
// =============================================================================
// OPAQUE TOKEN TESTS
// =============================================================================
func TestOpaqueTokenDetection(t *testing.T) {
tests := []struct {
name string
token string
description string
isOpaque bool
}{
{
name: "JWT token with 3 parts",
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", // trufflehog:ignore
isOpaque: false,
description: "Standard JWT with header.payload.signature",
},
{
name: "Auth0 opaque token",
token: "8n3d84nd92nf92nf92nf92nf923nf923nf923nf9",
isOpaque: true,
description: "Auth0 opaque access token",
},
{
name: "Okta opaque token",
token: "00Otkjhgt5Rfasde12345678901234567890",
isOpaque: true,
description: "Okta opaque access token",
},
{
name: "AWS Cognito opaque token",
token: "AGPAYJhZmU3NzI5YTQtNGQ0Yy00YTU5LWJjYTQtYzdlMzQ0MmQ3ZDJl",
isOpaque: true,
description: "AWS Cognito opaque access token",
},
{
name: "Invalid single dot token",
token: "invalid.token",
isOpaque: true,
description: "Invalid format with single dot",
},
{
name: "Token with no dots",
token: "opaquetoken1234567890abcdefghijklmnop",
isOpaque: true,
description: "Pure opaque token with no dots",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dotCount := strings.Count(tt.token, ".")
isOpaqueToken := dotCount != 2
if isOpaqueToken != tt.isOpaque {
t.Errorf("Token detection failed for %s: expected opaque=%v, got opaque=%v (dots=%d)",
tt.name, tt.isOpaque, isOpaqueToken, dotCount)
}
})
}
}
func TestOpaqueTokenValidation(t *testing.T) {
logger := GetSingletonNoOpLogger()
cm := NewChunkManager(logger)
defer cm.Shutdown()
tests := []struct {
name string
token string
wantError bool
}{
{
name: "Valid opaque token",
token: "opaquetoken1234567890abcdefghijklmnop",
wantError: false,
},
{
name: "Too short opaque token",
token: "short",
wantError: true,
},
{
name: "Opaque token with spaces",
token: "opaque token with spaces 1234567890",
wantError: true,
},
{
name: "Valid JWT token",
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", // trufflehog:ignore
wantError: false,
},
}
config := TokenConfig{
Type: "access",
MinLength: 5,
MaxLength: 100 * 1024,
MaxChunks: 25,
MaxChunkSize: maxCookieSize,
AllowOpaqueTokens: true,
RequireJWTFormat: false,
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cm.validateToken(tt.token, config)
hasError := result.Error != nil
if hasError != tt.wantError {
if tt.wantError {
t.Errorf("Expected error for %s but got none", tt.name)
} else {
t.Errorf("Unexpected error for %s: %v", tt.name, result.Error)
}
}
})
}
}
func TestOpaqueTokenStorage(t *testing.T) {
tests := []struct {
name string
token string
description string
shouldStore bool
}{
{
name: "Valid opaque token",
token: "auth0_opaque_token_1234567890abcdefghijklmnop",
shouldStore: true,
description: "Opaque token with sufficient length and no dots",
},
{
name: "Valid JWT token",
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", // trufflehog:ignore
shouldStore: true,
description: "Standard JWT with three parts",
},
{
name: "Invalid single-dot token",
token: "invalid.token",
shouldStore: false,
description: "Token with single dot - invalid format",
},
{
name: "Too short opaque token",
token: "short",
shouldStore: false,
description: "Opaque token too short (less than 20 chars)",
},
{
name: "Multi-dot invalid token",
token: "too.many.dots.here",
shouldStore: false,
description: "Token with more than 2 dots - invalid format",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shouldStore := true
if tt.token != "" {
dotCount := strings.Count(tt.token, ".")
if dotCount == 1 {
shouldStore = false
}
if dotCount == 0 && len(tt.token) < 20 {
shouldStore = false
}
if dotCount > 2 {
shouldStore = false
}
}
if shouldStore != tt.shouldStore {
t.Errorf("Token storage decision failed for %s: expected store=%v, got store=%v",
tt.name, tt.shouldStore, shouldStore)
}
})
}
}
// =============================================================================
// TOKEN INTROSPECTION TESTS
// =============================================================================
func TestIntrospectToken_Success(t *testing.T) {
logger := GetSingletonNoOpLogger()
cacheManager := GetUniversalCacheManager(logger)
defer ResetUniversalCacheManagerForTesting()
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("Expected POST request, got %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Errorf("Expected application/x-www-form-urlencoded, got %s", r.Header.Get("Content-Type"))
}
username, password, ok := r.BasicAuth()
if !ok || username != "test-client" || password != "test-secret" {
t.Errorf("Invalid basic auth: username=%s, password=%s, ok=%v", username, password, ok)
}
body, _ := io.ReadAll(r.Body)
values, _ := url.ParseQuery(string(body))
if values.Get("token") != "test-opaque-token" {
t.Errorf("Expected token=test-opaque-token, got %s", values.Get("token"))
}
if values.Get("token_type_hint") != "access_token" {
t.Errorf("Expected token_type_hint=access_token, got %s", values.Get("token_type_hint"))
}
resp := IntrospectionResponse{
Active: true,
Scope: "openid profile email",
ClientID: "test-client",
Username: "testuser",
TokenType: "Bearer",
Exp: time.Now().Add(1 * time.Hour).Unix(),
Iat: time.Now().Add(-5 * time.Minute).Unix(),
Nbf: time.Now().Add(-5 * time.Minute).Unix(),
Sub: "user123",
Aud: "test-audience",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer mockServer.Close()
tOidc := &TraefikOidc{
clientID: "test-client",
clientSecret: "test-secret",
introspectionURL: mockServer.URL,
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
resp, err := tOidc.introspectToken("test-opaque-token")
if err != nil {
t.Fatalf("introspectToken failed: %v", err)
}
if !resp.Active {
t.Error("Expected token to be active")
}
if resp.ClientID != "test-client" {
t.Errorf("Expected clientID=test-client, got %s", resp.ClientID)
}
if resp.Username != "testuser" {
t.Errorf("Expected username=testuser, got %s", resp.Username)
}
if resp.Scope != "openid profile email" {
t.Errorf("Expected scope='openid profile email', got %s", resp.Scope)
}
}
func TestIntrospectToken_CachedResult(t *testing.T) {
logger := GetSingletonNoOpLogger()
cacheManager := GetUniversalCacheManager(logger)
defer ResetUniversalCacheManagerForTesting()
requestCount := 0
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
resp := IntrospectionResponse{
Active: true,
ClientID: "test-client",
Exp: time.Now().Add(1 * time.Hour).Unix(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer mockServer.Close()
tOidc := &TraefikOidc{
clientID: "test-client",
clientSecret: "test-secret",
introspectionURL: mockServer.URL,
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
resp1, err := tOidc.introspectToken("cached-token")
if err != nil {
t.Fatalf("First introspectToken failed: %v", err)
}
if !resp1.Active {
t.Error("Expected first token to be active")
}
if requestCount != 1 {
t.Errorf("Expected 1 request after first call, got %d", requestCount)
}
resp2, err := tOidc.introspectToken("cached-token")
if err != nil {
t.Fatalf("Second introspectToken failed: %v", err)
}
if !resp2.Active {
t.Error("Expected second token to be active")
}
if requestCount != 1 {
t.Errorf("Expected 1 request after cache hit, got %d", requestCount)
}
}
func TestIntrospectToken_MissingEndpoint(t *testing.T) {
logger := GetSingletonNoOpLogger()
cacheManager := GetUniversalCacheManager(logger)
defer ResetUniversalCacheManagerForTesting()
tOidc := &TraefikOidc{
clientID: "test-client",
clientSecret: "test-secret",
introspectionURL: "",
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
_, err := tOidc.introspectToken("test-token")
if err == nil {
t.Error("Expected error for missing introspection endpoint")
}
if !strings.Contains(err.Error(), "introspection endpoint not available") {
t.Errorf("Expected 'introspection endpoint not available' error, got: %v", err)
}
}
func TestIntrospectToken_HTTPError(t *testing.T) {
logger := GetSingletonNoOpLogger()
cacheManager := GetUniversalCacheManager(logger)
defer ResetUniversalCacheManagerForTesting()
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "invalid_client"}`))
}))
defer mockServer.Close()
tOidc := &TraefikOidc{
clientID: "test-client",
clientSecret: "test-secret",
introspectionURL: mockServer.URL,
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
_, err := tOidc.introspectToken("test-token")
if err == nil {
t.Error("Expected error for HTTP 401 response")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("Expected error mentioning status 401, got: %v", err)
}
}
func TestIntrospectToken_InvalidJSON(t *testing.T) {
logger := GetSingletonNoOpLogger()
cacheManager := GetUniversalCacheManager(logger)
defer ResetUniversalCacheManagerForTesting()
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{invalid json`))
}))
defer mockServer.Close()
tOidc := &TraefikOidc{
clientID: "test-client",
clientSecret: "test-secret",
introspectionURL: mockServer.URL,
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
_, err := tOidc.introspectToken("test-token")
if err == nil {
t.Error("Expected error for invalid JSON response")
}
if !strings.Contains(err.Error(), "failed to decode") {
t.Errorf("Expected 'failed to decode' error, got: %v", err)
}
}
func TestValidateOpaqueToken_OpaqueTokensDisabled(t *testing.T) {
logger := GetSingletonNoOpLogger()
cacheManager := GetUniversalCacheManager(logger)
defer ResetUniversalCacheManagerForTesting()
tOidc := &TraefikOidc{
allowOpaqueTokens: false,
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
err := tOidc.validateOpaqueToken("test-token")
if err == nil {
t.Error("Expected error when opaque tokens are disabled")
}
if !strings.Contains(err.Error(), "opaque tokens are not enabled") {
t.Errorf("Expected 'opaque tokens are not enabled' error, got: %v", err)
}
}
func TestValidateOpaqueToken_InactiveToken(t *testing.T) {
logger := GetSingletonNoOpLogger()
cacheManager := GetUniversalCacheManager(logger)
defer ResetUniversalCacheManagerForTesting()
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := IntrospectionResponse{
Active: false,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer mockServer.Close()
tOidc := &TraefikOidc{
allowOpaqueTokens: true,
clientID: "test-client",
clientSecret: "test-secret",
introspectionURL: mockServer.URL,
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
err := tOidc.validateOpaqueToken("inactive-token")
if err == nil {
t.Error("Expected error for inactive token")
}
if !strings.Contains(err.Error(), "not active") {
t.Errorf("Expected 'not active' error, got: %v", err)
}
}
func TestValidateOpaqueToken_ExpiredToken(t *testing.T) {
logger := GetSingletonNoOpLogger()
cacheManager := GetUniversalCacheManager(logger)
defer ResetUniversalCacheManagerForTesting()
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := IntrospectionResponse{
Active: true,
Exp: time.Now().Add(-1 * time.Hour).Unix(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer mockServer.Close()
tOidc := &TraefikOidc{
allowOpaqueTokens: true,
clientID: "test-client",
clientSecret: "test-secret",
introspectionURL: mockServer.URL,
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
err := tOidc.validateOpaqueToken("expired-token")
if err == nil {
t.Error("Expected error for expired token")
}
if !strings.Contains(err.Error(), "expired") {
t.Errorf("Expected 'expired' error, got: %v", err)
}
}
func TestValidateOpaqueToken_InvalidAudience(t *testing.T) {
logger := GetSingletonNoOpLogger()
cacheManager := GetUniversalCacheManager(logger)
defer ResetUniversalCacheManagerForTesting()
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := IntrospectionResponse{
Active: true,
Aud: "wrong-audience",
Exp: time.Now().Add(1 * time.Hour).Unix(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer mockServer.Close()
tOidc := &TraefikOidc{
allowOpaqueTokens: true,
clientID: "test-client",
clientSecret: "test-secret",
audience: "expected-audience",
introspectionURL: mockServer.URL,
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
err := tOidc.validateOpaqueToken("wrong-aud-token")
if err == nil {
t.Error("Expected error for invalid audience")
}
if !strings.Contains(err.Error(), "invalid audience") {
t.Errorf("Expected 'invalid audience' error, got: %v", err)
}
}
func TestValidateOpaqueToken_SuccessfulValidation(t *testing.T) {
logger := GetSingletonNoOpLogger()
cacheManager := GetUniversalCacheManager(logger)
defer ResetUniversalCacheManagerForTesting()
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := IntrospectionResponse{
Active: true,
ClientID: "test-client",
Aud: "test-audience",
Exp: time.Now().Add(1 * time.Hour).Unix(),
Nbf: time.Now().Add(-5 * time.Minute).Unix(),
Scope: "openid profile",
Sub: "user123",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer mockServer.Close()
tOidc := &TraefikOidc{
allowOpaqueTokens: true,
clientID: "test-client",
clientSecret: "test-secret",
audience: "test-audience",
introspectionURL: mockServer.URL,
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
err := tOidc.validateOpaqueToken("valid-token")
if err != nil {
t.Errorf("Expected successful validation, got error: %v", err)
}
}
func TestIntrospectToken_ConcurrentCalls(t *testing.T) {
logger := GetSingletonNoOpLogger()
cacheManager := GetUniversalCacheManager(logger)
defer ResetUniversalCacheManagerForTesting()
var requestCount int
var mu sync.Mutex
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
requestCount++
mu.Unlock()
time.Sleep(10 * time.Millisecond)
resp := IntrospectionResponse{
Active: true,
ClientID: "test-client",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer mockServer.Close()
tOidc := &TraefikOidc{
clientID: "test-client",
clientSecret: "test-secret",
introspectionURL: mockServer.URL,
introspectionCache: &CacheInterfaceWrapper{cache: cacheManager.GetIntrospectionCache()},
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
var wg sync.WaitGroup
concurrency := 10
wg.Add(concurrency)
for i := 0; i < concurrency; i++ {
go func(id int) {
defer wg.Done()
token := fmt.Sprintf("concurrent-token-%d", id)
_, err := tOidc.introspectToken(token)
if err != nil {
t.Errorf("Concurrent introspection %d failed: %v", id, err)
}
}(i)
}
wg.Wait()
mu.Lock()
finalCount := requestCount
mu.Unlock()
if finalCount != concurrency {
t.Errorf("Expected %d requests for %d concurrent calls, got %d", concurrency, concurrency, finalCount)
}
}
// =============================================================================
// TOKEN TYPE DETECTION TESTS
// =============================================================================
func TestDetectTokenType(t *testing.T) {
tr := &TraefikOidc{
clientID: "test-client-id",
suppressDiagnosticLogs: true,
tokenTypeCache: NewTestCache(),
}
testCases := []struct {
jwt *JWT
name string
token string
description string
expectedID bool
}{
{
name: "ID token with nonce",
jwt: &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"nonce": "test-nonce",
"aud": "test-client-id",
},
},
token: "test-token-with-nonce",
expectedID: true,
description: "Should detect ID token via nonce claim",
},
{
name: "RFC 9068 access token",
jwt: &JWT{
Header: map[string]interface{}{
"alg": "RS256",
"typ": "at+jwt",
},
Claims: map[string]interface{}{
"scope": "openid profile",
},
},
token: "test-access-token-rfc9068",
expectedID: false,
description: "Should detect access token via typ=at+jwt header",
},
{
name: "Token with token_use=id",
jwt: &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"token_use": "id",
"aud": "test-client-id",
},
},
token: "test-token-use-id",
expectedID: true,
description: "Should detect ID token via token_use claim",
},
{
name: "Token with token_use=access",
jwt: &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"token_use": "access",
"scope": "read write",
},
},
token: "test-token-use-access",
expectedID: false,
description: "Should detect access token via token_use claim",
},
{
name: "Access token with scope",
jwt: &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"scope": "openid profile email",
"aud": "some-api-audience",
},
},
token: "test-access-token-with-scope",
expectedID: false,
description: "Should detect access token via scope claim",
},
{
name: "ID token with client_id audience",
jwt: &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"aud": "test-client-id",
"sub": "user123",
},
},
token: "test-id-token-client-aud",
expectedID: true,
description: "Should detect ID token via audience matching client_id",
},
{
name: "Default to access token",
jwt: &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"aud": "different-audience",
"sub": "user123",
},
},
token: "test-default-access-token",
expectedID: false,
description: "Should default to access token when no clear indicators",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := tr.detectTokenType(tc.jwt, tc.token)
if result != tc.expectedID {
t.Errorf("%s: expected isIDToken=%v, got %v", tc.description, tc.expectedID, result)
}
result2 := tr.detectTokenType(tc.jwt, tc.token)
if result2 != tc.expectedID {
t.Errorf("%s (cached): expected isIDToken=%v, got %v", tc.description, tc.expectedID, result2)
}
})
}
}
func TestDetectTokenTypeCaching(t *testing.T) {
cache := NewTestCache()
tr := &TraefikOidc{
clientID: "test-client-id",
suppressDiagnosticLogs: true,
tokenTypeCache: cache,
}
jwt := &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"nonce": "test-nonce",
},
}
token := "test-token-for-caching-with-enough-characters-for-key"
// The cache key is a SHA-256 hash of the full token (collision-resistant).
sum := sha256.Sum256([]byte(token))
cacheKey := hex.EncodeToString(sum[:])
result := tr.detectTokenType(jwt, token)
if !result {
t.Error("Expected ID token detection via nonce")
}
if cached, found := cache.Get(cacheKey); !found {
t.Error("Expected token type to be cached")
} else if cachedBool, ok := cached.(bool); !ok || !cachedBool {
t.Error("Expected cached value to be true (ID token)")
}
jwt.Claims = map[string]interface{}{
"scope": "openid profile",
}
result2 := tr.detectTokenType(jwt, token)
if !result2 {
t.Error("Expected cached ID token result, ignoring modified JWT")
}
}
// =============================================================================
// CONSOLIDATED TOKEN TESTS
// =============================================================================
func TestTokenTypes(t *testing.T) {
t.Run("TokenTypeDistinction", func(t *testing.T) {
type templateData struct {
Claims map[string]interface{}
AccessToken string
IDToken string
RefreshToken string
}
testData := templateData{
AccessToken: "test-access-token-abc123",
IDToken: "test-id-token-xyz789",
RefreshToken: "test-refresh-token",
Claims: map[string]interface{}{
"sub": "test-subject",
"email": "user@example.com",
},
}
tests := []struct {
name string
templateText string
expectedValue string
}{
{
name: "Access Token Only",
templateText: "Bearer {{.AccessToken}}",
expectedValue: "Bearer test-access-token-abc123",
},
{
name: "ID Token Only",
templateText: "ID: {{.IDToken}}",
expectedValue: "ID: test-id-token-xyz789",
},
{
name: "Both Tokens",
templateText: "Access: {{.AccessToken}} ID: {{.IDToken}}",
expectedValue: "Access: test-access-token-abc123 ID: test-id-token-xyz789",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := template.New("test").Parse(tc.templateText)
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, testData)
if err != nil {
t.Fatalf("Failed to execute template: %v", err)
}
result := buf.String()
if result != tc.expectedValue {
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
}
})
}
})
t.Run("TokenTypeIntegration", func(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(3000000000),
"sub": "id-token-subject",
"email": "id@example.com",
"nonce": "test-nonce",
"token_type": "id",
})
if err != nil {
t.Fatalf("Failed to create ID token: %v", err)
}
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(3000000000),
"sub": "access-token-subject",
"email": "access@example.com",
"scope": "openid email profile",
"token_type": "access",
})
if err != nil {
t.Fatalf("Failed to create access token: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com", nil)
session, err := ts.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
session.SetIDToken(idToken)
session.SetAccessToken(accessToken)
retrievedID := session.GetIDToken()
retrievedAccess := session.GetAccessToken()
if retrievedID != idToken {
t.Errorf("ID token mismatch: expected %q, got %q", idToken, retrievedID)
}
if retrievedAccess != accessToken {
t.Errorf("Access token mismatch: expected %q, got %q", accessToken, retrievedAccess)
}
})
}
func TestTokenCorruption(t *testing.T) {
t.Run("TokenCorruptionScenario", func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
testTokens := NewTestTokens()
validJWT := testTokens.CreateLargeValidJWT(100)
tests := []struct {
corruptionScenario func(*SessionData)
name string
tokenSize int
iterations int
expectConsistent bool
}{
{
name: "Small token - multiple retrievals",
tokenSize: len(validJWT),
iterations: 10,
expectConsistent: true,
},
{
name: "Large chunked token - multiple retrievals",
tokenSize: 5000,
iterations: 10,
expectConsistent: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
token := createTokenOfSize(validJWT, tt.tokenSize)
session.SetAccessToken(token)
var retrievedTokens []string
for i := 0; i < tt.iterations; i++ {
retrieved := session.GetAccessToken()
retrievedTokens = append(retrievedTokens, retrieved)
if tt.expectConsistent && retrieved != token {
t.Errorf("Iteration %d: Token changed unexpectedly", i)
}
}
if tt.expectConsistent {
for i, retrievedToken := range retrievedTokens {
if retrievedToken != token {
t.Errorf("Iteration %d: Token mismatch", i)
}
}
}
})
}
})
t.Run("Base64CorruptionHandling", func(t *testing.T) {
tests := []struct {
name string
input string
expectError bool
}{
{"Valid base64", "eyJhbGciOiJSUzI1NiJ9", false},
{"Invalid characters", "eyJ!@#$%^&*()", true},
{"Missing padding", "eyJhbGc", false},
{"Empty string", "", false},
{"Spaces in base64", "eyJ hbG ciOi JSU zI1 NiJ9", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(tt.input))
hasError := err != nil
if hasError != tt.expectError {
t.Errorf("Expected error=%v, got error=%v (err: %v)", tt.expectError, hasError, err)
}
})
}
})
}
func TestTokenResilience(t *testing.T) {
t.Run("ConcurrentTokenAccess", func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
req := httptest.NewRequest("GET", "http://example.com", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
testToken := "test-token-" + generateRandomString(100)
session.SetAccessToken(testToken)
var wg sync.WaitGroup
errors := make(chan error, 100)
successCount := int32(0)
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
retrieved := session.GetAccessToken()
if retrieved == testToken {
atomic.AddInt32(&successCount, 1)
} else {
errors <- fmt.Errorf("token mismatch: expected %q, got %q", testToken, retrieved)
}
}()
}
wg.Wait()
close(errors)
for err := range errors {
t.Error(err)
}
if successCount != 100 {
t.Errorf("Expected 100 successful retrievals, got %d", successCount)
}
})
t.Run("TokenSizeHandling", func(t *testing.T) {
logger := NewLogger("debug")
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
sizes := []int{
100,
1000,
4000,
5000,
10000,
}
for _, size := range sizes {
t.Run(fmt.Sprintf("Size_%d", size), func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer session.ReturnToPool()
token := createTokenOfSize(ValidAccessToken, size)
session.SetAccessToken(token)
retrieved := session.GetAccessToken()
if size > 15000 && retrieved == "" {
t.Logf("Token size %d exceeds chunk limits (expected)", size)
} else if retrieved != token {
t.Errorf("Token mismatch for size %d", size)
}
})
}
})
t.Run("RateLimitedTokenRefresh", func(t *testing.T) {
limiter := rate.NewLimiter(rate.Limit(10), 1)
var wg sync.WaitGroup
successCount := int32(0)
deniedCount := int32(0)
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if limiter.Allow() {
atomic.AddInt32(&successCount, 1)
} else {
atomic.AddInt32(&deniedCount, 1)
}
}()
time.Sleep(10 * time.Millisecond)
}
wg.Wait()
t.Logf("Allowed: %d, Denied: %d", successCount, deniedCount)
if successCount == 0 {
t.Error("No requests were allowed")
}
if successCount == 50 {
t.Error("All requests were allowed, rate limiting not working")
}
})
}
func TestTokenValidation(t *testing.T) {
t.Run("JWTStructureValidation", func(t *testing.T) {
tests := []struct {
name string
token string
expectValid bool
}{
{
name: "Valid JWT structure",
token: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature",
expectValid: true,
},
{
name: "Missing signature",
token: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0",
expectValid: false,
},
{
name: "Missing payload",
token: "eyJhbGciOiJSUzI1NiJ9..signature",
expectValid: true,
},
{
name: "Only header",
token: "eyJhbGciOiJSUzI1NiJ9",
expectValid: false,
},
{
name: "Too many parts",
token: "header.payload.signature.extra",
expectValid: false,
},
{
name: "Empty token",
token: "",
expectValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parts := strings.Split(tt.token, ".")
isValid := len(parts) == 3
if isValid != tt.expectValid {
t.Errorf("Expected valid=%v, got %v", tt.expectValid, isValid)
}
})
}
})
t.Run("TokenExpiryValidation", func(t *testing.T) {
now := time.Now()
tests := []struct {
exp time.Time
name string
expectValid bool
}{
{name: "Future expiry", exp: now.Add(time.Hour), expectValid: true},
{name: "Just expired", exp: now.Add(-time.Second), expectValid: false},
{name: "Long expired", exp: now.Add(-24 * time.Hour), expectValid: false},
{name: "Far future", exp: now.Add(365 * 24 * time.Hour), expectValid: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.exp.After(now)
if isValid != tt.expectValid {
t.Errorf("Expected valid=%v, got %v", tt.expectValid, isValid)
}
})
}
})
}
func TestTokenChunking(t *testing.T) {
t.Run("ChunkSplitting", func(t *testing.T) {
chunkSize := 4000
tests := []struct {
name string
tokenSize int
expectedChunks int
}{
{"Small token", 100, 1},
{"Just under chunk size", 3999, 1},
{"Exactly chunk size", 4000, 1},
{"Just over chunk size", 4100, 2},
{"Multiple chunks", 10000, 3},
{"Large token", 50000, 13},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token := generateRandomString(tt.tokenSize)
chunks := (len(token) + chunkSize - 1) / chunkSize
if chunks != tt.expectedChunks {
t.Errorf("Expected %d chunks, got %d", tt.expectedChunks, chunks)
}
})
}
})
t.Run("ChunkReassembly", func(t *testing.T) {
originalToken := generateRandomString(10000)
chunkSize := 4000
var chunks []string
for i := 0; i < len(originalToken); i += chunkSize {
end := i + chunkSize
if end > len(originalToken) {
end = len(originalToken)
}
chunks = append(chunks, originalToken[i:end])
}
var reassembled strings.Builder
for _, chunk := range chunks {
reassembled.WriteString(chunk)
}
if reassembled.String() != originalToken {
t.Error("Token reassembly failed")
}
})
}
func TestTokenCompression(t *testing.T) {
t.Run("CompressionEfficiency", func(t *testing.T) {
repetitiveToken := strings.Repeat("AAAA", 1000)
var compressed bytes.Buffer
gz := gzip.NewWriter(&compressed)
_, err := gz.Write([]byte(repetitiveToken))
if err != nil {
t.Fatalf("Compression failed: %v", err)
}
gz.Close()
compressionRatio := float64(len(repetitiveToken)) / float64(compressed.Len())
t.Logf("Compression ratio: %.2fx (original: %d, compressed: %d)",
compressionRatio, len(repetitiveToken), compressed.Len())
if compressionRatio < 10 {
t.Error("Expected better compression for repetitive data")
}
})
t.Run("CompressionDecompression", func(t *testing.T) {
tokens := []string{
generateRandomString(100),
generateRandomString(1000),
generateRandomString(10000),
strings.Repeat("A", 5000),
}
for i, token := range tokens {
t.Run(fmt.Sprintf("Token_%d", i), func(t *testing.T) {
var compressed bytes.Buffer
gz := gzip.NewWriter(&compressed)
_, err := gz.Write([]byte(token))
if err != nil {
t.Fatalf("Compression failed: %v", err)
}
gz.Close()
reader, err := gzip.NewReader(&compressed)
if err != nil {
t.Fatalf("Failed to create decompressor: %v", err)
}
var decompressed bytes.Buffer
_, err = decompressed.ReadFrom(reader)
if err != nil {
t.Fatalf("Decompression failed: %v", err)
}
reader.Close()
if decompressed.String() != token {
t.Error("Token changed after compression/decompression")
}
})
}
})
}
func TestAjaxTokenExpiry(t *testing.T) {
t.Run("AjaxExpiryDetection", func(t *testing.T) {
tests := []struct {
name string
isAjax bool
tokenExpired bool
expectedStatus int
}{
{"Regular request, valid token", false, false, http.StatusOK},
{"Regular request, expired token", false, true, http.StatusFound},
{"Ajax request, valid token", true, false, http.StatusOK},
{"Ajax request, expired token", true, true, http.StatusUnauthorized},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
if tt.isAjax {
req.Header.Set("X-Requested-With", "XMLHttpRequest")
}
w := httptest.NewRecorder()
if tt.tokenExpired {
if tt.isAjax {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "token_expired", "message": "Your session has expired"}`))
} else {
w.WriteHeader(http.StatusFound)
w.Header().Set("Location", "/auth/login")
}
} else {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Success"))
}
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
if tt.isAjax && tt.tokenExpired {
body := w.Body.String()
if !strings.Contains(body, "token_expired") {
t.Error("Expected token_expired error in response")
}
}
})
}
})
}
func TestTestTokens_CreateValidJWT(t *testing.T) {
tokens := NewTestTokens()
jwt := tokens.CreateValidJWT()
parts := strings.Split(jwt, ".")
if len(parts) != 3 {
t.Errorf("Expected 3 JWT parts, got %d", len(parts))
}
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Fatalf("Failed to decode header: %v", err)
}
var header map[string]interface{}
if err := json.Unmarshal(headerJSON, &header); err != nil {
t.Fatalf("Failed to parse header: %v", err)
}
if header["alg"] != "RS256" {
t.Errorf("Expected RS256 algorithm, got %v", header["alg"])
}
}
func TestTestTokens_CreateLargeValidJWT(t *testing.T) {
tokens := NewTestTokens()
sizes := []int{10, 100, 1000}
for _, size := range sizes {
t.Run(fmt.Sprintf("Size_%d", size), func(t *testing.T) {
jwt := tokens.CreateLargeValidJWT(size)
parts := strings.Split(jwt, ".")
if len(parts) != 3 {
t.Errorf("Expected 3 JWT parts, got %d", len(parts))
}
minExpectedSize := size + 200
if len(jwt) < minExpectedSize {
t.Errorf("JWT seems too small for requested claim size: got %d, expected at least %d", len(jwt), minExpectedSize)
}
})
}
}
func TestTestTokens_CreateExpiredJWT(t *testing.T) {
tokens := NewTestTokens()
jwt := tokens.CreateExpiredJWT()
parts := strings.Split(jwt, ".")
if len(parts) != 3 {
t.Errorf("Expected 3 JWT parts, got %d", len(parts))
}
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatalf("Failed to decode payload: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
t.Fatalf("Failed to parse payload: %v", err)
}
exp, ok := payload["exp"].(float64)
if !ok {
t.Fatal("Expected exp claim in payload")
}
if exp >= float64(time.Now().Unix()) {
t.Error("Token should be expired")
}
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
// Mock implementations for testing
type MockJWTVerifier struct {
valid bool
}
func (v *MockJWTVerifier) Verify(token string) error {
if !v.valid {
return fmt.Errorf("invalid token")
}
return nil
}
func equalSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i, v := range a {
if v != b[i] {
return false
}
}
return true
}
func createTokenOfSize(baseToken string, targetSize int) string {
if targetSize > 1000 {
testTokens := NewTestTokens()
claimSize := targetSize - 230
if claimSize < 0 {
claimSize = 10
}
return testTokens.CreateLargeValidJWT(claimSize)
}
return baseToken
}