Files
certctl/internal/api/handler/auth_session_oidc_test.go
T
shankar0123 9c679a5960 auth-bundle-2 Phase 5: OIDC + session HTTP surface (13 endpoints),
pre-login store, OpenID Connect Back-Channel Logout 1.0, cookieAuth
scheme, 7 new auth permissions, CI guard, handler tests

Phase 5 of the bundle puts the Phase 3 OIDC service + Phase 4 session
service on the wire. 13 HTTP endpoints split into three logical groups:

Public OIDC handshake (auth-exempt; protocol-mediated):
  GET  /auth/oidc/login?provider=<id>  -> 302 to IdP authorization URL
                                          + sets certctl_oidc_pending cookie
                                          (10-min TTL, Path=/auth/oidc/,
                                          SameSite=Lax)
  GET  /auth/oidc/callback?code=...&state=... -> consume pre-login row,
                                          run Phase 3's 11-step token
                                          validation, mint post-login
                                          session, 302 to dashboard
  POST /auth/oidc/back-channel-logout  -> OpenID Connect BCL 1.0 — IdP
                                          POSTs logout_token JWT; certctl
                                          validates signature against IdP
                                          JWKS via Phase 3 alg allow-list,
                                          required claims (iss/aud/iat/jti/
                                          events; exactly one of sub/sid;
                                          nonce ABSENT per spec §2.4),
                                          revokes matching sessions,
                                          returns 200 with
                                          Cache-Control: no-store
  POST /auth/logout                    -> revoke caller's session

Session management (RBAC-gated auth.session.*):
  GET    /api/v1/auth/sessions         -> auth.session.list (own / all)
  DELETE /api/v1/auth/sessions/{id}    -> auth.session.revoke (own bypass)

OIDC provider + group-mapping CRUD (RBAC-gated auth.oidc.*):
  GET    /api/v1/auth/oidc/providers              -> auth.oidc.list
  POST   /api/v1/auth/oidc/providers              -> auth.oidc.create
                                                     (client_secret encrypted
                                                     at rest via
                                                     internal/crypto.EncryptIfKeySet)
  PUT    /api/v1/auth/oidc/providers/{id}         -> auth.oidc.edit
  DELETE /api/v1/auth/oidc/providers/{id}         -> auth.oidc.delete
                                                     (refused via
                                                     ErrOIDCProviderInUse → 409
                                                     when users authenticated
                                                     via this provider)
  POST   /api/v1/auth/oidc/providers/{id}/refresh -> auth.oidc.edit
                                                     (re-runs IdP downgrade
                                                     defense via
                                                     OIDCService.RefreshKeys)
  GET    /api/v1/auth/oidc/group-mappings         -> auth.oidc.list
  POST   /api/v1/auth/oidc/group-mappings         -> auth.oidc.edit
  DELETE /api/v1/auth/oidc/group-mappings/{id}    -> auth.oidc.edit

Migration 000037 ships:

  - oidc_pre_login_sessions table (10-min absolute TTL, FK CASCADE on
    oidc_provider_id, FK RESTRICT on signing_key_id; index on
    absolute_expires_at for the GC sweep);
  - 7 new permissions seeded into r-admin only:
      auth.session.list, auth.session.list.all, auth.session.revoke,
      auth.oidc.list, auth.oidc.create, auth.oidc.edit, auth.oidc.delete

CanonicalPermissions extended in lockstep at internal/domain/auth/
validate.go.

Pre-login machinery:

  - internal/repository/oidc.go gains PreLoginRepository interface +
    PreLoginSession struct + ErrPreLoginNotFound / ErrPreLoginExpired
    sentinels.
  - internal/repository/postgres/oidc_prelogin.go ships the impl;
    LookupAndConsume uses DELETE ... RETURNING for atomic single-use.
  - internal/auth/oidc/prelogin.go is the PreLoginAdapter that bridges
    the OIDC service's Phase 3 PreLoginStore interface to the new
    repository, signing the cookie value under the active
    SessionSigningKey via the same v1.<id>.<key>.<HMAC> wire format
    Phase 4 uses for post-login cookies. Defense-in-depth: the
    pre-login `pl-` prefix is enforced by ParseCookieValue(prefix);
    a stolen pre-login cookie cannot be replayed against the
    post-login Validate path (pinned by
    TestService_Validate_RejectsPreLoginCookieAtPostLoginGate).

