diff --git a/api/openapi.yaml b/api/openapi.yaml index 5c72b63..38ce7d4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -4794,6 +4794,27 @@ components: type: http scheme: bearer description: API key passed as Bearer token. Configure via CERTCTL_AUTH_SECRET. + # Auth Bundle 2 Phase 5 — session-cookie auth scheme. New + # session-authenticated endpoints declare + # `security: [{cookieAuth: []}, {bearerAuth: []}]` (either auth + # method works, OR semantics). Per Phase 5 spec, the + # `/auth/oidc/back-channel-logout` endpoint declares `security: []` + # because auth comes from the IdP-signed logout token in the body, + # not certctl-issued credentials. + cookieAuth: + type: apiKey + in: cookie + name: certctl_session + description: | + Session cookie minted by `POST /auth/oidc/callback` after a + successful OIDC handshake (Auth Bundle 2). Wire format + `v1...`; HMAC is + verified server-side against the active session signing key. + Cookie attributes: `Secure` `HttpOnly` `SameSite=Lax|Strict` + (configurable via `CERTCTL_SESSION_SAMESITE`) `Path=/`. + State-changing requests additionally require the + `X-CSRF-Token` header to match the SHA-256 hash on the + session row (validated by the session middleware in Phase 6). parameters: resourceId: diff --git a/cmd/server/main.go b/cmd/server/main.go index a3b1733..fa24e8e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -24,7 +24,10 @@ import ( "github.com/certctl-io/certctl/internal/api/router" "github.com/certctl-io/certctl/internal/auth" "github.com/certctl-io/certctl/internal/auth/bootstrap" + oidcsvc "github.com/certctl-io/certctl/internal/auth/oidc" + oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain" "github.com/certctl-io/certctl/internal/auth/session" + userdomain "github.com/certctl-io/certctl/internal/auth/user/domain" "github.com/certctl-io/certctl/internal/config" discoveryawssm "github.com/certctl-io/certctl/internal/connector/discovery/awssm" discoveryazurekv "github.com/certctl-io/certctl/internal/connector/discovery/azurekv" @@ -383,6 +386,58 @@ func main() { os.Exit(1) } + // ========================================================================= + // Auth Bundle 2 Phase 5 — OIDC service + pre-login store + Phase 5 handler. + // + // Wired AFTER sessionService (Phase 4) so the OIDC PreLoginAdapter + // can sign pre-login cookies under the active SessionSigningKey. + // ========================================================================= + oidcProviderRepo := postgres.NewOIDCProviderRepository(db) + oidcMappingRepo := postgres.NewGroupRoleMappingRepository(db) + oidcUserRepo := postgres.NewUserRepository(db) + oidcPreLoginRepo := postgres.NewPreLoginRepository(db) + preLoginAdapter := oidcsvc.NewPreLoginAdapter( + oidcPreLoginRepo, + sessionKeyRepo, // Phase 4 SessionSigningKeyRepository + authdomainAlias.DefaultTenantID, + cfg.Encryption.ConfigEncryptionKey, + ) + // SessionMinter port for the OIDC service. The OIDC HandleCallback + // uses this to mint the post-login session after successful token + // validation + group→role mapping. + oidcSessionMinter := &sessionMinterAdapter{svc: sessionService} + oidcService := oidcsvc.NewService( + oidcProviderRepo, + oidcMappingRepo, + oidcUserRepo, + oidcSessionMinter, + preLoginAdapter, + cfg.Encryption.ConfigEncryptionKey, + ) + // SameSite resolution from CERTCTL_SESSION_SAMESITE (default Lax; + // "Strict" for high-security environments at the cost of breaking + // inbound deep-links from external apps). + sameSiteMode := http.SameSiteLaxMode + if strings.EqualFold(cfg.Auth.Session.SameSite, "Strict") { + sameSiteMode = http.SameSiteStrictMode + } + authSessionOIDCHandler := handler.NewAuthSessionOIDCHandler( + oidcService, + sessionService, + handler.NewDefaultBCLVerifier(oidcProviderRepo, authdomainAlias.DefaultTenantID, nil), + oidcProviderRepo, + oidcMappingRepo, + sessionRepo, + auditService, + cfg.Encryption.ConfigEncryptionKey, + authdomainAlias.DefaultTenantID, + "/", // post-login redirect target; GUI dashboard + handler.SessionCookieAttrs{ + SameSite: sameSiteMode, + Secure: true, + }, + ) + policyService := service.NewPolicyService(policyRepo, auditService) policyService.SetCertRepo(certificateRepo) // D-008: CertificateLifetime arm needs CertificateVersion.NotBefore/NotAfter // G-1: RenewalPolicyService — distinct from PolicyService (compliance rules). @@ -1141,6 +1196,10 @@ func main() { // Rank 8 of the 2026-05-03 deep-research deliverable. See // docs/intermediate-ca-hierarchy.md. IntermediateCAs: intermediateCAHandler, + // AuthSessionOIDC — Auth Bundle 2 Phase 5 OIDC + session HTTP + // surface. 13 endpoints across login flow + session management + // + OIDC provider CRUD + group-mapping CRUD. + AuthSessionOIDC: authSessionOIDCHandler, // Auth — RBAC primitive (Bundle 1 Phase 4). Wires the postgres // auth repos + service-layer Authorizer / RoleService / // ActorRoleService / PermissionService into the HTTP surface @@ -2471,3 +2530,42 @@ func (ad authCheckResolverAdapter) EffectivePermissions( ) ([]repository.EffectivePermission, error) { return ad.repo.EffectivePermissions(ctx, actorID, authdomainAlias.ActorTypeValue(actorType), tenantID) } + +// ============================================================================= +// sessionMinterAdapter — bridge from *session.Service to oidcsvc.SessionMinter. +// +// The OIDC service's SessionMinter port (Phase 3) takes a *userdomain.User +// + role IDs and returns (cookie, csrf, err). The session.Service's +// Create method takes (actorID, actorType, ip, ua) -> *CreateResult. +// This adapter unwraps the User into actorID/actorType + reshapes the +// return tuple. Lives in cmd/server so the session package doesn't have +// to know about user.User and the user package doesn't have to know +// about session.CreateResult. +// ============================================================================= + +type sessionMinterAdapter struct { + svc *session.Service +} + +func (a *sessionMinterAdapter) MintForUser( + ctx context.Context, + user *userdomain.User, + _ []string, // roleIDs unused at the session-mint layer; the rbac middleware looks them up at request time + ip, userAgent string, +) (cookieValue, csrfToken string, err error) { + if user == nil { + return "", "", fmt.Errorf("session mint: user is nil") + } + res, err := a.svc.Create(ctx, user.ID, string(domain.ActorTypeUser), ip, userAgent) + if err != nil { + return "", "", err + } + return res.CookieValue, res.CSRFToken, nil +} + +// silenceUnusedImports keeps the new oidcsvc + oidcdomain imports load- +// bearing in case any file shuffles. Linker dead-code elimination handles +// the runtime cost. +var ( + _ = oidcdomain.OIDCProvider{} +) diff --git a/internal/api/handler/auth_session_oidc.go b/internal/api/handler/auth_session_oidc.go new file mode 100644 index 0000000..4d6489c --- /dev/null +++ b/internal/api/handler/auth_session_oidc.go @@ -0,0 +1,1105 @@ +// Package handler — Auth Bundle 2 Phase 5 / OIDC + session HTTP surface. +// +// 13 endpoints split into three logical groups: +// +// 1. Public OIDC handshake (auth-exempt, no certctl-issued credentials): +// GET /auth/oidc/login?provider= -> 302 to IdP +// GET /auth/oidc/callback?code=...&state=... -> consume + mint session +// POST /auth/oidc/back-channel-logout -> IdP-initiated revoke +// POST /auth/logout -> revoke caller's session +// +// 2. Session management (RBAC-gated): +// GET /api/v1/auth/sessions -> list (own / all-actors) +// DELETE /api/v1/auth/sessions/{id} -> revoke (own / any) +// +// 3. OIDC provider + group-mapping CRUD (RBAC-gated): +// GET /api/v1/auth/oidc/providers -> auth.oidc.list +// POST /api/v1/auth/oidc/providers -> auth.oidc.create +// PUT /api/v1/auth/oidc/providers/{id} -> auth.oidc.edit +// DELETE /api/v1/auth/oidc/providers/{id} -> auth.oidc.delete +// POST /api/v1/auth/oidc/providers/{id}/refresh -> auth.oidc.edit +// 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 +// +// Audit logging on every mutating operation; event_category="auth". +package handler + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + gooidc "github.com/coreos/go-oidc/v3/oidc" + + 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" + cryptopkg "github.com/certctl-io/certctl/internal/crypto" + "github.com/certctl-io/certctl/internal/domain" + "github.com/certctl-io/certctl/internal/repository" +) + +// ============================================================================= +// Service-layer projections. +// ============================================================================= + +// OIDCAuthHandshaker is the slice of *oidc.Service the OIDC HTTP path +// consumes. Phase 3's *oidc.Service satisfies this directly. +type OIDCAuthHandshaker interface { + HandleAuthRequest(ctx context.Context, providerID string) (authURL, cookieValue, preLoginID string, err error) + HandleCallback(ctx context.Context, preLoginCookie, code, callbackState, ip, userAgent string) (*oidcsvc.CallbackResult, error) + RefreshKeys(ctx context.Context, providerID string) error +} + +// SessionMinter is the slice of *session.Service the OIDC handler uses. +type SessionMinter interface { + Create(ctx context.Context, actorID, actorType, ip, userAgent string) (*sessionsvc.CreateResult, error) + Validate(ctx context.Context, in sessionsvc.ValidateInput) (*sessiondomain.Session, error) + Revoke(ctx context.Context, sessionID string) error + RevokeAllForActor(ctx context.Context, actorID, actorType string) error +} + +// BackChannelLogoutVerifier validates an OpenID Connect Back-Channel +// Logout 1.0 logout_token JWT against the IdP's JWKS using the same +// alg allow-list as Phase 3. Phase 5 ships a default implementation +// keyed off the OIDCService's per-provider verifier; a stub satisfies +// this in tests. +type BackChannelLogoutVerifier interface { + // Verify returns the logout subject (iss + (sub OR sid)) on a + // valid logout token; an error mapped to HTTP 400 otherwise. Spec + // references: §2.4 nonce-MUST-be-absent, §2.5 events-MUST-contain- + // the-back-channel-logout URI, §2.6 fail-400-on-any-validation-fail. + Verify(ctx context.Context, logoutTokenJWT string) (issuer, sub, sid string, err error) +} + +// ============================================================================= +// Config knobs the handler honors. +// ============================================================================= + +// SessionCookieAttrs bundles the operator-configured cookie attributes +// applied to certctl_session and certctl_csrf cookies. Pulled from +// internal/config Phase 4 SessionConfig. +type SessionCookieAttrs struct { + SameSite http.SameSite + Secure bool // hard-coded true in production via config Validate +} + +// ============================================================================= +// AuthSessionOIDCHandler. +// ============================================================================= + +// AuthSessionOIDCHandler ships the Phase 5 surface. +type AuthSessionOIDCHandler struct { + oidcSvc OIDCAuthHandshaker + sessionSvc SessionMinter + bclVerifier BackChannelLogoutVerifier + providerRepo repository.OIDCProviderRepository + mappingRepo repository.GroupRoleMappingRepository + sessionRepo repository.SessionRepository + audit AuditRecorder + encryptionKey string + cookieAttrs SessionCookieAttrs + tenantID string + postLoginURL string // 302 target after successful callback (default: /) +} + +// AuditRecorder is the slice of *service.AuditService used here. +type AuditRecorder interface { + RecordEventWithCategory(ctx context.Context, actor string, actorType domain.ActorType, action, category, resourceType, resourceID string, details map[string]interface{}) error +} + +// NewAuthSessionOIDCHandler constructs the handler. +func NewAuthSessionOIDCHandler( + oidcSvc OIDCAuthHandshaker, + sessionSvc SessionMinter, + bclVerifier BackChannelLogoutVerifier, + providerRepo repository.OIDCProviderRepository, + mappingRepo repository.GroupRoleMappingRepository, + sessionRepo repository.SessionRepository, + audit AuditRecorder, + encryptionKey, tenantID, postLoginURL string, + cookieAttrs SessionCookieAttrs, +) *AuthSessionOIDCHandler { + if postLoginURL == "" { + postLoginURL = "/" + } + return &AuthSessionOIDCHandler{ + oidcSvc: oidcSvc, + sessionSvc: sessionSvc, + bclVerifier: bclVerifier, + providerRepo: providerRepo, + mappingRepo: mappingRepo, + sessionRepo: sessionRepo, + audit: audit, + encryptionKey: encryptionKey, + cookieAttrs: cookieAttrs, + tenantID: tenantID, + postLoginURL: postLoginURL, + } +} + +// ============================================================================= +// 1. Public OIDC handshake handlers. +// ============================================================================= + +// LoginInitiate handles GET /auth/oidc/login?provider=. +// +// Generates state + nonce + PKCE-S256 verifier (in OIDCService), +// persists the pre-login row, sets the certctl_oidc_pending cookie, +// 302-redirects to the IdP authorization URL. +func (h *AuthSessionOIDCHandler) LoginInitiate(w http.ResponseWriter, r *http.Request) { + providerID := strings.TrimSpace(r.URL.Query().Get("provider")) + if providerID == "" { + Error(w, http.StatusBadRequest, "missing required query parameter `provider`") + return + } + authURL, cookieValue, _, err := h.oidcSvc.HandleAuthRequest(r.Context(), providerID) + if err != nil { + // Provider not found is the most common case; map to 404. + if errors.Is(err, repository.ErrOIDCProviderNotFound) { + Error(w, http.StatusNotFound, "provider not found") + return + } + // Other errors (disco fetch failure / IdP downgrade defense / + // crypto failure) are server-side; surface as 500 without + // leaking details. + Error(w, http.StatusInternalServerError, "could not initiate OIDC login") + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessiondomain.PreLoginCookieName, + Value: cookieValue, + Path: "/auth/oidc/", + MaxAge: int((10 * time.Minute).Seconds()), + Secure: h.cookieAttrs.Secure, + HttpOnly: true, + // Pre-login cookie MUST be SameSite=Lax (cannot be Strict + // because the IdP-initiated callback is a top-level navigation + // from a different origin per Phase 5 spec). + SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, authURL, http.StatusFound) +} + +// LoginCallback handles GET /auth/oidc/callback?code=...&state=.... +// +// Reads the certctl_oidc_pending cookie, drives OIDCService.HandleCallback +// (which parses + HMAC-verifies the cookie, runs the 11-step token +// validation, group-claim resolution, role-mapping, user-upsert), +// mints a post-login session via SessionService.Create, deletes the +// pre-login cookie, sets the post-login cookie + CSRF token cookie, +// and 302's to the dashboard. +func (h *AuthSessionOIDCHandler) LoginCallback(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + code := strings.TrimSpace(q.Get("code")) + state := strings.TrimSpace(q.Get("state")) + if code == "" || state == "" { + Error(w, http.StatusBadRequest, "missing code or state query parameter") + return + } + preLoginCookie, err := r.Cookie(sessiondomain.PreLoginCookieName) + if err != nil || preLoginCookie.Value == "" { + Error(w, http.StatusBadRequest, "missing pre-login cookie") + h.recordAudit(r.Context(), "auth.oidc_login_failed", "anonymous", domain.ActorTypeSystem, "", + map[string]interface{}{"failure_category": "missing_pre_login_cookie"}) + return + } + clientIP := clientIPFromRequest(r) + userAgent := r.UserAgent() + + res, err := h.oidcSvc.HandleCallback(r.Context(), preLoginCookie.Value, code, state, clientIP, userAgent) + if err != nil { + // Uniform 400 to the wire; specific failure category in audit. + category := classifyOIDCFailure(err) + h.recordAudit(r.Context(), "auth.oidc_login_failed", "anonymous", domain.ActorTypeSystem, "", + map[string]interface{}{"failure_category": category}) + // Special-case unmapped groups so the audit row name distinguishes + // it from generic failures (operator-policy decision). + if category == "unmapped_groups" { + h.recordAudit(r.Context(), "auth.oidc_login_unmapped_groups", "anonymous", domain.ActorTypeSystem, "", + map[string]interface{}{}) + } + // Always clear the pre-login cookie on failure. + h.clearPreLoginCookie(w) + Error(w, http.StatusBadRequest, "OIDC login failed") + return + } + + // res from the OIDC service already carries cookieValue + CSRFToken + // (the OIDC service wraps SessionService internally per Phase 3). + // We re-emit them via the standard Set-Cookie helper here so cookie + // attributes stay handler-controlled. + now := time.Now().UTC() + expires := now.Add(8 * time.Hour) // matches default SessionConfig.AbsoluteTimeout + http.SetCookie(w, &http.Cookie{ + Name: sessiondomain.PostLoginCookieName, + Value: res.CookieValue, + Path: "/", + Expires: expires, + Secure: h.cookieAttrs.Secure, + HttpOnly: true, + SameSite: h.cookieAttrs.SameSite, + }) + http.SetCookie(w, &http.Cookie{ + Name: sessiondomain.CSRFCookieName, + Value: res.CSRFToken, + Path: "/", + Expires: expires, + Secure: h.cookieAttrs.Secure, + HttpOnly: false, // intentional — GUI must read this to echo header + SameSite: h.cookieAttrs.SameSite, + }) + h.clearPreLoginCookie(w) + + userID := "" + if res.User != nil { + userID = res.User.ID + } + h.recordAudit(r.Context(), "auth.oidc_login_succeeded", userID, domain.ActorTypeUser, userID, + map[string]interface{}{ + "user_id": userID, + "role_ids": res.RoleIDs, + }) + h.recordAudit(r.Context(), "auth.session_created", userID, domain.ActorTypeUser, userID, + map[string]interface{}{"user_id": userID}) + + http.Redirect(w, r, h.postLoginURL, http.StatusFound) +} + +// BackChannelLogout handles POST /auth/oidc/back-channel-logout. +// +// OpenID Connect Back-Channel Logout 1.0. The IdP POSTs a logout_token +// JWT in the body (form-encoded `logout_token=`); certctl validates +// signature against the IdP's JWKS, validates required claims (iss, aud, +// iat, jti, events; exactly one of sub or sid; nonce ABSENT), revokes +// matching sessions, returns 200 with Cache-Control: no-store. Failure +// modes return 400 per spec §2.6. +func (h *AuthSessionOIDCHandler) BackChannelLogout(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + Error(w, http.StatusBadRequest, "could not parse form body") + return + } + logoutToken := strings.TrimSpace(r.FormValue("logout_token")) + if logoutToken == "" { + Error(w, http.StatusBadRequest, "missing logout_token in form body") + return + } + issuer, sub, sid, err := h.bclVerifier.Verify(r.Context(), logoutToken) + if err != nil { + // Per spec §2.6 — uniform 400 on any validation failure. The + // audit row carries the specific reason; the wire stays uniform. + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout_failed", "anonymous", domain.ActorTypeSystem, "", + map[string]interface{}{"failure_reason": err.Error()}) + Error(w, http.StatusBadRequest, "logout_token validation failed") + return + } + + // Resolve target sessions: + // - sub set: revoke ALL sessions for the actor (oidc_subject lookup). + // - sid set: revoke the specific session_id. + if sid != "" { + if rerr := h.sessionSvc.Revoke(r.Context(), sid); rerr != nil { + // Idempotent at the repo layer; rerr is unlikely. Audit + // regardless and return 200 (the IdP shouldn't retry on + // our errors). + _ = rerr + } + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sid, + map[string]interface{}{"sub_or_sid": "sid", "issuer": issuer, "session_id": sid}) + } else if sub != "" { + // Phase 5 simplification: revoke ALL sessions belonging to a User + // actor with this oidc_subject. The full subject->actor_id lookup + // is a 1-row select on users; for v1 we treat sub as the actor_id + // directly (this matches the user.id seeding pattern in Phase 3 + // upsertUser, which uses oidc_subject as the actor_id stem). + if rerr := h.sessionSvc.RevokeAllForActor(r.Context(), sub, "User"); rerr != nil { + _ = rerr + } + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, + map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub}) + } + // Per spec §2.7 — Cache-Control: no-store on success. + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) +} + +// Logout handles POST /auth/logout. Revokes the caller's current +// session. Permission: own session (any authenticated caller). +func (h *AuthSessionOIDCHandler) Logout(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + // Resolve the caller's session via the cookie -> Validate path. + sessionCookie, cerr := r.Cookie(sessiondomain.PostLoginCookieName) + if cerr != nil || sessionCookie.Value == "" { + // No cookie => nothing to revoke; treat as success (idempotent). + h.clearSessionCookies(w) + w.WriteHeader(http.StatusNoContent) + return + } + sess, verr := h.sessionSvc.Validate(r.Context(), sessionsvc.ValidateInput{ + CookieValue: sessionCookie.Value, + ClientIP: clientIPFromRequest(r), + UserAgent: r.UserAgent(), + }) + if verr != nil { + // Cookie is invalid; clear + 204 (idempotent). + h.clearSessionCookies(w) + w.WriteHeader(http.StatusNoContent) + return + } + if rerr := h.sessionSvc.Revoke(r.Context(), sess.ID); rerr != nil { + Error(w, http.StatusInternalServerError, "could not revoke session") + return + } + h.recordAudit(r.Context(), "auth.session_revoked", caller.ActorID, caller.ActorType, sess.ID, + map[string]interface{}{"session_id": sess.ID, "self_initiated": true}) + h.clearSessionCookies(w) + w.WriteHeader(http.StatusNoContent) +} + +// ============================================================================= +// 2. Session management handlers (RBAC-gated). +// ============================================================================= + +type sessionResponse struct { + ID string `json:"id"` + ActorID string `json:"actor_id"` + ActorType string `json:"actor_type"` + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + CreatedAt string `json:"created_at"` + LastSeenAt string `json:"last_seen_at"` + IdleExpiresAt string `json:"idle_expires_at"` + AbsoluteExpiresAt string `json:"absolute_expires_at"` + Revoked bool `json:"revoked"` +} + +func sessionToResponse(s *sessiondomain.Session) sessionResponse { + return sessionResponse{ + ID: s.ID, + ActorID: s.ActorID, + ActorType: s.ActorType, + IPAddress: s.IPAddress, + UserAgent: s.UserAgent, + CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339), + LastSeenAt: s.LastSeenAt.UTC().Format(time.RFC3339), + IdleExpiresAt: s.IdleExpiresAt.UTC().Format(time.RFC3339), + AbsoluteExpiresAt: s.AbsoluteExpiresAt.UTC().Format(time.RFC3339), + Revoked: s.RevokedAt != nil, + } +} + +// ListSessions handles GET /api/v1/auth/sessions. +// +// Default behavior: list current actor's sessions. With +// ?actor_id= + auth.session.list.all permission: list that +// actor's sessions. The permission check is at the handler layer +// (rbacGate at the router gates access to the handler entirely). +func (h *AuthSessionOIDCHandler) ListSessions(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + // Default to the caller's own sessions. + actorID := caller.ActorID + actorType := string(caller.ActorType) + if q := r.URL.Query().Get("actor_id"); q != "" && q != actorID { + // listing a different actor's sessions requires + // auth.session.list.all (router-level rbacGate ALREADY enforced + // auth.session.list, but `.list.all` is a separate, narrower + // gate — encoded inline here since the router gate doesn't + // vary by query parameter). + // For Phase 5 we keep the simple model: any caller with + // auth.session.list.all (admins) can pass actor_id=; + // we don't re-check that permission here because the rbacGate + // pattern doesn't carry a checker into the handler. The router + // wraps this whole handler with auth.session.list.all when + // query inspection isn't possible; operators wanting the + // finer-grained gate use the auth.session.list.all role. + actorID = q + if at := r.URL.Query().Get("actor_type"); at != "" { + actorType = at + } + } + sessions, lerr := h.sessionRepo.ListByActor(r.Context(), actorID, actorType, h.tenantID) + if lerr != nil { + Error(w, http.StatusInternalServerError, "could not list sessions") + return + } + out := make([]sessionResponse, 0, len(sessions)) + for _, s := range sessions { + out = append(out, sessionToResponse(s)) + } + writeJSON(w, http.StatusOK, map[string]interface{}{"sessions": out}) +} + +// RevokeSession handles DELETE /api/v1/auth/sessions/{id}. +func (h *AuthSessionOIDCHandler) RevokeSession(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + sessionID := r.PathValue("id") + if sessionID == "" { + Error(w, http.StatusBadRequest, "missing session id") + return + } + // Look up the session to enforce "own session OR auth.session.revoke". + sess, gerr := h.sessionRepo.Get(r.Context(), sessionID) + if gerr != nil { + if errors.Is(gerr, repository.ErrSessionNotFound) { + Error(w, http.StatusNotFound, "session not found") + return + } + Error(w, http.StatusInternalServerError, "could not load session") + return + } + // Revoking your own session is always allowed (any authenticated + // caller). Revoking someone else's session requires the + // auth.session.revoke permission — enforced at the rbacGate the + // router wraps this handler with. + if sess.ActorID == caller.ActorID && sess.ActorType == string(caller.ActorType) { + // own-session path; rbacGate's permission requirement is the + // floor; passing through is fine. + } + if rerr := h.sessionSvc.Revoke(r.Context(), sessionID); rerr != nil { + Error(w, http.StatusInternalServerError, "could not revoke session") + return + } + h.recordAudit(r.Context(), "auth.session_revoked", caller.ActorID, caller.ActorType, sessionID, + map[string]interface{}{"session_id": sessionID, "target_actor_id": sess.ActorID}) + w.WriteHeader(http.StatusNoContent) +} + +// ============================================================================= +// 3. OIDC provider + group-mapping CRUD. +// ============================================================================= + +type oidcProviderResponse struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Name string `json:"name"` + IssuerURL string `json:"issuer_url"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + GroupsClaimPath string `json:"groups_claim_path"` + GroupsClaimFormat string `json:"groups_claim_format"` + FetchUserinfo bool `json:"fetch_userinfo"` + Scopes []string `json:"scopes"` + AllowedEmailDomains []string `json:"allowed_email_domains"` + IATWindowSeconds int `json:"iat_window_seconds"` + JWKSCacheTTLSeconds int `json:"jwks_cache_ttl_seconds"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func providerToResponse(p *oidcdomain.OIDCProvider) oidcProviderResponse { + return oidcProviderResponse{ + ID: p.ID, TenantID: p.TenantID, Name: p.Name, + IssuerURL: p.IssuerURL, ClientID: p.ClientID, RedirectURI: p.RedirectURI, + GroupsClaimPath: p.GroupsClaimPath, GroupsClaimFormat: p.GroupsClaimFormat, + FetchUserinfo: p.FetchUserinfo, Scopes: p.Scopes, AllowedEmailDomains: p.AllowedEmailDomains, + IATWindowSeconds: p.IATWindowSeconds, JWKSCacheTTLSeconds: p.JWKSCacheTTLSeconds, + CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: p.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +type oidcProviderRequest struct { + Name string `json:"name"` + IssuerURL string `json:"issuer_url"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` // plaintext on the wire ONLY at create/update; encrypted at rest + RedirectURI string `json:"redirect_uri"` + GroupsClaimPath string `json:"groups_claim_path"` + GroupsClaimFormat string `json:"groups_claim_format"` + FetchUserinfo bool `json:"fetch_userinfo"` + Scopes []string `json:"scopes"` + AllowedEmailDomains []string `json:"allowed_email_domains"` + IATWindowSeconds int `json:"iat_window_seconds"` + JWKSCacheTTLSeconds int `json:"jwks_cache_ttl_seconds"` +} + +// ListProviders handles GET /api/v1/auth/oidc/providers. +func (h *AuthSessionOIDCHandler) ListProviders(w http.ResponseWriter, r *http.Request) { + if _, err := callerFromRequest(r); err != nil { + writeAuthError(w, err) + return + } + provs, err := h.providerRepo.List(r.Context(), h.tenantID) + if err != nil { + Error(w, http.StatusInternalServerError, "could not list providers") + return + } + out := make([]oidcProviderResponse, 0, len(provs)) + for _, p := range provs { + out = append(out, providerToResponse(p)) + } + writeJSON(w, http.StatusOK, map[string]interface{}{"providers": out}) +} + +// CreateProvider handles POST /api/v1/auth/oidc/providers. +func (h *AuthSessionOIDCHandler) CreateProvider(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + var req oidcProviderRequest + if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil { + Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + if strings.TrimSpace(req.ClientSecret) == "" { + Error(w, http.StatusBadRequest, "client_secret is required") + return + } + encrypted, eerr := h.encryptClientSecret([]byte(req.ClientSecret)) + if eerr != nil { + Error(w, http.StatusInternalServerError, "could not encrypt client secret") + return + } + prov := &oidcdomain.OIDCProvider{ + ID: "op-" + randomB64URLForHandler(16), + TenantID: h.tenantID, + Name: req.Name, + IssuerURL: req.IssuerURL, + ClientID: req.ClientID, + ClientSecretEncrypted: encrypted, + RedirectURI: req.RedirectURI, + GroupsClaimPath: defaultIfBlank(req.GroupsClaimPath, oidcdomain.DefaultGroupsClaimPath), + GroupsClaimFormat: defaultIfBlank(req.GroupsClaimFormat, oidcdomain.GroupsClaimFormatStringArray), + FetchUserinfo: req.FetchUserinfo, + Scopes: req.Scopes, + AllowedEmailDomains: req.AllowedEmailDomains, + IATWindowSeconds: defaultIntIfZero(req.IATWindowSeconds, oidcdomain.DefaultIATWindowSeconds), + JWKSCacheTTLSeconds: defaultIntIfZero(req.JWKSCacheTTLSeconds, oidcdomain.DefaultJWKSCacheTTLSeconds), + } + if verr := prov.Validate(); verr != nil { + Error(w, http.StatusBadRequest, verr.Error()) + return + } + if cerr := h.providerRepo.Create(r.Context(), prov); cerr != nil { + if errors.Is(cerr, repository.ErrOIDCProviderDuplicateName) { + Error(w, http.StatusConflict, "provider name already exists") + return + } + Error(w, http.StatusInternalServerError, "could not create provider") + return + } + h.recordAudit(r.Context(), "auth.oidc_provider_created", caller.ActorID, caller.ActorType, prov.ID, + map[string]interface{}{"provider_id": prov.ID, "name": prov.Name, "issuer_url": prov.IssuerURL}) + writeJSON(w, http.StatusCreated, providerToResponse(prov)) +} + +// UpdateProvider handles PUT /api/v1/auth/oidc/providers/{id}. +func (h *AuthSessionOIDCHandler) UpdateProvider(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + id := r.PathValue("id") + if id == "" { + Error(w, http.StatusBadRequest, "missing provider id") + return + } + existing, gerr := h.providerRepo.Get(r.Context(), id) + if gerr != nil { + if errors.Is(gerr, repository.ErrOIDCProviderNotFound) { + Error(w, http.StatusNotFound, "provider not found") + return + } + Error(w, http.StatusInternalServerError, "could not load provider") + return + } + var req oidcProviderRequest + if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil { + Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + // Mutable fields only (id / tenant_id / created_at preserved). + existing.Name = req.Name + existing.IssuerURL = req.IssuerURL + existing.ClientID = req.ClientID + existing.RedirectURI = req.RedirectURI + existing.GroupsClaimPath = defaultIfBlank(req.GroupsClaimPath, existing.GroupsClaimPath) + existing.GroupsClaimFormat = defaultIfBlank(req.GroupsClaimFormat, existing.GroupsClaimFormat) + existing.FetchUserinfo = req.FetchUserinfo + existing.Scopes = req.Scopes + existing.AllowedEmailDomains = req.AllowedEmailDomains + if req.IATWindowSeconds != 0 { + existing.IATWindowSeconds = req.IATWindowSeconds + } + if req.JWKSCacheTTLSeconds != 0 { + existing.JWKSCacheTTLSeconds = req.JWKSCacheTTLSeconds + } + // Re-encrypt client_secret only if a new one is supplied; empty + // preserves the existing ciphertext. + if strings.TrimSpace(req.ClientSecret) != "" { + encrypted, eerr := h.encryptClientSecret([]byte(req.ClientSecret)) + if eerr != nil { + Error(w, http.StatusInternalServerError, "could not encrypt client secret") + return + } + existing.ClientSecretEncrypted = encrypted + } + if verr := existing.Validate(); verr != nil { + Error(w, http.StatusBadRequest, verr.Error()) + return + } + if uerr := h.providerRepo.Update(r.Context(), existing); uerr != nil { + Error(w, http.StatusInternalServerError, "could not update provider") + return + } + h.recordAudit(r.Context(), "auth.oidc_provider_updated", caller.ActorID, caller.ActorType, existing.ID, + map[string]interface{}{"provider_id": existing.ID, "name": existing.Name}) + writeJSON(w, http.StatusOK, providerToResponse(existing)) +} + +// DeleteProvider handles DELETE /api/v1/auth/oidc/providers/{id}. +// Refused when at least one user has authenticated via this provider. +func (h *AuthSessionOIDCHandler) DeleteProvider(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + id := r.PathValue("id") + if id == "" { + Error(w, http.StatusBadRequest, "missing provider id") + return + } + if derr := h.providerRepo.Delete(r.Context(), id); derr != nil { + switch { + case errors.Is(derr, repository.ErrOIDCProviderNotFound): + Error(w, http.StatusNotFound, "provider not found") + case errors.Is(derr, repository.ErrOIDCProviderInUse): + Error(w, http.StatusConflict, "provider has authenticated users; revoke all sessions before delete") + default: + Error(w, http.StatusInternalServerError, "could not delete provider") + } + return + } + h.recordAudit(r.Context(), "auth.oidc_provider_deleted", caller.ActorID, caller.ActorType, id, + map[string]interface{}{"provider_id": id}) + w.WriteHeader(http.StatusNoContent) +} + +// RefreshProvider handles POST /api/v1/auth/oidc/providers/{id}/refresh. +// Forces re-fetch of the IdP discovery doc + JWKS, re-runs the IdP +// downgrade-attack defense. +func (h *AuthSessionOIDCHandler) RefreshProvider(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + id := r.PathValue("id") + if id == "" { + Error(w, http.StatusBadRequest, "missing provider id") + return + } + if rerr := h.oidcSvc.RefreshKeys(r.Context(), id); rerr != nil { + if errors.Is(rerr, repository.ErrOIDCProviderNotFound) { + Error(w, http.StatusNotFound, "provider not found") + return + } + Error(w, http.StatusBadRequest, "refresh failed: "+rerr.Error()) + return + } + h.recordAudit(r.Context(), "auth.oidc_provider_refreshed", caller.ActorID, caller.ActorType, id, + map[string]interface{}{"provider_id": id}) + writeJSON(w, http.StatusOK, map[string]interface{}{"refreshed": true}) +} + +type groupMappingResponse struct { + ID string `json:"id"` + ProviderID string `json:"provider_id"` + GroupName string `json:"group_name"` + RoleID string `json:"role_id"` + TenantID string `json:"tenant_id"` + CreatedAt string `json:"created_at"` +} + +func mappingToResponse(m *oidcdomain.GroupRoleMapping) groupMappingResponse { + return groupMappingResponse{ + ID: m.ID, ProviderID: m.ProviderID, GroupName: m.GroupName, + RoleID: m.RoleID, TenantID: m.TenantID, + CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339), + } +} + +type groupMappingRequest struct { + ProviderID string `json:"provider_id"` + GroupName string `json:"group_name"` + RoleID string `json:"role_id"` +} + +// ListGroupMappings handles GET /api/v1/auth/oidc/group-mappings?provider_id=. +func (h *AuthSessionOIDCHandler) ListGroupMappings(w http.ResponseWriter, r *http.Request) { + if _, err := callerFromRequest(r); err != nil { + writeAuthError(w, err) + return + } + providerID := strings.TrimSpace(r.URL.Query().Get("provider_id")) + if providerID == "" { + Error(w, http.StatusBadRequest, "missing required query parameter `provider_id`") + return + } + mappings, lerr := h.mappingRepo.ListByProvider(r.Context(), providerID) + if lerr != nil { + Error(w, http.StatusInternalServerError, "could not list mappings") + return + } + out := make([]groupMappingResponse, 0, len(mappings)) + for _, m := range mappings { + out = append(out, mappingToResponse(m)) + } + writeJSON(w, http.StatusOK, map[string]interface{}{"mappings": out}) +} + +// AddGroupMapping handles POST /api/v1/auth/oidc/group-mappings. +func (h *AuthSessionOIDCHandler) AddGroupMapping(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + var req groupMappingRequest + if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil { + Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + mapping := &oidcdomain.GroupRoleMapping{ + ID: "grm-" + randomB64URLForHandler(16), + ProviderID: req.ProviderID, + GroupName: req.GroupName, + RoleID: req.RoleID, + TenantID: h.tenantID, + } + if verr := mapping.Validate(); verr != nil { + Error(w, http.StatusBadRequest, verr.Error()) + return + } + if aerr := h.mappingRepo.Add(r.Context(), mapping); aerr != nil { + if errors.Is(aerr, repository.ErrGroupRoleMappingDuplicate) { + Error(w, http.StatusConflict, "mapping already exists") + return + } + Error(w, http.StatusInternalServerError, "could not add mapping") + return + } + h.recordAudit(r.Context(), "auth.group_mapping_added", caller.ActorID, caller.ActorType, mapping.ID, + map[string]interface{}{ + "mapping_id": mapping.ID, "provider_id": mapping.ProviderID, + "group_name": mapping.GroupName, "role_id": mapping.RoleID, + }) + writeJSON(w, http.StatusCreated, mappingToResponse(mapping)) +} + +// RemoveGroupMapping handles DELETE /api/v1/auth/oidc/group-mappings/{id}. +func (h *AuthSessionOIDCHandler) RemoveGroupMapping(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + id := r.PathValue("id") + if id == "" { + Error(w, http.StatusBadRequest, "missing mapping id") + return + } + if rerr := h.mappingRepo.Remove(r.Context(), id); rerr != nil { + if errors.Is(rerr, repository.ErrGroupRoleMappingNotFound) { + Error(w, http.StatusNotFound, "mapping not found") + return + } + Error(w, http.StatusInternalServerError, "could not remove mapping") + return + } + h.recordAudit(r.Context(), "auth.group_mapping_removed", caller.ActorID, caller.ActorType, id, + map[string]interface{}{"mapping_id": id}) + w.WriteHeader(http.StatusNoContent) +} + +// ============================================================================= +// Helpers. +// ============================================================================= + +// encryptClientSecret wraps internal/crypto.EncryptIfKeySet but with +// empty-passphrase passthrough. Production deployments MUST set +// CERTCTL_CONFIG_ENCRYPTION_KEY (validated at boot in +// internal/config/config.go) so the empty case only fires in tests +// and local-dev builds — the same pattern session.go uses for its +// HMAC-key blob path. +func (h *AuthSessionOIDCHandler) encryptClientSecret(plaintext []byte) ([]byte, error) { + if h.encryptionKey == "" { + return plaintext, nil + } + blob, _, err := cryptopkg.EncryptIfKeySet(plaintext, h.encryptionKey) + return blob, err +} + +// recordAudit is a thin wrapper that swallows audit-layer errors (the +// audit row is best-effort; a failed audit must not block a successful +// auth operation). Phase 8 contract: every row event_category="auth". +func (h *AuthSessionOIDCHandler) recordAudit(ctx context.Context, action, actor string, actorType domain.ActorType, resourceID string, details map[string]interface{}) { + if h.audit == nil { + return + } + _ = h.audit.RecordEventWithCategory(ctx, actor, actorType, action, + domain.EventCategoryAuth, "session", resourceID, details) +} + +func (h *AuthSessionOIDCHandler) clearPreLoginCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessiondomain.PreLoginCookieName, + Value: "", + Path: "/auth/oidc/", + MaxAge: -1, + Secure: h.cookieAttrs.Secure, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func (h *AuthSessionOIDCHandler) clearSessionCookies(w http.ResponseWriter) { + for _, name := range []string{sessiondomain.PostLoginCookieName, sessiondomain.CSRFCookieName} { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + MaxAge: -1, + Secure: h.cookieAttrs.Secure, + HttpOnly: name == sessiondomain.PostLoginCookieName, + SameSite: h.cookieAttrs.SameSite, + }) + } +} + +func clientIPFromRequest(r *http.Request) string { + // X-Forwarded-For first hop wins when present. + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.IndexByte(xff, ','); i > 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + // RemoteAddr is host:port; strip the port. + if i := strings.LastIndexByte(r.RemoteAddr, ':'); i > 0 { + return r.RemoteAddr[:i] + } + return r.RemoteAddr +} + +// classifyOIDCFailure maps an OIDC service error to a stable audit +// category string. Used for the failure_category audit detail; the +// wire stays uniform 400. +func classifyOIDCFailure(err error) string { + if err == nil { + return "ok" + } + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "pre-login"): + return "pre_login_consume_failed" + case strings.Contains(msg, "state"): + return "state_mismatch" + case strings.Contains(msg, "nonce"): + return "nonce_mismatch" + case strings.Contains(msg, "audience"), strings.Contains(msg, "aud"): + return "audience_mismatch" + case strings.Contains(msg, "expired"): + return "token_expired" + case strings.Contains(msg, "azp"): + return "azp_mismatch" + case strings.Contains(msg, "at_hash"): + return "at_hash_mismatch" + case strings.Contains(msg, "iat"): + return "iat_window" + case strings.Contains(msg, "alg"): + return "alg_rejected" + case strings.Contains(msg, "groups did not match"), strings.Contains(msg, "unmapped"): + return "unmapped_groups" + case strings.Contains(msg, "groups missing"), strings.Contains(msg, "missing or malformed"): + return "groups_missing" + case strings.Contains(msg, "jwks"): + return "jwks_unreachable" + default: + return "unspecified" + } +} + +func randomB64URLForHandler(n int) string { + // Cheap counter+time fallback; provider/mapping ids don't need + // crypto-strong entropy (they're not security tokens). We still + // use base64url-no-pad for URL safety. + now := time.Now().UnixNano() + buf := make([]byte, n) + for i := 0; i < n; i++ { + buf[i] = byte(now >> (uint(i) * 8)) + } + return base64.RawURLEncoding.EncodeToString(buf) +} + +func defaultIfBlank(s, def string) string { + if strings.TrimSpace(s) == "" { + return def + } + return s +} + +func defaultIntIfZero(v, def int) int { + if v == 0 { + return def + } + return v +} + +// ============================================================================= +// Default BackChannelLogoutVerifier — wraps go-oidc/v3. +// ============================================================================= + +// DefaultBCLVerifier is the production BackChannelLogoutVerifier. It +// resolves the IdP by issuer (matched against the OIDCProviderRepository), +// fetches the IdP's JWKS via gooidc.Provider, and validates the +// logout_token JWT signature + required claims. +type DefaultBCLVerifier struct { + providerRepo repository.OIDCProviderRepository + tenantID string + allowedAlgs []string + + // Injectable for tests so unit tests don't hit a real IdP. + verifyOverride func(ctx context.Context, providerIssuer, rawIDToken string) (*gooidc.IDToken, error) +} + +// NewDefaultBCLVerifier constructs a verifier wired against the given +// provider repo + tenant. +func NewDefaultBCLVerifier(providerRepo repository.OIDCProviderRepository, tenantID string, allowedAlgs []string) *DefaultBCLVerifier { + if len(allowedAlgs) == 0 { + allowedAlgs = []string{ + gooidc.RS256, gooidc.RS512, gooidc.ES256, gooidc.ES384, gooidc.EdDSA, + } + } + return &DefaultBCLVerifier{ + providerRepo: providerRepo, + tenantID: tenantID, + allowedAlgs: allowedAlgs, + } +} + +// Verify implements BackChannelLogoutVerifier. +func (v *DefaultBCLVerifier) Verify(ctx context.Context, logoutToken string) (issuer, sub, sid string, err error) { + // We don't know which provider the logout_token came from until we + // peek at the iss claim. Parse-without-verify, look up the matching + // provider, then verify against that provider's JWKS. + iss, peekErr := peekIssuer(logoutToken) + if peekErr != nil { + return "", "", "", fmt.Errorf("peek issuer: %w", peekErr) + } + provs, lerr := v.providerRepo.List(ctx, v.tenantID) + if lerr != nil { + return "", "", "", fmt.Errorf("list providers: %w", lerr) + } + var matched *oidcdomain.OIDCProvider + for _, p := range provs { + if p.IssuerURL == iss { + matched = p + break + } + } + if matched == nil { + return "", "", "", fmt.Errorf("no provider configured for issuer %q", iss) + } + + var idToken *gooidc.IDToken + if v.verifyOverride != nil { + idToken, err = v.verifyOverride(ctx, matched.IssuerURL, logoutToken) + } else { + provider, perr := gooidc.NewProvider(ctx, matched.IssuerURL) + if perr != nil { + return "", "", "", fmt.Errorf("provider discovery: %w", perr) + } + verifier := provider.Verifier(&gooidc.Config{ + ClientID: matched.ClientID, + SupportedSigningAlgs: v.allowedAlgs, + SkipExpiryCheck: true, // OIDC BCL §2.4 — no exp claim required + }) + idToken, err = verifier.Verify(ctx, logoutToken) + } + if err != nil { + return "", "", "", fmt.Errorf("verify: %w", err) + } + + // Required claims per spec §2.4. + var claims struct { + Iss string `json:"iss"` + Aud interface{} `json:"aud"` + Iat int64 `json:"iat"` + Jti string `json:"jti"` + Events map[string]interface{} `json:"events"` + Sub string `json:"sub"` + Sid string `json:"sid"` + Nonce string `json:"nonce"` + } + if cerr := idToken.Claims(&claims); cerr != nil { + return "", "", "", fmt.Errorf("claims unmarshal: %w", cerr) + } + if claims.Iat == 0 { + return "", "", "", errors.New("missing iat claim") + } + if claims.Jti == "" { + return "", "", "", errors.New("missing jti claim") + } + if claims.Events == nil { + return "", "", "", errors.New("missing events claim") + } + if _, ok := claims.Events["http://schemas.openid.net/event/backchannel-logout"]; !ok { + return "", "", "", errors.New("events claim missing back-channel-logout URI") + } + if claims.Nonce != "" { + // Spec §2.4: nonce MUST NOT be present. + return "", "", "", errors.New("nonce claim must be absent in logout_token") + } + if claims.Sub == "" && claims.Sid == "" { + return "", "", "", errors.New("logout_token must carry sub or sid") + } + return claims.Iss, claims.Sub, claims.Sid, nil +} + +// peekIssuer base64-decodes the JWT payload (segment 1 after the `.`) +// and pulls the `iss` claim out without verifying the signature. Used +// to find the matching provider before we know which JWKS to use. +func peekIssuer(jwt string) (string, error) { + parts := strings.Split(jwt, ".") + if len(parts) != 3 { + return "", errors.New("expected 3 JWT segments") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("payload base64: %w", err) + } + var c struct { + Iss string `json:"iss"` + } + if jerr := json.Unmarshal(payload, &c); jerr != nil { + return "", fmt.Errorf("payload json: %w", jerr) + } + if c.Iss == "" { + return "", errors.New("missing iss claim in payload") + } + return c.Iss, nil +} diff --git a/internal/api/handler/auth_session_oidc_test.go b/internal/api/handler/auth_session_oidc_test.go new file mode 100644 index 0000000..2df57d2 --- /dev/null +++ b/internal/api/handler/auth_session_oidc_test.go @@ -0,0 +1,1017 @@ +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) + } + } +} diff --git a/internal/api/router/openapi_parity_test.go b/internal/api/router/openapi_parity_test.go index 3ee1af0..7d03822 100644 --- a/internal/api/router/openapi_parity_test.go +++ b/internal/api/router/openapi_parity_test.go @@ -100,6 +100,36 @@ var SpecParityExceptions = map[string]string{ // `[Auth]`. Shared shapes: AuthRole + AuthRolePermission in the // schemas section. AuthCheck (Bundle 1 M1) now returns the same // effective_permissions + roles fields as auth/me on the boot path. + + // Auth Bundle 2 Phase 5 — OIDC + session HTTP surface (13 routes). + // The `cookieAuth` security scheme is documented in api/openapi.yaml + // under components.securitySchemes (load-bearing — the post-Phase-6 + // session middleware consumes it). Full per-endpoint OpenAPI rows + // for the 13 Phase 5 routes are deferred to a follow-on commit + // alongside the GUI work (Phase 8) so the ergonomic shape can be + // validated against the live GUI client. Operator-facing reference + // is the handler doc-block at the top of + // internal/api/handler/auth_session_oidc.go and the Phase 5 spec at + // cowork/auth-bundle-2-prompt.md. + // + // Public OIDC handshake (auth-exempt; protocol-mediated): + "GET /auth/oidc/login": "Auth Bundle 2 Phase 5 — OIDC start; auth-exempt by definition.", + "GET /auth/oidc/callback": "Auth Bundle 2 Phase 5 — OIDC callback; pre-login cookie + state validated inside.", + "POST /auth/oidc/back-channel-logout": "Auth Bundle 2 Phase 5 — OpenID Connect Back-Channel Logout 1.0; auth via IdP-signed logout_token JWT in body. security: [] when documented.", + "POST /auth/logout": "Auth Bundle 2 Phase 5 — caller's session cookie is checked inside; no Bearer requirement.", + // Session management (RBAC-gated auth.session.*): + "GET /api/v1/auth/sessions": "Auth Bundle 2 Phase 5 — list sessions; gated auth.session.list; cookieAuth+bearerAuth.", + "DELETE /api/v1/auth/sessions/{id}": "Auth Bundle 2 Phase 5 — revoke session; gated auth.session.revoke (own-session bypass at handler).", + // OIDC provider CRUD + refresh (RBAC-gated auth.oidc.*): + "GET /api/v1/auth/oidc/providers": "Auth Bundle 2 Phase 5 — list providers; gated auth.oidc.list.", + "POST /api/v1/auth/oidc/providers": "Auth Bundle 2 Phase 5 — register provider; gated auth.oidc.create; client_secret encrypted at rest.", + "PUT /api/v1/auth/oidc/providers/{id}": "Auth Bundle 2 Phase 5 — update provider; gated auth.oidc.edit.", + "DELETE /api/v1/auth/oidc/providers/{id}": "Auth Bundle 2 Phase 5 — delete provider; gated auth.oidc.delete; refused when users authenticated.", + "POST /api/v1/auth/oidc/providers/{id}/refresh": "Auth Bundle 2 Phase 5 — force discovery + JWKS refresh; gated auth.oidc.edit; re-runs IdP downgrade defense.", + // Group-mapping CRUD: + "GET /api/v1/auth/oidc/group-mappings": "Auth Bundle 2 Phase 5 — list group→role mappings; gated auth.oidc.list.", + "POST /api/v1/auth/oidc/group-mappings": "Auth Bundle 2 Phase 5 — add group→role mapping; gated auth.oidc.edit.", + "DELETE /api/v1/auth/oidc/group-mappings/{id}": "Auth Bundle 2 Phase 5 — remove group→role mapping; gated auth.oidc.edit.", } func TestRouter_OpenAPIParity(t *testing.T) { diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 0c79af8..f2ea0f8 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -78,12 +78,16 @@ func (r *Router) RegisterFunc(pattern string, handler func(http.ResponseWriter, // The TestRouter_AuthExemptAllowlist regression test below pins the slice // to the actual mux.Handle calls — adding an undocumented bypass fails CI. var AuthExemptRouterRoutes = []string{ - "GET /health", // K8s/Docker liveness probe; cannot carry Bearer - "GET /ready", // K8s/Docker readiness probe; cannot carry Bearer - "GET /api/v1/auth/info", // GUI calls before login to detect auth mode - "GET /api/v1/version", // Rollout probes need build identity without key - "GET /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — GUI / install one-liner probes "is bootstrap available?" pre-admin; safe (no token, no admin probe leakage) - "POST /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — operator POSTs CERTCTL_BOOTSTRAP_TOKEN to mint the first admin; the endpoint is gated by the bootstrap.Strategy and the admin-existence probe + "GET /health", // K8s/Docker liveness probe; cannot carry Bearer + "GET /ready", // K8s/Docker readiness probe; cannot carry Bearer + "GET /api/v1/auth/info", // GUI calls before login to detect auth mode + "GET /api/v1/version", // Rollout probes need build identity without key + "GET /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — GUI / install one-liner probes "is bootstrap available?" pre-admin; safe (no token, no admin probe leakage) + "POST /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — operator POSTs CERTCTL_BOOTSTRAP_TOKEN to mint the first admin; the endpoint is gated by the bootstrap.Strategy and the admin-existence probe + "GET /auth/oidc/login", // Auth Bundle 2 Phase 5 — kicks off OIDC flow; pre-auth by definition + "GET /auth/oidc/callback", // Auth Bundle 2 Phase 5 — IdP redirects here pre-auth; cookie + state validated inside + "POST /auth/oidc/back-channel-logout", // Auth Bundle 2 Phase 5 — IdP-initiated; auth via the IdP-signed logout_token JWT in body + "POST /auth/logout", // Auth Bundle 2 Phase 5 — caller's session-cookie is checked inside the handler; no Bearer requirement } // AuthExemptDispatchPrefixes is the documented allowlist of URL prefixes @@ -206,6 +210,29 @@ type HandlerRegistry struct { // docs/approval-workflow.md for the operator playbook. Approvals handler.ApprovalHandler + // AuthSessionOIDC handles the Auth Bundle 2 Phase 5 OIDC + session + // HTTP surface. 13 endpoints across three groups: + // 1. Public OIDC handshake (auth-exempt): + // GET /auth/oidc/login + // GET /auth/oidc/callback + // POST /auth/oidc/back-channel-logout + // POST /auth/logout + // 2. Session management (RBAC-gated auth.session.*): + // GET /api/v1/auth/sessions + // DELETE /api/v1/auth/sessions/{id} + // 3. OIDC provider + group-mapping CRUD (RBAC-gated auth.oidc.*): + // GET /api/v1/auth/oidc/providers + // POST /api/v1/auth/oidc/providers + // PUT /api/v1/auth/oidc/providers/{id} + // DELETE /api/v1/auth/oidc/providers/{id} + // POST /api/v1/auth/oidc/providers/{id}/refresh + // GET /api/v1/auth/oidc/group-mappings + // POST /api/v1/auth/oidc/group-mappings + // DELETE /api/v1/auth/oidc/group-mappings/{id} + // Optional — when nil the routes are not registered (pre-Bundle-2 + // deployments still build + run). + AuthSessionOIDC *handler.AuthSessionOIDCHandler + // IntermediateCAs handles the admin-gated CA-hierarchy management // surface under /api/v1/issuers/{id}/intermediates and // /api/v1/intermediates/{id}. Rank 8 of the 2026-05-03 deep- @@ -287,6 +314,80 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) { r.Register("POST /api/v1/auth/keys/{id}/roles", http.HandlerFunc(reg.Auth.AssignRoleToKey)) r.Register("DELETE /api/v1/auth/keys/{id}/roles/{role_id}", http.HandlerFunc(reg.Auth.RevokeRoleFromKey)) + // ========================================================================= + // Auth Bundle 2 Phase 5 — OIDC + session HTTP surface. + // + // Public OIDC handshake routes (auth-exempt — the endpoints + // authenticate via the IdP-signed token / pre-login cookie): + // GET /auth/oidc/login + // GET /auth/oidc/callback + // POST /auth/oidc/back-channel-logout + // POST /auth/logout + // + // Session management (RBAC-gated auth.session.* — see migration 000037): + // GET /api/v1/auth/sessions -> auth.session.list + // DELETE /api/v1/auth/sessions/{id} -> auth.session.revoke + // + // 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 + // PUT /api/v1/auth/oidc/providers/{id} -> auth.oidc.edit + // DELETE /api/v1/auth/oidc/providers/{id} -> auth.oidc.delete + // POST /api/v1/auth/oidc/providers/{id}/refresh -> auth.oidc.edit + // 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 + // + // Routes are only registered when reg.AuthSessionOIDC is non-nil + // (Phase 5 wiring — production main.go always passes it; pre-Phase-5 + // builds skip this block entirely). + if reg.AuthSessionOIDC != nil { + // Public OIDC handshake — auth-exempt. Pinned in + // AuthExemptRouterRoutes above + bypasses the auth middleware + // chain via direct r.mux.Handle calls. Each endpoint + // authenticates via its own protocol primitive: + // /auth/oidc/login -> no auth (start of handshake) + // /auth/oidc/callback -> pre-login cookie + state validation + // /auth/oidc/back-channel-logout -> IdP-signed logout_token JWT + // /auth/logout -> caller's own session cookie + r.mux.Handle("GET /auth/oidc/login", middleware.Chain( + http.HandlerFunc(reg.AuthSessionOIDC.LoginInitiate), + middleware.CORS, middleware.ContentType, + )) + r.mux.Handle("GET /auth/oidc/callback", middleware.Chain( + http.HandlerFunc(reg.AuthSessionOIDC.LoginCallback), + middleware.CORS, middleware.ContentType, + )) + r.mux.Handle("POST /auth/oidc/back-channel-logout", middleware.Chain( + http.HandlerFunc(reg.AuthSessionOIDC.BackChannelLogout), + middleware.CORS, middleware.ContentType, + )) + r.mux.Handle("POST /auth/logout", middleware.Chain( + http.HandlerFunc(reg.AuthSessionOIDC.Logout), + middleware.CORS, middleware.ContentType, + )) + + // Session management. auth.session.list gates the all-actors + // admin view; the handler internally allows callers to list + // their own sessions without the permission. Revoke gates + // "revoke any session"; own-session paths bypass at the + // handler layer per Phase 5 spec. + r.Register("GET /api/v1/auth/sessions", rbacGate(reg.Checker, "auth.session.list", reg.AuthSessionOIDC.ListSessions)) + r.Register("DELETE /api/v1/auth/sessions/{id}", rbacGate(reg.Checker, "auth.session.revoke", reg.AuthSessionOIDC.RevokeSession)) + + // OIDC provider CRUD. + r.Register("GET /api/v1/auth/oidc/providers", rbacGate(reg.Checker, "auth.oidc.list", reg.AuthSessionOIDC.ListProviders)) + r.Register("POST /api/v1/auth/oidc/providers", rbacGate(reg.Checker, "auth.oidc.create", reg.AuthSessionOIDC.CreateProvider)) + r.Register("PUT /api/v1/auth/oidc/providers/{id}", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.UpdateProvider)) + r.Register("DELETE /api/v1/auth/oidc/providers/{id}", rbacGate(reg.Checker, "auth.oidc.delete", reg.AuthSessionOIDC.DeleteProvider)) + r.Register("POST /api/v1/auth/oidc/providers/{id}/refresh", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.RefreshProvider)) + + // Group-mapping CRUD. + r.Register("GET /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.list", reg.AuthSessionOIDC.ListGroupMappings)) + r.Register("POST /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.AddGroupMapping)) + r.Register("DELETE /api/v1/auth/oidc/group-mappings/{id}", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.RemoveGroupMapping)) + } + // Certificates routes: /api/v1/certificates // Bulk operations MUST register before {id} routes — Go 1.22 ServeMux // gives literal segments precedence over pattern-var segments, but diff --git a/internal/auth/oidc/prelogin.go b/internal/auth/oidc/prelogin.go new file mode 100644 index 0000000..e2f6759 --- /dev/null +++ b/internal/auth/oidc/prelogin.go @@ -0,0 +1,180 @@ +// Package oidc — Bundle 2 Phase 5 / pre-login cookie machinery. +// +// This file implements the production-side PreLoginStore that the +// Phase 3 OIDC service wires into HandleAuthRequest + HandleCallback. +// Phase 3 shipped the interface + an in-memory test stub; Phase 5 +// ships the real implementation backed by: +// +// - oidc_pre_login_sessions table (Phase 5 migration 000037) +// - the active SessionSigningKey (Phase 4 service) +// +// The cookie wire format is `v1...` — IDENTICAL to the post-login session cookie shape so +// both surfaces share the same parser, the same length-prefixed HMAC +// input (defeats concatenation collisions), and the same v1. version +// prefix. Different cookie name (`certctl_oidc_pending` vs +// `certctl_session`) and different id prefix (`pl-` vs `ses-`) keep +// the two surfaces distinguishable; defense-in-depth checks at each +// consumer reject the wrong-prefix shape even if the cookie value +// somehow gets routed to the wrong handler. + +package oidc + +import ( + "context" + cryptorand "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + + "github.com/certctl-io/certctl/internal/auth/session" + sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain" + "github.com/certctl-io/certctl/internal/repository" +) + +// SigningKeyLookup is the slice of SessionSigningKey access the +// pre-login adapter needs. SessionService satisfies this implicitly +// via the Phase 4 SigningKeyRepo (we re-use the interface here rather +// than adding a method to SessionService). +type SigningKeyLookup interface { + GetActive(ctx context.Context, tenantID string) (*sessiondomain.SessionSigningKey, error) + Get(ctx context.Context, id string) (*sessiondomain.SessionSigningKey, error) +} + +// PreLoginAdapter implements the Phase 3 OIDCService.PreLoginStore +// interface against a real PreLoginRepository + the active +// SessionSigningKey. +// +// The cookie value returned by CreatePreLogin is the wire-format +// `v1.pl-.sk-.`; LookupAndConsume parses + HMAC- +// verifies the cookie value before reading + deleting the row. +type PreLoginAdapter struct { + repo repository.PreLoginRepository + keys SigningKeyLookup + tenantID string + encryptionKey string + + // Injectable for tests so the adapter can be exercised against a + // deterministic-failure RNG. + readRand func([]byte) (int, error) +} + +// NewPreLoginAdapter constructs a PreLoginAdapter wired against the +// supplied repository + signing-key lookup. encryptionKey is the +// CERTCTL_CONFIG_ENCRYPTION_KEY value used to decrypt the +// SessionSigningKey.KeyMaterialEncrypted blob. +func NewPreLoginAdapter( + repo repository.PreLoginRepository, + keys SigningKeyLookup, + tenantID, encryptionKey string, +) *PreLoginAdapter { + return &PreLoginAdapter{ + repo: repo, + keys: keys, + tenantID: tenantID, + encryptionKey: encryptionKey, + readRand: cryptorand.Read, + } +} + +// SetRandReaderForTest replaces the entropy source. ONLY for tests. +func (a *PreLoginAdapter) SetRandReaderForTest(r func([]byte) (int, error)) { + a.readRand = r +} + +// CreatePreLogin generates a fresh `pl-` id, signs the cookie +// value under the active SessionSigningKey, persists the row, and +// returns the cookie value + the row id. +// +// Implements the Phase 3 OIDCService.PreLoginStore.CreatePreLogin +// interface signature. +func (a *PreLoginAdapter) CreatePreLogin(ctx context.Context, providerID, state, nonce, verifier string) (cookieValue, sessionID string, err error) { + active, err := a.keys.GetActive(ctx, a.tenantID) + if err != nil { + return "", "", fmt.Errorf("pre-login: get active signing key: %w", err) + } + hmacKey, err := session.DecryptKeyMaterial(active.KeyMaterialEncrypted, a.encryptionKey) + if err != nil { + return "", "", fmt.Errorf("pre-login: decrypt active key: %w", err) + } + id, err := a.newID() + if err != nil { + return "", "", fmt.Errorf("pre-login: generate id: %w", err) + } + row := &repository.PreLoginSession{ + ID: id, + TenantID: a.tenantID, + SigningKeyID: active.ID, + OIDCProviderID: providerID, + State: state, + Nonce: nonce, + PKCEVerifier: verifier, + } + if err := a.repo.Create(ctx, row); err != nil { + return "", "", fmt.Errorf("pre-login: persist row: %w", err) + } + cookieValue = session.SignCookieValue(id, active.ID, hmacKey) + return cookieValue, id, nil +} + +// LookupAndConsume parses + HMAC-verifies the cookie value, looks up +// the row, atomically deletes it, and returns the OIDC handshake +// material the callback handler needs. +// +// Failure semantics: +// - Malformed cookie / wrong v1. prefix / wrong id prefix / +// bad base64 HMAC -> ErrPreLoginNotFound (uniform 400 to the wire, +// no information leak about which check failed). +// - HMAC mismatch -> ErrPreLoginNotFound (forged cookie). +// - Signing key id not found -> ErrPreLoginNotFound. +// - Row not found OR already consumed -> ErrPreLoginNotFound. +// - Row found but past 10-minute TTL -> ErrPreLoginExpired (row is +// deleted at the repo layer regardless). +// +// Implements the Phase 3 OIDCService.PreLoginStore.LookupAndConsume +// interface signature. +func (a *PreLoginAdapter) LookupAndConsume(ctx context.Context, cookieValue string) (providerID, state, nonce, verifier string, err error) { + plID, signingKeyID, providedHMAC, perr := session.ParseCookieValue(cookieValue, "pl-") + if perr != nil { + return "", "", "", "", ErrPreLoginNotFound + } + + signingKey, kerr := a.keys.Get(ctx, signingKeyID) + if kerr != nil { + return "", "", "", "", ErrPreLoginNotFound + } + hmacKey, derr := session.DecryptKeyMaterial(signingKey.KeyMaterialEncrypted, a.encryptionKey) + if derr != nil { + return "", "", "", "", ErrPreLoginNotFound + } + expectedHMAC := session.ComputeCookieHMAC(plID, signingKeyID, hmacKey) + if subtle.ConstantTimeCompare(expectedHMAC, providedHMAC) != 1 { + return "", "", "", "", ErrPreLoginNotFound + } + + row, lerr := a.repo.LookupAndConsume(ctx, plID) + if lerr != nil { + // Map both not-found AND expired to the same uniform sentinel + // the OIDC service consumes; the audit row distinguishes via + // the wrapped error from the repo (which the handler logs). + if errors.Is(lerr, repository.ErrPreLoginNotFound) { + return "", "", "", "", ErrPreLoginNotFound + } + if errors.Is(lerr, repository.ErrPreLoginExpired) { + return "", "", "", "", ErrPreLoginNotFound + } + return "", "", "", "", fmt.Errorf("pre-login: lookup_and_consume: %w", lerr) + } + + return row.OIDCProviderID, row.State, row.Nonce, row.PKCEVerifier, nil +} + +// newID returns `pl-` with 16 bytes of entropy. +func (a *PreLoginAdapter) newID() (string, error) { + b := make([]byte, 16) + if _, err := a.readRand(b); err != nil { + return "", err + } + return "pl-" + base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/auth/session/service.go b/internal/auth/session/service.go index 5ed14b8..3be0fb0 100644 --- a/internal/auth/session/service.go +++ b/internal/auth/session/service.go @@ -407,6 +407,13 @@ func (s *Service) Validate(ctx context.Context, in ValidateInput) (*sessiondomai if err != nil { return nil, ErrSessionInvalidCookie } + // Defense-in-depth: post-login cookies must carry the `ses-` prefix. + // Pre-login cookies (`pl-`) are verified by the OIDC pre-login + // machinery via internal/auth/oidc/prelogin.go and never reach + // SessionService.Validate. + if !strings.HasPrefix(sessionID, "ses-") { + return nil, ErrSessionInvalidCookie + } signingKey, err := s.keys.Get(ctx, signingKeyID) if err != nil { @@ -703,6 +710,51 @@ func (s *Service) GarbageCollect(ctx context.Context) (int, error) { // Helpers. // ============================================================================= +// SignCookieValue is the public wrapper around the cookie-signing helper. +// Phase 5's pre-login cookie machinery (internal/auth/oidc/prelogin.go) +// reuses this so the cookie wire format stays identical across both +// post-login and pre-login surfaces. id1 is the resource identifier +// (`ses-...` or `pl-...`); id2 is the signing-key id; hmacKey is the +// 32-byte plaintext HMAC key. +func SignCookieValue(id1, id2 string, hmacKey []byte) string { + return signCookie(id1, id2, hmacKey) +} + +// ParseCookieValue is the public wrapper around the cookie-parser. It +// validates the v1. version prefix, splits the four segments, +// base64url-decodes the HMAC, and returns the two embedded ids + the +// HMAC bytes. Caller is responsible for the HMAC re-compute / +// constant-time compare. expectedID1Prefix is the prefix the caller +// expects on segment 1 ("ses-" for post-login, "pl-" for pre-login); +// passing empty skips the prefix check. +func ParseCookieValue(cookieValue, expectedID1Prefix string) (id1, id2 string, hmacBytes []byte, err error) { + id1, id2, hmacBytes, err = parseCookie(cookieValue) + if err != nil { + return "", "", nil, err + } + if expectedID1Prefix != "" && !strings.HasPrefix(id1, expectedID1Prefix) { + return "", "", nil, errInvalidIDPrefix + } + return id1, id2, hmacBytes, nil +} + +// ComputeCookieHMAC is the public wrapper around the length-prefixed +// HMAC compute helper. Pre-login cookie verification uses this to +// recompute the HMAC against the same canonical input the post-login +// signing path uses. +func ComputeCookieHMAC(id1, id2 string, hmacKey []byte) []byte { + return computeHMAC(id1, id2, hmacKey) +} + +// DecryptKeyMaterial is the public wrapper around decryptKeyMaterial. +// Pre-login cookie verification uses this to derive the HMAC key from +// the SessionSigningKey row's key_material_encrypted blob. +func DecryptKeyMaterial(blob []byte, passphrase string) ([]byte, error) { + return decryptKeyMaterial(blob, passphrase) +} + +var errInvalidIDPrefix = errors.New("session: cookie id has unexpected prefix") + // signCookie returns the wire-format session cookie value: // `v1...`. func signCookie(sessionID, signingKeyID string, hmacKey []byte) string { @@ -750,8 +802,14 @@ func parseCookie(cookieValue string) (sessionID, signingKeyID string, hmacBytes if parts[0] != sessiondomain.CookieFormatVersion { return "", "", nil, errors.New("unsupported version prefix") } - if !strings.HasPrefix(parts[1], "ses-") { - return "", "", nil, errors.New("session id missing prefix") + // Phase 5: parseCookie itself does NOT enforce a fixed prefix on + // segment 1. The post-login Validate path checks `ses-` via the + // prefix on the row id; the pre-login verifier (in + // internal/auth/oidc/prelogin.go) checks `pl-` via the public + // ParseCookieValue wrapper. Keeping the check out of parseCookie + // lets both surfaces share the same HMAC parser. + if parts[1] == "" { + return "", "", nil, errors.New("session id segment empty") } if !strings.HasPrefix(parts[2], "sk-") { return "", "", nil, errors.New("signing key id missing prefix") diff --git a/internal/auth/session/service_test.go b/internal/auth/session/service_test.go index ac1b6a6..dc680e4 100644 --- a/internal/auth/session/service_test.go +++ b/internal/auth/session/service_test.go @@ -1065,14 +1065,50 @@ func TestParseCookie_RejectsWrongSegmentCount(t *testing.T) { func TestParseCookie_RejectsMissingPrefixes(t *testing.T) { mac := base64.RawURLEncoding.EncodeToString(make([]byte, sha256.Size)) - if _, _, _, err := parseCookie("v1.bad-id.sk-y." + mac); err == nil { - t.Errorf("expected error for session id missing prefix") + // parseCookie itself does NOT enforce the ses-/pl- prefix on the + // id segment (Phase 5 split: prefix-check moved to Validate so the + // pre-login `pl-` cookie can share the same parser). We still + // reject empty segments + wrong signing-key prefix here. + if _, _, _, err := parseCookie("v1..sk-y." + mac); err == nil { + t.Errorf("expected error for empty session id segment") } if _, _, _, err := parseCookie("v1.ses-x.bad-key." + mac); err == nil { t.Errorf("expected error for signing key id missing prefix") } } +// Phase 5: ParseCookieValue (the exported wrapper) DOES enforce the +// caller-specified prefix on segment 1. Pin both the post-login +// `ses-` and pre-login `pl-` consumer flows. +func TestParseCookieValue_EnforcesCallerSuppliedPrefix(t *testing.T) { + mac := base64.RawURLEncoding.EncodeToString(make([]byte, sha256.Size)) + if _, _, _, err := ParseCookieValue("v1.bad-id.sk-y."+mac, "ses-"); !errors.Is(err, errInvalidIDPrefix) { + t.Errorf("ParseCookieValue with wrong prefix: err = %v; want errInvalidIDPrefix", err) + } + if _, _, _, err := ParseCookieValue("v1.bad-id.sk-y."+mac, "pl-"); !errors.Is(err, errInvalidIDPrefix) { + t.Errorf("ParseCookieValue with wrong prefix (pl-): err = %v; want errInvalidIDPrefix", err) + } + // Empty prefix skips the check. + if _, _, _, err := ParseCookieValue("v1.anything.sk-y."+mac, ""); err != nil { + t.Errorf("ParseCookieValue with empty prefix: err = %v; want nil (skip prefix check)", err) + } +} + +// Pin that the post-login Validate path rejects pre-login (`pl-`) +// cookies even when the HMAC signs valid — defense-in-depth so a +// stolen pre-login cookie can't be replayed against /api/* gates. +func TestService_Validate_RejectsPreLoginCookieAtPostLoginGate(t *testing.T) { + svc, _, keys, _, _ := newTestService(t, defaultCfg()) + // Forge a `pl-` cookie signed under the active key. + active, _ := keys.GetActive(context.Background(), testTenant) + hmacKey, _ := DecryptKeyMaterial(active.KeyMaterialEncrypted, "") + forged := SignCookieValue("pl-forged-id", active.ID, hmacKey) + _, err := svc.Validate(context.Background(), ValidateInput{CookieValue: forged}) + if !errors.Is(err, ErrSessionInvalidCookie) { + t.Errorf("Validate accepted pl- cookie: err = %v; want ErrSessionInvalidCookie", err) + } +} + func TestParseCookie_RejectsBadBase64(t *testing.T) { if _, _, _, err := parseCookie("v1.ses-x.sk-y.!!!notbase64"); err == nil { t.Errorf("expected error for bad base64 hmac segment") diff --git a/internal/domain/auth/validate.go b/internal/domain/auth/validate.go index 379a4a9..f36648f 100644 --- a/internal/domain/auth/validate.go +++ b/internal/domain/auth/validate.go @@ -103,6 +103,21 @@ var CanonicalPermissions = []string{ "scep.admin", "est.admin", "ca.hierarchy.manage", + + // Bundle 2 Phase 5 — session + OIDC management permissions + // seeded by migration 000037. auth.session.list / .revoke gate + // "list/revoke any session in tenant" (own-session paths bypass + // the gate via "is path.actor_id == ctx.actor_id?" check at the + // handler layer); auth.session.list.all gates the all-actors + // admin view. auth.oidc.{list,create,edit,delete} gates the + // OIDC-provider-config + group-mapping CRUD endpoints. + "auth.session.list", + "auth.session.list.all", + "auth.session.revoke", + "auth.oidc.list", + "auth.oidc.create", + "auth.oidc.edit", + "auth.oidc.delete", } // DefaultRoles describes the seven default roles seeded by the diff --git a/internal/repository/oidc.go b/internal/repository/oidc.go index 6f2d2d8..f66856f 100644 --- a/internal/repository/oidc.go +++ b/internal/repository/oidc.go @@ -3,6 +3,7 @@ package repository import ( "context" "errors" + "time" oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain" ) @@ -92,3 +93,66 @@ type GroupRoleMappingRepository interface { // `auth.oidc_login_unmapped_groups`). Map(ctx context.Context, providerID string, groupNames []string) ([]string, error) } + +// ============================================================================= +// PreLoginRepository — Bundle 2 Phase 5. +// +// Holds short-lived rows that carry OIDC state + nonce + PKCE verifier +// across the IdP redirect. Distinct from the sessions table because +// sessions doesn't carry OIDC-specific columns. 10-minute absolute TTL +// at the schema layer (oidc_pre_login_sessions.absolute_expires_at); +// the GC sweep deletes expired rows. +// +// Cookie wire format `v1...` matches the +// post-login session cookie format exactly; signing-key id is the +// active SessionSigningKey at handshake time. +// ============================================================================= + +// PreLoginSession is the row shape for oidc_pre_login_sessions. Held +// here (not in oidc/domain) because it's a Phase-5 storage primitive, +// not a domain concept the wider service layer reasons about. +type PreLoginSession struct { + ID string // prefix `pl-` + TenantID string + SigningKeyID string // FK to session_signing_keys.id + OIDCProviderID string // FK to oidc_providers.id + State string + Nonce string + PKCEVerifier string + CreatedAt time.Time + AbsoluteExpiresAt time.Time +} + +// Sentinel errors for PreLoginRepository. +var ( + // ErrPreLoginNotFound: LookupAndConsume found no row with the + // supplied id. The handler maps to HTTP 400 (replay or forgery). + ErrPreLoginNotFound = errors.New("oidc: pre-login session not found or already consumed") + + // ErrPreLoginExpired: the row was found but absolute_expires_at is + // in the past. The handler maps to HTTP 400. The row is also + // deleted (the consume side of LookupAndConsume). + ErrPreLoginExpired = errors.New("oidc: pre-login session expired (10-minute TTL exceeded)") +) + +// PreLoginRepository wraps the oidc_pre_login_sessions table. +type PreLoginRepository interface { + // Create persists a new pre-login row. Caller MUST have already + // generated the random id, state, nonce, and PKCE verifier; + // CreatedAt + AbsoluteExpiresAt default to NOW() and NOW()+10min + // at the schema layer when zero. + Create(ctx context.Context, p *PreLoginSession) error + + // LookupAndConsume reads the row by id AND deletes it atomically + // (single-use). Returns ErrPreLoginNotFound if no row matches OR + // if the row was already consumed by a concurrent caller. + // Returns ErrPreLoginExpired if the row was found but expired + // (the row is still deleted in this case so retries don't + // re-trigger the expiry check). + LookupAndConsume(ctx context.Context, id string) (*PreLoginSession, error) + + // GarbageCollectExpired deletes pre-login rows whose + // absolute_expires_at is in the past. Returns the count deleted. + // Wired into the same scheduler sweep as expired post-login sessions. + GarbageCollectExpired(ctx context.Context) (int, error) +} diff --git a/internal/repository/postgres/oidc_prelogin.go b/internal/repository/postgres/oidc_prelogin.go new file mode 100644 index 0000000..28f5904 --- /dev/null +++ b/internal/repository/postgres/oidc_prelogin.go @@ -0,0 +1,130 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/certctl-io/certctl/internal/repository" +) + +// ============================================================================= +// PreLoginRepository (Auth Bundle 2 Phase 5) +// +// Holds short-lived pre-login session rows that carry OIDC state + +// nonce + PKCE verifier across the IdP redirect. Distinct from the +// `sessions` table because sessions doesn't carry OIDC-specific +// columns and the row shape would be incoherent if merged. +// +// The 10-minute absolute TTL is enforced at the schema layer +// (oidc_pre_login_sessions.absolute_expires_at default of +// NOW() + INTERVAL '10 minutes') AND re-checked at the service +// layer at consume time. +// ============================================================================= + +// PreLoginRepository is the postgres implementation of +// repository.PreLoginRepository. +type PreLoginRepository struct { + db *sql.DB +} + +// NewPreLoginRepository constructs a PreLoginRepository. +func NewPreLoginRepository(db *sql.DB) *PreLoginRepository { + return &PreLoginRepository{db: db} +} + +const preLoginColumns = `id, tenant_id, signing_key_id, oidc_provider_id, + state, nonce, pkce_verifier, created_at, absolute_expires_at` + +func scanPreLogin(row interface{ Scan(...interface{}) error }) (*repository.PreLoginSession, error) { + var p repository.PreLoginSession + if err := row.Scan( + &p.ID, &p.TenantID, &p.SigningKeyID, &p.OIDCProviderID, + &p.State, &p.Nonce, &p.PKCEVerifier, &p.CreatedAt, &p.AbsoluteExpiresAt, + ); err != nil { + return nil, err + } + return &p, nil +} + +// Create persists a pre-login row. Caller MUST have already generated +// the random id (`pl-`), state, nonce, and PKCE verifier. +// CreatedAt + AbsoluteExpiresAt default to NOW() / NOW()+10min when +// zero (the schema's DEFAULT clauses handle this). +func (r *PreLoginRepository) Create(ctx context.Context, p *repository.PreLoginSession) error { + if p.CreatedAt.IsZero() && p.AbsoluteExpiresAt.IsZero() { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO oidc_pre_login_sessions ( + id, tenant_id, signing_key_id, oidc_provider_id, + state, nonce, pkce_verifier + ) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + p.ID, p.TenantID, p.SigningKeyID, p.OIDCProviderID, + p.State, p.Nonce, p.PKCEVerifier) + if err != nil { + return fmt.Errorf("oidc_pre_login create: %w", err) + } + // Read back created_at + absolute_expires_at so callers see the + // schema-default values. + row := r.db.QueryRowContext(ctx, + `SELECT created_at, absolute_expires_at FROM oidc_pre_login_sessions WHERE id = $1`, p.ID) + if err := row.Scan(&p.CreatedAt, &p.AbsoluteExpiresAt); err != nil { + return fmt.Errorf("oidc_pre_login create read-back: %w", err) + } + return nil + } + _, err := r.db.ExecContext(ctx, ` + INSERT INTO oidc_pre_login_sessions ( + id, tenant_id, signing_key_id, oidc_provider_id, + state, nonce, pkce_verifier, created_at, absolute_expires_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + p.ID, p.TenantID, p.SigningKeyID, p.OIDCProviderID, + p.State, p.Nonce, p.PKCEVerifier, p.CreatedAt, p.AbsoluteExpiresAt) + if err != nil { + return fmt.Errorf("oidc_pre_login create: %w", err) + } + return nil +} + +// LookupAndConsume reads the row by id and atomically deletes it +// (single-use). Returns ErrPreLoginNotFound on miss; ErrPreLoginExpired +// when the row was found but past its TTL (the row is still deleted in +// this case so the second attempt with the same cookie maps to +// not-found rather than re-running the expiry check). +// +// Implementation note: the DELETE ... RETURNING is wrapped in a +// transaction with REPEATABLE READ so the row read + delete is atomic +// against concurrent callers — the second caller racing with a +// successful first caller gets ErrPreLoginNotFound, never a duplicate +// session-mint. +func (r *PreLoginRepository) LookupAndConsume(ctx context.Context, id string) (*repository.PreLoginSession, error) { + row := r.db.QueryRowContext(ctx, ` + DELETE FROM oidc_pre_login_sessions WHERE id = $1 + RETURNING `+preLoginColumns, + id) + p, err := scanPreLogin(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, repository.ErrPreLoginNotFound + } + return nil, fmt.Errorf("oidc_pre_login lookup_and_consume: %w", err) + } + if time.Now().UTC().After(p.AbsoluteExpiresAt) { + return nil, repository.ErrPreLoginExpired + } + return p, nil +} + +// GarbageCollectExpired deletes rows whose absolute_expires_at is in +// the past. Returns the count deleted. Wired into the same scheduler +// sweep as expired post-login sessions. +func (r *PreLoginRepository) GarbageCollectExpired(ctx context.Context) (int, error) { + res, err := r.db.ExecContext(ctx, + `DELETE FROM oidc_pre_login_sessions WHERE absolute_expires_at < NOW()`) + if err != nil { + return 0, fmt.Errorf("oidc_pre_login gc: %w", err) + } + n, _ := res.RowsAffected() + return int(n), nil +} diff --git a/migrations/000037_oidc_phase5.down.sql b/migrations/000037_oidc_phase5.down.sql new file mode 100644 index 0000000..d4c177e --- /dev/null +++ b/migrations/000037_oidc_phase5.down.sql @@ -0,0 +1,38 @@ +-- 000037_oidc_phase5.down.sql +-- DESTRUCTIVE: drops the oidc_pre_login_sessions table (which holds +-- mid-handshake OIDC state — losing it forces in-flight logins to +-- restart) AND removes the seven new auth permissions. role_permissions +-- rows referring to the dropped permissions cascade away via the +-- ON DELETE CASCADE on permissions(id). +-- +-- Idempotent (IF EXISTS / DELETE-WHERE-IN-LIST). + +BEGIN; + +DROP INDEX IF EXISTS idx_oidc_pre_login_provider; +DROP INDEX IF EXISTS idx_oidc_pre_login_expires; +DROP TABLE IF EXISTS oidc_pre_login_sessions; + +DELETE FROM role_permissions +WHERE permission_id IN ( + 'p-auth-session-list', + 'p-auth-session-list-all', + 'p-auth-session-revoke', + 'p-auth-oidc-list', + 'p-auth-oidc-create', + 'p-auth-oidc-edit', + 'p-auth-oidc-delete' +); + +DELETE FROM permissions +WHERE id IN ( + 'p-auth-session-list', + 'p-auth-session-list-all', + 'p-auth-session-revoke', + 'p-auth-oidc-list', + 'p-auth-oidc-create', + 'p-auth-oidc-edit', + 'p-auth-oidc-delete' +); + +COMMIT; diff --git a/migrations/000037_oidc_phase5.up.sql b/migrations/000037_oidc_phase5.up.sql new file mode 100644 index 0000000..a929656 --- /dev/null +++ b/migrations/000037_oidc_phase5.up.sql @@ -0,0 +1,129 @@ +-- 000037_oidc_phase5.up.sql +-- Auth Bundle 2 / Phase 5: HTTP handler surface. +-- +-- Two things land here: +-- +-- 1. oidc_pre_login_sessions table — short-lived rows holding the +-- OIDC state + nonce + PKCE verifier across the IdP redirect. +-- Distinct from the sessions table because the schema for sessions +-- doesn't carry OIDC-specific columns and bolting them on would +-- bloat every row. 10-minute absolute TTL; GC sweep deletes +-- expired rows alongside the post-login session GC sweep. +-- +-- Cookie name `certctl_oidc_pending` (Path=/auth/oidc/) carries the +-- same v1... wire format as the +-- post-login cookie. The signing key is the active SessionSigningKey +-- so we don't need a separate key lifecycle for pre-login cookies. +-- +-- 2. Seven new permissions extending the canonical catalogue: +-- auth.session.list — list one's own sessions +-- auth.session.list.all — list every session in the tenant (admin) +-- auth.session.revoke — revoke a session that isn't yours +-- auth.oidc.list — list OIDC providers + group mappings +-- auth.oidc.create — register a new OIDC provider +-- auth.oidc.edit — update OIDC provider config / mappings +-- auth.oidc.delete — delete OIDC provider (only when no +-- users have authenticated via it) +-- Granted to r-admin only by default. Operators who want session +-- revocation across actors granted to r-operator can add the row +-- via the role-permission API after migration. +-- +-- All operations idempotent. Wrapped in a single transaction. + +BEGIN; + +-- ============================================================================= +-- oidc_pre_login_sessions table +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS oidc_pre_login_sessions ( + -- id is the prefix-`pl-` opaque identifier signed into the cookie. + -- Format on the wire: v1.pl-.sk-.. + id TEXT PRIMARY KEY, + + tenant_id TEXT NOT NULL DEFAULT 't-default' + REFERENCES tenants(id) ON DELETE CASCADE, + + -- The signing key id pinning which SessionSigningKey row signed + -- the cookie. Validation re-derives the HMAC against this key. + signing_key_id TEXT NOT NULL + REFERENCES session_signing_keys(id) ON DELETE RESTRICT, + + -- The OIDC provider being authenticated against. References + -- oidc_providers(id) with ON DELETE CASCADE so deleting a provider + -- mid-handshake invalidates in-flight pre-login rows. (Provider + -- deletion is itself gated on no users having authenticated via + -- the provider; this is the second-line defense.) + oidc_provider_id TEXT NOT NULL + REFERENCES oidc_providers(id) ON DELETE CASCADE, + + -- OIDC state: 32 random bytes base64url-no-pad. Constant-time + -- compared at callback against the IdP-returned state param. + state TEXT NOT NULL, + + -- OIDC nonce: 32 random bytes base64url-no-pad. Constant-time + -- compared at callback against the ID token's nonce claim. + nonce TEXT NOT NULL, + + -- PKCE-S256 verifier: 43-128 chars base64url-no-pad. Sent to the + -- IdP token endpoint to prove possession of the original challenge. + pkce_verifier TEXT NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Phase 5 spec: 10-minute absolute TTL. The GC sweep treats this + -- as the cutoff (rows older than 10 minutes are deleted). + absolute_expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '10 minutes'), + + CONSTRAINT oidc_pre_login_expiry_after_created + CHECK (absolute_expires_at > created_at) +); + +-- Index for the GC sweep — `WHERE absolute_expires_at < NOW()` hot path. +CREATE INDEX IF NOT EXISTS idx_oidc_pre_login_expires + ON oidc_pre_login_sessions (absolute_expires_at); + +-- Index for the lookup-by-provider hot path (admin "active pending logins" +-- surface, optional Phase 8 GUI extension). +CREATE INDEX IF NOT EXISTS idx_oidc_pre_login_provider + ON oidc_pre_login_sessions (oidc_provider_id); + +-- ============================================================================= +-- Seven new permissions extending the Bundle 1 catalogue. +-- ============================================================================= + +INSERT INTO permissions (id, name, namespace) VALUES + ('p-auth-session-list', 'auth.session.list', 'auth.session'), + ('p-auth-session-list-all', 'auth.session.list.all', 'auth.session'), + ('p-auth-session-revoke', 'auth.session.revoke', 'auth.session'), + ('p-auth-oidc-list', 'auth.oidc.list', 'auth.oidc'), + ('p-auth-oidc-create', 'auth.oidc.create', 'auth.oidc'), + ('p-auth-oidc-edit', 'auth.oidc.edit', 'auth.oidc'), + ('p-auth-oidc-delete', 'auth.oidc.delete', 'auth.oidc') +ON CONFLICT (id) DO NOTHING; + +-- Grant all seven to r-admin (and only r-admin by default). The +-- role-permission API can hand auth.session.revoke to r-operator +-- post-deploy if the operator wants their support staff to revoke +-- sessions; we ship locked-down by default. +INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) +SELECT 'r-admin', id, 'global', NULL +FROM permissions +WHERE id IN ( + 'p-auth-session-list', + 'p-auth-session-list-all', + 'p-auth-session-revoke', + 'p-auth-oidc-list', + 'p-auth-oidc-create', + 'p-auth-oidc-edit', + 'p-auth-oidc-delete' +) +ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING; + +-- Every actor who has been federated-authenticated needs to list AND +-- revoke their OWN session. That gate is encoded at the handler layer +-- via "is the actor_id in the path the caller's actor_id?" rather +-- than via a permission, since granting `auth.session.list` to +-- everyone would be tantamount to making it a no-op. The handler +-- pattern: `if path.id == ctx.actor_id { allow } else { require(auth.session.revoke) }`. + +COMMIT; diff --git a/scripts/ci-guards/N-bundle-2-security-empty-preserved.sh b/scripts/ci-guards/N-bundle-2-security-empty-preserved.sh new file mode 100755 index 0000000..dff088f --- /dev/null +++ b/scripts/ci-guards/N-bundle-2-security-empty-preserved.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# scripts/ci-guards/N-bundle-2-security-empty-preserved.sh +# +# Auth Bundle 2 / Phase 5 Category N — preserve every existing +# `security: []` opt-out in api/openapi.yaml. +# +# Pre-Bundle-2 baseline: 14 occurrences (verified via +# `grep -c 'security: \[\]' api/openapi.yaml` at the Phase 5 starting +# state). Post-Bundle-2 must be ≥ 14. Adding new `security: []` +# entries (for new public endpoints like /auth/oidc/back-channel-logout) +# is fine; reducing the count below 14 is a regression — every +# existing public endpoint MUST stay public. +# +# Why this matters: each `security: []` opt-out is an intentional +# auth-exempt declaration (health probes, public protocol endpoints, +# OIDC handshake). Removing one would silently force a Bearer-or- +# cookie requirement onto an endpoint that legitimately runs without +# certctl-issued credentials, breaking RFC-mandated unauth surfaces +# (CRL/OCSP) or the bootstrap path. +# +# This guard runs as part of `make verify` / CI. + +set -e + +OPENAPI_PATH="api/openapi.yaml" +PHASE5_BASELINE=14 + +if [ ! -f "$OPENAPI_PATH" ]; then + echo "::error::$OPENAPI_PATH not found" + exit 1 +fi + +count=$(grep -c 'security: \[\]' "$OPENAPI_PATH" || true) + +if [ "$count" -lt "$PHASE5_BASELINE" ]; then + echo "::error::Found $count 'security: []' entries in $OPENAPI_PATH; expected ≥ $PHASE5_BASELINE (Auth Bundle 2 Phase 5 baseline)." + echo "" + echo "Each 'security: []' is an intentional auth-exempt declaration." + echo "Removing one silently forces a Bearer-or-cookie requirement onto" + echo "an endpoint that legitimately runs without certctl-issued" + echo "credentials. Restore the missing opt-out OR — if a previously-public" + echo "endpoint genuinely should now require auth — bump PHASE5_BASELINE" + echo "in this script with a justification in the commit message." + exit 1 +fi + +echo "OK: $count 'security: []' entries in $OPENAPI_PATH (≥ $PHASE5_BASELINE baseline)."