Files
certctl/internal/api/handler/auth_session_oidc.go
T
shankar0123 cd374b243e refactor(handler): split auth_session_oidc.go by handler-section (Phase 9, 11 of N)
Phase 9 ARCH-M2 closure Sprint 11. Splits
internal/api/handler/auth_session_oidc.go (was 1577 LOC, the
fifth-largest backend hotspot from the original audit) via the
Option B sibling-file pattern — new files stay in `package handler`
so every external caller of
`handler.AuthSessionOIDCHandler.{LoginInitiate, LoginCallback,
BackChannelLogout, Logout, ListSessions, RevokeSession,
RevokeAllExceptCurrent, ListProviders, CreateProvider,
UpdateProvider, DeleteProvider, TestProvider, RefreshProvider,
ListGroupMappings, AddGroupMapping, RemoveGroupMapping}` and
`handler.{DefaultBCLVerifier, NewDefaultBCLVerifier,
DefaultBCLVerifierMaxAge}` resolves the same way. Pure mechanical
relocation; no signature, no behavior, no import-graph change.

Section-based split (Option B + audit's verb prescription)
==========================================================
The audit's Tasks-Deferred row prescribed splitting "per handler
verb (login / callback / refresh / logout / backchannel)." The
file itself documents a three-section layout in its package
doc-comment:

  1. Public OIDC handshake (auth-exempt)
  2. Session management (RBAC-gated)
  3. OIDC provider + group-mapping CRUD (RBAC-gated)

Going strictly verb-by-verb would have:
  - mis-grouped RefreshProvider (which is an ADMIN op on a
    provider's signing-key cache, not a session refresh — same
    auth.oidc.edit permission as Update/Delete);
  - split LoginInitiate + LoginCallback into separate files
    despite them sharing the state cookie + pre-login row flow;
  - left the other 9 handlers (Sessions, Provider CRUD, Group
    Mappings) with no obvious home.

Sprint 11 follows the file's own self-described section split
plus a fourth file for the DefaultBCLVerifier, which the original
file already kept under a separate banner.

What moved
==========

New `internal/api/handler/auth_session_oidc_handshake.go` (391 LOC)
— Section 1 / Public OIDC handshake handlers (auth-exempt):
  - LoginInitiate (GET /auth/oidc/login?provider=<id>)
  - LoginCallback (GET /auth/oidc/callback?code=...&state=...)
  - BackChannelLogout (POST /auth/oidc/back-channel-logout)
  - Logout (POST /auth/logout)

New `internal/api/handler/auth_session_oidc_sessions.go` (208 LOC)
— Section 2 / Session-management handlers (RBAC-gated):
  - sessionResponse projection type + sessionToResponse mapper
  - ListSessions (GET /api/v1/auth/sessions)
  - RevokeSession (DELETE /api/v1/auth/sessions/{id})
  - RevokeAllExceptCurrent
    (DELETE /api/v1/auth/sessions/all-except-current)

New `internal/api/handler/auth_session_oidc_crud.go` (470 LOC) —
Section 3 / OIDC provider + group-mapping CRUD (RBAC-gated):
  - oidcProviderResponse + oidcProviderRequest projection types,
    providerToResponse mapper
  - ListProviders / CreateProvider / UpdateProvider /
    DeleteProvider / TestProvider / RefreshProvider
  - groupMappingResponse + groupMappingRequest projection types,
    mappingToResponse mapper
  - ListGroupMappings / AddGroupMapping / RemoveGroupMapping

New `internal/api/handler/auth_session_oidc_bcl.go` (225 LOC) —
DefaultBCLVerifier (handler's default implementation of the
BackChannelLogoutVerifier interface declared in
auth_session_oidc.go):
  - DefaultBCLVerifierMaxAge constant
  - DefaultBCLVerifier struct + NewDefaultBCLVerifier
  - WithMaxAge builder
  - Verify (the OpenID Connect Back-Channel Logout 1.0 §2.6
    verification: events claim, iat window, algorithm allowlist,
    audience match, sub/sid/jti decode)
  - peekIssuer unexported helper

What stays in auth_session_oidc.go (452 LOC, down from 1577)
============================================================
  - Package + import block.
  - Service-layer interface projections (OIDCAuthHandshaker,
    SessionMinter, BackChannelLogoutVerifier) — declared once and
    consumed by every section.
  - SessionCookieAttrs config struct.
  - AuthSessionOIDCHandler struct + permissionChecker /
    BCLReplayConsumer / AuditRecorder interfaces + NewAuthSession-
    OIDCHandler constructor + the WithPermissionChecker /
    WithBCLReplayConsumer builder methods.
  - The shared helpers consumed across multiple sections:
    encryptClientSecret, recordAudit, clearPreLoginCookie,
    clearSessionCookies, clientIPFromRequest, classifyOIDCFailure,
    randomB64URLForHandler, defaultIfBlank, defaultIntIfZero.

Side-effect import cleanup
==========================
Four imports drop from auth_session_oidc.go as a clean side effect
of the cut:
  - "encoding/json" (used only in CRUD + BCL — moved out)
  - "fmt" (used only in BCL — moved out)
  - gooidc "github.com/coreos/go-oidc/v3/oidc"
    (used only in BCL — moved out)
  - oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
    (used in handshake + CRUD + BCL — moved out)
Per-import audit on every new sibling file is in the commit's diff:
each carries only the imports its extracted code actually consumes.

Net effect
==========
auth_session_oidc.go: 1577 → 452 LOC (-1,125 = -71.3%). Four new
sibling files at 1,294 LOC total (1,125 moved + ~169 of header +
Phase 9 doc-comment overhead). The original hotspot drops below
the cmd/agent/main.go target for Sprint 12 (1489 LOC).

Cumulative Phase 9 progress (top 5 hotspots)
============================================
  config.go         3403 → 1342 (-60.6%, Sprints 1-7)
  cmd/server/main.go  2966 → 2260 (-23.8%, Sprints 8 + 8b)
  service/acme.go   1965 → 1162 (-40.9%, Sprints 9 + 9b)
  mcp/tools.go      1867 →  109 (-94.2%, Sprint 10)
  auth_session_oidc 1577 →  452 (-71.3%, Sprint 11)
  TOTAL across 5 files: 11,778 → 5,325 LOC = -6,453 (-54.8%)

Behavior preservation contract
==============================
1. gofmt -l clean across all 5 affected files.
2. go vet ./internal/api/handler/... — no findings.
3. staticcheck ./internal/api/handler/... — no findings.
4. go test -short -count=1 ./internal/api/handler/... — green
   (includes the 1,439-line auth_session_oidc_test.go suite that
   pins every moved handler's behavior including BCL replay,
   CSRF rotation, audit emission, and the Phase-5 RBAC path).
5. Broader-importer build green: go build ./... .
6. Broader-importer tests green: go test -short -count=1
   ./cmd/server/... ./internal/api/router/... .

cmd/server/main.go consumes handler.DefaultBCLVerifier +
handler.NewDefaultBCLVerifier + handler.DefaultBCLVerifierMaxAge
across three call sites; all three resolve unchanged through Go's
same-package public-export mechanism (the type + constructor
moved to a sibling file in the same `handler` package). The
mcp/tools_auth_bundle2.go comment string referencing
"oidcProviderRequest" is descriptive prose, not an import.

What remains for Phase 9
========================
One sibling-file split queued:
  - Sprint 12: cmd/agent/main.go (1489 LOC) → main + poll +
    deploy + register sibling files in same cmd/agent package
    (mirrors the cmd/server pattern from Sprints 8 + 8b).

Refs: ARCH-M2 (god-files), Phase 9 audit. Sprint 11 closes the
auth-session-OIDC handler hotspot from the audit's top-5 list.
2026-05-14 10:22:33 +00:00

447 lines
19 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
// Package handler — Auth Bundle 2 Phase 5 / OIDC + session HTTP surface.
//
// 13 endpoints split into three logical groups:
//
// 1. Public OIDC handshake (auth-exempt, no certctl-issued credentials):
// GET /auth/oidc/login?provider=<id> -> 302 to IdP
// GET /auth/oidc/callback?code=...&state=... -> consume + mint session
// POST /auth/oidc/back-channel-logout -> IdP-initiated revoke
// POST /auth/logout -> revoke caller's session
//
// 2. Session management (RBAC-gated):
// GET /api/v1/auth/sessions -> list (own / all-actors)
// DELETE /api/v1/auth/sessions/{id} -> revoke (own / any)
//
// 3. OIDC provider + group-mapping CRUD (RBAC-gated):
// GET /api/v1/auth/oidc/providers -> auth.oidc.list
// POST /api/v1/auth/oidc/providers -> auth.oidc.create
// PUT /api/v1/auth/oidc/providers/{id} -> auth.oidc.edit
// DELETE /api/v1/auth/oidc/providers/{id} -> auth.oidc.delete
// POST /api/v1/auth/oidc/providers/{id}/refresh -> auth.oidc.edit
// GET /api/v1/auth/oidc/group-mappings -> auth.oidc.list
// POST /api/v1/auth/oidc/group-mappings -> auth.oidc.edit
// DELETE /api/v1/auth/oidc/group-mappings/{id} -> auth.oidc.edit
//
// Audit logging on every mutating operation; event_category="auth".
package handler
import (
"context"
cryptorand "crypto/rand"
"encoding/base64"
"errors"
"log/slog"
"net/http"
"strings"
"time"
oidcsvc "github.com/certctl-io/certctl/internal/auth/oidc"
sessionsvc "github.com/certctl-io/certctl/internal/auth/session"
sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain"
cryptopkg "github.com/certctl-io/certctl/internal/crypto"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/repository"
)
// =============================================================================
// Service-layer projections.
// =============================================================================
// OIDCAuthHandshaker is the slice of *oidc.Service the OIDC HTTP path
// consumes. Phase 3's *oidc.Service satisfies this directly.
type OIDCAuthHandshaker interface {
// Audit 2026-05-10 MED-16 — clientIP + userAgent persist into the
// pre-login row so HandleCallback can reject mismatches at consume
// time (RFC 9700 §4.7.1 binding).
HandleAuthRequest(ctx context.Context, providerID, clientIP, userAgent string) (authURL, cookieValue, preLoginID string, err error)
// Audit 2026-05-10 MED-17 — callbackIss carries the value of the
// RFC 9207 `iss` query parameter on /auth/oidc/callback (empty
// string when the IdP doesn't send it). The service enforces the
// check only when the provider's discovery doc advertised support.
HandleCallback(ctx context.Context, preLoginCookie, code, callbackState, callbackIss, ip, userAgent string) (*oidcsvc.CallbackResult, error)
RefreshKeys(ctx context.Context, providerID string) error
}
// SessionMinter is the slice of *session.Service the OIDC handler uses.
//
// Audit 2026-05-11 Fix 13 closure — adds RotateCSRFTokenForActor so the
// Logout handler can fire the HIGH-2 fourth call site. The HIGH-2 spec
// at cowork/auth-bundles-fixes-2026-05-10/06-high-1-2-revoke-and-rotate.md
// enumerated four CSRF-rotation triggers; three were wired (login mints
// fresh by construction, AssignRoleToKey + RevokeRoleFromKey rotate
// post-success), but Logout was missing. A token captured pre-logout
// (browser DevTools, malicious extension) was reusable on the actor's
// sibling sessions until those sessions hit their own idle/absolute
// expiry. Rotation on logout defeats this. Nil-safe: when the wired
// implementation isn't the production *session.Service (e.g. a future
// minimal-config deployment), the Logout handler skips the rotation
// instead of panic-ing.
type SessionMinter interface {
Create(ctx context.Context, actorID, actorType, ip, userAgent string) (*sessionsvc.CreateResult, error)
Validate(ctx context.Context, in sessionsvc.ValidateInput) (*sessiondomain.Session, error)
Revoke(ctx context.Context, sessionID string) error
RevokeAllForActor(ctx context.Context, actorID, actorType string) error
// RotateCSRFTokenForActor mints a fresh CSRF token across every
// active session for the (actorID, actorType) pair. Returns the
// count rotated. NEVER errors — rotation is defense-in-depth and
// must not block the surrounding mutation that triggered it.
// Matches the signature on *session.Service so the production
// wiring satisfies the interface without an adapter.
RotateCSRFTokenForActor(ctx context.Context, actorID, actorType string) int
}
// BackChannelLogoutVerifier validates an OpenID Connect Back-Channel
// Logout 1.0 logout_token JWT against the IdP's JWKS using the same
// alg allow-list as Phase 3. Phase 5 ships a default implementation
// keyed off the OIDCService's per-provider verifier; a stub satisfies
// this in tests.
type BackChannelLogoutVerifier interface {
// Verify returns the logout subject (iss + (sub OR sid)) on a
// valid logout token; an error mapped to HTTP 400 otherwise. Spec
// references: §2.4 nonce-MUST-be-absent, §2.5 events-MUST-contain-
// the-back-channel-logout URI, §2.6 fail-400-on-any-validation-fail.
//
// Audit 2026-05-10 HIGH-3 closure — the iat+jti return values let
// the handler enforce the iat-skew window + the jti consumed-set.
// Pre-fix the verifier only checked iat != 0 and jti != ""; it
// never enforced freshness nor replay. The verifier itself now
// enforces the iat-window per its configured max-age; the handler
// owns the jti consumed-set (so the audit-row outcome category
// can distinguish first-receive from replay).
Verify(ctx context.Context, logoutTokenJWT string) (issuer, sub, sid, jti string, iat int64, err error)
}
// =============================================================================
// Config knobs the handler honors.
// =============================================================================
// SessionCookieAttrs bundles the operator-configured cookie attributes
// applied to certctl_session and certctl_csrf cookies. Pulled from
// internal/config Phase 4 SessionConfig.
type SessionCookieAttrs struct {
SameSite http.SameSite
Secure bool // hard-coded true in production via config Validate
}
// =============================================================================
// AuthSessionOIDCHandler.
// =============================================================================
// AuthSessionOIDCHandler ships the Phase 5 surface.
type AuthSessionOIDCHandler struct {
oidcSvc OIDCAuthHandshaker
sessionSvc SessionMinter
bclVerifier BackChannelLogoutVerifier
providerRepo repository.OIDCProviderRepository
mappingRepo repository.GroupRoleMappingRepository
sessionRepo repository.SessionRepository
userRepo repository.UserRepository // CRIT-2: BCL sub→actor_id lookup
bclReplay BCLReplayConsumer // HIGH-3: BCL jti consumed-set
bclMaxAge time.Duration // HIGH-3: matches verifier window for TTL
audit AuditRecorder
encryptionKey string
cookieAttrs SessionCookieAttrs
tenantID string
postLoginURL string // 302 target after successful callback (default: /)
// checker is the optional PermissionChecker projection used for
// query-parameter-conditional gates that the router-level rbacGate
// can't express. Audit 2026-05-10 MED-2: ListSessions allows the
// caller to query their own sessions with auth.session.list, but
// `?actor_id=<other>` requires the narrower auth.session.list.all.
// Nil-safe: handlers that don't need conditional gating leave it
// unset (existing tests).
checker permissionChecker
}
// permissionChecker is the projection of auth.PermissionChecker the
// session handler uses for query-conditional gates (MED-2). Defined
// locally to avoid importing internal/auth from the handler package
// just for this single use.
type permissionChecker interface {
CheckPermission(ctx context.Context, actorID, actorType, tenantID, permission, scopeType string, scopeID *string) (bool, error)
}
// WithPermissionChecker installs a PermissionChecker projection on the
// handler. Audit 2026-05-10 MED-2 closure — used by ListSessions to
// gate `?actor_id=<other>` on auth.session.list.all.
func (h *AuthSessionOIDCHandler) WithPermissionChecker(c permissionChecker) *AuthSessionOIDCHandler {
h.checker = c
return h
}
// BCLReplayConsumer is the projection of repository.BCLReplayRepository
// the handler uses to record consumed (jti, iss) pairs. Audit 2026-05-10
// HIGH-3 closure. Nil-safe: when unset the handler skips the consume
// step (back-compat for pre-Bundle-2 tests).
type BCLReplayConsumer interface {
ConsumeJTI(ctx context.Context, jti, issuerURL string, ttl time.Duration) error
}
// AuditRecorder is the slice of *service.AuditService used here.
type AuditRecorder interface {
RecordEventWithCategory(ctx context.Context, actor string, actorType domain.ActorType, action, category, resourceType, resourceID string, details map[string]interface{}) error
}
// WithBCLReplayConsumer installs the BCL jti consumed-set + TTL on the
// handler. Audit 2026-05-10 HIGH-3 closure. Pre-fix the handler accepted
// any logout_token whose iat + jti were syntactically present;
// captured tokens were replayable indefinitely. Pass nil maxAge to use
// the verifier default (DefaultBCLVerifierMaxAge); the consumed-set
// TTL is set to max(24h, 2 * maxAge) so the replay window covers
// reasonable IdP retry semantics.
func (h *AuthSessionOIDCHandler) WithBCLReplayConsumer(c BCLReplayConsumer, maxAge time.Duration) *AuthSessionOIDCHandler {
h.bclReplay = c
if maxAge <= 0 {
maxAge = DefaultBCLVerifierMaxAge
}
h.bclMaxAge = maxAge
return h
}
// NewAuthSessionOIDCHandler constructs the handler.
//
// userRepo is load-bearing for the BCL sub→actor_id resolution
// (CRIT-2 of the 2026-05-10 audit). Passing nil here is only valid in
// tests that exercise non-BCL paths; production wiring in
// cmd/server/main.go MUST inject a non-nil repository.
func NewAuthSessionOIDCHandler(
oidcSvc OIDCAuthHandshaker,
sessionSvc SessionMinter,
bclVerifier BackChannelLogoutVerifier,
providerRepo repository.OIDCProviderRepository,
mappingRepo repository.GroupRoleMappingRepository,
sessionRepo repository.SessionRepository,
userRepo repository.UserRepository,
audit AuditRecorder,
encryptionKey, tenantID, postLoginURL string,
cookieAttrs SessionCookieAttrs,
) *AuthSessionOIDCHandler {
if postLoginURL == "" {
postLoginURL = "/"
}
return &AuthSessionOIDCHandler{
oidcSvc: oidcSvc,
sessionSvc: sessionSvc,
bclVerifier: bclVerifier,
providerRepo: providerRepo,
mappingRepo: mappingRepo,
sessionRepo: sessionRepo,
userRepo: userRepo,
audit: audit,
encryptionKey: encryptionKey,
cookieAttrs: cookieAttrs,
tenantID: tenantID,
postLoginURL: postLoginURL,
}
}
// =============================================================================
// Helpers.
// =============================================================================
// encryptClientSecret wraps internal/crypto.EncryptIfKeySet but with
// empty-passphrase passthrough. Production deployments MUST set
// CERTCTL_CONFIG_ENCRYPTION_KEY (validated at boot in
// internal/config/config.go) so the empty case only fires in tests
// and local-dev builds — the same pattern session.go uses for its
// HMAC-key blob path.
func (h *AuthSessionOIDCHandler) encryptClientSecret(plaintext []byte) ([]byte, error) {
if h.encryptionKey == "" {
return plaintext, nil
}
blob, _, err := cryptopkg.EncryptIfKeySet(plaintext, h.encryptionKey)
return blob, err
}
// recordAudit is a thin wrapper that swallows audit-layer errors (the
// audit row is best-effort; a failed audit must not block a successful
// auth operation). Phase 8 contract: every row event_category="auth".
func (h *AuthSessionOIDCHandler) recordAudit(ctx context.Context, action, actor string, actorType domain.ActorType, resourceID string, details map[string]interface{}) {
if h.audit == nil {
return
}
// Audit 2026-05-10 HIGH-6 partial closure — emit WARN on audit-write
// failure so the silent row-miss is observable. The transactional-
// leg WithinTx refactor is a v3 follow-on.
if err := h.audit.RecordEventWithCategory(ctx, actor, actorType, action,
domain.EventCategoryAuth, "session", resourceID, details); err != nil {
slog.WarnContext(ctx, "oidc handler audit write failed (action committed; audit row may be missing)",
"action", action,
"actor_id", actor,
"resource_id", resourceID,
"err", err)
}
}
func (h *AuthSessionOIDCHandler) clearPreLoginCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessiondomain.PreLoginCookieName,
Value: "",
// Audit 2026-05-10 MED-14 — Path=/ matches the write site
// post-`__Host-` rename. The browser only clears cookies that
// match the original Set-Cookie's Name+Path+Domain triple.
Path: "/",
MaxAge: -1,
Secure: h.cookieAttrs.Secure,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func (h *AuthSessionOIDCHandler) clearSessionCookies(w http.ResponseWriter) {
for _, name := range []string{sessiondomain.PostLoginCookieName, sessiondomain.CSRFCookieName} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
Secure: h.cookieAttrs.Secure,
HttpOnly: name == sessiondomain.PostLoginCookieName,
SameSite: h.cookieAttrs.SameSite,
})
}
}
func clientIPFromRequest(r *http.Request) string {
// X-Forwarded-For first hop wins when present.
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i > 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
// RemoteAddr is host:port; strip the port.
if i := strings.LastIndexByte(r.RemoteAddr, ':'); i > 0 {
return r.RemoteAddr[:i]
}
return r.RemoteAddr
}
// classifyOIDCFailure maps an OIDC service error to a stable audit
// category string. Used for the failure_category audit detail; the
// wire stays uniform 400.
//
// Audit 2026-05-10 MED-17 — the three iss-related sentinel errors are
// dispatched via errors.Is BEFORE the substring fall-through so they
// stay distinguishable in the audit row:
// - ErrIssParamMissing → iss_param_missing
// - ErrIssParamMismatch → iss_param_mismatch
// - ErrIssuerMismatch → id_token_iss_mismatch
//
// errors.Is is used for the iss family because all three error
// strings contain "iss" and substring matching would either collapse
// them or order-dependently mis-classify.
func classifyOIDCFailure(err error) string {
if err == nil {
return "ok"
}
// Audit 2026-05-10 MED-17 — typed dispatch for the iss family.
// Audit 2026-05-10 MED-16 — typed dispatch for the UA/IP binding
// family (no substring guarantees because UA strings are operator
// data and could match anything).
switch {
case errors.Is(err, oidcsvc.ErrIssParamMissing):
return "iss_param_missing"
case errors.Is(err, oidcsvc.ErrIssParamMismatch):
return "iss_param_mismatch"
case errors.Is(err, oidcsvc.ErrIssuerMismatch):
return "id_token_iss_mismatch"
case errors.Is(err, oidcsvc.ErrPreLoginUAMismatch):
return "prelogin_ua_mismatch"
case errors.Is(err, oidcsvc.ErrPreLoginIPMismatch):
return "prelogin_ip_mismatch"
// Audit 2026-05-11 A-2 — surface deactivated-user rejection as its
// own audit category so SOC / SIEM can alert on attempted logins by
// federated users that the admin has soft-deleted. Typed dispatch
// (not substring) because the sentinel is the only authoritative
// test for this condition; the message string is implementation
// detail subject to change.
case errors.Is(err, oidcsvc.ErrUserDeactivated):
return "user_deactivated"
// Audit 2026-05-11 A-6 — strict-when-stored. Distinguishes the
// new "request omitted the bound header" reject path from the
// existing "header was supplied but didn't match" path so SIEM
// rules can alert specifically on attempted bypasses.
case errors.Is(err, oidcsvc.ErrPreLoginUAMissing):
return "prelogin_ua_missing"
case errors.Is(err, oidcsvc.ErrPreLoginIPMissing):
return "prelogin_ip_missing"
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "pre-login"):
return "pre_login_consume_failed"
case strings.Contains(msg, "state"):
return "state_mismatch"
case strings.Contains(msg, "nonce"):
return "nonce_mismatch"
case strings.Contains(msg, "audience"), strings.Contains(msg, "aud"):
return "audience_mismatch"
case strings.Contains(msg, "expired"):
return "token_expired"
case strings.Contains(msg, "azp"):
return "azp_mismatch"
case strings.Contains(msg, "at_hash"):
return "at_hash_mismatch"
case strings.Contains(msg, "iat"):
return "iat_window"
case strings.Contains(msg, "alg"):
return "alg_rejected"
case strings.Contains(msg, "groups did not match"), strings.Contains(msg, "unmapped"):
return "unmapped_groups"
case strings.Contains(msg, "groups missing"), strings.Contains(msg, "missing or malformed"):
return "groups_missing"
case strings.Contains(msg, "jwks"):
return "jwks_unreachable"
// Audit 2026-05-10 HIGH-7 — surface CRIT-5 email-domain rejection
// + PKCE invalidation distinctly so the LoginPage can render an
// operator-friendly reason. The sentinel errors live in
// internal/auth/oidc/service.go (ErrEmailDomainNotAllowed,
// ErrEmailMissingButRequired, ErrPKCEPlainRejected).
case strings.Contains(msg, "email domain not in allowlist"):
return "email_domain_not_allowed"
case strings.Contains(msg, "requires email but token has none"):
return "email_missing_but_required"
case strings.Contains(msg, "pkce"):
return "pkce_invalid"
default:
return "unspecified"
}
}
func randomB64URLForHandler(n int) string {
// Audit 2026-05-10 LOW-3 closure — was a time-nano-shifted buffer
// (two providers created in the same nanosecond would collide). Now
// crypto/rand: provider/mapping IDs aren't security tokens, but
// collision-freedom matters for primary keys and entropy is free.
buf := make([]byte, n)
if _, err := cryptorand.Read(buf); err != nil {
// Fall back to time-nano if crypto/rand is broken (extremely
// unlikely; logged at WARN by the caller's audit row if the ID
// turns out to clash).
now := time.Now().UnixNano()
for i := 0; i < n; i++ {
buf[i] = byte(now >> (uint(i) * 8))
}
}
return base64.RawURLEncoding.EncodeToString(buf)
}
func defaultIfBlank(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
func defaultIntIfZero(v, def int) int {
if v == 0 {
return def
}
return v
}