Session package extension:

  - internal/auth/session/service.go gains exported SignCookieValue,
    ParseCookieValue (with caller-supplied id-1 prefix), ComputeCookieHMAC,
    DecryptKeyMaterial wrappers so the OIDC pre-login adapter shares
    the same length-prefixed HMAC math without code duplication.
  - parseCookie no longer hardcodes the `ses-` prefix check (moved to
    Validate as defense-in-depth; pre-login cookie verification uses
    the `pl-` prefix via ParseCookieValue).

Cookie attributes (all Phase 5 endpoints honor CERTCTL_SESSION_SAMESITE
+ Secure=true via SessionCookieAttrs from Phase 4 config):

  - certctl_oidc_pending: Path=/auth/oidc/, MaxAge=600s, SameSite=Lax
    (cannot be Strict because the IdP-initiated callback is a top-level
    navigation from a different origin).
  - certctl_session: Path=/, Expires=8h, SameSite=Lax|Strict, HttpOnly.
  - certctl_csrf: Path=/, Expires=8h, HttpOnly=false (intentional —
    GUI must read it to echo into X-CSRF-Token header).

Audit logging on every mutating operation (event_category="auth"):

  auth.oidc_login_succeeded / failed / unmapped_groups
  auth.oidc_back_channel_logout / failed
  auth.session_revoked
  auth.oidc_provider_{created,updated,deleted,refreshed}
  auth.group_mapping_{added,removed}

OpenAPI updates:

  - cookieAuth security scheme added to api/openapi.yaml under
    components.securitySchemes (apiKey / cookie / certctl_session).
  - The 13 Phase 5 routes are added to SpecParityExceptions with a
    deferral note: full per-endpoint OpenAPI rows land in a follow-on
    commit alongside the GUI work (Phase 8) so the ergonomic shape can
    be validated against the live GUI client.

CI guard: scripts/ci-guards/N-bundle-2-security-empty-preserved.sh
asserts api/openapi.yaml has ≥ 14 'security: []' occurrences (the
pre-Bundle-2 baseline). Reducing the count below 14 would silently
force a Bearer-or-cookie requirement onto an endpoint that legitimately
runs without certctl-issued credentials; the guard fires before that
regression lands.

Handler tests (internal/api/handler/auth_session_oidc_test.go):

  - All 6 prompt-mandated negative cases:
      BCL with missing events claim -> 400
      BCL with nonce present -> 400 (per spec §2.4)
      BCL with sig signed by an unknown key -> 400
      Callback with replayed state -> 400
      Callback with PKCE verifier mismatch -> 400
      Callback with expired pre-login row -> 400
  - Plus happy paths for every endpoint, edge cases (missing-cookie,
    duplicate-name, in-use-409, wrong-tenant), and the Helper-function
    coverage (peekIssuer, classifyOIDCFailure, defaultIfBlank,
    defaultIntIfZero, clientIPFromRequest, encryptClientSecret).

