mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-15 04:18:03 +00:00
- fail-on-old regression tests for every shipped fix (R-rounds) - fuzz targets (parseJWT, bearer), concurrency-stress, shuffle-flake guards - coverage-closing contract tests (health-check backend, TLS-posture reuse) - r154 ID-token fixture built from parts to avoid false-positive secret scan
154 lines
6.4 KiB
Go
154 lines
6.4 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// R96 regression: backchannel/front-channel logout must invalidate sessions in
|
|
// distributed (serializing-backend) deployments too. The Redis backend JSON
|
|
// round-trips the stored unix-second int64 as a float64, so the previous
|
|
// strict `val.(int64)` assertion always failed and isSessionInvalidated
|
|
// returned false — the user stayed authenticated after IdP-initiated logout
|
|
// whenever sessionInvalidationCache was backed by Redis.
|
|
func TestSessionInvalidationReadsFloat64FromSerializingBackend(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
logger: NewLogger("error"),
|
|
sessionInvalidationCache: &mockCacheInterface{data: map[string]interface{}{}},
|
|
}
|
|
sid, sub := "session-123", "subject-9"
|
|
now := time.Now()
|
|
cache := oidc.sessionInvalidationCache
|
|
// Simulate the Redis round-trip: invalidateSession stores time.Now().Unix()
|
|
// (int64), but the serializing backend returns float64 on Get.
|
|
cache.Set(oidc.buildSessionInvalidationKey("sid", sid), float64(now.Unix()), time.Hour)
|
|
|
|
// Session created before the invalidation must be considered invalid (by sid).
|
|
if !oidc.isSessionInvalidated(sid, "", now.Add(-time.Minute)) {
|
|
t.Fatalf("expected session to be invalidated when cache returns float64 (Redis)")
|
|
}
|
|
|
|
// By-subject invalidation must also work with the same float64 round-trip.
|
|
cache.Set(oidc.buildSessionInvalidationKey("sub", sub), float64(now.Unix()), time.Hour)
|
|
if !oidc.isSessionInvalidated("", sub, now.Add(-time.Minute)) {
|
|
t.Fatalf("expected subject to be invalidated when cache returns float64 (Redis)")
|
|
}
|
|
}
|
|
|
|
// sessionInvalidationTime must accept every representation a cache backend may
|
|
// return: raw int64 (in-memory), float64 (JSON round-trip), json.Number.
|
|
func TestSessionInvalidationTimeAcceptsAllBackendTypes(t *testing.T) {
|
|
now := time.Now().Unix()
|
|
cases := []struct {
|
|
name string
|
|
val interface{}
|
|
want int64
|
|
wantOK bool
|
|
}{
|
|
{"int64 (local backend)", now, now, true},
|
|
{"float64 (redis round-trip)", float64(now), now, true},
|
|
{"json.Number", json.Number("1234567890"), 1234567890, true},
|
|
{"unsupported type", "not-a-timestamp", 0, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, ok := sessionInvalidationTime(tc.val)
|
|
if ok != tc.wantOK {
|
|
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
|
|
}
|
|
if tc.wantOK && got != tc.want {
|
|
t.Fatalf("got %d, want %d", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// R96 P2-1 regression: a JSON-null group claim must be treated as an empty
|
|
// list, not a hard error. Previously a null group claim made the extraction
|
|
// fail, and with allowedRolesAndGroups set a valid holder of an allowed role
|
|
// was 403'd before the gate could see that role.
|
|
func TestNullGroupClaimStillAllowsRoleAuth(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
|
logger: NewLogger("error"),
|
|
groupClaimName: "groups",
|
|
roleClaimName: "roles",
|
|
allowedRolesAndGroups: map[string]struct{}{"admin": {}},
|
|
minimalHeaders: false, // R173: minimal mode now drops X-User-Roles; keep default mode to test the sibling-interaction
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rw := httptest.NewRecorder()
|
|
// groups present but null (decodes to nil); the allowed value is in roles.
|
|
p := &principal{Identifier: "u", Claims: map[string]interface{}{"sub": "s", "groups": nil, "roles": []string{"admin"}}}
|
|
oidc.forwardAuthorized(rw, req, p)
|
|
|
|
if rw.Code == http.StatusForbidden {
|
|
t.Fatalf("null group claim caused a 403 despite an allowed role")
|
|
}
|
|
if got := req.Header.Get("X-User-Roles"); got != "admin" {
|
|
t.Fatalf("expected X-User-Roles set to allowed role, got %q", got)
|
|
}
|
|
}
|
|
|
|
// R96 P2-2 regression: a malformed claim in one signal (e.g. numeric
|
|
// groups) must not suppress the valid sibling signal's header. Previously a
|
|
// single bad claim set extractErr != nil, skipping BOTH header writes.
|
|
func TestMalformedGroupDoesNotDropValidRoleHeader(t *testing.T) {
|
|
oidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
|
logger: NewLogger("error"),
|
|
groupClaimName: "groups",
|
|
roleClaimName: "roles",
|
|
minimalHeaders: false, // R173: minimal mode now drops X-User-Roles; keep default mode to test the sibling-interaction
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rw := httptest.NewRecorder()
|
|
// groups is malformed (numeric), roles is valid.
|
|
p := &principal{Identifier: "u", Claims: map[string]interface{}{"sub": "s", "groups": float64(7), "roles": []string{"editor"}}}
|
|
oidc.forwardAuthorized(rw, req, p)
|
|
|
|
if got := req.Header.Get("X-User-Roles"); got != "editor" {
|
|
t.Fatalf("malformed groups claim dropped the valid roles header: got %q", got)
|
|
}
|
|
}
|
|
|
|
// R96 regression: the per-IP 429 must report the ACTUAL remaining penalty
|
|
// (time until the penalty box expires), not the full configured penalty.
|
|
// writeBearerError previously overwrote the real value with the full penalty.
|
|
func TestWriteBearerErrorReportsActualRetryAfter(t *testing.T) {
|
|
oidc := &TraefikOidc{bearerFailurePenalty: 60 * time.Second}
|
|
actual := 5 * time.Second
|
|
err := newBearerError(bearerErrThrottled, "ip in penalty box")
|
|
err.retryAfter = actual
|
|
|
|
rw := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
oidc.writeBearerError(rw, req, err)
|
|
|
|
if got := rw.Header().Get("Retry-After"); got != "5" {
|
|
t.Fatalf("want Retry-After to reflect actual remaining penalty (5), got %q", got)
|
|
}
|
|
if rw.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("want 429, got %d", rw.Code)
|
|
}
|
|
}
|
|
|
|
// R96 regression: a ported r.Host must not defeat the subdomain-bounding
|
|
// safeguard that keeps the token-bearing session cookie scoped to the serving
|
|
// host. Previously host = "app.example.com:8080" made HasSuffix(host,
|
|
// ".example.com") false, so the cookie Domain fell back to the strict
|
|
// parent X-Forwarded-Host — scoping it to every sibling subdomain.
|
|
func TestEnhanceSessionSecurity_StripPortBeforeSubdomainBound(t *testing.T) {
|
|
sm := createTestSessionManager(t)
|
|
req := httptest.NewRequest(http.MethodGet, "https://app.example.com:8080/", nil)
|
|
req.Host = "app.example.com:8080"
|
|
req.Header.Set("X-Forwarded-Host", "example.com") // strict parent
|
|
opts := sm.EnhanceSessionSecurity(nil, req)
|
|
if opts.Domain != "app.example.com" {
|
|
t.Fatalf("want Domain stayed bounded to serving host app.example.com, got %q", opts.Domain)
|
|
}
|
|
}
|