Files
certctl/internal/api/handler/auth_session_oidc_test.go
T
shankar0123 9a8130de32 harden(auth/sessions): CSRF rotation on logout closes HIGH-2 fourth call site
Audit 2026-05-11 Fix 13 closure. The HIGH-2 closure on
dev/auth-bundle-2 documented four RotateCSRFTokenForActor call
sites — login completion (fresh by construction), Assign/Revoke
RoleToKey (wired at internal/api/handler/auth.go:498 + 546),
Logout, and an explicit operator endpoint. The 2026-05-11
adversarial review observed only 3 of the 4: Logout did NOT
rotate the actor's sibling sessions post-revoke.

Threat closed: a token captured pre-logout (browser DevTools,
malicious extension, session-storage leak) could be replayed
against the user's other-device/other-browser sessions until
those sessions hit their own idle/absolute expiry. Rotation on
logout defeats this — the captured token is dead the moment
the user clicks 'Sign out' anywhere.

What this changes:

* internal/api/handler/auth_session_oidc.go::SessionMinter
  interface gains RotateCSRFTokenForActor(ctx, actorID,
  actorType string) int. Nil-safe semantics by convention —
  the production wiring is *session.Service which already
  implements the method; rotation NEVER errors (returns int
  count, swallows per-row failures via the underlying
  Service.RotateCSRFToken) so it can't block the surrounding
  Revoke that triggered it.

* internal/api/handler/auth_session_oidc.go::Logout calls
  RotateCSRFTokenForActor after Revoke(sess.ID) succeeds. The
  auth.session_revoked audit row gains a csrf_rotated detail
  key carrying the count so SOC/SIEM can correlate logout
  events with CSRF churn on sibling sessions.

* The no-cookie + invalid-cookie 204 short-circuit paths
  skip rotation. No session row exists to rotate against;
  the caller is already unauthenticated. Rotation on those
  paths would do nothing useful and pollute the audit log.

Test coverage in internal/api/handler/auth_session_oidc_test.go:

* TestLogout_RotatesCSRFForActor — happy path. Mocks
  rotateCSRFReturnCount=2; asserts Revoke fires before
  rotation, rotation fires exactly once with caller's
  (actor_id, actor_type), audit details carry csrf_rotated=2.

* TestLogout_NoCookie_SkipsCSRFRotation — pins the 204
  short-circuit branch when there's no cookie. Rotation count
  stays at 0.

* TestLogout_InvalidCookie_SkipsCSRFRotation — pins the 204
  short-circuit branch when Validate rejects the cookie.
  Same rationale: no session row, no rotation.

The stubSession test fake gains RotateCSRFTokenForActor with
call-recording fields; the phase5StubAudit gains a details
slice append-aligned 1:1 with events so the happy-path test
can index into the latest entry and assert the count.

Spec Phase 3 (explicit operator endpoint) — intentionally
NOT shipped. The three automatic triggers (login + role-
mutation + logout) cover the HIGH-2 threat model; operators
who want a nuclear option can use the existing
RevokeAllForActor flow which forces re-login → fresh session
→ fresh CSRF. Adding a dedicated POST /api/v1/auth/sessions/
rotate-csrf admin endpoint would be defense-in-depth without
new attack-surface coverage. Documented in the audit-doc
annotation.

Verify gate:

* gofmt -l — clean
* go vet ./internal/api/handler/... — clean
* go build ./cmd/server/... ./internal/... — clean (production
  *session.Service satisfies the extended interface
  out of the box)
* go test -short -count=1 ./internal/api/handler/...
  ./internal/auth/session/... — all green; 3 new Logout
  cases + the 2 pre-existing Logout cases all pass.

Audit doc annotation at cowork/auth-bundles-audit-2026-05-10.md
flips the HIGH-2 row from 'CLOSED 2026-05-10 (3/4 call sites
wired)' to 'A-B-3 verified 2026-05-11: HIGH-2 fully closed
across all four documented call sites.'

Refs cowork/auth-bundles-fixes-2026-05-11/13-verify-logout-csrf-rotation.md.
2026-05-11 12:24:41 +00:00

1440 lines
57 KiB
Go

