mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
546ceb949c
* 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.
1668 lines
54 KiB
Go
1668 lines
54 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestBackchannelLogoutBasic tests the basic backchannel logout flow
|
|
func TestBackchannelLogoutBasic(t *testing.T) {
|
|
// Create a mock cache for session invalidation
|
|
mockCache := &mockCacheInterface{}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
body string
|
|
contentType string
|
|
expectedStatus int
|
|
}{
|
|
{
|
|
name: "GET method not allowed",
|
|
method: http.MethodGet,
|
|
body: "",
|
|
contentType: "",
|
|
expectedStatus: http.StatusMethodNotAllowed,
|
|
},
|
|
{
|
|
name: "Missing logout_token",
|
|
method: http.MethodPost,
|
|
body: "",
|
|
contentType: "application/x-www-form-urlencoded",
|
|
expectedStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "Invalid logout_token format",
|
|
method: http.MethodPost,
|
|
body: "logout_token=not-a-valid-jwt",
|
|
contentType: "application/x-www-form-urlencoded",
|
|
expectedStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(tc.method, "/backchannel-logout", strings.NewReader(tc.body))
|
|
if tc.contentType != "" {
|
|
req.Header.Set("Content-Type", tc.contentType)
|
|
}
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != tc.expectedStatus {
|
|
t.Errorf("Expected status %d, got %d", tc.expectedStatus, rw.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFrontchannelLogoutBasic tests the basic front-channel logout flow
|
|
func TestFrontchannelLogoutBasic(t *testing.T) {
|
|
// Create a mock cache for session invalidation
|
|
mockCache := &mockCacheInterface{}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableFrontchannelLogout: true,
|
|
frontchannelLogoutPath: "/frontchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
queryParams map[string]string
|
|
expectedStatus int
|
|
}{
|
|
{
|
|
name: "POST method not allowed",
|
|
method: http.MethodPost,
|
|
queryParams: map[string]string{},
|
|
expectedStatus: http.StatusMethodNotAllowed,
|
|
},
|
|
{
|
|
name: "Missing sid parameter",
|
|
method: http.MethodGet,
|
|
queryParams: map[string]string{"iss": "https://provider.example.com"},
|
|
expectedStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "Invalid issuer",
|
|
method: http.MethodGet,
|
|
queryParams: map[string]string{"iss": "https://wrong-issuer.com", "sid": "session123"},
|
|
expectedStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "Valid front-channel logout",
|
|
method: http.MethodGet,
|
|
queryParams: map[string]string{"iss": "https://provider.example.com", "sid": "session123"},
|
|
expectedStatus: http.StatusOK,
|
|
},
|
|
{
|
|
// Front-channel logout MUST carry a matching issuer. A request
|
|
// omitting iss is rejected so an unauthenticated attacker cannot
|
|
// force-logout a session whose sid is known by simply leaving iss
|
|
// out (audit rank 30).
|
|
name: "Missing issuer is rejected",
|
|
method: http.MethodGet,
|
|
queryParams: map[string]string{"sid": "session456"},
|
|
expectedStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
urlStr := "/frontchannel-logout"
|
|
if len(tc.queryParams) > 0 {
|
|
params := url.Values{}
|
|
for k, v := range tc.queryParams {
|
|
params.Set(k, v)
|
|
}
|
|
urlStr += "?" + params.Encode()
|
|
}
|
|
|
|
req := httptest.NewRequest(tc.method, urlStr, nil)
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleFrontchannelLogout(rw, req)
|
|
|
|
if rw.Code != tc.expectedStatus {
|
|
t.Errorf("Expected status %d, got %d", tc.expectedStatus, rw.Code)
|
|
}
|
|
|
|
// For successful logout, verify response headers
|
|
if tc.expectedStatus == http.StatusOK {
|
|
// Should not have X-Frame-Options (to allow iframe embedding)
|
|
if rw.Header().Get("X-Frame-Options") != "" {
|
|
t.Error("Expected X-Frame-Options to be removed for front-channel logout")
|
|
}
|
|
// Should have HTML content type
|
|
contentType := rw.Header().Get("Content-Type")
|
|
if !strings.Contains(contentType, "text/html") {
|
|
t.Errorf("Expected HTML content type, got %s", contentType)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSessionInvalidation tests session invalidation storage and retrieval
|
|
func TestSessionInvalidation(t *testing.T) {
|
|
mockCache := &mockCacheInterface{
|
|
data: make(map[string]interface{}),
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
sessionInvalidationCache: mockCache,
|
|
}
|
|
|
|
// Test invalidating by session ID
|
|
err := oidc.invalidateSession("session123", "")
|
|
if err != nil {
|
|
t.Fatalf("Failed to invalidate session by sid: %v", err)
|
|
}
|
|
|
|
// Verify the session was invalidated
|
|
key := oidc.buildSessionInvalidationKey("sid", "session123")
|
|
if _, found := mockCache.data[key]; !found {
|
|
t.Error("Session invalidation by sid was not stored")
|
|
}
|
|
|
|
// Test invalidating by subject
|
|
err = oidc.invalidateSession("", "user@example.com")
|
|
if err != nil {
|
|
t.Fatalf("Failed to invalidate session by sub: %v", err)
|
|
}
|
|
|
|
// Verify the subject was invalidated
|
|
key = oidc.buildSessionInvalidationKey("sub", "user@example.com")
|
|
if _, found := mockCache.data[key]; !found {
|
|
t.Error("Session invalidation by sub was not stored")
|
|
}
|
|
}
|
|
|
|
// TestIsSessionInvalidated tests checking if a session is invalidated
|
|
func TestIsSessionInvalidated(t *testing.T) {
|
|
mockCache := &mockCacheInterface{
|
|
data: make(map[string]interface{}),
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
sessionInvalidationCache: mockCache,
|
|
}
|
|
|
|
// Session created now
|
|
sessionCreatedAt := time.Now()
|
|
|
|
// Initially, session should not be invalidated
|
|
if oidc.isSessionInvalidated("session123", "user@example.com", sessionCreatedAt) {
|
|
t.Error("Session should not be invalidated initially")
|
|
}
|
|
|
|
// Invalidate the session
|
|
_ = oidc.invalidateSession("session123", "")
|
|
|
|
// Now session should be invalidated
|
|
if !oidc.isSessionInvalidated("session123", "", sessionCreatedAt) {
|
|
t.Error("Session should be invalidated after invalidateSession call")
|
|
}
|
|
|
|
// Session created after invalidation should not be affected
|
|
futureSession := time.Now().Add(1 * time.Hour)
|
|
if oidc.isSessionInvalidated("session123", "", futureSession) {
|
|
t.Error("Session created after invalidation should not be affected")
|
|
}
|
|
}
|
|
|
|
// TestLogoutTokenValidation tests logout token claim validation
|
|
func TestLogoutTokenValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
claims *LogoutTokenClaims
|
|
expectError bool
|
|
errorMsg string
|
|
}{
|
|
{
|
|
name: "Missing events claim",
|
|
claims: &LogoutTokenClaims{
|
|
Issuer: "https://provider.example.com",
|
|
Audience: "test-client",
|
|
IssuedAt: time.Now().Unix(),
|
|
SessionID: "session123",
|
|
},
|
|
expectError: true,
|
|
errorMsg: "missing events claim",
|
|
},
|
|
{
|
|
name: "Missing both sid and sub",
|
|
claims: &LogoutTokenClaims{
|
|
Issuer: "https://provider.example.com",
|
|
Audience: "test-client",
|
|
IssuedAt: time.Now().Unix(),
|
|
Events: map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
},
|
|
expectError: true,
|
|
errorMsg: "must contain either sid or sub",
|
|
},
|
|
{
|
|
name: "Nonce present (not allowed)",
|
|
claims: &LogoutTokenClaims{
|
|
Issuer: "https://provider.example.com",
|
|
Audience: "test-client",
|
|
IssuedAt: time.Now().Unix(),
|
|
SessionID: "session123",
|
|
Nonce: "should-not-be-here",
|
|
Events: map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
},
|
|
expectError: true,
|
|
errorMsg: "nonce claim must not be present",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// We can't directly test validateLogoutToken without a properly signed JWT,
|
|
// but we can verify the validation logic through the claims struct
|
|
if tc.claims.Events == nil && tc.expectError && strings.Contains(tc.errorMsg, "events") {
|
|
// Events validation would fail
|
|
}
|
|
if tc.claims.SessionID == "" && tc.claims.Subject == "" && tc.expectError && strings.Contains(tc.errorMsg, "sid or sub") {
|
|
// sid/sub validation would fail
|
|
}
|
|
if tc.claims.Nonce != "" && tc.expectError && strings.Contains(tc.errorMsg, "nonce") {
|
|
// nonce validation would fail
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLogoutTokenAudienceValidation tests audience validation for logout tokens
|
|
func TestLogoutTokenAudienceValidation(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
clientID: "test-client",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
audience interface{}
|
|
valid bool
|
|
}{
|
|
{
|
|
name: "String audience matching client ID",
|
|
audience: "test-client",
|
|
valid: true,
|
|
},
|
|
{
|
|
name: "String audience not matching",
|
|
audience: "other-client",
|
|
valid: false,
|
|
},
|
|
{
|
|
name: "Array audience containing client ID",
|
|
audience: []interface{}{"other-client", "test-client"},
|
|
valid: true,
|
|
},
|
|
{
|
|
name: "Array audience not containing client ID",
|
|
audience: []interface{}{"other-client", "another-client"},
|
|
valid: false,
|
|
},
|
|
{
|
|
name: "String array audience containing client ID",
|
|
audience: []string{"other-client", "test-client"},
|
|
valid: true,
|
|
},
|
|
{
|
|
name: "Nil audience",
|
|
audience: nil,
|
|
valid: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := oidc.validateLogoutTokenAudience(tc.audience)
|
|
if result != tc.valid {
|
|
t.Errorf("Expected %v, got %v", tc.valid, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestExtractSessionInfo tests extraction of session info from ID tokens
|
|
func TestExtractSessionInfo(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
}
|
|
|
|
// Test with empty token
|
|
sid, sub, createdAt := oidc.extractSessionInfo("")
|
|
if sid != "" || sub != "" || !createdAt.IsZero() {
|
|
t.Error("Empty token should return empty values")
|
|
}
|
|
|
|
// Test with invalid token
|
|
sid, sub, createdAt = oidc.extractSessionInfo("not-a-valid-jwt")
|
|
if sid != "" || sub != "" || !createdAt.IsZero() {
|
|
t.Error("Invalid token should return empty values")
|
|
}
|
|
|
|
// Create a simple unsigned JWT for testing (header.claims.signature)
|
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
|
now := time.Now().Unix()
|
|
claimsJSON := fmt.Sprintf(`{"sid":"test-session-id","sub":"test-subject","iat":%d}`, now)
|
|
claims := base64.RawURLEncoding.EncodeToString([]byte(claimsJSON))
|
|
testToken := header + "." + claims + "."
|
|
|
|
sid, sub, createdAt = oidc.extractSessionInfo(testToken)
|
|
if sid != "test-session-id" {
|
|
t.Errorf("Expected sid 'test-session-id', got '%s'", sid)
|
|
}
|
|
if sub != "test-subject" {
|
|
t.Errorf("Expected sub 'test-subject', got '%s'", sub)
|
|
}
|
|
if createdAt.Unix() != now {
|
|
t.Errorf("Expected createdAt %d, got %d", now, createdAt.Unix())
|
|
}
|
|
}
|
|
|
|
// TestMiddlewareBackchannelLogoutRouting tests that backchannel logout requests are routed correctly
|
|
func TestMiddlewareBackchannelLogoutRouting(t *testing.T) {
|
|
mockCache := &mockCacheInterface{
|
|
data: make(map[string]interface{}),
|
|
}
|
|
|
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("next handler called"))
|
|
})
|
|
|
|
oidc := &TraefikOidc{
|
|
next: nextHandler,
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
initComplete: make(chan struct{}),
|
|
firstRequestStarted: 1,
|
|
metadataRefreshStartedAtomic: 1,
|
|
logoutURLPath: "/logout",
|
|
}
|
|
close(oidc.initComplete)
|
|
|
|
// Request to backchannel logout path should be handled
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout", nil)
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
// Should return 400 (bad request) because no logout_token provided
|
|
// but importantly should NOT call next handler
|
|
if rw.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for missing logout_token, got %d", rw.Code)
|
|
}
|
|
if strings.Contains(rw.Body.String(), "next handler called") {
|
|
t.Error("Backchannel logout should not call next handler")
|
|
}
|
|
}
|
|
|
|
// TestMiddlewareFrontchannelLogoutRouting tests that front-channel logout requests are routed correctly
|
|
func TestMiddlewareFrontchannelLogoutRouting(t *testing.T) {
|
|
mockCache := &mockCacheInterface{
|
|
data: make(map[string]interface{}),
|
|
}
|
|
|
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("next handler called"))
|
|
})
|
|
|
|
oidc := &TraefikOidc{
|
|
next: nextHandler,
|
|
logger: NewLogger("debug"),
|
|
enableFrontchannelLogout: true,
|
|
frontchannelLogoutPath: "/frontchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
initComplete: make(chan struct{}),
|
|
firstRequestStarted: 1,
|
|
metadataRefreshStartedAtomic: 1,
|
|
logoutURLPath: "/logout",
|
|
}
|
|
close(oidc.initComplete)
|
|
|
|
// Request to front-channel logout path with valid sid + matching issuer
|
|
// should succeed. The issuer is now required (audit rank 30), so supply it.
|
|
req := httptest.NewRequest(http.MethodGet, "/frontchannel-logout?sid=test-session&iss=https://provider.example.com", nil)
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.ServeHTTP(rw, req)
|
|
|
|
// Should return 200 OK
|
|
if rw.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d", rw.Code)
|
|
}
|
|
if strings.Contains(rw.Body.String(), "next handler called") {
|
|
t.Error("Front-channel logout should not call next handler")
|
|
}
|
|
}
|
|
|
|
// TestNormalizeLogoutPath tests the path normalization function
|
|
func TestNormalizeLogoutPath(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"", ""},
|
|
{"/logout", "/logout"},
|
|
{"logout", "/logout"},
|
|
{"/backchannel-logout", "/backchannel-logout"},
|
|
{"backchannel-logout", "/backchannel-logout"},
|
|
// Security: prevent open redirect via //
|
|
{"//evil.com", "/evil.com"},
|
|
{"//evil.com/path", "/evil.com/path"},
|
|
// Security: prevent open redirect via /\
|
|
{"/\\evil.com", "/evil.com"},
|
|
{"/\\evil.com/path", "/evil.com/path"},
|
|
// Security: multiple leading slashes
|
|
{"///example.com", "/example.com"},
|
|
// Security: mixed slashes
|
|
{"//\\example.com", "/example.com"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.input, func(t *testing.T) {
|
|
result := normalizeLogoutPath(tc.input)
|
|
if result != tc.expected {
|
|
t.Errorf("normalizeLogoutPath(%q) = %q, expected %q", tc.input, result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// mockCacheInterface implements CacheInterface for testing
|
|
type mockCacheInterface struct {
|
|
mu sync.Mutex
|
|
data map[string]interface{}
|
|
}
|
|
|
|
func (m *mockCacheInterface) Set(key string, value interface{}, ttl time.Duration) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.data == nil {
|
|
m.data = make(map[string]interface{})
|
|
}
|
|
m.data[key] = value
|
|
}
|
|
|
|
func (m *mockCacheInterface) Get(key string) (interface{}, bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.data == nil {
|
|
return nil, false
|
|
}
|
|
val, found := m.data[key]
|
|
return val, found
|
|
}
|
|
|
|
func (m *mockCacheInterface) Delete(key string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.data != nil {
|
|
delete(m.data, key)
|
|
}
|
|
}
|
|
|
|
func (m *mockCacheInterface) SetMaxSize(size int) {}
|
|
func (m *mockCacheInterface) Size() int {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return len(m.data)
|
|
}
|
|
func (m *mockCacheInterface) Clear() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.data = make(map[string]interface{})
|
|
}
|
|
func (m *mockCacheInterface) Cleanup() {}
|
|
func (m *mockCacheInterface) Close() {}
|
|
func (m *mockCacheInterface) GetStats() map[string]interface{} {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return map[string]interface{}{"size": len(m.data)}
|
|
}
|
|
|
|
// TestBackchannelLogoutWithValidToken tests backchannel logout with a properly formatted (but unsigned) token
|
|
func TestBackchannelLogoutWithValidToken(t *testing.T) {
|
|
// This test verifies the token parsing and validation logic
|
|
// Note: In production, the token would need to be properly signed by the IdP
|
|
mockCache := &mockCacheInterface{
|
|
data: make(map[string]interface{}),
|
|
}
|
|
|
|
// Create mock JWK cache that returns keys
|
|
mockJWKCache := &mockJWKCacheForLogout{}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
}
|
|
|
|
// Create a minimal logout token structure (this won't pass signature verification
|
|
// but tests the parsing logic)
|
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"ES256","typ":"logout+jwt"}`))
|
|
now := time.Now().Unix()
|
|
claimsJSON := fmt.Sprintf(`{
|
|
"iss":"https://provider.example.com",
|
|
"aud":"test-client",
|
|
"iat":%d,
|
|
"jti":"unique-id-123",
|
|
"events":{"http://schemas.openid.net/event/backchannel-logout":{}},
|
|
"sid":"session-to-logout"
|
|
}`, now)
|
|
claims := base64.RawURLEncoding.EncodeToString([]byte(claimsJSON))
|
|
logoutToken := header + "." + claims + ".fake-signature"
|
|
|
|
// This should fail because of invalid signature, but we can verify
|
|
// the token parsing works up to signature verification
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
// Should fail with 400 due to signature verification failure
|
|
if rw.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// mockJWKCacheForLogout implements JWKCacheInterface for testing
|
|
type mockJWKCacheForLogout struct{}
|
|
|
|
func (m *mockJWKCacheForLogout) GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
|
|
// Generate a test ECDSA key pair
|
|
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
|
|
// Convert public key to JWK format
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
return &JWKSet{
|
|
Keys: []JWK{
|
|
{
|
|
Kty: "EC",
|
|
Crv: "P-256",
|
|
X: x,
|
|
Y: y,
|
|
Kid: "test-key-1",
|
|
Use: "sig",
|
|
Alg: "ES256",
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockJWKCacheForLogout) GetPublicKey(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
|
|
jwks, err := m.GetJWKS(ctx, jwksURL, httpClient)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range jwks.Keys {
|
|
k := &jwks.Keys[i]
|
|
if k.Kid != kid {
|
|
continue
|
|
}
|
|
switch k.Kty {
|
|
case "RSA":
|
|
return k.ToRSAPublicKey()
|
|
case "EC":
|
|
return k.ToECDSAPublicKey()
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
|
|
}
|
|
|
|
func (m *mockJWKCacheForLogout) Clear() {}
|
|
func (m *mockJWKCacheForLogout) Cleanup() {}
|
|
func (m *mockJWKCacheForLogout) Close() {}
|
|
|
|
// TestBackchannelLogoutIntegration tests the full backchannel logout flow with a properly signed token
|
|
func TestBackchannelLogoutIntegration(t *testing.T) {
|
|
// Generate ECDSA key pair for signing
|
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate key: %v", err)
|
|
}
|
|
|
|
mockCache := &mockCacheInterface{
|
|
data: make(map[string]interface{}),
|
|
}
|
|
|
|
// Create JWK cache that returns our test key
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{
|
|
{
|
|
Kty: "EC",
|
|
Crv: "P-256",
|
|
X: x,
|
|
Y: y,
|
|
Kid: "test-key-1",
|
|
Use: "sig",
|
|
Alg: "ES256",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
// Create and sign a valid logout token
|
|
header := map[string]interface{}{
|
|
"alg": "ES256",
|
|
"typ": "logout+jwt",
|
|
"kid": "test-key-1",
|
|
}
|
|
headerJSON, _ := json.Marshal(header)
|
|
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
|
|
|
now := time.Now().Unix()
|
|
claims := map[string]interface{}{
|
|
"iss": "https://provider.example.com",
|
|
"aud": "test-client",
|
|
"iat": now,
|
|
"jti": "unique-id-123",
|
|
"events": map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
"sid": "session-to-logout",
|
|
}
|
|
claimsJSON, _ := json.Marshal(claims)
|
|
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
|
|
|
// Sign the token
|
|
signingInput := headerB64 + "." + claimsB64
|
|
hash := sha256.Sum256([]byte(signingInput))
|
|
r, s, err := ecdsa.Sign(rand.Reader, privateKey, hash[:])
|
|
if err != nil {
|
|
t.Fatalf("Failed to sign token: %v", err)
|
|
}
|
|
|
|
// Convert signature to fixed-size format (32 bytes each for P-256)
|
|
rBytes := r.Bytes()
|
|
sBytes := s.Bytes()
|
|
sigBytes := make([]byte, 64)
|
|
copy(sigBytes[32-len(rBytes):32], rBytes)
|
|
copy(sigBytes[64-len(sBytes):], sBytes)
|
|
signatureB64 := base64.RawURLEncoding.EncodeToString(sigBytes)
|
|
|
|
logoutToken := headerB64 + "." + claimsB64 + "." + signatureB64
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
// Should succeed with 200 OK
|
|
if rw.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d: %s", rw.Code, rw.Body.String())
|
|
}
|
|
|
|
// Verify session was invalidated
|
|
key := oidc.buildSessionInvalidationKey("sid", "session-to-logout")
|
|
if _, found := mockCache.data[key]; !found {
|
|
t.Error("Session should have been invalidated")
|
|
}
|
|
}
|
|
|
|
// staticJWKCache returns a static JWKS for testing
|
|
type staticJWKCache struct {
|
|
jwks *JWKSet
|
|
}
|
|
|
|
func (s *staticJWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
|
|
return s.jwks, nil
|
|
}
|
|
|
|
func (s *staticJWKCache) GetPublicKey(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
|
|
for i := range s.jwks.Keys {
|
|
k := &s.jwks.Keys[i]
|
|
if k.Kid != kid {
|
|
continue
|
|
}
|
|
switch k.Kty {
|
|
case "RSA":
|
|
return k.ToRSAPublicKey()
|
|
case "EC":
|
|
return k.ToECDSAPublicKey()
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
|
|
}
|
|
|
|
func (s *staticJWKCache) Clear() {}
|
|
func (s *staticJWKCache) Cleanup() {}
|
|
func (s *staticJWKCache) Close() {}
|
|
|
|
// TestDetermineLogoutPath tests the logout path determination function
|
|
func TestDetermineLogoutPath(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
logoutURLPath: "/logout",
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
frontchannelLogoutPath: "/frontchannel-logout",
|
|
}
|
|
|
|
tests := []struct {
|
|
path string
|
|
expected string
|
|
}{
|
|
{"/logout", "rp"},
|
|
{"/backchannel-logout", "backchannel"},
|
|
{"/frontchannel-logout", "frontchannel"},
|
|
{"/api/resource", ""},
|
|
{"/", ""},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.path, func(t *testing.T) {
|
|
result := oidc.determineLogoutPath(tc.path)
|
|
if result != tc.expected {
|
|
t.Errorf("determineLogoutPath(%q) = %q, expected %q", tc.path, result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSessionInvalidationWithNilCache tests that session invalidation handles nil cache gracefully
|
|
func TestSessionInvalidationWithNilCache(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
sessionInvalidationCache: nil,
|
|
}
|
|
|
|
// Should return error for nil cache
|
|
err := oidc.invalidateSession("session123", "")
|
|
if err == nil {
|
|
t.Error("Expected error for nil cache")
|
|
}
|
|
|
|
// isSessionInvalidated should return false for nil cache
|
|
if oidc.isSessionInvalidated("session123", "", time.Now()) {
|
|
t.Error("Expected false for nil cache")
|
|
}
|
|
}
|
|
|
|
// TestBackchannelLogoutWithSubOnly tests logout with subject claim only (no sid)
|
|
func TestBackchannelLogoutWithSubOnly(t *testing.T) {
|
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate key: %v", err)
|
|
}
|
|
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{{Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256"}},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
logoutToken := createSignedLogoutToken(t, privateKey, map[string]interface{}{
|
|
"iss": "https://provider.example.com",
|
|
"aud": "test-client",
|
|
"iat": time.Now().Unix(),
|
|
"jti": "unique-id-sub-only",
|
|
"events": map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
"sub": "user@example.com", // Only sub, no sid
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d: %s", rw.Code, rw.Body.String())
|
|
}
|
|
|
|
// Verify subject was invalidated
|
|
key := oidc.buildSessionInvalidationKey("sub", "user@example.com")
|
|
if _, found := mockCache.data[key]; !found {
|
|
t.Error("Subject should have been invalidated")
|
|
}
|
|
}
|
|
|
|
// TestBackchannelLogoutWithBothSidAndSub tests logout with both sid and sub claims
|
|
func TestBackchannelLogoutWithBothSidAndSub(t *testing.T) {
|
|
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{{Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256"}},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
logoutToken := createSignedLogoutToken(t, privateKey, map[string]interface{}{
|
|
"iss": "https://provider.example.com",
|
|
"aud": "test-client",
|
|
"iat": time.Now().Unix(),
|
|
"jti": "unique-id-both",
|
|
"events": map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
"sid": "session-123",
|
|
"sub": "user@example.com",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d", rw.Code)
|
|
}
|
|
|
|
// Both sid and sub should be invalidated
|
|
sidKey := oidc.buildSessionInvalidationKey("sid", "session-123")
|
|
subKey := oidc.buildSessionInvalidationKey("sub", "user@example.com")
|
|
if _, found := mockCache.data[sidKey]; !found {
|
|
t.Error("Session ID should have been invalidated")
|
|
}
|
|
if _, found := mockCache.data[subKey]; !found {
|
|
t.Error("Subject should have been invalidated")
|
|
}
|
|
}
|
|
|
|
// TestBackchannelLogoutWrongIssuer tests that wrong issuer is rejected
|
|
func TestBackchannelLogoutWrongIssuer(t *testing.T) {
|
|
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{{Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256"}},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
logoutToken := createSignedLogoutToken(t, privateKey, map[string]interface{}{
|
|
"iss": "https://wrong-issuer.com", // Wrong issuer
|
|
"aud": "test-client",
|
|
"iat": time.Now().Unix(),
|
|
"jti": "unique-id-wrong-iss",
|
|
"events": map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
"sid": "session-123",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for wrong issuer, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestBackchannelLogoutWrongAudience tests that wrong audience is rejected
|
|
func TestBackchannelLogoutWrongAudience(t *testing.T) {
|
|
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{{Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256"}},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
logoutToken := createSignedLogoutToken(t, privateKey, map[string]interface{}{
|
|
"iss": "https://provider.example.com",
|
|
"aud": "wrong-client-id", // Wrong audience
|
|
"iat": time.Now().Unix(),
|
|
"jti": "unique-id-wrong-aud",
|
|
"events": map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
"sid": "session-123",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for wrong audience, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestBackchannelLogoutExpiredToken tests that expired tokens are rejected
|
|
func TestBackchannelLogoutExpiredToken(t *testing.T) {
|
|
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{{Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256"}},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
// Token issued 20 minutes ago (> 15 min allowed)
|
|
logoutToken := createSignedLogoutToken(t, privateKey, map[string]interface{}{
|
|
"iss": "https://provider.example.com",
|
|
"aud": "test-client",
|
|
"iat": time.Now().Add(-20 * time.Minute).Unix(), // Too old
|
|
"jti": "unique-id-expired",
|
|
"events": map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
"sid": "session-123",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for expired token, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestBackchannelLogoutFutureToken tests that future-dated tokens are rejected
|
|
func TestBackchannelLogoutFutureToken(t *testing.T) {
|
|
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{{Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256"}},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
// Token issued 10 minutes in the future (> 5 min clock skew allowed)
|
|
logoutToken := createSignedLogoutToken(t, privateKey, map[string]interface{}{
|
|
"iss": "https://provider.example.com",
|
|
"aud": "test-client",
|
|
"iat": time.Now().Add(10 * time.Minute).Unix(), // Future
|
|
"jti": "unique-id-future",
|
|
"events": map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
"sid": "session-123",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for future token, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestBackchannelLogoutMissingEvents tests that missing events claim is rejected
|
|
func TestBackchannelLogoutMissingEvents(t *testing.T) {
|
|
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{{Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256"}},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
// Token without events claim
|
|
logoutToken := createSignedLogoutToken(t, privateKey, map[string]interface{}{
|
|
"iss": "https://provider.example.com",
|
|
"aud": "test-client",
|
|
"iat": time.Now().Unix(),
|
|
"jti": "unique-id-no-events",
|
|
"sid": "session-123",
|
|
// No events claim
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for missing events, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestBackchannelLogoutWrongEventType tests that wrong event type is rejected
|
|
func TestBackchannelLogoutWrongEventType(t *testing.T) {
|
|
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{{Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256"}},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
// Token with wrong event type
|
|
logoutToken := createSignedLogoutToken(t, privateKey, map[string]interface{}{
|
|
"iss": "https://provider.example.com",
|
|
"aud": "test-client",
|
|
"iat": time.Now().Unix(),
|
|
"jti": "unique-id-wrong-event",
|
|
"events": map[string]interface{}{
|
|
"http://schemas.openid.net/event/wrong-event": map[string]interface{}{}, // Wrong event
|
|
},
|
|
"sid": "session-123",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for wrong event type, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestBackchannelLogoutWithNonce tests that nonce presence is rejected
|
|
func TestBackchannelLogoutWithNonce(t *testing.T) {
|
|
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{{Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256"}},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
// Token with nonce (not allowed per spec)
|
|
logoutToken := createSignedLogoutToken(t, privateKey, map[string]interface{}{
|
|
"iss": "https://provider.example.com",
|
|
"aud": "test-client",
|
|
"iat": time.Now().Unix(),
|
|
"jti": "unique-id-with-nonce",
|
|
"events": map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
"sid": "session-123",
|
|
"nonce": "should-not-be-here", // Nonce not allowed
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for token with nonce, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestBackchannelLogoutRawJWTBody tests logout with raw JWT in body (not form-urlencoded)
|
|
func TestBackchannelLogoutRawJWTBody(t *testing.T) {
|
|
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{{Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256"}},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
logoutToken := createSignedLogoutToken(t, privateKey, map[string]interface{}{
|
|
"iss": "https://provider.example.com",
|
|
"aud": "test-client",
|
|
"iat": time.Now().Unix(),
|
|
"jti": "unique-id-raw-body",
|
|
"events": map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
"sid": "session-raw-body",
|
|
})
|
|
|
|
// Send raw JWT in body (no form encoding)
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout", strings.NewReader(logoutToken))
|
|
req.Header.Set("Content-Type", "application/jwt")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d: %s", rw.Code, rw.Body.String())
|
|
}
|
|
|
|
// Verify session was invalidated
|
|
key := oidc.buildSessionInvalidationKey("sid", "session-raw-body")
|
|
if _, found := mockCache.data[key]; !found {
|
|
t.Error("Session should have been invalidated")
|
|
}
|
|
}
|
|
|
|
// TestBackchannelLogoutArrayAudience tests logout with array audience claim
|
|
func TestBackchannelLogoutArrayAudience(t *testing.T) {
|
|
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{{Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256"}},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
// Array audience containing our client ID
|
|
logoutToken := createSignedLogoutToken(t, privateKey, map[string]interface{}{
|
|
"iss": "https://provider.example.com",
|
|
"aud": []string{"other-client", "test-client", "another-client"},
|
|
"iat": time.Now().Unix(),
|
|
"jti": "unique-id-array-aud",
|
|
"events": map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
"sid": "session-array-aud",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d: %s", rw.Code, rw.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestFrontchannelLogoutWithSubOnly tests front-channel logout with sub parameter only
|
|
func TestFrontchannelLogoutWithSubOnly(t *testing.T) {
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableFrontchannelLogout: true,
|
|
frontchannelLogoutPath: "/frontchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
}
|
|
|
|
// Front-channel with sub parameter (some IdPs use this)
|
|
req := httptest.NewRequest(http.MethodGet, "/frontchannel-logout?sub=user@example.com&iss=https://provider.example.com", nil)
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleFrontchannelLogout(rw, req)
|
|
|
|
// Should fail because sid is required
|
|
if rw.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 (sid required), got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestFrontchannelLogoutCacheControl tests that front-channel logout sets proper cache headers
|
|
func TestFrontchannelLogoutCacheControl(t *testing.T) {
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableFrontchannelLogout: true,
|
|
frontchannelLogoutPath: "/frontchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
}
|
|
|
|
// Issuer is now required (audit rank 30); supply a matching one so the
|
|
// successful-logout cache headers can be asserted.
|
|
req := httptest.NewRequest(http.MethodGet, "/frontchannel-logout?sid=session123&iss=https://provider.example.com", nil)
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleFrontchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d", rw.Code)
|
|
}
|
|
|
|
// Check cache headers
|
|
cacheControl := rw.Header().Get("Cache-Control")
|
|
if !strings.Contains(cacheControl, "no-cache") || !strings.Contains(cacheControl, "no-store") {
|
|
t.Errorf("Expected Cache-Control to contain no-cache and no-store, got %s", cacheControl)
|
|
}
|
|
|
|
pragma := rw.Header().Get("Pragma")
|
|
if pragma != "no-cache" {
|
|
t.Errorf("Expected Pragma: no-cache, got %s", pragma)
|
|
}
|
|
|
|
// X-Frame-Options should be removed (to allow iframe embedding)
|
|
if rw.Header().Get("X-Frame-Options") != "" {
|
|
t.Error("X-Frame-Options should be removed for front-channel logout")
|
|
}
|
|
}
|
|
|
|
// TestConcurrentSessionInvalidation tests concurrent session invalidations
|
|
func TestConcurrentSessionInvalidation(t *testing.T) {
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
sessionInvalidationCache: mockCache,
|
|
}
|
|
|
|
// Invalidate multiple sessions concurrently
|
|
done := make(chan bool, 10)
|
|
for i := 0; i < 10; i++ {
|
|
go func(idx int) {
|
|
sid := fmt.Sprintf("session-%d", idx)
|
|
sub := fmt.Sprintf("user%d@example.com", idx)
|
|
err := oidc.invalidateSession(sid, sub)
|
|
if err != nil {
|
|
t.Errorf("Failed to invalidate session %d: %v", idx, err)
|
|
}
|
|
done <- true
|
|
}(i)
|
|
}
|
|
|
|
// Wait for all goroutines
|
|
for i := 0; i < 10; i++ {
|
|
<-done
|
|
}
|
|
|
|
// Verify all sessions were invalidated
|
|
for i := 0; i < 10; i++ {
|
|
sid := fmt.Sprintf("session-%d", i)
|
|
sub := fmt.Sprintf("user%d@example.com", i)
|
|
sidKey := oidc.buildSessionInvalidationKey("sid", sid)
|
|
subKey := oidc.buildSessionInvalidationKey("sub", sub)
|
|
if _, found := mockCache.Get(sidKey); !found {
|
|
t.Errorf("Session %d should have been invalidated by sid", i)
|
|
}
|
|
if _, found := mockCache.Get(subKey); !found {
|
|
t.Errorf("Session %d should have been invalidated by sub", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSessionInvalidationTimeComparison tests the time comparison logic
|
|
func TestSessionInvalidationTimeComparison(t *testing.T) {
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
sessionInvalidationCache: mockCache,
|
|
}
|
|
|
|
// Create session at specific time
|
|
sessionCreatedAt := time.Now()
|
|
|
|
// Wait a tiny bit and invalidate
|
|
time.Sleep(10 * time.Millisecond)
|
|
_ = oidc.invalidateSession("session-time-test", "")
|
|
|
|
// Session created before invalidation should be invalidated
|
|
if !oidc.isSessionInvalidated("session-time-test", "", sessionCreatedAt) {
|
|
t.Error("Session created before invalidation should be marked as invalidated")
|
|
}
|
|
|
|
// Session created after invalidation (simulated) should NOT be invalidated
|
|
futureSession := time.Now().Add(1 * time.Second)
|
|
if oidc.isSessionInvalidated("session-time-test", "", futureSession) {
|
|
t.Error("Session created after invalidation should NOT be marked as invalidated")
|
|
}
|
|
}
|
|
|
|
// TestBackchannelLogoutMissingIat tests that missing iat is rejected
|
|
func TestBackchannelLogoutMissingIat(t *testing.T) {
|
|
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{{Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256"}},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
// Token without iat claim
|
|
logoutToken := createSignedLogoutToken(t, privateKey, map[string]interface{}{
|
|
"iss": "https://provider.example.com",
|
|
"aud": "test-client",
|
|
// No iat
|
|
"jti": "unique-id-no-iat",
|
|
"events": map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
"sid": "session-123",
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for missing iat, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestBackchannelLogoutMissingSidAndSub tests that missing both sid and sub is rejected
|
|
func TestBackchannelLogoutMissingSidAndSub(t *testing.T) {
|
|
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
mockCache := &mockCacheInterface{data: make(map[string]interface{})}
|
|
x := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes())
|
|
y := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes())
|
|
|
|
mockJWKCache := &staticJWKCache{
|
|
jwks: &JWKSet{
|
|
Keys: []JWK{{Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256"}},
|
|
},
|
|
}
|
|
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("debug"),
|
|
enableBackchannelLogout: true,
|
|
backchannelLogoutPath: "/backchannel-logout",
|
|
sessionInvalidationCache: mockCache,
|
|
clientID: "test-client",
|
|
issuerURL: "https://provider.example.com",
|
|
jwkCache: mockJWKCache,
|
|
jwksURL: "https://provider.example.com/.well-known/jwks.json",
|
|
}
|
|
|
|
// Token without sid or sub
|
|
logoutToken := createSignedLogoutToken(t, privateKey, map[string]interface{}{
|
|
"iss": "https://provider.example.com",
|
|
"aud": "test-client",
|
|
"iat": time.Now().Unix(),
|
|
"jti": "unique-id-no-sid-sub",
|
|
"events": map[string]interface{}{
|
|
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
|
|
},
|
|
// No sid or sub
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
|
|
strings.NewReader("logout_token="+url.QueryEscape(logoutToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rw := httptest.NewRecorder()
|
|
|
|
oidc.handleBackchannelLogout(rw, req)
|
|
|
|
if rw.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for missing sid and sub, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// createSignedLogoutToken is a helper to create properly signed logout tokens for testing
|
|
func createSignedLogoutToken(t *testing.T, privateKey *ecdsa.PrivateKey, claims map[string]interface{}) string {
|
|
t.Helper()
|
|
|
|
header := map[string]interface{}{
|
|
"alg": "ES256",
|
|
"typ": "logout+jwt",
|
|
"kid": "test-key-1",
|
|
}
|
|
headerJSON, _ := json.Marshal(header)
|
|
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
|
|
|
claimsJSON, _ := json.Marshal(claims)
|
|
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
|
|
|
// Sign the token
|
|
signingInput := headerB64 + "." + claimsB64
|
|
hash := sha256.Sum256([]byte(signingInput))
|
|
r, s, err := ecdsa.Sign(rand.Reader, privateKey, hash[:])
|
|
if err != nil {
|
|
t.Fatalf("Failed to sign token: %v", err)
|
|
}
|
|
|
|
// Convert signature to fixed-size format (32 bytes each for P-256)
|
|
sigBytes := make([]byte, 64)
|
|
rBytes := r.Bytes()
|
|
sBytes := s.Bytes()
|
|
copy(sigBytes[32-len(rBytes):32], rBytes)
|
|
copy(sigBytes[64-len(sBytes):], sBytes)
|
|
signatureB64 := base64.RawURLEncoding.EncodeToString(sigBytes)
|
|
|
|
return headerB64 + "." + claimsB64 + "." + signatureB64
|
|
}
|