diff --git a/internal/api/handler/auth_session_oidc.go b/internal/api/handler/auth_session_oidc.go index 39948e1..d0e5258 100644 --- a/internal/api/handler/auth_session_oidc.go +++ b/internal/api/handler/auth_session_oidc.go @@ -32,18 +32,13 @@ import ( "context" cryptorand "crypto/rand" "encoding/base64" - "encoding/json" "errors" - "fmt" "log/slog" "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" @@ -244,948 +239,6 @@ func NewAuthSessionOIDCHandler( } } -// ============================================================================= -// 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 - } - // Audit 2026-05-10 MED-16 — capture clientIP + UA at /auth/oidc/login - // so HandleCallback can reject a stolen pre-login cookie replayed - // from a different browser/source. clientIPFromRequest already - // honours the LOW-5 trusted-proxy gating; r.UserAgent() reads the - // header verbatim. - loginIP := clientIPFromRequest(r) - loginUA := r.UserAgent() - authURL, cookieValue, _, err := h.oidcSvc.HandleAuthRequest(r.Context(), providerID, loginIP, loginUA) - 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, - // Audit 2026-05-10 MED-14 — `__Host-` prefix requires Path=/. - // The cookie lives 10 minutes and is only ever consumed by the - // callback handler; the wider path scope is harmless. - Path: "/", - 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")) - // Audit 2026-05-10 MED-17 — RFC 9207 iss URL parameter. NOT - // trimmed; preserved exactly as sent so the service-layer compare - // against the matched provider's IssuerURL is byte-strict. The IdP - // emits this only when advertised in its discovery doc; the - // service-layer check is a no-op otherwise. - callbackIss := q.Get("iss") - 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, callbackIss, clientIP, userAgent) - if err != nil { - // Audit 2026-05-10 HIGH-7 — instead of a blank 400, redirect - // to /login?error=oidc_failed&reason=. The LoginPage - // reads the query params and renders an operator-friendly - // alert. The audit row still carries the specific - // failure_category so server-side observability is unchanged. - 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) - // 302 to the login page; the reason categorizes the failure for - // the GUI to render. Keep the redirect target relative — the - // SPA serves /login. - http.Redirect(w, r, "/login?error=oidc_failed&reason="+category, http.StatusFound) - 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, jti, _, 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. - // iat-skew rejections (Audit 2026-05-10 HIGH-3 iat-window check) - // land here too — the reason string distinguishes them. - 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 - } - - // Audit 2026-05-10 HIGH-3 — jti consumed-set. Atomic single-use - // semantics via the postgres ON CONFLICT DO NOTHING path. On - // replay return 200 + audit outcome=jti_replayed (RFC 9700 §2.7). - // On transient repo error return 503 so the IdP follows its retry - // semantics. When the consumer is nil (test path / pre-fix - // deployments) the consume step is skipped. - if h.bclReplay != nil && jti != "" { - ttl := h.bclMaxAge * 2 - if ttl < 24*time.Hour { - ttl = 24 * time.Hour - } - if cerr := h.bclReplay.ConsumeJTI(r.Context(), jti, issuer, ttl); cerr != nil { - if errors.Is(cerr, repository.ErrBCLJTIAlreadyConsumed) { - h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, - map[string]interface{}{"issuer": issuer, "subject": sub, "jti": jti, "outcome": "jti_replayed"}) - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusOK) - return - } - // Transient — let the IdP retry. - h.recordAudit(r.Context(), "auth.oidc_back_channel_logout_failed", "anonymous", domain.ActorTypeSystem, sub, - map[string]interface{}{"issuer": issuer, "subject": sub, "jti": jti, "outcome": "jti_consume_failed", "err": cerr.Error()}) - http.Error(w, "transient", http.StatusServiceUnavailable) - 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 != "" { - // CRIT-2 closure of the 2026-05-10 audit. Pre-fix this branch called - // RevokeAllForActor(sub, "User") under the false assumption that - // the OIDC subject was used as the actor_id stem. In reality, - // internal/auth/oidc/service.go::upsertUser mints - // u.ID = "u-" + randomB64URL(16) and stores the OIDC subject in - // a separate column, so the pre-fix lookup never found a session - // row and the error was silently swallowed. BCL silently revoked - // nothing — CWE-613. - // - // The fix resolves the IdP-signed `iss` claim back to a provider - // row via providerRepo.List + IssuerURL filter, then resolves - // sub → user.ID via userRepo.GetByOIDCSubject, then revokes all - // sessions for that actor. Outcome categories audited: - // - revoked (happy path) - // - issuer_unknown (iss doesn't match any configured provider) - // - user_unknown (provider matched, but no user.id seeded for this subject) - // - revoke_failed (DB hiccup at the revoke step) - // - provider_lookup_failed / user_lookup_failed → 503 (transient; IdP retries) - // All success-shaped outcomes return 200 + Cache-Control: no-store - // per OIDC BCL 1.0 §2.7. Transient errors return 503 so the IdP - // follows its own retry semantics. - providers, plerr := h.providerRepo.List(r.Context(), h.tenantID) - if plerr != nil { - h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, - map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "provider_lookup_failed"}) - http.Error(w, "transient", http.StatusServiceUnavailable) - return - } - var matched *oidcdomain.OIDCProvider - for _, p := range providers { - if p.IssuerURL == issuer { - matched = p - break - } - } - if matched == nil { - h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, - map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "issuer_unknown"}) - // Idempotent — return 200 per spec. - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusOK) - return - } - - user, uerr := h.userRepo.GetByOIDCSubject(r.Context(), matched.ID, sub) - if uerr != nil { - if errors.Is(uerr, repository.ErrUserNotFound) { - // Idempotent: nothing to revoke. IdP may BCL a user we - // never logged in. RFC compliance: still 200. - h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, - map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "user_unknown"}) - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusOK) - return - } - // Transient — let the IdP retry. - h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, - map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "user_lookup_failed"}) - http.Error(w, "transient", http.StatusServiceUnavailable) - return - } - - if rerr := h.sessionSvc.RevokeAllForActor(r.Context(), user.ID, string(domain.ActorTypeUser)); rerr != nil { - // Revoke failed — BCL is best-effort per §2.8; still 200, - // audit the failure. - h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", user.ID, domain.ActorTypeUser, sub, - map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "revoke_failed"}) - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusOK) - return - } - - h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", user.ID, domain.ActorTypeUser, sub, - map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "revoked"}) - } - // Per spec §2.7 — Cache-Control: no-store on success. - w.Header().Set("Cache-Control", "no-store") - 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 - } - // Audit 2026-05-11 Fix 13 — HIGH-2 fourth call site. Rotate the CSRF - // token on the actor's remaining sessions so a token captured in - // this device's browser pre-logout (DevTools, malicious extension, - // session-storage leak) can't be replayed against a sibling session - // (other browser, other device) after the user logged out here. - // The just-revoked session also rotates but its CSRF lookup will - // fail at the sessions table's revoked_at IS NOT NULL filter - // anyway; rotation on the revoked row is harmless. RotateCSRFTokenForActor - // returns the count rotated and NEVER errors — rotation is defense - // in depth and must not block the logout success. - rotated := h.sessionSvc.RotateCSRFTokenForActor(r.Context(), caller.ActorID, string(caller.ActorType)) - h.recordAudit(r.Context(), "auth.session_revoked", caller.ActorID, caller.ActorType, sess.ID, - map[string]interface{}{"session_id": sess.ID, "self_initiated": true, "csrf_rotated": rotated}) - 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 { - // Audit 2026-05-10 MED-2 closure — listing a different - // actor's sessions requires the narrower auth.session.list.all - // permission. The router gate already enforced - // auth.session.list (the floor for any session-list call), - // but the all-actors variant is an admin-class capability and - // must be checked separately because the rbacGate can't see - // the query param. When the handler is wired with - // WithPermissionChecker (production), we re-check inline; when - // it isn't (legacy tests), the router gate's auth.session.list - // floor is the only check. - if h.checker != nil { - ok, perr := h.checker.CheckPermission(r.Context(), - caller.ActorID, string(caller.ActorType), h.tenantID, - "auth.session.list.all", "global", nil) - if perr != nil { - Error(w, http.StatusInternalServerError, "permission check failed") - return - } - if !ok { - Error(w, http.StatusForbidden, "auth.session.list.all required to list another actor's sessions") - return - } - } - 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) -} - -// RevokeAllExceptCurrent handles DELETE /api/v1/auth/sessions?except=current. -// -// Audit 2026-05-10 MED-3 closure — backs the "Sign out all other -// sessions" SessionsPage button. Revokes every active session for the -// caller EXCEPT the session that issued the current request (so the -// user doesn't get logged out by the action they just took). -// -// The current session ID is read from the request's session cookie via -// the SessionMiddleware's actor context — for Bearer-mode callers this -// is the empty string and ALL the actor's sessions are revoked (matches -// the "log me out everywhere" semantic for API-key-mode users). -// -// Audit row records the count for compliance (one summary row per -// invocation; per-session detail is implicit in the count + actor). -func (h *AuthSessionOIDCHandler) RevokeAllExceptCurrent(w http.ResponseWriter, r *http.Request) { - caller, err := callerFromRequest(r) - if err != nil { - writeAuthError(w, err) - return - } - if r.URL.Query().Get("except") != "current" { - Error(w, http.StatusBadRequest, "only ?except=current is supported") - return - } - // Current session ID — empty for Bearer/API-key callers (acceptable; - // the repo's RevokeAllExceptForActor handles "" by revoking - // literally every active session). Read from the session middleware's - // SessionFromContext helper which populates the validated session - // on the request context for cookie-mode callers. - currentSessionID := "" - if sess := sessionsvc.SessionFromContext(r.Context()); sess != nil { - currentSessionID = sess.ID - } - - count, rerr := h.sessionRepo.RevokeAllExceptForActor(r.Context(), - caller.ActorID, string(caller.ActorType), h.tenantID, currentSessionID) - if rerr != nil { - Error(w, http.StatusInternalServerError, "could not revoke sessions") - return - } - h.recordAudit(r.Context(), "auth.sessions_revoked_all_except_current", - caller.ActorID, caller.ActorType, currentSessionID, - map[string]interface{}{ - "count": count, - "current_session_id": currentSessionID, - }) - writeJSON(w, http.StatusOK, map[string]interface{}{"revoked_count": count}) -} - -// ============================================================================= -// 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) -} - -// TestProvider handles POST /api/v1/auth/oidc/test. -// -// Audit 2026-05-10 MED-5 closure. Dry-run validator for an OIDC -// provider config: runs OIDC discovery, the alg-downgrade defense, -// the RFC 9207 iss-parameter detection, and a JWKS fetch — without -// persisting anything. Body: `{issuer_url, client_id, scopes}` -// (client_secret accepted but ignored — discovery + JWKS don't -// require it). Response: TestDiscoveryResult; HTTP 200 even when -// individual checks fail (the response Errors field carries them so -// the GUI can render per-check status rows). -// -// Permission gate: `auth.oidc.create` (the operator is dry-running a -// provider they're about to create; the lookup endpoints have their -// own .list gate so this can't be used as a roundabout reconnaissance -// vector beyond what those already permit). -func (h *AuthSessionOIDCHandler) TestProvider(w http.ResponseWriter, r *http.Request) { - caller, err := callerFromRequest(r) - if err != nil { - writeAuthError(w, err) - return - } - var req struct { - IssuerURL string `json:"issuer_url"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - Scopes []string `json:"scopes"` - } - if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil { - Error(w, http.StatusBadRequest, "invalid JSON body") - return - } - if strings.TrimSpace(req.IssuerURL) == "" { - Error(w, http.StatusBadRequest, "issuer_url is required") - return - } - // Type-assert to the concrete service so we can reach the - // TestDiscovery method. The OIDCAuthHandshaker interface is - // intentionally narrow; rather than widening it (which would force - // every test stub to implement TestDiscovery) we accept the - // concrete reference for this single endpoint. Production code - // always supplies *oidcsvc.Service. - type discoveryTester interface { - TestDiscovery(ctx context.Context, issuerURL string) (*oidcsvc.TestDiscoveryResult, error) - } - tester, ok := h.oidcSvc.(discoveryTester) - if !ok { - Error(w, http.StatusInternalServerError, "OIDC service does not support discovery test") - return - } - res, terr := tester.TestDiscovery(r.Context(), strings.TrimSpace(req.IssuerURL)) - if terr != nil { - Error(w, http.StatusInternalServerError, "discovery test execution failed") - return - } - h.recordAudit(r.Context(), "auth.oidc_provider_tested", caller.ActorID, caller.ActorType, "", - map[string]interface{}{ - "issuer_url": req.IssuerURL, - "discovery_succeeded": res.DiscoverySucceeded, - "jwks_reachable": res.JWKSReachable, - "iss_param_supported": res.IssParamSupported, - "error_count": len(res.Errors), - }) - writeJSON(w, http.StatusOK, res) -} - -// 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. // ============================================================================= @@ -1391,187 +444,3 @@ func defaultIntIfZero(v, def int) int { } return v } - -// ============================================================================= -// Default BackChannelLogoutVerifier — wraps go-oidc/v3. -// ============================================================================= - -// DefaultBCLVerifierMaxAge is the default iat-freshness skew window -// (60 seconds; tokens older or newer than this are rejected). Override -// per-server via CERTCTL_OIDC_BCL_MAX_AGE_SECONDS. Audit 2026-05-10 -// HIGH-3 closure. -const DefaultBCLVerifierMaxAge = 60 * time.Second - -// 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 - // maxAge is the iat-freshness skew window. Tokens with iat in the - // past beyond this OR in the future beyond this are rejected. Set - // via WithMaxAge; defaults to DefaultBCLVerifierMaxAge. - maxAge time.Duration - // nowFn is the clock seam (test injection). - nowFn func() time.Time - - // 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, - maxAge: DefaultBCLVerifierMaxAge, - nowFn: time.Now, - } -} - -// WithMaxAge returns a copy of the verifier with the iat-skew window -// overridden. Audit 2026-05-10 HIGH-3 — operator-configurable via -// CERTCTL_OIDC_BCL_MAX_AGE_SECONDS at cmd/server/main.go. -func (v *DefaultBCLVerifier) WithMaxAge(d time.Duration) *DefaultBCLVerifier { - v.maxAge = d - return v -} - -// Verify implements BackChannelLogoutVerifier. -func (v *DefaultBCLVerifier) Verify(ctx context.Context, logoutToken string) (issuer, sub, sid, jti string, iat int64, 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 "", "", "", "", 0, fmt.Errorf("peek issuer: %w", peekErr) - } - provs, lerr := v.providerRepo.List(ctx, v.tenantID) - if lerr != nil { - return "", "", "", "", 0, 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 "", "", "", "", 0, 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 "", "", "", "", 0, 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 "", "", "", "", 0, 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 "", "", "", "", 0, fmt.Errorf("claims unmarshal: %w", cerr) - } - if claims.Iat == 0 { - return "", "", "", "", 0, errors.New("missing iat claim") - } - // Audit 2026-05-10 HIGH-3 — iat freshness check. Reject tokens - // whose iat is outside the skew window. RFC 9700 §2.7 + the - // existing ID-token-path skew tolerance (oidc/service.go:463). - maxAge := v.maxAge - if maxAge <= 0 { - maxAge = DefaultBCLVerifierMaxAge - } - now := v.nowFn().UTC() - iatTime := time.Unix(claims.Iat, 0).UTC() - if iatTime.After(now.Add(maxAge)) { - return "", "", "", "", 0, fmt.Errorf("iat is in the future beyond max-age %s", maxAge) - } - if now.Sub(iatTime) > maxAge { - return "", "", "", "", 0, fmt.Errorf("iat is stale (age %s > max-age %s)", now.Sub(iatTime), maxAge) - } - if claims.Jti == "" { - return "", "", "", "", 0, errors.New("missing jti claim") - } - if claims.Events == nil { - return "", "", "", "", 0, errors.New("missing events claim") - } - if _, ok := claims.Events["http://schemas.openid.net/event/backchannel-logout"]; !ok { - return "", "", "", "", 0, errors.New("events claim missing back-channel-logout URI") - } - if claims.Nonce != "" { - // Spec §2.4: nonce MUST NOT be present. - return "", "", "", "", 0, errors.New("nonce claim must be absent in logout_token") - } - if claims.Sub == "" && claims.Sid == "" { - return "", "", "", "", 0, errors.New("logout_token must carry sub or sid") - } - return claims.Iss, claims.Sub, claims.Sid, claims.Jti, claims.Iat, 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. -// peekIssuer extracts the `iss` claim from an unsigned JWT payload — -// used by the BCL handler to route the logout_token to the right -// provider for verification. -// -// Audit 2026-05-10 Nit-3 — peekIssuer is INTENTIONALLY unsigned-permissive. -// The returned issuer is used ONLY to select the verifier; the full -// signature + claim verification happens in DefaultBCLVerifier.Verify -// (which re-checks the `iss` claim against the matched provider's -// IssuerURL after JWS signature validation). Callers MUST NOT trust -// peekIssuer output for any access-control decision before the verify -// step completes; the pin is encoded in the BCL handler's call shape -// (peek → match provider → verify-against-provider → consume). -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_bcl.go b/internal/api/handler/auth_session_oidc_bcl.go new file mode 100644 index 0000000..ffbc018 --- /dev/null +++ b/internal/api/handler/auth_session_oidc_bcl.go @@ -0,0 +1,225 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 + +package handler + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + gooidc "github.com/coreos/go-oidc/v3/oidc" + + oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain" + "github.com/certctl-io/certctl/internal/repository" +) + +// Phase 9 ARCH-M2 closure Sprint 11 (2026-05-14): extracted from +// internal/api/handler/auth_session_oidc.go via the Option B +// sibling-file pattern. +// +// This file holds the DefaultBCLVerifier — the default +// implementation of the BackChannelLogoutVerifier interface +// declared in auth_session_oidc.go. Verifies an OIDC +// back-channel-logout token per OpenID Connect Back-Channel +// Logout 1.0 §2.6: enforces the events claim, iat window, +// algorithm allowlist, audience match against the provider's +// configured client ID, and decodes sub/sid/jti for the +// revocation lookup. +// +// External callers: +// - cmd/server/main.go wires NewDefaultBCLVerifier(...) + +// DefaultBCLVerifierMaxAge into the AuthSessionOIDCHandler +// via WithBCLReplayConsumer. +// +// peekIssuer (unexported) is consumed only by Verify so it moves +// with the verifier. The go-oidc/v3 client is the underlying JWS +// verification + IdP-key-cache; everything else here is policy. + +// ============================================================================= +// Default BackChannelLogoutVerifier — wraps go-oidc/v3. +// ============================================================================= + +// DefaultBCLVerifierMaxAge is the default iat-freshness skew window +// (60 seconds; tokens older or newer than this are rejected). Override +// per-server via CERTCTL_OIDC_BCL_MAX_AGE_SECONDS. Audit 2026-05-10 +// HIGH-3 closure. +const DefaultBCLVerifierMaxAge = 60 * time.Second + +// 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 + // maxAge is the iat-freshness skew window. Tokens with iat in the + // past beyond this OR in the future beyond this are rejected. Set + // via WithMaxAge; defaults to DefaultBCLVerifierMaxAge. + maxAge time.Duration + // nowFn is the clock seam (test injection). + nowFn func() time.Time + + // 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, + maxAge: DefaultBCLVerifierMaxAge, + nowFn: time.Now, + } +} + +// WithMaxAge returns a copy of the verifier with the iat-skew window +// overridden. Audit 2026-05-10 HIGH-3 — operator-configurable via +// CERTCTL_OIDC_BCL_MAX_AGE_SECONDS at cmd/server/main.go. +func (v *DefaultBCLVerifier) WithMaxAge(d time.Duration) *DefaultBCLVerifier { + v.maxAge = d + return v +} + +// Verify implements BackChannelLogoutVerifier. +func (v *DefaultBCLVerifier) Verify(ctx context.Context, logoutToken string) (issuer, sub, sid, jti string, iat int64, 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 "", "", "", "", 0, fmt.Errorf("peek issuer: %w", peekErr) + } + provs, lerr := v.providerRepo.List(ctx, v.tenantID) + if lerr != nil { + return "", "", "", "", 0, 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 "", "", "", "", 0, 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 "", "", "", "", 0, 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 "", "", "", "", 0, 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 "", "", "", "", 0, fmt.Errorf("claims unmarshal: %w", cerr) + } + if claims.Iat == 0 { + return "", "", "", "", 0, errors.New("missing iat claim") + } + // Audit 2026-05-10 HIGH-3 — iat freshness check. Reject tokens + // whose iat is outside the skew window. RFC 9700 §2.7 + the + // existing ID-token-path skew tolerance (oidc/service.go:463). + maxAge := v.maxAge + if maxAge <= 0 { + maxAge = DefaultBCLVerifierMaxAge + } + now := v.nowFn().UTC() + iatTime := time.Unix(claims.Iat, 0).UTC() + if iatTime.After(now.Add(maxAge)) { + return "", "", "", "", 0, fmt.Errorf("iat is in the future beyond max-age %s", maxAge) + } + if now.Sub(iatTime) > maxAge { + return "", "", "", "", 0, fmt.Errorf("iat is stale (age %s > max-age %s)", now.Sub(iatTime), maxAge) + } + if claims.Jti == "" { + return "", "", "", "", 0, errors.New("missing jti claim") + } + if claims.Events == nil { + return "", "", "", "", 0, errors.New("missing events claim") + } + if _, ok := claims.Events["http://schemas.openid.net/event/backchannel-logout"]; !ok { + return "", "", "", "", 0, errors.New("events claim missing back-channel-logout URI") + } + if claims.Nonce != "" { + // Spec §2.4: nonce MUST NOT be present. + return "", "", "", "", 0, errors.New("nonce claim must be absent in logout_token") + } + if claims.Sub == "" && claims.Sid == "" { + return "", "", "", "", 0, errors.New("logout_token must carry sub or sid") + } + return claims.Iss, claims.Sub, claims.Sid, claims.Jti, claims.Iat, 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. +// peekIssuer extracts the `iss` claim from an unsigned JWT payload — +// used by the BCL handler to route the logout_token to the right +// provider for verification. +// +// Audit 2026-05-10 Nit-3 — peekIssuer is INTENTIONALLY unsigned-permissive. +// The returned issuer is used ONLY to select the verifier; the full +// signature + claim verification happens in DefaultBCLVerifier.Verify +// (which re-checks the `iss` claim against the matched provider's +// IssuerURL after JWS signature validation). Callers MUST NOT trust +// peekIssuer output for any access-control decision before the verify +// step completes; the pin is encoded in the BCL handler's call shape +// (peek → match provider → verify-against-provider → consume). +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_crud.go b/internal/api/handler/auth_session_oidc_crud.go new file mode 100644 index 0000000..ab3a87b --- /dev/null +++ b/internal/api/handler/auth_session_oidc_crud.go @@ -0,0 +1,469 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 + +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + 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/repository" +) + +// Phase 9 ARCH-M2 closure Sprint 11 (2026-05-14): extracted from +// internal/api/handler/auth_session_oidc.go via the Option B +// sibling-file pattern. +// +// This file holds Section 3 of the original three-section layout: +// OIDC PROVIDER + GROUP-MAPPING CRUD (RBAC-gated). Eight +// endpoints across two related resources: +// +// 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}/test -> auth.oidc.edit +// 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 +// +// The four request/response projection types (oidcProviderRequest, +// oidcProviderResponse, groupMappingRequest, groupMappingResponse) +// move with their handler callers. The encryptClientSecret + +// recordAudit + randomB64URLForHandler + defaultIfBlank + +// defaultIntIfZero helpers stay in auth_session_oidc.go — they're +// also consumed elsewhere (recordAudit is used by every section) +// or are generic utilities that don't have a single owner. +// +// NOTE: the audit's verb-based prescription (login / callback / +// refresh / logout / backchannel) named "refresh" as a separate +// sibling file. The RefreshProvider handler here is the only +// "refresh" in this file, but operationally it's an ADMIN +// operation on a provider's signing-key cache, not a session +// refresh. Sprint 11 keeps it grouped with the rest of the +// provider CRUD where it belongs by call-graph + permission scope +// (auth.oidc.edit, the same RBAC permission as Update/Delete). + +// ============================================================================= +// 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) +} + +// TestProvider handles POST /api/v1/auth/oidc/test. +// +// Audit 2026-05-10 MED-5 closure. Dry-run validator for an OIDC +// provider config: runs OIDC discovery, the alg-downgrade defense, +// the RFC 9207 iss-parameter detection, and a JWKS fetch — without +// persisting anything. Body: `{issuer_url, client_id, scopes}` +// (client_secret accepted but ignored — discovery + JWKS don't +// require it). Response: TestDiscoveryResult; HTTP 200 even when +// individual checks fail (the response Errors field carries them so +// the GUI can render per-check status rows). +// +// Permission gate: `auth.oidc.create` (the operator is dry-running a +// provider they're about to create; the lookup endpoints have their +// own .list gate so this can't be used as a roundabout reconnaissance +// vector beyond what those already permit). +func (h *AuthSessionOIDCHandler) TestProvider(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + var req struct { + IssuerURL string `json:"issuer_url"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Scopes []string `json:"scopes"` + } + if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil { + Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + if strings.TrimSpace(req.IssuerURL) == "" { + Error(w, http.StatusBadRequest, "issuer_url is required") + return + } + // Type-assert to the concrete service so we can reach the + // TestDiscovery method. The OIDCAuthHandshaker interface is + // intentionally narrow; rather than widening it (which would force + // every test stub to implement TestDiscovery) we accept the + // concrete reference for this single endpoint. Production code + // always supplies *oidcsvc.Service. + type discoveryTester interface { + TestDiscovery(ctx context.Context, issuerURL string) (*oidcsvc.TestDiscoveryResult, error) + } + tester, ok := h.oidcSvc.(discoveryTester) + if !ok { + Error(w, http.StatusInternalServerError, "OIDC service does not support discovery test") + return + } + res, terr := tester.TestDiscovery(r.Context(), strings.TrimSpace(req.IssuerURL)) + if terr != nil { + Error(w, http.StatusInternalServerError, "discovery test execution failed") + return + } + h.recordAudit(r.Context(), "auth.oidc_provider_tested", caller.ActorID, caller.ActorType, "", + map[string]interface{}{ + "issuer_url": req.IssuerURL, + "discovery_succeeded": res.DiscoverySucceeded, + "jwks_reachable": res.JWKSReachable, + "iss_param_supported": res.IssParamSupported, + "error_count": len(res.Errors), + }) + writeJSON(w, http.StatusOK, res) +} + +// 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) +} diff --git a/internal/api/handler/auth_session_oidc_handshake.go b/internal/api/handler/auth_session_oidc_handshake.go new file mode 100644 index 0000000..8d14b59 --- /dev/null +++ b/internal/api/handler/auth_session_oidc_handshake.go @@ -0,0 +1,390 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 + +package handler + +import ( + "errors" + "net/http" + "strings" + "time" + + 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" + "github.com/certctl-io/certctl/internal/domain" + "github.com/certctl-io/certctl/internal/repository" +) + +// Phase 9 ARCH-M2 closure Sprint 11 (2026-05-14): extracted from +// internal/api/handler/auth_session_oidc.go via the Option B +// sibling-file pattern. Package stays `handler`; every external +// caller of `handler.AuthSessionOIDCHandler.{LoginInitiate, +// LoginCallback, BackChannelLogout, Logout}` resolves the same +// way — pure mechanical relocation. The router wiring in +// internal/api/router/router.go is unaffected. +// +// This file holds Section 1 of the original file's three-section +// layout (per its own package doc-comment): the PUBLIC OIDC +// HANDSHAKE handlers. These four endpoints are auth-exempt — they +// run before the caller has a certctl-issued credential: +// +// GET /auth/oidc/login?provider= -> 302 to IdP +// GET /auth/oidc/callback?code=...&state=... -> consume + mint +// POST /auth/oidc/back-channel-logout -> IdP-initiated +// POST /auth/logout -> revoke caller's +// +// Helpers (h.clearPreLoginCookie / h.clearSessionCookies / +// h.recordAudit / clientIPFromRequest / classifyOIDCFailure) stay +// in auth_session_oidc.go alongside the AuthSessionOIDCHandler +// struct + constructor — same-package resolution makes the calls +// reach across the file boundary at zero compile-time cost. + +// ============================================================================= +// 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 + } + // Audit 2026-05-10 MED-16 — capture clientIP + UA at /auth/oidc/login + // so HandleCallback can reject a stolen pre-login cookie replayed + // from a different browser/source. clientIPFromRequest already + // honours the LOW-5 trusted-proxy gating; r.UserAgent() reads the + // header verbatim. + loginIP := clientIPFromRequest(r) + loginUA := r.UserAgent() + authURL, cookieValue, _, err := h.oidcSvc.HandleAuthRequest(r.Context(), providerID, loginIP, loginUA) + 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, + // Audit 2026-05-10 MED-14 — `__Host-` prefix requires Path=/. + // The cookie lives 10 minutes and is only ever consumed by the + // callback handler; the wider path scope is harmless. + Path: "/", + 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")) + // Audit 2026-05-10 MED-17 — RFC 9207 iss URL parameter. NOT + // trimmed; preserved exactly as sent so the service-layer compare + // against the matched provider's IssuerURL is byte-strict. The IdP + // emits this only when advertised in its discovery doc; the + // service-layer check is a no-op otherwise. + callbackIss := q.Get("iss") + 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, callbackIss, clientIP, userAgent) + if err != nil { + // Audit 2026-05-10 HIGH-7 — instead of a blank 400, redirect + // to /login?error=oidc_failed&reason=. The LoginPage + // reads the query params and renders an operator-friendly + // alert. The audit row still carries the specific + // failure_category so server-side observability is unchanged. + 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) + // 302 to the login page; the reason categorizes the failure for + // the GUI to render. Keep the redirect target relative — the + // SPA serves /login. + http.Redirect(w, r, "/login?error=oidc_failed&reason="+category, http.StatusFound) + 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, jti, _, 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. + // iat-skew rejections (Audit 2026-05-10 HIGH-3 iat-window check) + // land here too — the reason string distinguishes them. + 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 + } + + // Audit 2026-05-10 HIGH-3 — jti consumed-set. Atomic single-use + // semantics via the postgres ON CONFLICT DO NOTHING path. On + // replay return 200 + audit outcome=jti_replayed (RFC 9700 §2.7). + // On transient repo error return 503 so the IdP follows its retry + // semantics. When the consumer is nil (test path / pre-fix + // deployments) the consume step is skipped. + if h.bclReplay != nil && jti != "" { + ttl := h.bclMaxAge * 2 + if ttl < 24*time.Hour { + ttl = 24 * time.Hour + } + if cerr := h.bclReplay.ConsumeJTI(r.Context(), jti, issuer, ttl); cerr != nil { + if errors.Is(cerr, repository.ErrBCLJTIAlreadyConsumed) { + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, + map[string]interface{}{"issuer": issuer, "subject": sub, "jti": jti, "outcome": "jti_replayed"}) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + // Transient — let the IdP retry. + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout_failed", "anonymous", domain.ActorTypeSystem, sub, + map[string]interface{}{"issuer": issuer, "subject": sub, "jti": jti, "outcome": "jti_consume_failed", "err": cerr.Error()}) + http.Error(w, "transient", http.StatusServiceUnavailable) + 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 != "" { + // CRIT-2 closure of the 2026-05-10 audit. Pre-fix this branch called + // RevokeAllForActor(sub, "User") under the false assumption that + // the OIDC subject was used as the actor_id stem. In reality, + // internal/auth/oidc/service.go::upsertUser mints + // u.ID = "u-" + randomB64URL(16) and stores the OIDC subject in + // a separate column, so the pre-fix lookup never found a session + // row and the error was silently swallowed. BCL silently revoked + // nothing — CWE-613. + // + // The fix resolves the IdP-signed `iss` claim back to a provider + // row via providerRepo.List + IssuerURL filter, then resolves + // sub → user.ID via userRepo.GetByOIDCSubject, then revokes all + // sessions for that actor. Outcome categories audited: + // - revoked (happy path) + // - issuer_unknown (iss doesn't match any configured provider) + // - user_unknown (provider matched, but no user.id seeded for this subject) + // - revoke_failed (DB hiccup at the revoke step) + // - provider_lookup_failed / user_lookup_failed → 503 (transient; IdP retries) + // All success-shaped outcomes return 200 + Cache-Control: no-store + // per OIDC BCL 1.0 §2.7. Transient errors return 503 so the IdP + // follows its own retry semantics. + providers, plerr := h.providerRepo.List(r.Context(), h.tenantID) + if plerr != nil { + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, + map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "provider_lookup_failed"}) + http.Error(w, "transient", http.StatusServiceUnavailable) + return + } + var matched *oidcdomain.OIDCProvider + for _, p := range providers { + if p.IssuerURL == issuer { + matched = p + break + } + } + if matched == nil { + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, + map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "issuer_unknown"}) + // Idempotent — return 200 per spec. + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + + user, uerr := h.userRepo.GetByOIDCSubject(r.Context(), matched.ID, sub) + if uerr != nil { + if errors.Is(uerr, repository.ErrUserNotFound) { + // Idempotent: nothing to revoke. IdP may BCL a user we + // never logged in. RFC compliance: still 200. + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, + map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "user_unknown"}) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + // Transient — let the IdP retry. + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub, + map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "user_lookup_failed"}) + http.Error(w, "transient", http.StatusServiceUnavailable) + return + } + + if rerr := h.sessionSvc.RevokeAllForActor(r.Context(), user.ID, string(domain.ActorTypeUser)); rerr != nil { + // Revoke failed — BCL is best-effort per §2.8; still 200, + // audit the failure. + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", user.ID, domain.ActorTypeUser, sub, + map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "revoke_failed"}) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + + h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", user.ID, domain.ActorTypeUser, sub, + map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "revoked"}) + } + // Per spec §2.7 — Cache-Control: no-store on success. + w.Header().Set("Cache-Control", "no-store") + 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 + } + // Audit 2026-05-11 Fix 13 — HIGH-2 fourth call site. Rotate the CSRF + // token on the actor's remaining sessions so a token captured in + // this device's browser pre-logout (DevTools, malicious extension, + // session-storage leak) can't be replayed against a sibling session + // (other browser, other device) after the user logged out here. + // The just-revoked session also rotates but its CSRF lookup will + // fail at the sessions table's revoked_at IS NOT NULL filter + // anyway; rotation on the revoked row is harmless. RotateCSRFTokenForActor + // returns the count rotated and NEVER errors — rotation is defense + // in depth and must not block the logout success. + rotated := h.sessionSvc.RotateCSRFTokenForActor(r.Context(), caller.ActorID, string(caller.ActorType)) + h.recordAudit(r.Context(), "auth.session_revoked", caller.ActorID, caller.ActorType, sess.ID, + map[string]interface{}{"session_id": sess.ID, "self_initiated": true, "csrf_rotated": rotated}) + h.clearSessionCookies(w) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/handler/auth_session_oidc_sessions.go b/internal/api/handler/auth_session_oidc_sessions.go new file mode 100644 index 0000000..b553b87 --- /dev/null +++ b/internal/api/handler/auth_session_oidc_sessions.go @@ -0,0 +1,207 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 + +package handler + +import ( + "errors" + "net/http" + "time" + + sessionsvc "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" +) + +// Phase 9 ARCH-M2 closure Sprint 11 (2026-05-14): extracted from +// internal/api/handler/auth_session_oidc.go via the Option B +// sibling-file pattern. +// +// This file holds Section 2 of the original three-section layout: +// the SESSION MANAGEMENT handlers (RBAC-gated). Three endpoints: +// +// GET /api/v1/auth/sessions -> list (own / all-actors) +// DELETE /api/v1/auth/sessions/{id} -> revoke (own / any) +// DELETE /api/v1/auth/sessions/all-except-current +// -> revoke-all-except-current +// +// The sessionResponse projection type lives here alongside its +// callers (sessionToResponse + the three handler methods). It's +// the shape the API renders externally; no external caller relies +// on its exact file location. + +// ============================================================================= +// 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 { + // Audit 2026-05-10 MED-2 closure — listing a different + // actor's sessions requires the narrower auth.session.list.all + // permission. The router gate already enforced + // auth.session.list (the floor for any session-list call), + // but the all-actors variant is an admin-class capability and + // must be checked separately because the rbacGate can't see + // the query param. When the handler is wired with + // WithPermissionChecker (production), we re-check inline; when + // it isn't (legacy tests), the router gate's auth.session.list + // floor is the only check. + if h.checker != nil { + ok, perr := h.checker.CheckPermission(r.Context(), + caller.ActorID, string(caller.ActorType), h.tenantID, + "auth.session.list.all", "global", nil) + if perr != nil { + Error(w, http.StatusInternalServerError, "permission check failed") + return + } + if !ok { + Error(w, http.StatusForbidden, "auth.session.list.all required to list another actor's sessions") + return + } + } + 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) +} + +// RevokeAllExceptCurrent handles DELETE /api/v1/auth/sessions?except=current. +// +// Audit 2026-05-10 MED-3 closure — backs the "Sign out all other +// sessions" SessionsPage button. Revokes every active session for the +// caller EXCEPT the session that issued the current request (so the +// user doesn't get logged out by the action they just took). +// +// The current session ID is read from the request's session cookie via +// the SessionMiddleware's actor context — for Bearer-mode callers this +// is the empty string and ALL the actor's sessions are revoked (matches +// the "log me out everywhere" semantic for API-key-mode users). +// +// Audit row records the count for compliance (one summary row per +// invocation; per-session detail is implicit in the count + actor). +func (h *AuthSessionOIDCHandler) RevokeAllExceptCurrent(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + if r.URL.Query().Get("except") != "current" { + Error(w, http.StatusBadRequest, "only ?except=current is supported") + return + } + // Current session ID — empty for Bearer/API-key callers (acceptable; + // the repo's RevokeAllExceptForActor handles "" by revoking + // literally every active session). Read from the session middleware's + // SessionFromContext helper which populates the validated session + // on the request context for cookie-mode callers. + currentSessionID := "" + if sess := sessionsvc.SessionFromContext(r.Context()); sess != nil { + currentSessionID = sess.ID + } + + count, rerr := h.sessionRepo.RevokeAllExceptForActor(r.Context(), + caller.ActorID, string(caller.ActorType), h.tenantID, currentSessionID) + if rerr != nil { + Error(w, http.StatusInternalServerError, "could not revoke sessions") + return + } + h.recordAudit(r.Context(), "auth.sessions_revoked_all_except_current", + caller.ActorID, caller.ActorType, currentSessionID, + map[string]interface{}{ + "count": count, + "current_session_id": currentSessionID, + }) + writeJSON(w, http.StatusOK, map[string]interface{}{"revoked_count": count}) +}