package handler
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/certctl-io/certctl/internal/auth"
oidcsvc "github.com/certctl-io/certctl/internal/auth/oidc"
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
sessionsvc "github.com/certctl-io/certctl/internal/auth/session"
sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain"
userdomain "github.com/certctl-io/certctl/internal/auth/user/domain"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/repository"
)
// authWithActor builds a context indistinguishable from what the auth
// middleware would set after a successful Bearer-or-cookie auth.
func authWithActor(ctx context.Context, actorID, actorType string) context.Context {
ctx = context.WithValue(ctx, auth.ActorIDKey{}, actorID)
ctx = context.WithValue(ctx, auth.ActorTypeKey{}, actorType)
ctx = context.WithValue(ctx, auth.TenantIDKey{}, "t-default")
return ctx
}
// =============================================================================
// In-memory stubs.
// =============================================================================
type stubOIDCSvc struct {
authURL string
cookie string
preLoginID string
authReqErr error
callbackRes *oidcsvc.CallbackResult
callbackErr error
refreshErr error
// Audit 2026-05-10 MED-5 — stub for the TestDiscovery dry-run.
// When testResult is non-nil, the handler-level type assertion
// resolves and the response carries this verbatim.
testResult *oidcsvc.TestDiscoveryResult
testErr error
}
func (s *stubOIDCSvc) HandleAuthRequest(_ context.Context, _, _, _ string) (string, string, string, error) {
return s.authURL, s.cookie, s.preLoginID, s.authReqErr
}
func (s *stubOIDCSvc) HandleCallback(_ context.Context, _, _, _, _, _, _ string) (*oidcsvc.CallbackResult, error) {
return s.callbackRes, s.callbackErr
}
func (s *stubOIDCSvc) RefreshKeys(_ context.Context, _ string) error { return s.refreshErr }
// TestDiscovery satisfies the inline discoveryTester interface used by
// the TestProvider HTTP handler. Audit 2026-05-10 MED-5.
func (s *stubOIDCSvc) TestDiscovery(_ context.Context, _ string) (*oidcsvc.TestDiscoveryResult, error) {
return s.testResult, s.testErr
}
type stubSession struct {
createRes *sessionsvc.CreateResult
createErr error
validateRes *sessiondomain.Session
validateErr error
revokeErr error
revokeAllErr error
revokedIDs []string
revokeAllIDs []string
revokeAllTypes []string
// Audit 2026-05-11 Fix 13 — record RotateCSRFTokenForActor calls so
// the Logout test can assert HIGH-2's fourth call site fires.
rotateCSRFCalls int
rotateCSRFActorIDs []string
rotateCSRFActorTypes []string
rotateCSRFReturnCount int
}
func (s *stubSession) Create(_ context.Context, _, _, _, _ string) (*sessionsvc.CreateResult, error) {
return s.createRes, s.createErr
}
func (s *stubSession) Validate(_ context.Context, _ sessionsvc.ValidateInput) (*sessiondomain.Session, error) {
return s.validateRes, s.validateErr
}
func (s *stubSession) Revoke(_ context.Context, id string) error {
s.revokedIDs = append(s.revokedIDs, id)
return s.revokeErr
}
func (s *stubSession) RevokeAllForActor(_ context.Context, actorID, actorType string) error {
s.revokeAllIDs = append(s.revokeAllIDs, actorID)
s.revokeAllTypes = append(s.revokeAllTypes, actorType)
return s.revokeAllErr
}
func (s *stubSession) RotateCSRFTokenForActor(_ context.Context, actorID, actorType string) int {
s.rotateCSRFCalls++
s.rotateCSRFActorIDs = append(s.rotateCSRFActorIDs, actorID)
s.rotateCSRFActorTypes = append(s.rotateCSRFActorTypes, actorType)
return s.rotateCSRFReturnCount
}
type stubBCLVerifier struct {
issuer string
sub string
sid string
jti string
iat int64
err error
}
func (s *stubBCLVerifier) Verify(_ context.Context, _ string) (string, string, string, string, int64, error) {
return s.issuer, s.sub, s.sid, s.jti, s.iat, s.err
}
// stubProviderRepo implements just enough of repository.OIDCProviderRepository.
type stubProviderRepo struct {
provs []*oidcdomain.OIDCProvider
getErr error
deleteErr error
createErr error
updateErr error
}
func (s *stubProviderRepo) List(_ context.Context, _ string) ([]*oidcdomain.OIDCProvider, error) {
return s.provs, nil
}
func (s *stubProviderRepo) Get(_ context.Context, id string) (*oidcdomain.OIDCProvider, error) {
if s.getErr != nil {
return nil, s.getErr
}
for _, p := range s.provs {
if p.ID == id {
return p, nil
}
}
return nil, repository.ErrOIDCProviderNotFound
}
func (s *stubProviderRepo) GetByName(_ context.Context, _, _ string) (*oidcdomain.OIDCProvider, error) {
return nil, repository.ErrOIDCProviderNotFound
}
func (s *stubProviderRepo) Create(_ context.Context, p *oidcdomain.OIDCProvider) error {
if s.createErr != nil {
return s.createErr
}
s.provs = append(s.provs, p)
return nil
}
func (s *stubProviderRepo) Update(_ context.Context, _ *oidcdomain.OIDCProvider) error {
return s.updateErr
}
func (s *stubProviderRepo) Delete(_ context.Context, _ string) error { return s.deleteErr }
type stubMappingRepo struct {
mappings []*oidcdomain.GroupRoleMapping
addErr error
rmErr error
}
func (s *stubMappingRepo) ListByProvider(_ context.Context, _ string) ([]*oidcdomain.GroupRoleMapping, error) {
return s.mappings, nil
}
func (s *stubMappingRepo) Get(_ context.Context, _ string) (*oidcdomain.GroupRoleMapping, error) {
return nil, repository.ErrGroupRoleMappingNotFound
}
func (s *stubMappingRepo) Add(_ context.Context, m *oidcdomain.GroupRoleMapping) error {
if s.addErr != nil {
return s.addErr
}
s.mappings = append(s.mappings, m)
return nil
}
func (s *stubMappingRepo) Remove(_ context.Context, _ string) error { return s.rmErr }
func (s *stubMappingRepo) Map(_ context.Context, _ string, _ []string) ([]string, error) {
return nil, nil
}
type stubSessionRepo struct {
rows map[string]*sessiondomain.Session
}
func newStubSessionRepo() *stubSessionRepo {
return &stubSessionRepo{rows: make(map[string]*sessiondomain.Session)}
}
func (s *stubSessionRepo) Create(_ context.Context, sess *sessiondomain.Session) error {
s.rows[sess.ID] = sess
return nil
}
func (s *stubSessionRepo) Get(_ context.Context, id string) (*sessiondomain.Session, error) {
r, ok := s.rows[id]
if !ok {
return nil, repository.ErrSessionNotFound
}
return r, nil
}
func (s *stubSessionRepo) ListByActor(_ context.Context, actorID, actorType, _ string) ([]*sessiondomain.Session, error) {
var out []*sessiondomain.Session
for _, r := range s.rows {
if r.ActorID == actorID && r.ActorType == actorType {
out = append(out, r)
}
}
return out, nil
}
func (s *stubSessionRepo) UpdateLastSeen(_ context.Context, _ string) error { return nil }
func (s *stubSessionRepo) UpdateCSRFTokenHash(_ context.Context, _, _ string) error {
return nil
}
func (s *stubSessionRepo) Revoke(_ context.Context, id string) error {
if r, ok := s.rows[id]; ok {
t := time.Now()
r.RevokedAt = &t
}
return nil
}
func (s *stubSessionRepo) RevokeAllForActor(_ context.Context, _, _, _ string) error { return nil }
func (s *stubSessionRepo) RevokeAllExceptForActor(_ context.Context, _, _, _, _ string) (int, error) {
return 0, nil
}
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
// Audit 2026-05-11 Fix 13 — capture the details map so the
// TestLogout_RotatesCSRFForActor case can assert the rotated
// count carried by the auth.session_revoked row. Existing tests
// only consume `events`; details is append-aligned 1:1 with
// events for easy index-based correlation.
details []map[string]interface{}
}
func (s *phase5StubAudit) RecordEventWithCategory(_ context.Context, _ string, _ domain.ActorType, action, _, _, _ string, details map[string]interface{}) error {
s.events = append(s.events, action)
s.details = append(s.details, details)
return nil
}
// =============================================================================
// Helpers.
// =============================================================================
func newPhase5Handler(
t *testing.T,
oidcSvc *stubOIDCSvc,
sess *stubSession,
bcl *stubBCLVerifier,
) (*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, userRepo, audit,
"", "t-default", "/dashboard",
SessionCookieAttrs{SameSite: http.SameSiteLaxMode, Secure: true},
)
return h, provRepo, mapRepo, sessRepo, audit, userRepo
}
// withActor adds the same context keys the auth middleware would set.
func withActor(req *http.Request, actorID, actorType string) *http.Request {
ctx := req.Context()
// Use the same context-key constants the production auth package
// sets via NewDemoModeAuth — since we don't have a clean export,
// rely on the auth package's GetActorID accessors. The handler
// uses callerFromRequest which calls auth.GetActorID etc.
// Easiest: use auth.WithActor helper which is in
// internal/auth/testfixtures.go (Bundle 1 Phase 0).
return req.WithContext(authWithActor(ctx, actorID, actorType))
}
// =============================================================================
// 1. /auth/oidc/login — happy path + missing provider param.
// =============================================================================
func TestLoginInitiate_HappyPath(t *testing.T) {
o := &stubOIDCSvc{
authURL: "https://idp/authorize?state=x&nonce=y",
cookie: "v1.pl-abc.sk-xyz.somemac",
preLoginID: "pl-abc",
}
h, _, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/login?provider=op-x", nil)
w := httptest.NewRecorder()
h.LoginInitiate(w, req)
if w.Code != http.StatusFound {
t.Errorf("status = %d; want 302", w.Code)
}
if loc := w.Header().Get("Location"); !strings.Contains(loc, "idp/authorize") {
t.Errorf("Location header missing IdP URL: %q", loc)
}
cookies := w.Result().Cookies()
hasPreLogin := false
for _, c := range cookies {
if c.Name == sessiondomain.PreLoginCookieName && c.Value == o.cookie {
hasPreLogin = true
}
}
if !hasPreLogin {
t.Errorf("pre-login cookie not set")
}
}
func TestLoginInitiate_MissingProvider(t *testing.T) {
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil)
w := httptest.NewRecorder()
h.LoginInitiate(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d; want 400", w.Code)
}
}
func TestLoginInitiate_ProviderNotFound(t *testing.T) {
o := &stubOIDCSvc{authReqErr: repository.ErrOIDCProviderNotFound}
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)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d; want 404", w.Code)
}
}
// =============================================================================
// 2. /auth/oidc/callback — happy path + 3 spec-mandated negatives.
// =============================================================================
func TestLoginCallback_HappyPath(t *testing.T) {
user := &userdomain.User{ID: "u-alice"}
o := &stubOIDCSvc{callbackRes: &oidcsvc.CallbackResult{
User: user,
RoleIDs: []string{"r-operator"},
CookieValue: "v1.ses-abc.sk-xyz.mac",
CSRFToken: "csrf-token-value",
}}
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()
h.LoginCallback(w, req)
if w.Code != http.StatusFound {
t.Errorf("status = %d; want 302", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/dashboard" {
t.Errorf("Location = %q; want /dashboard", loc)
}
if !contains(audit.events, "auth.oidc_login_succeeded") {
t.Errorf("expected auth.oidc_login_succeeded audit event; got %v", audit.events)
}
if !contains(audit.events, "auth.session_created") {
t.Errorf("expected auth.session_created audit event")
}
}
// Phase 5 spec mandate #4: Callback with replayed state -> 302 to /login.
// (The OIDC service's PreLoginStore.LookupAndConsume returns
// ErrPreLoginNotFound on the second call; Audit 2026-05-10 HIGH-7
// flipped this from a blank 400 to a 302 to /login?error=oidc_failed
// &reason=<category>. The audit row still records failure_category.)
func TestLoginCallback_ReplayedState_Returns400(t *testing.T) {
o := &stubOIDCSvc{callbackErr: oidcsvc.ErrPreLoginNotFound}
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()
h.LoginCallback(w, req)
if w.Code != http.StatusFound {
t.Errorf("status = %d; want 302 (post-HIGH-7 redirect)", w.Code)
}
if loc := w.Header().Get("Location"); !strings.HasPrefix(loc, "/login?error=oidc_failed&reason=") {
t.Errorf("Location = %q; want /login?error=oidc_failed&reason=...", loc)
}
if !contains(audit.events, "auth.oidc_login_failed") {
t.Errorf("expected auth.oidc_login_failed audit event; got %v", audit.events)
}
}
// Phase 5 spec mandate #5: Callback with PKCE verifier mismatch -> 302.
// The OIDC service's code-exchange step fails when the verifier doesn't
// match the challenge; HIGH-7 redirects to /login with reason.
func TestLoginCallback_PKCEVerifierMismatch_Returns400(t *testing.T) {
o := &stubOIDCSvc{callbackErr: errors.New("oidc: code exchange failed: invalid_grant")}
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()
h.LoginCallback(w, req)
if w.Code != http.StatusFound {
t.Errorf("status = %d; want 302 (post-HIGH-7 redirect)", w.Code)
}
if loc := w.Header().Get("Location"); !strings.HasPrefix(loc, "/login?error=oidc_failed") {
t.Errorf("Location = %q; want /login?error=oidc_failed&reason=...", loc)
}
}
// Phase 5 spec mandate #6: Callback with expired pre-login row -> 302.
func TestLoginCallback_ExpiredPreLoginRow_Returns400(t *testing.T) {
// Adapter maps ErrPreLoginExpired -> ErrPreLoginNotFound; HIGH-7
// flipped the wire shape from 400 to a 302 redirect (specific
// reason still in audit row).
o := &stubOIDCSvc{callbackErr: oidcsvc.ErrPreLoginNotFound}
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()
h.LoginCallback(w, req)
if w.Code != http.StatusFound {
t.Errorf("status = %d; want 302 (post-HIGH-7 redirect)", w.Code)
}
}
func TestLoginCallback_MissingPreLoginCookie_Returns400(t *testing.T) {
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)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d; want 400", w.Code)
}
if !contains(audit.events, "auth.oidc_login_failed") {
t.Errorf("expected auth.oidc_login_failed audit; got %v", audit.events)
}
}
func TestLoginCallback_UnmappedGroups_AuditRowDistinguished(t *testing.T) {
o := &stubOIDCSvc{callbackErr: oidcsvc.ErrGroupsUnmapped}
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()
h.LoginCallback(w, req)
if w.Code != http.StatusFound {
t.Errorf("status = %d; want 302 (post-HIGH-7 redirect)", w.Code)
}
if loc := w.Header().Get("Location"); !strings.Contains(loc, "reason=unmapped_groups") {
t.Errorf("Location = %q; want reason=unmapped_groups", loc)
}
if !contains(audit.events, "auth.oidc_login_unmapped_groups") {
t.Errorf("expected auth.oidc_login_unmapped_groups; got %v", audit.events)
}
}
// =============================================================================
// 3. /auth/oidc/back-channel-logout — 3 spec-mandated negatives.
// =============================================================================
// 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)
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.StatusBadRequest {
t.Errorf("status = %d; want 400", w.Code)
}
if !contains(audit.events, "auth.oidc_back_channel_logout_failed") {
t.Errorf("expected failure audit event; got %v", audit.events)
}
}
// 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)
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.StatusBadRequest {
t.Errorf("status = %d; want 400", w.Code)
}
}
// 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)
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.StatusBadRequest {
t.Errorf("status = %d; want 400", w.Code)
}
}
// TestBackChannelLogout_HappyPath_RevokesSubject pins the CRIT-2
// closure happy-path: an IdP fires BCL with sub=<oidc-subject>, 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: "alice@example.com"}
sess := &stubSession{}
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")
w := httptest.NewRecorder()
h.BackChannelLogout(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d; want 200", 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) != 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)
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", w.Code)
}
if len(sess.revokedIDs) != 1 || sess.revokedIDs[0] != "ses-xyz" {
t.Errorf("expected Revoke(ses-xyz); got %v", sess.revokedIDs)
}
}
func TestBackChannelLogout_MissingTokenReturns400(t *testing.T) {
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()
h.BackChannelLogout(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d; want 400", w.Code)
}
}
// =============================================================================
// 4. /auth/logout — happy path.
// =============================================================================
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{})
req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
req = withActor(req, "u-x", "User")
req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses-abc.sk-xyz.mac"})
w := httptest.NewRecorder()
h.Logout(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d; want 204", w.Code)
}
if len(sess.revokedIDs) != 1 || sess.revokedIDs[0] != "ses-abc" {
t.Errorf("expected Revoke(ses-abc); got %v", sess.revokedIDs)
}
if !contains(audit.events, "auth.session_revoked") {
t.Errorf("expected auth.session_revoked audit; got %v", audit.events)
}
}
func TestLogout_NoCookie_Returns204(t *testing.T) {
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
req = withActor(req, "u-x", "User")
w := httptest.NewRecorder()
h.Logout(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d; want 204", w.Code)
}
}
// TestLogout_RotatesCSRFForActor pins the HIGH-2 fourth call site
// (Audit 2026-05-11 Fix 13). After Revoke succeeds, the handler must
// call RotateCSRFTokenForActor with the caller's (actorID, actorType)
// pair so a token captured pre-logout (browser DevTools, malicious
// extension) can't be replayed against a sibling session after the
// user logged out here. The audit row must record the rotated count
// so SOC / SIEM can correlate logout events with CSRF churn.
func TestLogout_RotatesCSRFForActor(t *testing.T) {
sess := &stubSession{
validateRes: &sessiondomain.Session{ID: "ses-abc", ActorID: "u-x", ActorType: "User"},
rotateCSRFReturnCount: 2, // caller has 2 active sessions before logout
}
h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, sess, &stubBCLVerifier{})
req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
req = withActor(req, "u-x", "User")
req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses-abc.sk-xyz.mac"})
w := httptest.NewRecorder()
h.Logout(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d; want 204", w.Code)
}
// Rotation MUST fire exactly once with the caller's (actor_id, actor_type).
if sess.rotateCSRFCalls != 1 {
t.Errorf("RotateCSRFTokenForActor call count = %d; want 1", sess.rotateCSRFCalls)
}
if len(sess.rotateCSRFActorIDs) != 1 || sess.rotateCSRFActorIDs[0] != "u-x" {
t.Errorf("rotateCSRF actor_ids = %v; want [u-x]", sess.rotateCSRFActorIDs)
}
if len(sess.rotateCSRFActorTypes) != 1 || sess.rotateCSRFActorTypes[0] != "User" {
t.Errorf("rotateCSRF actor_types = %v; want [User]", sess.rotateCSRFActorTypes)
}
// Revoke must still fire BEFORE rotation — pin the ordering by
// asserting the revokedIDs collection has been populated.
if len(sess.revokedIDs) != 1 || sess.revokedIDs[0] != "ses-abc" {
t.Errorf("expected Revoke(ses-abc) to fire; got revokedIDs=%v", sess.revokedIDs)
}
// Audit row carries the rotated count so SOC / SIEM can correlate
// logout events with CSRF churn on sibling sessions.
if !contains(audit.events, "auth.session_revoked") {
t.Fatalf("expected auth.session_revoked audit; got %v", audit.events)
}
last := audit.details[len(audit.details)-1]
if got, _ := last["csrf_rotated"].(int); got != 2 {
t.Errorf("audit details csrf_rotated = %v; want 2", last["csrf_rotated"])
}
}
// TestLogout_NoCookie_SkipsCSRFRotation pins the "no session →
// short-circuit" path. When the caller has no session cookie, Logout
// returns 204 immediately without touching Revoke OR the rotator —
// rotating CSRF for a caller who's already logged out (or never logged
// in) would do nothing useful and pollutes the audit log.
func TestLogout_NoCookie_SkipsCSRFRotation(t *testing.T) {
sess := &stubSession{}
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, sess, &stubBCLVerifier{})
req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
req = withActor(req, "u-x", "User")
w := httptest.NewRecorder()
h.Logout(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d; want 204", w.Code)
}
if sess.rotateCSRFCalls != 0 {
t.Errorf("RotateCSRFTokenForActor called %d times on the no-cookie path; want 0",
sess.rotateCSRFCalls)
}
}
// TestLogout_InvalidCookie_SkipsCSRFRotation pins the "invalid cookie
// → 204 + clear" path. Same rationale as the no-cookie test — there's
// no session row to rotate against, and the caller is already
// unauthenticated.
func TestLogout_InvalidCookie_SkipsCSRFRotation(t *testing.T) {
sess := &stubSession{validateErr: errors.New("invalid session")}
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, sess, &stubBCLVerifier{})
req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
req = withActor(req, "u-x", "User")
req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.junk.sk.mac"})
w := httptest.NewRecorder()
h.Logout(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d; want 204", w.Code)
}
if sess.rotateCSRFCalls != 0 {
t.Errorf("RotateCSRFTokenForActor called %d times on the invalid-cookie path; want 0",
sess.rotateCSRFCalls)
}
}
// =============================================================================
// 5. /api/v1/auth/sessions — list + revoke.
// =============================================================================
func TestListSessions_OwnSessions(t *testing.T) {
h, _, _, sessRepo, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
now := time.Now()
sessRepo.rows["ses-1"] = &sessiondomain.Session{
ID: "ses-1", ActorID: "u-x", ActorType: "User",
IdleExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(8 * time.Hour),
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/sessions", nil)
req = withActor(req, "u-x", "User")
w := httptest.NewRecorder()
h.ListSessions(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d; want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "ses-1") {
t.Errorf("response missing session id; body = %q", body)
}
}
func TestRevokeSession_HappyPath(t *testing.T) {
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")
req = withActor(req, "u-x", "User")
w := httptest.NewRecorder()
h.RevokeSession(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d; want 204", w.Code)
}
if !contains(audit.events, "auth.session_revoked") {
t.Errorf("expected auth.session_revoked audit; got %v", audit.events)
}
}
func TestRevokeSession_NotFound(t *testing.T) {
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")
w := httptest.NewRecorder()
h.RevokeSession(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d; want 404", w.Code)
}
}
// =============================================================================
// 6. OIDC provider CRUD.
// =============================================================================
func TestListProviders(t *testing.T) {
h, provRepo, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
provRepo.provs = []*oidcdomain.OIDCProvider{
{ID: "op-x", Name: "Okta", IssuerURL: "https://x", ClientID: "c"},
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/providers", nil)
req = withActor(req, "u-admin", "User")
w := httptest.NewRecorder()
h.ListProviders(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d; want 200", w.Code)
}
if !strings.Contains(w.Body.String(), "op-x") {
t.Errorf("response missing provider id")
}
}
func TestCreateProvider_MissingClientSecret(t *testing.T) {
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")
w := httptest.NewRecorder()
h.CreateProvider(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d; want 400", w.Code)
}
}
func TestDeleteProvider_InUse_Returns409(t *testing.T) {
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")
req = withActor(req, "u-admin", "User")
w := httptest.NewRecorder()
h.DeleteProvider(w, req)
if w.Code != http.StatusConflict {
t.Errorf("status = %d; want 409", w.Code)
}
}
func TestRefreshProvider_HappyPath(t *testing.T) {
o := &stubOIDCSvc{}
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")
w := httptest.NewRecorder()
h.RefreshProvider(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d; want 200", w.Code)
}
if !contains(audit.events, "auth.oidc_provider_refreshed") {
t.Errorf("expected auth.oidc_provider_refreshed audit; got %v", audit.events)
}
}
// =============================================================================
// 7. Group-mapping CRUD.
// =============================================================================
func TestListGroupMappings_MissingProviderID(t *testing.T) {
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()
h.ListGroupMappings(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d; want 400", w.Code)
}
}
func TestAddGroupMapping_HappyPath(t *testing.T) {
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")
w := httptest.NewRecorder()
h.AddGroupMapping(w, req)
if w.Code != http.StatusCreated {
t.Errorf("status = %d; want 201", w.Code)
}
if !contains(audit.events, "auth.group_mapping_added") {
t.Errorf("expected auth.group_mapping_added audit; got %v", audit.events)
}
}
func TestRemoveGroupMapping_NotFound(t *testing.T) {
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")
req = withActor(req, "u-admin", "User")
w := httptest.NewRecorder()
h.RemoveGroupMapping(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d; want 404", w.Code)
}
}
// =============================================================================
// Helpers.
// =============================================================================
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// peekIssuer test (touches the BCL verifier helper directly).
func TestDefaultIfBlank(t *testing.T) {
if got := defaultIfBlank("", "x"); got != "x" {
t.Errorf("got %q; want x", got)
}
if got := defaultIfBlank("y", "x"); got != "y" {
t.Errorf("got %q; want y", got)
}
if got := defaultIfBlank(" ", "x"); got != "x" {
t.Errorf("got %q; want x (whitespace-only treated as blank)", got)
}
}
func TestDefaultIntIfZero(t *testing.T) {
if got := defaultIntIfZero(0, 5); got != 5 {
t.Errorf("got %d; want 5", got)
}
if got := defaultIntIfZero(7, 5); got != 7 {
t.Errorf("got %d; want 7", got)
}
}
func TestClientIPFromRequest(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "1.2.3.4:5555"
if ip := clientIPFromRequest(r); ip != "1.2.3.4" {
t.Errorf("RemoteAddr: got %q; want 1.2.3.4", ip)
}
r.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2")
if ip := clientIPFromRequest(r); ip != "10.0.0.1" {
t.Errorf("XFF first hop: got %q; want 10.0.0.1", ip)
}
r.Header.Set("X-Forwarded-For", "10.0.0.99")
if ip := clientIPFromRequest(r); ip != "10.0.0.99" {
t.Errorf("XFF single: got %q; want 10.0.0.99", ip)
}
}
func TestNewAuthSessionOIDCHandler_DefaultsPostLoginURL(t *testing.T) {
h := NewAuthSessionOIDCHandler(
&stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{},
&stubProviderRepo{}, &stubMappingRepo{}, newStubSessionRepo(), &stubUserRepo{}, &phase5StubAudit{},
"key", "t-default", "", // empty postLoginURL
SessionCookieAttrs{},
)
if h.postLoginURL != "/" {
t.Errorf("default postLoginURL = %q; want /", h.postLoginURL)
}
}
func TestEncryptClientSecret_EmptyKeyPassthrough(t *testing.T) {
h := &AuthSessionOIDCHandler{encryptionKey: ""}
got, err := h.encryptClientSecret([]byte("secret"))
if err != nil {
t.Fatalf("encryptClientSecret: %v", err)
}
if string(got) != "secret" {
t.Errorf("got %q; want secret (passthrough)", string(got))
}
}
func TestEncryptClientSecret_RealEncryption(t *testing.T) {
h := &AuthSessionOIDCHandler{encryptionKey: "test-passphrase-12345-abcdef"}
got, err := h.encryptClientSecret([]byte("secret"))
if err != nil {
t.Fatalf("encryptClientSecret: %v", err)
}
if string(got) == "secret" {
t.Errorf("encrypted output equals plaintext; encryption did not run")
}
}
func TestNewDefaultBCLVerifier_DefaultsAlgs(t *testing.T) {
v := NewDefaultBCLVerifier(&stubProviderRepo{}, "t-default", nil)
if len(v.allowedAlgs) == 0 {
t.Errorf("expected default allowedAlgs; got empty")
}
v2 := NewDefaultBCLVerifier(&stubProviderRepo{}, "t-default", []string{"RS256"})
if len(v2.allowedAlgs) != 1 || v2.allowedAlgs[0] != "RS256" {
t.Errorf("explicit alg list not honored: %v", v2.allowedAlgs)
}
}
func TestDefaultBCLVerifier_NoMatchingProviderRejected(t *testing.T) {
provs := &stubProviderRepo{provs: []*oidcdomain.OIDCProvider{
{ID: "op-x", IssuerURL: "https://different-idp"},
}}
v := NewDefaultBCLVerifier(provs, "t-default", nil)
// JWT with iss=https://idp (which doesn't match any registered provider).
// header={"alg":"RS256"}, payload={"iss":"https://idp"}.
jwt := "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2lkcCJ9.AAAA"
_, _, _, _, _, err := v.Verify(context.Background(), jwt)
if err == nil {
t.Errorf("expected error when iss doesn't match any registered provider")
}
}
func TestPeekIssuer_HappyPath(t *testing.T) {
// header.payload.sig where payload base64-decodes to {"iss":"https://idp"}.
header := "eyJhbGciOiJSUzI1NiJ9"
payload := "eyJpc3MiOiJodHRwczovL2lkcCJ9"
sig := "AAAA"
jwt := fmt.Sprintf("%s.%s.%s", header, payload, sig)
iss, err := peekIssuer(jwt)
if err != nil {
t.Fatalf("peekIssuer: %v", err)
}
if iss != "https://idp" {
t.Errorf("iss = %q; want https://idp", iss)
}
}
func TestPeekIssuer_RejectsBadSegmentCount(t *testing.T) {
if _, err := peekIssuer("just.two"); err == nil {
t.Errorf("expected error for 2-segment JWT")
}
}
func TestCreateProvider_HappyPath(t *testing.T) {
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")
w := httptest.NewRecorder()
h.CreateProvider(w, req)
if w.Code != http.StatusCreated {
t.Errorf("status = %d; want 201; body=%q", w.Code, w.Body.String())
}
if !contains(audit.events, "auth.oidc_provider_created") {
t.Errorf("expected auth.oidc_provider_created audit; got %v", audit.events)
}
}
func TestCreateProvider_DuplicateName_Returns409(t *testing.T) {
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)
req = withActor(req, "u-admin", "User")
w := httptest.NewRecorder()
h.CreateProvider(w, req)
if w.Code != http.StatusConflict {
t.Errorf("status = %d; want 409", w.Code)
}
}
func TestCreateProvider_InvalidJSON_Returns400(t *testing.T) {
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()
h.CreateProvider(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d; want 400", w.Code)
}
}
func TestUpdateProvider_HappyPath(t *testing.T) {
h, provRepo, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
provRepo.provs = []*oidcdomain.OIDCProvider{
{
ID: "op-x", TenantID: "t-default", Name: "Old",
IssuerURL: "https://x", ClientID: "c", ClientSecretEncrypted: []byte("blob"),
RedirectURI: "https://r/cb", GroupsClaimPath: "groups",
GroupsClaimFormat: "string-array", Scopes: []string{"openid"},
IATWindowSeconds: 300, JWKSCacheTTLSeconds: 3600,
},
}
body := strings.NewReader(`{"name":"NewName","issuer_url":"https://x","client_id":"c","redirect_uri":"https://r/cb","groups_claim_path":"groups","groups_claim_format":"string-array","scopes":["openid","email"]}`)
req := httptest.NewRequest(http.MethodPut, "/api/v1/auth/oidc/providers/op-x", body)
req.SetPathValue("id", "op-x")
req = withActor(req, "u-admin", "User")
w := httptest.NewRecorder()
h.UpdateProvider(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d; want 200; body=%q", w.Code, w.Body.String())
}
if !contains(audit.events, "auth.oidc_provider_updated") {
t.Errorf("expected auth.oidc_provider_updated audit; got %v", audit.events)
}
}
func TestUpdateProvider_NotFound(t *testing.T) {
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")
req = withActor(req, "u-admin", "User")
w := httptest.NewRecorder()
h.UpdateProvider(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d; want 404", w.Code)
}
}
func TestRefreshProvider_NotFound(t *testing.T) {
o := &stubOIDCSvc{refreshErr: repository.ErrOIDCProviderNotFound}
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")
w := httptest.NewRecorder()
h.RefreshProvider(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d; want 404", w.Code)
}
}
func TestListGroupMappings_HappyPath(t *testing.T) {
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"},
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/group-mappings?provider_id=op-x", nil)
req = withActor(req, "u-admin", "User")
w := httptest.NewRecorder()
h.ListGroupMappings(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d; want 200", w.Code)
}
}
func TestAddGroupMapping_Duplicate_Returns409(t *testing.T) {
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)
req = withActor(req, "u-admin", "User")
w := httptest.NewRecorder()
h.AddGroupMapping(w, req)
if w.Code != http.StatusConflict {
t.Errorf("status = %d; want 409", w.Code)
}
}
func TestRemoveGroupMapping_HappyPath(t *testing.T) {
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")
w := httptest.NewRecorder()
h.RemoveGroupMapping(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d; want 204", w.Code)
}
if !contains(audit.events, "auth.group_mapping_removed") {
t.Errorf("expected auth.group_mapping_removed audit")
}
}
func TestRevokeSession_MissingID(t *testing.T) {
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()
h.RevokeSession(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d; want 400", w.Code)
}
}
func TestListSessions_AsAdmin_QueryActorID(t *testing.T) {
h, _, _, sessRepo, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
now := time.Now()
sessRepo.rows["ses-other"] = &sessiondomain.Session{
ID: "ses-other", ActorID: "u-other", ActorType: "User",
IdleExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(8 * time.Hour),
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/sessions?actor_id=u-other&actor_type=User", nil)
req = withActor(req, "u-admin", "User")
w := httptest.NewRecorder()
h.ListSessions(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d; want 200", w.Code)
}
if !strings.Contains(w.Body.String(), "ses-other") {
t.Errorf("expected ses-other in response")
}
}
func TestClassifyOIDCFailure(t *testing.T) {
cases := []struct {
err error
want string
}{
{nil, "ok"},
{errors.New("oidc: pre-login session not found"), "pre_login_consume_failed"},
{errors.New("oidc: state parameter mismatch"), "state_mismatch"},
{errors.New("oidc: nonce mismatch"), "nonce_mismatch"},
{errors.New("oidc: audience mismatch"), "audience_mismatch"},
{errors.New("oidc: ID token expired"), "token_expired"},
{errors.New("oidc: azp mismatch"), "azp_mismatch"},
{errors.New("oidc: at_hash mismatch"), "at_hash_mismatch"},
{errors.New("oidc: ID token iat older than configured window"), "iat_window"},
{errors.New("oidc: alg rejected"), "alg_rejected"},
{errors.New("oidc: groups did not match any configured mapping"), "unmapped_groups"},
{errors.New("oidc: configured groups claim missing or malformed"), "groups_missing"},
{errors.New("oidc: jwks unreachable"), "jwks_unreachable"},
// Audit 2026-05-10 MED-17 — typed dispatch beats the substring
// fallthrough because all three iss-family sentinels contain
// "iss" in their message and would otherwise mis-classify.
{oidcsvc.ErrIssParamMissing, "iss_param_missing"},
{oidcsvc.ErrIssParamMismatch, "iss_param_mismatch"},
{oidcsvc.ErrIssuerMismatch, "id_token_iss_mismatch"},
// Wrapped variants must round-trip through errors.Is.
{fmt.Errorf("upstream: %w", oidcsvc.ErrIssParamMissing), "iss_param_missing"},
{fmt.Errorf("upstream: %w", oidcsvc.ErrIssParamMismatch), "iss_param_mismatch"},
// Audit 2026-05-11 A-2 — deactivated-user rejection is its own
// audit category (typed dispatch; wrapped variant must also
// round-trip).
{oidcsvc.ErrUserDeactivated, "user_deactivated"},
{fmt.Errorf("upstream: %w", oidcsvc.ErrUserDeactivated), "user_deactivated"},
// Audit 2026-05-11 A-6 — strict-when-stored. Distinguishes the
// new request-omitted-binding reject path from the existing
// mismatch leg. Wrapped variants must round-trip through
// errors.Is so the audit category remains stable even when
// the service layer adds context wrapping.
{oidcsvc.ErrPreLoginUAMismatch, "prelogin_ua_mismatch"},
{oidcsvc.ErrPreLoginIPMismatch, "prelogin_ip_mismatch"},
{oidcsvc.ErrPreLoginUAMissing, "prelogin_ua_missing"},
{oidcsvc.ErrPreLoginIPMissing, "prelogin_ip_missing"},
{fmt.Errorf("upstream: %w", oidcsvc.ErrPreLoginUAMissing), "prelogin_ua_missing"},
{fmt.Errorf("upstream: %w", oidcsvc.ErrPreLoginIPMissing), "prelogin_ip_missing"},
{errors.New("some other error"), "unspecified"},
}
for _, tc := range cases {
got := classifyOIDCFailure(tc.err)
if got != tc.want {
t.Errorf("classifyOIDCFailure(%v) = %q; want %q", tc.err, got, tc.want)
}
}
}
// =============================================================================
// MED-5 regression tests — TestProvider dry-run endpoint.
// =============================================================================
func TestTestProvider_HappyPath(t *testing.T) {
o := &stubOIDCSvc{
testResult: &oidcsvc.TestDiscoveryResult{
DiscoverySucceeded: true,
JWKSReachable: true,
SupportedAlgValues: []string{"RS256", "ES256"},
IssParamSupported: true,
IssuerEcho: "https://idp.example.com",
},
}
h, _, _, _, audit, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
body := strings.NewReader(`{"issuer_url":"https://idp.example.com","client_id":"app","scopes":["openid"]}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/test", body)
req = withActor(req, "u-admin", "User")
w := httptest.NewRecorder()
h.TestProvider(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; want 200; body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"discovery_succeeded":true`) {
t.Errorf("body missing discovery_succeeded:true; got %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), `"iss_param_supported":true`) {
t.Errorf("body missing iss_param_supported:true")
}
if !contains(audit.events, "auth.oidc_provider_tested") {
t.Errorf("expected auth.oidc_provider_tested audit event; got %v", audit.events)
}
}
func TestTestProvider_MissingIssuerURL_Returns400(t *testing.T) {
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, &stubBCLVerifier{})
body := strings.NewReader(`{"client_id":"app"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/test", body)
req = withActor(req, "u-admin", "User")
w := httptest.NewRecorder()
h.TestProvider(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d; want 400", w.Code)
}
}
// TestTestProvider_DiscoveryFailureReturns200WithErrors pins the
// failure-shape contract: discovery failure is a per-leg failure
// surfaced in the response body's `errors` array, NOT a 5xx — the
// GUI renders the per-check status row from the response.
func TestTestProvider_DiscoveryFailureReturns200WithErrors(t *testing.T) {
o := &stubOIDCSvc{
testResult: &oidcsvc.TestDiscoveryResult{
DiscoverySucceeded: false,
JWKSReachable: false,
Errors: []string{"discovery fetch failed: connection refused"},
},
}
h, _, _, _, _, _ := newPhase5Handler(t, o, &stubSession{}, &stubBCLVerifier{})
body := strings.NewReader(`{"issuer_url":"https://broken.example.com"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/test", body)
req = withActor(req, "u-admin", "User")
w := httptest.NewRecorder()
h.TestProvider(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; want 200 (per-leg failure rides in body); body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"discovery_succeeded":false`) {
t.Errorf("expected discovery_succeeded:false in body; got %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), "connection refused") {
t.Errorf("expected error detail in body; got %s", w.Body.String())
}
}