package traefikoidc import ( "encoding/json" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" ) // 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", // client_id binds the introspected token to this client // (FIX-03): audience==clientID here, so buildPrincipalFromOpaqueIntrospection // requires a matching client_id or aud. "client_id": "https://api.example.com", // iat is required (FIX-03): buildPrincipalFromOpaqueIntrospection // bounds it via enforceIatAge, and makeBearerOIDC sets // maxTokenAge=24h, so a response with no iat is rejected. "iat": time.Now().Unix(), }) })) 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 // FIX-03: opaque bearer tokens additionally require allowOpaqueTokens. oidc.allowOpaqueTokens = 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()) } }