mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:41:29 +00:00
cd374b243e
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.
447 lines
19 KiB
Go
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
|
|
}
|