From ca1e135aa3e873fd9ff1d4c4cdae4c21516e8833 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sun, 10 May 2026 20:07:29 +0000 Subject: [PATCH] =?UTF-8?q?fix(oidc/bcl):=20resolve=20sub=E2=86=92actor=5F?= =?UTF-8?q?id=20via=20users.GetByOIDCSubject=20(CRIT-2=20closure)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes CRIT-2 of the 2026-05-10 audit. The BCL handler previously called sessionSvc.RevokeAllForActor(sub, "User") but session rows are keyed by user.ID (a random "u-" + 16-byte token), not the OIDC subject — the "Phase 5 simplification" comment in the source was factually wrong about how internal/auth/oidc/service.go::upsertUser seeds user.ID. As a result, the SQL lookup returned zero rows on every BCL receive, the error was silently swallowed (`_ = rerr`), an audit row was written claiming success, and the handler returned 200 + Cache-Control: no-store. OIDC BCL 1.0 §2.6 ("MUST destroy all sessions identified by the sub or sid") was unimplemented. CWE-613. This commit: - Adds userRepo (repository.UserRepository) to AuthSessionOIDCHandler struct + NewAuthSessionOIDCHandler constructor. cmd/server/main.go injects the existing oidcUserRepo (no new repository instance). - Replaces the broken sub-as-actor-id path with: 1. providerRepo.List(ctx, tenantID) + IssuerURL filter to map claims.iss → provider row (N is small; typically 1-5). 2. userRepo.GetByOIDCSubject(ctx, provider.ID, sub) to resolve the OIDC subject → user.ID. 3. sessionSvc.RevokeAllForActor(user.ID, "User") with the RESOLVED actor_id (not the OIDC subject). - Audits four success-shaped outcome categories: - outcome=revoked — happy path - outcome=user_unknown — IdP BCLs a user we never logged in (idempotent 200) - outcome=issuer_unknown — iss doesn't match any configured provider (idempotent 200) - outcome=revoke_failed — RevokeAllForActor returned an error (200, best-effort per §2.8) And two transient outcomes that return 503 (IdP retries per §2.8): - outcome=provider_lookup_failed — providerRepo.List error - outcome=user_lookup_failed — non-NotFound userRepo error - Removes the misleading "Phase 5 simplification" comment block; replaces with a doc explaining the resolution path + outcome taxonomy + spec refs. - Adds 5 regression tests in internal/api/handler/auth_session_oidc_test.go: - TestBackChannelLogout_HappyPath_RevokesSubject (updated to seed provider + user; asserts RevokeAllForActor was called with the resolved user.ID, not the raw OIDC subject — the test that would have caught CRIT-2 had it existed) - TestBackChannelLogout_UnknownUserReturns200WithAudit - TestBackChannelLogout_IssuerUnknownReturns200WithAudit - TestBackChannelLogout_TransientUserRepoErrorReturns503 - TestBackChannelLogout_RevokeFailureReturns200WithAuditFailureOutcome - Introduces stubUserRepo in the handler test file (matching the four repository.UserRepository interface methods) so the existing newPhase5Handler fixture seeds a usable user resolver. Verification gate green: - gofmt -l . clean - go vet ./... clean - go test -short -count=1 ./internal/api/handler/ ./internal/api/router/ ./internal/auth/... ./internal/domain/auth/ ./internal/service/auth/ ./cmd/server/ — all pass - go build ./... clean CRIT-1 from the same audit is already closed on this branch (commit 68ca42f); CRIT-3 / CRIT-4 / CRIT-5 remain open and continue to block the v2.1.0 tag. Spec: cowork/auth-bundles-fixes-2026-05-10/02-crit-2-bcl-sub-lookup.md. Refs: cowork/auth-bundles-audit-2026-05-10.md CRIT-2 --- cmd/server/main.go | 1 + internal/api/handler/auth_session_oidc.go | 90 +++++- .../api/handler/auth_session_oidc_test.go | 260 +++++++++++++++--- 3 files changed, 299 insertions(+), 52 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index af19d06..e6e0bd3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -429,6 +429,7 @@ func main() { oidcProviderRepo, oidcMappingRepo, sessionRepo, + oidcUserRepo, // CRIT-2: BCL sub→actor_id lookup via users.GetByOIDCSubject auditService, cfg.Encryption.ConfigEncryptionKey, authdomainAlias.DefaultTenantID, diff --git a/internal/api/handler/auth_session_oidc.go b/internal/api/handler/auth_session_oidc.go index 4d6489c..3e70f92 100644 --- a/internal/api/handler/auth_session_oidc.go +++ b/internal/api/handler/auth_session_oidc.go @@ -103,6 +103,7 @@ type AuthSessionOIDCHandler struct { providerRepo repository.OIDCProviderRepository mappingRepo repository.GroupRoleMappingRepository sessionRepo repository.SessionRepository + userRepo repository.UserRepository // CRIT-2: BCL sub→actor_id lookup audit AuditRecorder encryptionKey string cookieAttrs SessionCookieAttrs @@ -116,6 +117,11 @@ type AuditRecorder interface { } // NewAuthSessionOIDCHandler constructs the handler. +// +// userRepo is load-bearing for the BCL sub→actor_id resolution +// (CRIT-2 of the 2026-05-10 audit). Passing nil here is only valid in +// tests that exercise non-BCL paths; production wiring in +// cmd/server/main.go MUST inject a non-nil repository. func NewAuthSessionOIDCHandler( oidcSvc OIDCAuthHandshaker, sessionSvc SessionMinter, @@ -123,6 +129,7 @@ func NewAuthSessionOIDCHandler( providerRepo repository.OIDCProviderRepository, mappingRepo repository.GroupRoleMappingRepository, sessionRepo repository.SessionRepository, + userRepo repository.UserRepository, audit AuditRecorder, encryptionKey, tenantID, postLoginURL string, cookieAttrs SessionCookieAttrs, @@ -137,6 +144,7 @@ func NewAuthSessionOIDCHandler( providerRepo: providerRepo, mappingRepo: mappingRepo, sessionRepo: sessionRepo, + userRepo: userRepo, audit: audit, encryptionKey: encryptionKey, cookieAttrs: cookieAttrs, @@ -314,16 +322,80 @@ func (h *AuthSessionOIDCHandler) BackChannelLogout(w http.ResponseWriter, r *htt h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sid, map[string]interface{}{"sub_or_sid": "sid", "issuer": issuer, "session_id": sid}) } else if sub != "" { - // Phase 5 simplification: revoke ALL sessions belonging to a User - // actor with this oidc_subject. The full subject->actor_id lookup - // is a 1-row select on users; for v1 we treat sub as the actor_id - // directly (this matches the user.id seeding pattern in Phase 3 - // upsertUser, which uses oidc_subject as the actor_id stem). - if rerr := h.sessionSvc.RevokeAllForActor(r.Context(), sub, "User"); rerr != nil { - _ = rerr + // CRIT-2 closure of the 2026-05-10 audit. Pre-fix this branch called + // RevokeAllForActor(sub, "User") under the false assumption that + // the OIDC subject was used as the actor_id stem. In reality, + // internal/auth/oidc/service.go::upsertUser mints + // u.ID = "u-" + randomB64URL(16) and stores the OIDC subject in + // a separate column, so the pre-fix lookup never found a session + // row and the error was silently swallowed. BCL silently revoked + // nothing — CWE-613. + // + // The fix resolves the IdP-signed `iss` claim back to a provider + // row via providerRepo.List + IssuerURL filter, then resolves + // sub → user.ID via userRepo.GetByOIDCSubject, then revokes all + // sessions for that actor. Outcome categories audited: + // - revoked (happy path) + // - issuer_unknown (iss doesn't match any configured provider) + // - user_unknown (provider matched, but no user.id seeded for this subject) + // - revoke_failed (DB hiccup at the revoke step) + // - provider_lookup_failed / user_lookup_failed → 503 (transient; IdP retries) + // All success-shaped outcomes return 200 + Cache-Control: no-store + // per OIDC BCL 1.0 §2.7. Transient errors return 503 so the IdP + // follows its own retry semantics. + providers, plerr := h.providerRepo.List(r.Context(), h.tenantID) + if plerr != nil { + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, + map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "provider_lookup_failed"}) + http.Error(w, "transient", http.StatusServiceUnavailable) + return } - h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, - map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub}) + var matched *oidcdomain.OIDCProvider + for _, p := range providers { + if p.IssuerURL == issuer { + matched = p + break + } + } + if matched == nil { + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, + map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "issuer_unknown"}) + // Idempotent — return 200 per spec. + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + + user, uerr := h.userRepo.GetByOIDCSubject(r.Context(), matched.ID, sub) + if uerr != nil { + if errors.Is(uerr, repository.ErrUserNotFound) { + // Idempotent: nothing to revoke. IdP may BCL a user we + // never logged in. RFC compliance: still 200. + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, + map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "user_unknown"}) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + // Transient — let the IdP retry. + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, + map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "user_lookup_failed"}) + http.Error(w, "transient", http.StatusServiceUnavailable) + return + } + + if rerr := h.sessionSvc.RevokeAllForActor(r.Context(), user.ID, string(domain.ActorTypeUser)); rerr != nil { + // Revoke failed — BCL is best-effort per §2.8; still 200, + // audit the failure. + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", user.ID, domain.ActorTypeUser, sub, + map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "revoke_failed"}) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", user.ID, domain.ActorTypeUser, sub, + map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "revoked"}) } // Per spec §2.7 — Cache-Control: no-store on success. w.Header().Set("Cache-Control", "no-store") diff --git a/internal/api/handler/auth_session_oidc_test.go b/internal/api/handler/auth_session_oidc_test.go index 2df57d2..e45f07d 100644 --- a/internal/api/handler/auth_session_oidc_test.go +++ b/internal/api/handler/auth_session_oidc_test.go @@ -194,6 +194,39 @@ func (s *stubSessionRepo) RevokeAllForActor(_ context.Context, _, _, _ string) e func (s *stubSessionRepo) GarbageCollectExpired(_ context.Context) (int, error) { return 0, nil } func (s *stubSessionRepo) Delete(_ context.Context, _ string) error { return nil } +// stubUserRepo implements just enough of repository.UserRepository for +// the BCL sub→actor_id resolution path (CRIT-2 closure). Lookups by +// (providerID, subject) return the seeded row if present, ErrUserNotFound +// otherwise. lookupErr forces a non-NotFound error (the "transient" +// 503 path). +type stubUserRepo struct { + users map[string]*userdomain.User // key = providerID|subject + lookupErr error // when non-nil, GetByOIDCSubject returns this +} + +func (s *stubUserRepo) Get(_ context.Context, _ string) (*userdomain.User, error) { + return nil, repository.ErrUserNotFound +} + +func (s *stubUserRepo) GetByOIDCSubject(_ context.Context, providerID, subject string) (*userdomain.User, error) { + if s.lookupErr != nil { + return nil, s.lookupErr + } + if s.users == nil { + return nil, repository.ErrUserNotFound + } + if u, ok := s.users[providerID+"|"+subject]; ok { + return u, nil + } + return nil, repository.ErrUserNotFound +} + +func (s *stubUserRepo) Create(_ context.Context, _ *userdomain.User) error { return nil } +func (s *stubUserRepo) Update(_ context.Context, _ *userdomain.User) error { return nil } +func (s *stubUserRepo) ListAll(_ context.Context, _ string) ([]*userdomain.User, error) { + return nil, nil +} + type phase5StubAudit struct { events []string } @@ -212,18 +245,19 @@ func newPhase5Handler( oidcSvc *stubOIDCSvc, sess *stubSession, bcl *stubBCLVerifier, -) (*AuthSessionOIDCHandler, *stubProviderRepo, *stubMappingRepo, *stubSessionRepo, *phase5StubAudit) { +) (*AuthSessionOIDCHandler, *stubProviderRepo, *stubMappingRepo, *stubSessionRepo, *phase5StubAudit, *stubUserRepo) { t.Helper() provRepo := &stubProviderRepo{} mapRepo := &stubMappingRepo{} sessRepo := newStubSessionRepo() + userRepo := &stubUserRepo{} audit := &phase5StubAudit{} h := NewAuthSessionOIDCHandler( - oidcSvc, sess, bcl, provRepo, mapRepo, sessRepo, audit, + oidcSvc, sess, bcl, provRepo, mapRepo, sessRepo, userRepo, audit, "", "t-default", "/dashboard", SessionCookieAttrs{SameSite: http.SameSiteLaxMode, Secure: true}, ) - return h, provRepo, mapRepo, sessRepo, audit + return h, provRepo, mapRepo, sessRepo, audit, userRepo } // withActor adds the same context keys the auth middleware would set. @@ -248,7 +282,7 @@ func TestLoginInitiate_HappyPath(t *testing.T) { cookie: "v1.pl-abc.sk-xyz.somemac", preLoginID: "pl-abc", } - h, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodGet, "/auth/oidc/login?provider=op-x", nil) w := httptest.NewRecorder() @@ -273,7 +307,7 @@ func TestLoginInitiate_HappyPath(t *testing.T) { } func TestLoginInitiate_MissingProvider(t *testing.T) { - h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil) w := httptest.NewRecorder() h.LoginInitiate(w, req) @@ -284,7 +318,7 @@ func TestLoginInitiate_MissingProvider(t *testing.T) { func TestLoginInitiate_ProviderNotFound(t *testing.T) { o := &stubOIDCSvc{authReqErr: repository.ErrOIDCProviderNotFound} - h, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodGet, "/auth/oidc/login?provider=op-missing", nil) w := httptest.NewRecorder() h.LoginInitiate(w, req) @@ -305,7 +339,7 @@ func TestLoginCallback_HappyPath(t *testing.T) { CookieValue: "v1.ses-abc.sk-xyz.mac", CSRFToken: "csrf-token-value", }} - h, _, _, _, audit := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, audit, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?code=abc&state=xyz", nil) req.AddCookie(&http.Cookie{Name: sessiondomain.PreLoginCookieName, Value: "v1.pl-abc.sk-xyz.mac"}) @@ -331,7 +365,7 @@ func TestLoginCallback_HappyPath(t *testing.T) { // ErrPreLoginNotFound on the second call; the handler maps to 400.) func TestLoginCallback_ReplayedState_Returns400(t *testing.T) { o := &stubOIDCSvc{callbackErr: oidcsvc.ErrPreLoginNotFound} - h, _, _, _, audit := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, audit, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?code=abc&state=xyz", nil) req.AddCookie(&http.Cookie{Name: sessiondomain.PreLoginCookieName, Value: "v1.pl-abc.sk-xyz.mac"}) @@ -350,7 +384,7 @@ func TestLoginCallback_ReplayedState_Returns400(t *testing.T) { // match the challenge; the handler surfaces it as 400. func TestLoginCallback_PKCEVerifierMismatch_Returns400(t *testing.T) { o := &stubOIDCSvc{callbackErr: errors.New("oidc: code exchange failed: invalid_grant")} - h, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?code=abc&state=xyz", nil) req.AddCookie(&http.Cookie{Name: sessiondomain.PreLoginCookieName, Value: "v1.pl-abc.sk-xyz.mac"}) w := httptest.NewRecorder() @@ -365,7 +399,7 @@ func TestLoginCallback_ExpiredPreLoginRow_Returns400(t *testing.T) { // Adapter maps ErrPreLoginExpired -> ErrPreLoginNotFound (uniform // 400 per spec; specific reason in audit row). o := &stubOIDCSvc{callbackErr: oidcsvc.ErrPreLoginNotFound} - h, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?code=abc&state=xyz", nil) req.AddCookie(&http.Cookie{Name: sessiondomain.PreLoginCookieName, Value: "v1.pl-abc.sk-xyz.mac"}) w := httptest.NewRecorder() @@ -376,7 +410,7 @@ func TestLoginCallback_ExpiredPreLoginRow_Returns400(t *testing.T) { } func TestLoginCallback_MissingPreLoginCookie_Returns400(t *testing.T) { - h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?code=abc&state=xyz", nil) w := httptest.NewRecorder() h.LoginCallback(w, req) @@ -390,7 +424,7 @@ func TestLoginCallback_MissingPreLoginCookie_Returns400(t *testing.T) { func TestLoginCallback_UnmappedGroups_AuditRowDistinguished(t *testing.T) { o := &stubOIDCSvc{callbackErr: oidcsvc.ErrGroupsUnmapped} - h, _, _, _, audit := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, audit, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?code=abc&state=xyz", nil) req.AddCookie(&http.Cookie{Name: sessiondomain.PreLoginCookieName, Value: "v1.pl-abc.sk-xyz.mac"}) w := httptest.NewRecorder() @@ -410,7 +444,7 @@ func TestLoginCallback_UnmappedGroups_AuditRowDistinguished(t *testing.T) { // Phase 5 spec mandate #1: BCL with missing events claim -> 400. func TestBackChannelLogout_MissingEvents_Returns400(t *testing.T) { bcl := &stubBCLVerifier{err: errors.New("missing events claim")} - h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl) + h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl) req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout", strings.NewReader("logout_token=eyJ.payload.sig")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -427,7 +461,7 @@ func TestBackChannelLogout_MissingEvents_Returns400(t *testing.T) { // Phase 5 spec mandate #2: BCL with nonce present -> 400 (per spec §2.4). func TestBackChannelLogout_NoncePresent_Returns400(t *testing.T) { bcl := &stubBCLVerifier{err: errors.New("nonce claim must be absent in logout_token")} - h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl) + h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl) req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout", strings.NewReader("logout_token=eyJ.payload.sig")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -441,7 +475,7 @@ func TestBackChannelLogout_NoncePresent_Returns400(t *testing.T) { // Phase 5 spec mandate #3: BCL with sig signed by an unknown key -> 400. func TestBackChannelLogout_UnknownKeySig_Returns400(t *testing.T) { bcl := &stubBCLVerifier{err: errors.New("verify: signature key not found in JWKS")} - h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl) + h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl) req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout", strings.NewReader("logout_token=eyJ.payload.sig")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -452,10 +486,26 @@ func TestBackChannelLogout_UnknownKeySig_Returns400(t *testing.T) { } } +// TestBackChannelLogout_HappyPath_RevokesSubject pins the CRIT-2 +// closure happy-path: an IdP fires BCL with sub=, the +// handler resolves sub → user.ID via providerRepo (issuer match) + +// userRepo.GetByOIDCSubject, then calls sessionSvc.RevokeAllForActor +// with the RESOLVED actor_id (NOT the OIDC subject — pre-fix bug +// where the handler called RevokeAllForActor(sub, "User") and silently +// revoked nothing because session rows are keyed by user.ID). func TestBackChannelLogout_HappyPath_RevokesSubject(t *testing.T) { - bcl := &stubBCLVerifier{issuer: "https://idp", sub: "u-alice"} + bcl := &stubBCLVerifier{issuer: "https://idp", sub: "alice@example.com"} sess := &stubSession{} - h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl) + h, provRepo, _, _, audit, userRepo := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl) + + // Seed: provider with matching IssuerURL + user keyed by (provider.ID, sub). + provRepo.provs = []*oidcdomain.OIDCProvider{ + {ID: "iss-1", IssuerURL: "https://idp", TenantID: "t-default"}, + } + userRepo.users = map[string]*userdomain.User{ + "iss-1|alice@example.com": {ID: "u-alice", TenantID: "t-default"}, + } + req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout", strings.NewReader("logout_token=eyJ.payload.sig")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -470,15 +520,139 @@ func TestBackChannelLogout_HappyPath_RevokesSubject(t *testing.T) { if len(sess.revokeAllIDs) != 1 || sess.revokeAllIDs[0] != "u-alice" { t.Errorf("expected RevokeAllForActor(u-alice); got %v", sess.revokeAllIDs) } + if len(sess.revokeAllTypes) != 1 || sess.revokeAllTypes[0] != "User" { + t.Errorf("expected actor_type=User; got %v", sess.revokeAllTypes) + } if !contains(audit.events, "auth.oidc_back_channel_logout") { t.Errorf("expected auth.oidc_back_channel_logout audit event") } } +// TestBackChannelLogout_UnknownUserReturns200WithAudit covers the +// idempotent-200 path when the IdP BCLs a user we never logged in. +// Per OIDC BCL §2.7 we still return 200 + Cache-Control: no-store; the +// audit row carries outcome=user_unknown so forensics can distinguish. +func TestBackChannelLogout_UnknownUserReturns200WithAudit(t *testing.T) { + bcl := &stubBCLVerifier{issuer: "https://idp", sub: "stranger@example.com"} + sess := &stubSession{} + h, provRepo, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl) + // Provider matches, but no user is seeded for the subject. + provRepo.provs = []*oidcdomain.OIDCProvider{ + {ID: "iss-1", IssuerURL: "https://idp", TenantID: "t-default"}, + } + + req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout", + strings.NewReader("logout_token=eyJ.payload.sig")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.BackChannelLogout(w, req) + if w.Code != http.StatusOK { + t.Errorf("status = %d; want 200 (idempotent); got %d", http.StatusOK, w.Code) + } + if cc := w.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control = %q; want no-store", cc) + } + if len(sess.revokeAllIDs) != 0 { + t.Errorf("expected no RevokeAllForActor calls (no user seeded); got %v", sess.revokeAllIDs) + } + if !contains(audit.events, "auth.oidc_back_channel_logout") { + t.Errorf("expected auth.oidc_back_channel_logout audit event with outcome=user_unknown") + } +} + +// TestBackChannelLogout_IssuerUnknownReturns200WithAudit covers the +// "iss doesn't match any configured provider" path. Per RFC idempotency, +// still 200; outcome=issuer_unknown in the audit row. +func TestBackChannelLogout_IssuerUnknownReturns200WithAudit(t *testing.T) { + bcl := &stubBCLVerifier{issuer: "https://wrong-idp", sub: "alice@example.com"} + sess := &stubSession{} + h, provRepo, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl) + provRepo.provs = []*oidcdomain.OIDCProvider{ + {ID: "iss-1", IssuerURL: "https://idp", TenantID: "t-default"}, // mismatched + } + + req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout", + strings.NewReader("logout_token=eyJ.payload.sig")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.BackChannelLogout(w, req) + if w.Code != http.StatusOK { + t.Errorf("status = %d; want 200 (idempotent on unknown issuer)", w.Code) + } + if len(sess.revokeAllIDs) != 0 { + t.Errorf("expected no RevokeAllForActor calls; got %v", sess.revokeAllIDs) + } + if !contains(audit.events, "auth.oidc_back_channel_logout") { + t.Errorf("expected audit event with outcome=issuer_unknown") + } +} + +// TestBackChannelLogout_TransientUserRepoErrorReturns503 covers the +// transient-DB-failure path. A non-NotFound error from the user +// repository surfaces as 503 so the IdP follows its retry semantics +// (per OIDC BCL §2.8 IdPs SHOULD retry on transient failures). +func TestBackChannelLogout_TransientUserRepoErrorReturns503(t *testing.T) { + bcl := &stubBCLVerifier{issuer: "https://idp", sub: "alice@example.com"} + sess := &stubSession{} + h, provRepo, _, _, _, userRepo := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl) + provRepo.provs = []*oidcdomain.OIDCProvider{ + {ID: "iss-1", IssuerURL: "https://idp", TenantID: "t-default"}, + } + userRepo.lookupErr = errors.New("db connection reset") + + req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout", + strings.NewReader("logout_token=eyJ.payload.sig")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.BackChannelLogout(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d; want 503 (transient → IdP retries)", w.Code) + } + if len(sess.revokeAllIDs) != 0 { + t.Errorf("expected no revoke on transient error; got %v", sess.revokeAllIDs) + } +} + +// TestBackChannelLogout_RevokeFailureReturns200WithAuditFailureOutcome +// covers the path where user resolution succeeds but the +// RevokeAllForActor call fails. BCL is best-effort per §2.8; still 200, +// audit row carries outcome=revoke_failed. +func TestBackChannelLogout_RevokeFailureReturns200WithAuditFailureOutcome(t *testing.T) { + bcl := &stubBCLVerifier{issuer: "https://idp", sub: "alice@example.com"} + sess := &stubSession{revokeAllErr: errors.New("transient")} + h, provRepo, _, _, audit, userRepo := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl) + provRepo.provs = []*oidcdomain.OIDCProvider{ + {ID: "iss-1", IssuerURL: "https://idp", TenantID: "t-default"}, + } + userRepo.users = map[string]*userdomain.User{ + "iss-1|alice@example.com": {ID: "u-alice", TenantID: "t-default"}, + } + + req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout", + strings.NewReader("logout_token=eyJ.payload.sig")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.BackChannelLogout(w, req) + if w.Code != http.StatusOK { + t.Errorf("status = %d; want 200 (best-effort on revoke failure)", w.Code) + } + if cc := w.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control = %q; want no-store", cc) + } + // RevokeAllForActor WAS called (and failed); audit MUST record the + // outcome so the operator can debug. + if len(sess.revokeAllIDs) != 1 || sess.revokeAllIDs[0] != "u-alice" { + t.Errorf("expected RevokeAllForActor(u-alice) attempted; got %v", sess.revokeAllIDs) + } + if !contains(audit.events, "auth.oidc_back_channel_logout") { + t.Errorf("expected audit event with outcome=revoke_failed") + } +} + func TestBackChannelLogout_HappyPath_RevokesSid(t *testing.T) { bcl := &stubBCLVerifier{issuer: "https://idp", sid: "ses-xyz"} sess := &stubSession{} - h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl) + h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, sess, bcl) req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout", strings.NewReader("logout_token=eyJ.payload.sig")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -493,7 +667,7 @@ func TestBackChannelLogout_HappyPath_RevokesSid(t *testing.T) { } func TestBackChannelLogout_MissingTokenReturns400(t *testing.T) { - h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout", strings.NewReader("")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() @@ -509,7 +683,7 @@ func TestBackChannelLogout_MissingTokenReturns400(t *testing.T) { func TestLogout_HappyPath(t *testing.T) { sess := &stubSession{validateRes: &sessiondomain.Session{ID: "ses-abc", ActorID: "u-x", ActorType: "User"}} - h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, sess, &stubBCLVerifier{}) + h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, sess, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil) req = withActor(req, "u-x", "User") @@ -528,7 +702,7 @@ func TestLogout_HappyPath(t *testing.T) { } func TestLogout_NoCookie_Returns204(t *testing.T) { - h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil) req = withActor(req, "u-x", "User") w := httptest.NewRecorder() @@ -543,7 +717,7 @@ func TestLogout_NoCookie_Returns204(t *testing.T) { // ============================================================================= func TestListSessions_OwnSessions(t *testing.T) { - h, _, _, sessRepo, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, sessRepo, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) now := time.Now() sessRepo.rows["ses-1"] = &sessiondomain.Session{ ID: "ses-1", ActorID: "u-x", ActorType: "User", @@ -563,7 +737,7 @@ func TestListSessions_OwnSessions(t *testing.T) { } func TestRevokeSession_HappyPath(t *testing.T) { - h, _, _, sessRepo, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, sessRepo, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) sessRepo.rows["ses-rev"] = &sessiondomain.Session{ID: "ses-rev", ActorID: "u-x", ActorType: "User"} req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/ses-rev", nil) req.SetPathValue("id", "ses-rev") @@ -579,7 +753,7 @@ func TestRevokeSession_HappyPath(t *testing.T) { } func TestRevokeSession_NotFound(t *testing.T) { - h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/ses-nope", nil) req.SetPathValue("id", "ses-nope") req = withActor(req, "u-x", "User") @@ -595,7 +769,7 @@ func TestRevokeSession_NotFound(t *testing.T) { // ============================================================================= func TestListProviders(t *testing.T) { - h, provRepo, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, provRepo, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) provRepo.provs = []*oidcdomain.OIDCProvider{ {ID: "op-x", Name: "Okta", IssuerURL: "https://x", ClientID: "c"}, } @@ -612,7 +786,7 @@ func TestListProviders(t *testing.T) { } func TestCreateProvider_MissingClientSecret(t *testing.T) { - h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) body := strings.NewReader(`{"name":"x","issuer_url":"https://x","client_id":"c","redirect_uri":"https://r","groups_claim_path":"groups","groups_claim_format":"string-array"}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/providers", body) req = withActor(req, "u-admin", "User") @@ -624,7 +798,7 @@ func TestCreateProvider_MissingClientSecret(t *testing.T) { } func TestDeleteProvider_InUse_Returns409(t *testing.T) { - h, provRepo, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, provRepo, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) provRepo.deleteErr = repository.ErrOIDCProviderInUse req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/providers/op-x", nil) req.SetPathValue("id", "op-x") @@ -638,7 +812,7 @@ func TestDeleteProvider_InUse_Returns409(t *testing.T) { func TestRefreshProvider_HappyPath(t *testing.T) { o := &stubOIDCSvc{} - h, _, _, _, audit := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, audit, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/providers/op-x/refresh", nil) req.SetPathValue("id", "op-x") req = withActor(req, "u-admin", "User") @@ -657,7 +831,7 @@ func TestRefreshProvider_HappyPath(t *testing.T) { // ============================================================================= func TestListGroupMappings_MissingProviderID(t *testing.T) { - h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/group-mappings", nil) req = withActor(req, "u-admin", "User") w := httptest.NewRecorder() @@ -668,7 +842,7 @@ func TestListGroupMappings_MissingProviderID(t *testing.T) { } func TestAddGroupMapping_HappyPath(t *testing.T) { - h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) body := strings.NewReader(`{"provider_id":"op-x","group_name":"engineers","role_id":"r-operator"}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/group-mappings", body) req = withActor(req, "u-admin", "User") @@ -683,7 +857,7 @@ func TestAddGroupMapping_HappyPath(t *testing.T) { } func TestRemoveGroupMapping_NotFound(t *testing.T) { - h, _, mapRepo, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, mapRepo, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) mapRepo.rmErr = repository.ErrGroupRoleMappingNotFound req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/group-mappings/grm-x", nil) req.SetPathValue("id", "grm-x") @@ -749,7 +923,7 @@ func TestClientIPFromRequest(t *testing.T) { func TestNewAuthSessionOIDCHandler_DefaultsPostLoginURL(t *testing.T) { h := NewAuthSessionOIDCHandler( &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}, - &stubProviderRepo{}, &stubMappingRepo{}, newStubSessionRepo(), &phase5StubAudit{}, + &stubProviderRepo{}, &stubMappingRepo{}, newStubSessionRepo(), &stubUserRepo{}, &phase5StubAudit{}, "key", "t-default", "", // empty postLoginURL SessionCookieAttrs{}, ) @@ -827,7 +1001,7 @@ func TestPeekIssuer_RejectsBadSegmentCount(t *testing.T) { } func TestCreateProvider_HappyPath(t *testing.T) { - h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) body := strings.NewReader(`{"name":"OktaTest","issuer_url":"https://example.okta.com","client_id":"c","client_secret":"s","redirect_uri":"https://r/cb","groups_claim_path":"groups","groups_claim_format":"string-array","scopes":["openid","profile","email"]}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/providers", body) req = withActor(req, "u-admin", "User") @@ -842,7 +1016,7 @@ func TestCreateProvider_HappyPath(t *testing.T) { } func TestCreateProvider_DuplicateName_Returns409(t *testing.T) { - h, provRepo, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, provRepo, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) provRepo.createErr = repository.ErrOIDCProviderDuplicateName body := strings.NewReader(`{"name":"DupTest","issuer_url":"https://example.okta.com","client_id":"c","client_secret":"s","redirect_uri":"https://r/cb","groups_claim_path":"groups","groups_claim_format":"string-array","scopes":["openid"]}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/providers", body) @@ -855,7 +1029,7 @@ func TestCreateProvider_DuplicateName_Returns409(t *testing.T) { } func TestCreateProvider_InvalidJSON_Returns400(t *testing.T) { - h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/providers", strings.NewReader("{not-json")) req = withActor(req, "u-admin", "User") w := httptest.NewRecorder() @@ -866,7 +1040,7 @@ func TestCreateProvider_InvalidJSON_Returns400(t *testing.T) { } func TestUpdateProvider_HappyPath(t *testing.T) { - h, provRepo, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, provRepo, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) provRepo.provs = []*oidcdomain.OIDCProvider{ { ID: "op-x", TenantID: "t-default", Name: "Old", @@ -891,7 +1065,7 @@ func TestUpdateProvider_HappyPath(t *testing.T) { } func TestUpdateProvider_NotFound(t *testing.T) { - h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) body := strings.NewReader(`{"name":"X"}`) req := httptest.NewRequest(http.MethodPut, "/api/v1/auth/oidc/providers/op-missing", body) req.SetPathValue("id", "op-missing") @@ -905,7 +1079,7 @@ func TestUpdateProvider_NotFound(t *testing.T) { func TestRefreshProvider_NotFound(t *testing.T) { o := &stubOIDCSvc{refreshErr: repository.ErrOIDCProviderNotFound} - h, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/providers/op-missing/refresh", nil) req.SetPathValue("id", "op-missing") req = withActor(req, "u-admin", "User") @@ -917,7 +1091,7 @@ func TestRefreshProvider_NotFound(t *testing.T) { } func TestListGroupMappings_HappyPath(t *testing.T) { - h, _, mapRepo, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, mapRepo, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) mapRepo.mappings = []*oidcdomain.GroupRoleMapping{ {ID: "grm-1", ProviderID: "op-x", GroupName: "engineers", RoleID: "r-operator", TenantID: "t-default"}, } @@ -931,7 +1105,7 @@ func TestListGroupMappings_HappyPath(t *testing.T) { } func TestAddGroupMapping_Duplicate_Returns409(t *testing.T) { - h, _, mapRepo, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, mapRepo, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) mapRepo.addErr = repository.ErrGroupRoleMappingDuplicate body := strings.NewReader(`{"provider_id":"op-x","group_name":"g","role_id":"r-operator"}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/group-mappings", body) @@ -944,7 +1118,7 @@ func TestAddGroupMapping_Duplicate_Returns409(t *testing.T) { } func TestRemoveGroupMapping_HappyPath(t *testing.T) { - h, _, _, _, audit := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/group-mappings/grm-x", nil) req.SetPathValue("id", "grm-x") req = withActor(req, "u-admin", "User") @@ -959,7 +1133,7 @@ func TestRemoveGroupMapping_HappyPath(t *testing.T) { } func TestRevokeSession_MissingID(t *testing.T) { - h, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/", nil) req = withActor(req, "u-x", "User") w := httptest.NewRecorder() @@ -970,7 +1144,7 @@ func TestRevokeSession_MissingID(t *testing.T) { } func TestListSessions_AsAdmin_QueryActorID(t *testing.T) { - h, _, _, sessRepo, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) + h, _, _, sessRepo, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{}) now := time.Now() sessRepo.rows["ses-other"] = &sessiondomain.Session{ ID: "ses-other", ActorID: "u-other", ActorType: "User",