Coverage on internal/api/handler/auth_session_oidc.go: 80.9% per-function
(above the Phase 5 spec's ≥ 80% floor).

Server wiring (cmd/server/main.go):

  Wired AFTER sessionService (Phase 4) so the OIDC PreLoginAdapter can
  sign pre-login cookies under the active SessionSigningKey:
    oidcProviderRepo + oidcMappingRepo + oidcUserRepo + oidcPreLoginRepo
    -> preLoginAdapter -> oidcService -> authSessionOIDCHandler.
  sessionMinterAdapter shim bridges *session.Service.Create to the
  oidcsvc.SessionMinter port the OIDC service consumes.

Router wiring (internal/api/router/router.go):

  4 public OIDC routes via direct r.mux.Handle (auth-exempt; pinned in
  AuthExemptRouterRoutes); 9 RBAC-gated routes via r.Register +
  rbacGate(checker, perm, h). Routes only register when
  reg.AuthSessionOIDC != nil so pre-Phase-5 builds skip the block
  entirely.

Verifications: gofmt clean, go vet clean across all touched packages,
go test -short -count=1 green across internal/api/handler (74 tests +
new Phase 5 batch), internal/api/router (parity + auth-exempt
allowlist), internal/auth/oidc + session (no regressions), full domain
+ scheduler + config sweeps green, ci-guard
N-bundle-2-security-empty-preserved.sh green (17 ≥ 14 baseline).
2026-05-10 06:08:27 +00:00

1018 lines
38 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
}
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 }
type stubSession struct {
createRes *sessionsvc.CreateResult
createErr error
validateRes *sessiondomain.Session
validateErr error
revokeErr error
revokeAllErr error
revokedIDs []string
revokeAllIDs []string
revokeAllTypes []string
}
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
}
type stubBCLVerifier struct {
issuer string
sub string
sid string
err error
}
func (s *stubBCLVerifier) Verify(_ context.Context, _ string) (string, string, string, error) {
return s.issuer, s.sub, s.sid, 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) GarbageCollectExpired(_ context.Context) (int, error) { return 0, nil }
func (s *stubSessionRepo) Delete(_ context.Context, _ string) error { return nil }
type phase5StubAudit struct {
events []string
}
func (s *phase5StubAudit) RecordEventWithCategory(_ context.Context, _ string, _ domain.ActorType, action, _, _, _ string, _ map[string]interface{}) error {
s.events = append(s.events, action)
return nil
}
// =============================================================================
// Helpers.
// =============================================================================
func newPhase5Handler(
t *testing.T,
oidcSvc *stubOIDCSvc,
sess *stubSession,
bcl *stubBCLVerifier,
) (*AuthSessionOIDCHandler, *stubProviderRepo, *stubMappingRepo, *stubSessionRepo, *phase5StubAudit) {
t.Helper()
provRepo := &stubProviderRepo{}
mapRepo := &stubMappingRepo{}
sessRepo := newStubSessionRepo()
audit := &phase5StubAudit{}
h := NewAuthSessionOIDCHandler(
oidcSvc, sess, bcl, provRepo, mapRepo, sessRepo, audit,
"", "t-default", "/dashboard",
SessionCookieAttrs{SameSite: http.SameSiteLaxMode, Secure: true},
)
return h, provRepo, mapRepo, sessRepo, audit
}
// 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 -> 400.
// (The OIDC service's PreLoginStore.LookupAndConsume returns
// 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{})
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.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 event; got %v", audit.events)
}
}
// Phase 5 spec mandate #5: Callback with PKCE verifier mismatch -> 400.
// The OIDC service's code-exchange step fails when the verifier doesn'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{})
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.StatusBadRequest {
t.Errorf("status = %d; want 400", w.Code)
}
}
// Phase 5 spec mandate #6: Callback with expired pre-login row -> 400.
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{})
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.StatusBadRequest {
t.Errorf("status = %d; want 400", 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.StatusBadRequest {
t.Errorf("status = %d; want 400", w.Code)
}
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)
}
}
func TestBackChannelLogout_HappyPath_RevokesSubject(t *testing.T) {
bcl := &stubBCLVerifier{issuer: "https://idp", sub: "u-alice"}
sess := &stubSession{}
h, _, _, _, audit := 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 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 !contains(audit.events, "auth.oidc_back_channel_logout") {
t.Errorf("expected auth.oidc_back_channel_logout audit event")
}
}
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)
}
}
// =============================================================================
// 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(), &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"},
{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)
}
}
}