mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-21 04:57:48 +00:00
test: add R-round regression and security suite
- 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
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// serializingCache simulates a UniversalCache backed by a serializing
|
||||
// store (e.g. Redis): Set JSON-round-trips the value, so Get returns a
|
||||
// generic map, never the original concrete type. This is exactly what
|
||||
// production sees on the distributed path.
|
||||
type serializingCache struct {
|
||||
m map[string]interface{}
|
||||
}
|
||||
|
||||
func (c *serializingCache) Set(key string, value any, ttl time.Duration) {
|
||||
b, _ := json.Marshal(value)
|
||||
var v interface{}
|
||||
_ = json.Unmarshal(b, &v)
|
||||
c.m[key] = v
|
||||
}
|
||||
func (c *serializingCache) Get(key string) (any, bool) {
|
||||
v, ok := c.m[key]
|
||||
return v, ok
|
||||
}
|
||||
func (c *serializingCache) Delete(key string) { delete(c.m, key) }
|
||||
func (c *serializingCache) SetMaxSize(int) {}
|
||||
func (c *serializingCache) Size() int { return len(c.m) }
|
||||
func (c *serializingCache) Clear() { c.m = map[string]interface{}{} }
|
||||
func (c *serializingCache) Cleanup() {}
|
||||
func (c *serializingCache) Close() {}
|
||||
func (c *serializingCache) GetStats() map[string]any { return nil }
|
||||
|
||||
// TestR159_RefreshResultDedupAcrossSerializingCache guards the
|
||||
// lookupCachedRefreshResult cross-backend decode (token_manager.go). The
|
||||
// refresh-result dedup stored a *TokenResponse but the serializing
|
||||
// backend returns it as a JSON map, so the *TokenResponse type-assert
|
||||
// always failed and every replica still posted the refresh token — the
|
||||
// advertised cross-replica grant coalescing was silently inert. The fix
|
||||
// normalizes serialized forms back to *TokenResponse.
|
||||
// Fail-on-old: lookup returns nil/false on a serializing backend.
|
||||
func TestR159_RefreshResultDedupAcrossSerializingCache(t *testing.T) {
|
||||
tObj := &TraefikOidc{
|
||||
refreshResultCache: &serializingCache{m: map[string]interface{}{}},
|
||||
}
|
||||
|
||||
tObj.cacheRefreshResult("session-1", &TokenResponse{
|
||||
AccessToken: "at-1",
|
||||
RefreshToken: "rt-rotated",
|
||||
})
|
||||
|
||||
tr, ok := tObj.lookupCachedRefreshResult("session-1")
|
||||
if !ok || tr == nil {
|
||||
t.Fatal("refresh result dedup must survive a serializing backend; lookup returned nothing")
|
||||
}
|
||||
if tr.AccessToken != "at-1" || tr.RefreshToken != "rt-rotated" {
|
||||
t.Fatalf("dedup round-trip corrupted the token; got %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestR159_BearerOpaqueTokenIntrospectedWhenRequired guards the bearer
|
||||
// opaque-token path (bearer_auth.go). The JWT-shape gate
|
||||
// (strings.Count(token,'.') != 2) ran before the introspection branch,
|
||||
// so every opaque bearer access token got a 401 even when
|
||||
// requireTokenIntrospection was enabled for exactly that purpose. The fix
|
||||
// introspects opaque tokens on the bearer path (mirroring the session
|
||||
// path's validateOpaqueToken) and forwards with the introspected
|
||||
// subject.
|
||||
// Fail-on-old: opaque token rejected (401) despite active introspection.
|
||||
func TestR159_BearerOpaqueTokenIntrospectedWhenRequired(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"active": true,
|
||||
"sub": "opaque-user",
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
var nextCalled atomic.Bool
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled.Store(true)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
oidc.requireTokenIntrospection = true
|
||||
oidc.introspectionURL = ts.URL
|
||||
oidc.httpClient = ts.Client()
|
||||
oidc.clientSecret = "secret"
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/x", nil)
|
||||
req.Header.Set("Authorization", "Bearer opaque-token-with-no-dots")
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if !nextCalled.Load() {
|
||||
t.Fatalf("opaque token must be introspected and forwarded when requireTokenIntrospection is set; status=%d body=%q", rw.Code, rw.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user