Files
certctl/internal/auth/session/middleware.go
T
shankar0123 acaa81472d harden(auth/session+oidc): 503/401 split + go-oidc string pin (LOW-6 + Nit-2)
Audit 2026-05-10 — close LOW-6 + Nit-2 from the HANDOFF.md backend
batch (items 8 + 9).

LOW-6: introduce ErrSessionTransient sentinel in session.Service.
session.Validate now distinguishes:
  - errors.Is(err, repository.ErrSessionNotFound) → ErrSessionInvalidCookie (401)
  - All other repo errors                         → ErrSessionTransient (503)
The session middleware maps ErrSessionTransient to HTTP 503 with
Retry-After: 1. Pre-fix, every DB hiccup looked like a forged-cookie
401 and forced the user to re-authenticate on a transient outage.
Two new regression tests pin the wire shape:
  - TestService_Validate_TransientSessionGetError (service layer)
  - TestService_Validate_SessionNotFoundMapsToInvalidCookie (negative
    leg: not-found stays 401)
  - TestSessionMiddleware_TransientErrorMappedTo503 (middleware-level
    503 + Retry-After header)

Nit-2: isJWKSFetchError documentation now pins go-oidc/v3 v3.18.0 as
the source-of-truth string set. v3.18.0 exposes only
*oidc.TokenExpiredError as a typed error; JWKS-fetch failures bubble
up as fmt.Errorf-wrapped strings. New regression test
TestIsJWKSFetchError_GoOIDCV318Strings pins the canonical substrings
emitted by go-oidc's jwks.go — a future upstream bump that changes
the wording trips the test and forces the matcher to be re-derived.
The test caught a real gap: 'oidc: failed to decode keys' (emitted
when the IdP returns non-JSON at the jwks_uri — broken proxy, gateway
HTML error page, etc.) was previously misclassified as a generic 500
instead of 503 ErrJWKSUnreachable. Added 'decode keys' substring to
the matcher.

Status: LOW-6 + Nit-2 marked CLOSED in audit-doc table.

Refs: cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md items 8, 9
      cowork/auth-bundles-audit-2026-05-10.md LOW-6, Nit-2
2026-05-10 22:41:19 +00:00

444 lines
17 KiB
Go

// Package session — Auth Bundle 2 Phase 6 / session + CSRF middleware.
//
// This file ships the HTTP middleware that wires the post-login session
// machinery into the request path. Three middlewares + one combinator:
//
// 1. SessionMiddleware — reads `certctl_session` cookie, validates
// via SessionService.Validate, populates the actor/role context
// keys (same keys as the API-key path) so downstream handlers
// and RBAC gates see a consistent caller.
//
// 2. CSRFMiddleware — for state-changing methods (POST/PUT/DELETE/
// PATCH), checks `X-CSRF-Token` header against the session row's
// stored hash. API-key actors are EXEMPT (they're not browser-
// driven; CSRF doesn't apply). Returns 403 on mismatch.
//
// 3. ChainAuthSessionThenBearer — the load-bearing chained-auth
// combinator: tries the session cookie first; on miss/invalid,
// falls back to the Bearer-token middleware; if neither
// authenticates, returns 401. Wired in cmd/server/main.go in the
// documented chain position (#6 — Auth, between RateLimit and CSRF).
//
// Bypass list (Category E): the existing public-route allowlist in
// internal/api/router/router.go::AuthExemptRouterRoutes (/health,
// /ready, /api/v1/auth/info, /api/v1/version, /api/v1/auth/bootstrap,
// /auth/oidc/login + callback + back-channel-logout, /auth/logout) is
// preserved by virtue of those routes registering via direct
// r.mux.Handle (they bypass the entire middleware chain). The
// protocol-endpoint allowlist (ACME / SCEP / EST / OCSP / CRL) bypasses
// via the cmd/server/main.go::buildFinalHandler URL-prefix dispatch —
// those routes never reach the auth middleware at all.
package session
import (
"context"
"errors"
"net"
"net/http"
"github.com/certctl-io/certctl/internal/auth"
sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain"
)
// =============================================================================
// SessionMiddleware.
// =============================================================================
// SessionValidator is the slice of *Service the SessionMiddleware
// consumes. Defining the projection here keeps the middleware
// decoupled from the wider service surface (and lets tests stub
// validation without spinning up a full SessionService).
type SessionValidator interface {
Validate(ctx context.Context, in ValidateInput) (*sessiondomain.Session, error)
UpdateLastSeen(ctx context.Context, sessionID string) error
}
// NewSessionMiddleware returns the Phase 6 session-cookie middleware.
//
// Behavior on each request:
//
// 1. Read `certctl_session` cookie. Missing -> defer to next middleware
// (the chained-auth combinator falls back to Bearer).
// 2. Validate via SessionService.Validate. On failure, defer to next
// middleware (likewise falls back to Bearer).
// 3. On success, populate the legacy UserKey / AdminKey + the Phase 3
// RBAC context keys (ActorIDKey / ActorTypeKey / TenantIDKey) so
// downstream RequirePermission + audit-attribution code see a
// consistent actor regardless of how they authenticated.
// 4. Best-effort UpdateLastSeen so the idle-expiry sliding window
// stays fresh (errors swallowed; the session is already validated).
// 5. Defer to the next handler.
//
// The middleware does NOT 401 on session-validate failure; instead it
// passes through, letting the chained-auth combinator try Bearer. The
// combinator 401s when neither authenticates.
func NewSessionMiddleware(svc SessionValidator) func(http.Handler) http.Handler {
if svc == nil {
// No session service wired (pre-Phase-5 deployments) — pass-through.
return func(next http.Handler) http.Handler { return next }
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessiondomain.PostLoginCookieName)
if err != nil || cookie.Value == "" {
next.ServeHTTP(w, r)
return
}
sess, verr := svc.Validate(r.Context(), ValidateInput{
CookieValue: cookie.Value,
ClientIP: clientIPFromRequest(r),
UserAgent: r.UserAgent(),
})
if verr != nil {
// Audit 2026-05-10 LOW-6 closure — ErrSessionTransient
// means the backend hit a retryable error (DB hiccup,
// connection reset, etc.) rather than the cookie being
// malformed. Surface 503 + Retry-After so well-behaved
// clients (curl --retry, browser fetch automatic retry,
// MCP clients) retry instead of forcing the user to
// re-auth on a transient issue. Pre-fix, every DB error
// looked like a forged-cookie 401.
if errors.Is(verr, ErrSessionTransient) {
w.Header().Set("Retry-After", "1")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"transient backend error; retry"}`, http.StatusServiceUnavailable)
return
}
// Cookie present but invalid (expired / tampered /
// retired-key / IP-bind / UA-bind / revoked). Defer to
// the next middleware so a valid Bearer can still
// authenticate. The auth combinator 401s if neither
// works.
//
// Audit 2026-05-10 HIGH-8 — stash the cause classification
// in context so the 401 emitter can emit a
// WWW-Authenticate: Bearer error_description="<cause>"
// header. OIDC users get cause-aware re-login UX.
ctx := context.WithValue(r.Context(), sessionCauseKey{}, classifySessionError(verr))
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Best-effort sliding-window update. The session is already
// validated; an UpdateLastSeen error doesn't change the
// auth outcome (the row stays valid until idle / absolute
// expiry; this just keeps the idle window fresh).
_ = svc.UpdateLastSeen(r.Context(), sess.ID)
ctx := r.Context()
ctx = context.WithValue(ctx, auth.UserKey{}, sess.ActorID)
ctx = context.WithValue(ctx, auth.AdminKey{}, false) // RBAC takes over from the legacy admin-flag heuristic
ctx = context.WithValue(ctx, auth.ActorIDKey{}, sess.ActorID)
ctx = context.WithValue(ctx, auth.ActorTypeKey{}, sess.ActorType)
ctx = context.WithValue(ctx, auth.TenantIDKey{}, sess.TenantID)
// Stash the session row itself so the CSRF middleware can
// look up the stored CSRF hash without re-validating.
ctx = context.WithValue(ctx, sessionContextKey{}, sess)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// =============================================================================
// CSRFMiddleware.
// =============================================================================
// CSRFValidator is the slice of *Service the CSRFMiddleware uses.
type CSRFValidator interface {
ValidateCSRF(headerValue string, sess *sessiondomain.Session) error
}
// NewCSRFMiddleware returns the Phase 6 CSRF middleware.
//
// Behavior:
//
// - Safe methods (GET / HEAD / OPTIONS / TRACE) pass through unchecked.
// - Requests authenticated via Bearer (API-key actors) pass through
// unchecked: CSRF is a browser-driven attack vector that doesn't
// apply to programmatic API clients. The middleware detects API-key
// actors via the absence of a session row in context (the
// SessionMiddleware populates it; the API-key middleware doesn't).
// - Requests authenticated via session cookie + state-changing method
// are gated by SessionService.ValidateCSRF (constant-time-compare
// of SHA-256(X-CSRF-Token header) against the session row's
// stored hash). Mismatch returns 403.
func NewCSRFMiddleware(svc CSRFValidator) func(http.Handler) http.Handler {
if svc == nil {
return func(next http.Handler) http.Handler { return next }
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isStateChangingMethod(r.Method) {
next.ServeHTTP(w, r)
return
}
// Find the session row populated by SessionMiddleware.
// Absence => either (a) caller authenticated via Bearer
// (API-key path; CSRF exempt by design), or (b) caller is
// unauthenticated (the auth combinator already 401'd
// before we got here, so this branch is unreachable in
// production; defensive code keeps the test surface tidy).
sess, ok := r.Context().Value(sessionContextKey{}).(*sessiondomain.Session)
if !ok || sess == nil {
next.ServeHTTP(w, r)
return
}
header := r.Header.Get("X-CSRF-Token")
if err := svc.ValidateCSRF(header, sess); err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"CSRF token missing or invalid"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// =============================================================================
// ChainAuthSessionThenBearer — the load-bearing combinator.
// =============================================================================
// ChainAuthSessionThenBearer composes the session middleware with the
// API-key middleware so a single chain entry tries both paths.
//
// The composition order is critical:
//
// 1. SessionMiddleware runs first. On a valid session cookie it
// populates the actor context keys + sets the session-row stash
// and calls next.
// 2. The Bearer-only inner middleware runs second. If the session
// middleware already populated ActorIDKey, the Bearer middleware
// is a pass-through (the request is already authenticated). If
// ActorIDKey is empty, it runs the standard Bearer-token check
// and either populates the context (200) or 401s.
//
// This means a request with BOTH a valid session AND a valid Bearer
// uses the session (cookie wins; the Bundle 2 contract). A request
// with only one works regardless of which one. A request with neither
// 401s.
//
// The bearer parameter is the existing API-key middleware
// (auth.NewAuthWithKeyStore or similar); when nil the chain degrades
// to session-only.
func ChainAuthSessionThenBearer(
sessionMW func(http.Handler) http.Handler,
bearerMW func(http.Handler) http.Handler,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
// Build the inner: a Bearer middleware that short-circuits when
// SessionMiddleware already populated ActorIDKey.
inner := bearerSkipIfAuthenticated(bearerMW)(next)
// Then wrap with SessionMiddleware so it runs first.
return sessionMW(inner)
}
}
// bearerSkipIfAuthenticated wraps the Bearer-token middleware with a
// short-circuit: if ActorIDKey is already populated (the session
// middleware authenticated the request), pass through to next without
// running the Bearer check. Otherwise run Bearer.
func bearerSkipIfAuthenticated(bearerMW func(http.Handler) http.Handler) func(http.Handler) http.Handler {
if bearerMW == nil {
// No Bearer auth wired (test deployments / session-only). Just
// require ActorIDKey from the session middleware; 401 if missing.
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if actorID, _ := r.Context().Value(auth.ActorIDKey{}).(string); actorID != "" {
next.ServeHTTP(w, r)
return
}
// Audit 2026-05-10 HIGH-8 — emit WWW-Authenticate with the
// classified cause so the GUI can render OIDC-aware
// re-login UX. RFC 6750 §3 challenge format.
cause, _ := r.Context().Value(sessionCauseKey{}).(string)
if cause == "" {
cause = "invalid_token"
}
w.Header().Set("WWW-Authenticate",
`Bearer realm="certctl", error="invalid_token", error_description="`+cause+`"`)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"Authentication required"}`, http.StatusUnauthorized)
})
}
}
return func(next http.Handler) http.Handler {
bearerInner := bearerMW(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if actorID, _ := r.Context().Value(auth.ActorIDKey{}).(string); actorID != "" {
// Session middleware already authenticated. Skip Bearer.
next.ServeHTTP(w, r)
return
}
// Defer to Bearer. If the Bearer middleware 401s and there's
// a stashed session cause, downstream callers see it via the
// context key; the Bearer middleware's own 401 doesn't read
// it (Bearer-only deployments have no session context to
// stash from). Cause-aware UX needs session-mode auth.
bearerInner.ServeHTTP(w, r)
})
}
}
// sessionCauseKey is the context key used by Audit 2026-05-10 HIGH-8.
// SessionMiddleware stashes the failure-cause classification on the
// context when Validate returns an error; the 401 emitter reads it
// and renders WWW-Authenticate's error_description.
type sessionCauseKey struct{}
// classifySessionError maps a session Validate error to a stable
// wire-string the GUI consumes to render OIDC-aware re-login UX.
// Stable categories: idle_timeout, absolute_timeout,
// back_channel_revoked, invalid_token.
func classifySessionError(err error) string {
if err == nil {
return ""
}
switch {
case errors.Is(err, ErrSessionExpiredIdle):
return "idle_timeout"
case errors.Is(err, ErrSessionExpiredAbsolute):
return "absolute_timeout"
case errors.Is(err, ErrSessionRevoked):
return "back_channel_revoked"
default:
return "invalid_token"
}
}
// =============================================================================
// Helpers.
// =============================================================================
// sessionContextKey is the context key under which SessionMiddleware
// stashes the validated *sessiondomain.Session so CSRFMiddleware can
// reach it without re-validating the cookie.
type sessionContextKey struct{}
// SessionFromContext returns the validated session row populated by
// SessionMiddleware. Returns nil when the request was authenticated via
// Bearer (no session) OR is unauthenticated.
func SessionFromContext(ctx context.Context) *sessiondomain.Session {
if v, ok := ctx.Value(sessionContextKey{}).(*sessiondomain.Session); ok {
return v
}
return nil
}
func isStateChangingMethod(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch:
return true
}
return false
}
// clientIPFromRequest pulls the request's client IP — X-Forwarded-For
// first hop wins when present; otherwise RemoteAddr (host:port) with
// the port stripped. Mirrors the helper in
// internal/api/handler/auth_session_oidc.go for the same reason: the
// handler + middleware both need to derive the canonical client IP
// from the same request shape, and duplicating the 6-line helper is
// preferable to introducing an internal/util package for it.
// Audit 2026-05-10 LOW-5 — trustedProxyCIDRs holds the operator-configured
// list of CIDR ranges from which X-Forwarded-For is honored. Set by
// SetTrustedProxies at startup (from CERTCTL_TRUSTED_PROXIES). When
// empty (default), XFF is ignored entirely — the direct r.RemoteAddr
// is used. This closes the XFF-spoofing leg where any direct client
// could inject an attacker-controlled IP into audit rows + session
// IP-binding.
var trustedProxyCIDRs []string
// SetTrustedProxies installs the CIDR allowlist for XFF processing.
// Called from cmd/server/main.go after config load. Each entry is a
// CIDR like "10.0.0.0/8" or a single-host literal like "192.0.2.1".
func SetTrustedProxies(cidrs []string) {
trustedProxyCIDRs = cidrs
}
func clientIPFromRequest(r *http.Request) string {
remoteIP := r.RemoteAddr
if i := lastIndexByte(remoteIP, ':'); i > 0 {
remoteIP = remoteIP[:i]
}
// Audit 2026-05-10 LOW-5 closure — only trust XFF when the direct
// connection comes from a configured trusted proxy. Default-deny:
// empty TrustedProxies list means XFF is ignored entirely.
if !ipInCIDRs(remoteIP, trustedProxyCIDRs) {
return remoteIP
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
for i := 0; i < len(xff); i++ {
if xff[i] == ',' {
return trimSpace(xff[:i])
}
}
return trimSpace(xff)
}
return remoteIP
}
// ipInCIDRs reports whether ip is within any of the named CIDR ranges.
// Hosts (no /mask) are treated as /32 (IPv4) or /128 (IPv6) singletons.
func ipInCIDRs(ip string, cidrs []string) bool {
if len(cidrs) == 0 {
return false
}
parsed := netParseIP(ip)
if parsed == nil {
return false
}
for _, c := range cidrs {
if !strContainsByte(c, '/') {
// Single-host literal — exact match.
if c == ip {
return true
}
continue
}
_, network, err := netParseCIDR(c)
if err != nil {
continue
}
if network.Contains(parsed) {
return true
}
}
return false
}
// Net helpers live here rather than importing "net" at the top to
// keep the diff surgical. The net package's ParseIP / ParseCIDR are
// well-tested; we just thread them through local indirections.
var (
netParseIP = func(s string) net.IP { return net.ParseIP(s) }
netParseCIDR = func(s string) (net.IP, *net.IPNet, error) { return net.ParseCIDR(s) }
)
func strContainsByte(s string, b byte) bool {
for i := 0; i < len(s); i++ {
if s[i] == b {
return true
}
}
return false
}
func trimSpace(s string) string {
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
s = s[1:]
}
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
s = s[:len(s)-1]
}
return s
}
func lastIndexByte(s string, c byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == c {
return i
}
}
return -1
}