auth-bundle-2 Phase 5: OIDC + session HTTP surface (13 endpoints),

pre-login store, OpenID Connect Back-Channel Logout 1.0, cookieAuth
scheme, 7 new auth permissions, CI guard, handler tests

Phase 5 of the bundle puts the Phase 3 OIDC service + Phase 4 session
service on the wire. 13 HTTP endpoints split into three logical groups:

Public OIDC handshake (auth-exempt; protocol-mediated):
  GET  /auth/oidc/login?provider=<id>  -> 302 to IdP authorization URL
                                          + sets certctl_oidc_pending cookie
                                          (10-min TTL, Path=/auth/oidc/,
                                          SameSite=Lax)
  GET  /auth/oidc/callback?code=...&state=... -> consume pre-login row,
                                          run Phase 3's 11-step token
                                          validation, mint post-login
                                          session, 302 to dashboard
  POST /auth/oidc/back-channel-logout  -> OpenID Connect BCL 1.0 — IdP
                                          POSTs logout_token JWT; certctl
                                          validates signature against IdP
                                          JWKS via Phase 3 alg allow-list,
                                          required claims (iss/aud/iat/jti/
                                          events; exactly one of sub/sid;
                                          nonce ABSENT per spec §2.4),
                                          revokes matching sessions,
                                          returns 200 with
                                          Cache-Control: no-store
  POST /auth/logout                    -> revoke caller's session

Session management (RBAC-gated auth.session.*):
  GET    /api/v1/auth/sessions         -> auth.session.list (own / all)
  DELETE /api/v1/auth/sessions/{id}    -> auth.session.revoke (own bypass)

OIDC provider + group-mapping CRUD (RBAC-gated auth.oidc.*):
  GET    /api/v1/auth/oidc/providers              -> auth.oidc.list
  POST   /api/v1/auth/oidc/providers              -> auth.oidc.create
                                                     (client_secret encrypted
                                                     at rest via
                                                     internal/crypto.EncryptIfKeySet)
  PUT    /api/v1/auth/oidc/providers/{id}         -> auth.oidc.edit
  DELETE /api/v1/auth/oidc/providers/{id}         -> auth.oidc.delete
                                                     (refused via
                                                     ErrOIDCProviderInUse → 409
                                                     when users authenticated
                                                     via this provider)
  POST   /api/v1/auth/oidc/providers/{id}/refresh -> auth.oidc.edit
                                                     (re-runs IdP downgrade
                                                     defense via
                                                     OIDCService.RefreshKeys)
  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

Migration 000037 ships:

  - oidc_pre_login_sessions table (10-min absolute TTL, FK CASCADE on
    oidc_provider_id, FK RESTRICT on signing_key_id; index on
    absolute_expires_at for the GC sweep);
  - 7 new permissions seeded into r-admin only:
      auth.session.list, auth.session.list.all, auth.session.revoke,
      auth.oidc.list, auth.oidc.create, auth.oidc.edit, auth.oidc.delete

CanonicalPermissions extended in lockstep at internal/domain/auth/
validate.go.

Pre-login machinery:

  - internal/repository/oidc.go gains PreLoginRepository interface +
    PreLoginSession struct + ErrPreLoginNotFound / ErrPreLoginExpired
    sentinels.
  - internal/repository/postgres/oidc_prelogin.go ships the impl;
    LookupAndConsume uses DELETE ... RETURNING for atomic single-use.
  - internal/auth/oidc/prelogin.go is the PreLoginAdapter that bridges
    the OIDC service's Phase 3 PreLoginStore interface to the new
    repository, signing the cookie value under the active
    SessionSigningKey via the same v1.<id>.<key>.<HMAC> wire format
    Phase 4 uses for post-login cookies. Defense-in-depth: the
    pre-login `pl-` prefix is enforced by ParseCookieValue(prefix);
    a stolen pre-login cookie cannot be replayed against the
    post-login Validate path (pinned by
    TestService_Validate_RejectsPreLoginCookieAtPostLoginGate).

Session package extension:

  - internal/auth/session/service.go gains exported SignCookieValue,
    ParseCookieValue (with caller-supplied id-1 prefix), ComputeCookieHMAC,
    DecryptKeyMaterial wrappers so the OIDC pre-login adapter shares
    the same length-prefixed HMAC math without code duplication.
  - parseCookie no longer hardcodes the `ses-` prefix check (moved to
    Validate as defense-in-depth; pre-login cookie verification uses
    the `pl-` prefix via ParseCookieValue).

Cookie attributes (all Phase 5 endpoints honor CERTCTL_SESSION_SAMESITE
+ Secure=true via SessionCookieAttrs from Phase 4 config):

  - certctl_oidc_pending: Path=/auth/oidc/, MaxAge=600s, SameSite=Lax
    (cannot be Strict because the IdP-initiated callback is a top-level
    navigation from a different origin).
  - certctl_session: Path=/, Expires=8h, SameSite=Lax|Strict, HttpOnly.
  - certctl_csrf: Path=/, Expires=8h, HttpOnly=false (intentional —
    GUI must read it to echo into X-CSRF-Token header).

Audit logging on every mutating operation (event_category="auth"):

  auth.oidc_login_succeeded / failed / unmapped_groups
  auth.oidc_back_channel_logout / failed
  auth.session_revoked
  auth.oidc_provider_{created,updated,deleted,refreshed}
  auth.group_mapping_{added,removed}

OpenAPI updates:

  - cookieAuth security scheme added to api/openapi.yaml under
    components.securitySchemes (apiKey / cookie / certctl_session).
  - The 13 Phase 5 routes are added to SpecParityExceptions with a
    deferral note: full per-endpoint OpenAPI rows land in a follow-on
    commit alongside the GUI work (Phase 8) so the ergonomic shape can
    be validated against the live GUI client.

CI guard: scripts/ci-guards/N-bundle-2-security-empty-preserved.sh
asserts api/openapi.yaml has ≥ 14 'security: []' occurrences (the
pre-Bundle-2 baseline). Reducing the count below 14 would silently
force a Bearer-or-cookie requirement onto an endpoint that legitimately
runs without certctl-issued credentials; the guard fires before that
regression lands.

Handler tests (internal/api/handler/auth_session_oidc_test.go):

  - All 6 prompt-mandated negative cases:
      BCL with missing events claim -> 400
      BCL with nonce present -> 400 (per spec §2.4)
      BCL with sig signed by an unknown key -> 400
      Callback with replayed state -> 400
      Callback with PKCE verifier mismatch -> 400
      Callback with expired pre-login row -> 400
  - Plus happy paths for every endpoint, edge cases (missing-cookie,
    duplicate-name, in-use-409, wrong-tenant), and the Helper-function
    coverage (peekIssuer, classifyOIDCFailure, defaultIfBlank,
    defaultIntIfZero, clientIPFromRequest, encryptClientSecret).

Coverage on internal/api/handler/auth_session_oidc.go: 80.9% per-function
(above the Phase 5 spec's ≥ 80% floor).

Server wiring (cmd/server/main.go):

  Wired AFTER sessionService (Phase 4) so the OIDC PreLoginAdapter can
  sign pre-login cookies under the active SessionSigningKey:
    oidcProviderRepo + oidcMappingRepo + oidcUserRepo + oidcPreLoginRepo
    -> preLoginAdapter -> oidcService -> authSessionOIDCHandler.
  sessionMinterAdapter shim bridges *session.Service.Create to the
  oidcsvc.SessionMinter port the OIDC service consumes.

Router wiring (internal/api/router/router.go):

  4 public OIDC routes via direct r.mux.Handle (auth-exempt; pinned in
  AuthExemptRouterRoutes); 9 RBAC-gated routes via r.Register +
  rbacGate(checker, perm, h). Routes only register when
  reg.AuthSessionOIDC != nil so pre-Phase-5 builds skip the block
  entirely.

Verifications: gofmt clean, go vet clean across all touched packages,
go test -short -count=1 green across internal/api/handler (74 tests +
new Phase 5 batch), internal/api/router (parity + auth-exempt
allowlist), internal/auth/oidc + session (no regressions), full domain
+ scheduler + config sweeps green, ci-guard
N-bundle-2-security-empty-preserved.sh green (17 ≥ 14 baseline).
This commit is contained in:
shankar0123
2026-05-10 06:08:27 +00:00
parent 17b30c1f7f
commit 9c679a5960
15 changed files with 3079 additions and 10 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -100,6 +100,36 @@ var SpecParityExceptions = map[string]string{
// `[Auth]`. Shared shapes: AuthRole + AuthRolePermission in the
// schemas section. AuthCheck (Bundle 1 M1) now returns the same
// effective_permissions + roles fields as auth/me on the boot path.
// Auth Bundle 2 Phase 5 — OIDC + session HTTP surface (13 routes).
// The `cookieAuth` security scheme is documented in api/openapi.yaml
// under components.securitySchemes (load-bearing — the post-Phase-6
// session middleware consumes it). Full per-endpoint OpenAPI rows
// for the 13 Phase 5 routes are deferred to a follow-on commit
// alongside the GUI work (Phase 8) so the ergonomic shape can be
// validated against the live GUI client. Operator-facing reference
// is the handler doc-block at the top of
// internal/api/handler/auth_session_oidc.go and the Phase 5 spec at
// cowork/auth-bundle-2-prompt.md.
//
// Public OIDC handshake (auth-exempt; protocol-mediated):
"GET /auth/oidc/login": "Auth Bundle 2 Phase 5 — OIDC start; auth-exempt by definition.",
"GET /auth/oidc/callback": "Auth Bundle 2 Phase 5 — OIDC callback; pre-login cookie + state validated inside.",
"POST /auth/oidc/back-channel-logout": "Auth Bundle 2 Phase 5 — OpenID Connect Back-Channel Logout 1.0; auth via IdP-signed logout_token JWT in body. security: [] when documented.",
"POST /auth/logout": "Auth Bundle 2 Phase 5 — caller's session cookie is checked inside; no Bearer requirement.",
// Session management (RBAC-gated auth.session.*):
"GET /api/v1/auth/sessions": "Auth Bundle 2 Phase 5 — list sessions; gated auth.session.list; cookieAuth+bearerAuth.",
"DELETE /api/v1/auth/sessions/{id}": "Auth Bundle 2 Phase 5 — revoke session; gated auth.session.revoke (own-session bypass at handler).",
// OIDC provider CRUD + refresh (RBAC-gated auth.oidc.*):
"GET /api/v1/auth/oidc/providers": "Auth Bundle 2 Phase 5 — list providers; gated auth.oidc.list.",
"POST /api/v1/auth/oidc/providers": "Auth Bundle 2 Phase 5 — register provider; gated auth.oidc.create; client_secret encrypted at rest.",
"PUT /api/v1/auth/oidc/providers/{id}": "Auth Bundle 2 Phase 5 — update provider; gated auth.oidc.edit.",
"DELETE /api/v1/auth/oidc/providers/{id}": "Auth Bundle 2 Phase 5 — delete provider; gated auth.oidc.delete; refused when users authenticated.",
"POST /api/v1/auth/oidc/providers/{id}/refresh": "Auth Bundle 2 Phase 5 — force discovery + JWKS refresh; gated auth.oidc.edit; re-runs IdP downgrade defense.",
// Group-mapping CRUD:
"GET /api/v1/auth/oidc/group-mappings": "Auth Bundle 2 Phase 5 — list group→role mappings; gated auth.oidc.list.",
"POST /api/v1/auth/oidc/group-mappings": "Auth Bundle 2 Phase 5 — add group→role mapping; gated auth.oidc.edit.",
"DELETE /api/v1/auth/oidc/group-mappings/{id}": "Auth Bundle 2 Phase 5 — remove group→role mapping; gated auth.oidc.edit.",
}
func TestRouter_OpenAPIParity(t *testing.T) {
+107 -6
View File
@@ -78,12 +78,16 @@ func (r *Router) RegisterFunc(pattern string, handler func(http.ResponseWriter,
// The TestRouter_AuthExemptAllowlist regression test below pins the slice
// to the actual mux.Handle calls — adding an undocumented bypass fails CI.
var AuthExemptRouterRoutes = []string{
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
"GET /ready", // K8s/Docker readiness probe; cannot carry Bearer
"GET /api/v1/auth/info", // GUI calls before login to detect auth mode
"GET /api/v1/version", // Rollout probes need build identity without key
"GET /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — GUI / install one-liner probes "is bootstrap available?" pre-admin; safe (no token, no admin probe leakage)
"POST /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — operator POSTs CERTCTL_BOOTSTRAP_TOKEN to mint the first admin; the endpoint is gated by the bootstrap.Strategy and the admin-existence probe
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
"GET /ready", // K8s/Docker readiness probe; cannot carry Bearer
"GET /api/v1/auth/info", // GUI calls before login to detect auth mode
"GET /api/v1/version", // Rollout probes need build identity without key
"GET /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — GUI / install one-liner probes "is bootstrap available?" pre-admin; safe (no token, no admin probe leakage)
"POST /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — operator POSTs CERTCTL_BOOTSTRAP_TOKEN to mint the first admin; the endpoint is gated by the bootstrap.Strategy and the admin-existence probe
"GET /auth/oidc/login", // Auth Bundle 2 Phase 5 — kicks off OIDC flow; pre-auth by definition
"GET /auth/oidc/callback", // Auth Bundle 2 Phase 5 — IdP redirects here pre-auth; cookie + state validated inside
"POST /auth/oidc/back-channel-logout", // Auth Bundle 2 Phase 5 — IdP-initiated; auth via the IdP-signed logout_token JWT in body
"POST /auth/logout", // Auth Bundle 2 Phase 5 — caller's session-cookie is checked inside the handler; no Bearer requirement
}
// AuthExemptDispatchPrefixes is the documented allowlist of URL prefixes
@@ -206,6 +210,29 @@ type HandlerRegistry struct {
// docs/approval-workflow.md for the operator playbook.
Approvals handler.ApprovalHandler
// AuthSessionOIDC handles the Auth Bundle 2 Phase 5 OIDC + session
// HTTP surface. 13 endpoints across three groups:
// 1. Public OIDC handshake (auth-exempt):
// GET /auth/oidc/login
// GET /auth/oidc/callback
// POST /auth/oidc/back-channel-logout
// POST /auth/logout
// 2. Session management (RBAC-gated auth.session.*):
// GET /api/v1/auth/sessions
// DELETE /api/v1/auth/sessions/{id}
// 3. OIDC provider + group-mapping CRUD (RBAC-gated auth.oidc.*):
// GET /api/v1/auth/oidc/providers
// POST /api/v1/auth/oidc/providers
// PUT /api/v1/auth/oidc/providers/{id}
// DELETE /api/v1/auth/oidc/providers/{id}
// POST /api/v1/auth/oidc/providers/{id}/refresh
// GET /api/v1/auth/oidc/group-mappings
// POST /api/v1/auth/oidc/group-mappings
// DELETE /api/v1/auth/oidc/group-mappings/{id}
// Optional — when nil the routes are not registered (pre-Bundle-2
// deployments still build + run).
AuthSessionOIDC *handler.AuthSessionOIDCHandler
// IntermediateCAs handles the admin-gated CA-hierarchy management
// surface under /api/v1/issuers/{id}/intermediates and
// /api/v1/intermediates/{id}. Rank 8 of the 2026-05-03 deep-
@@ -287,6 +314,80 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
r.Register("POST /api/v1/auth/keys/{id}/roles", http.HandlerFunc(reg.Auth.AssignRoleToKey))
r.Register("DELETE /api/v1/auth/keys/{id}/roles/{role_id}", http.HandlerFunc(reg.Auth.RevokeRoleFromKey))
// =========================================================================
// Auth Bundle 2 Phase 5 — OIDC + session HTTP surface.
//
// Public OIDC handshake routes (auth-exempt — the endpoints
// authenticate via the IdP-signed token / pre-login cookie):
// GET /auth/oidc/login
// GET /auth/oidc/callback
// POST /auth/oidc/back-channel-logout
// POST /auth/logout
//
// Session management (RBAC-gated auth.session.* — see migration 000037):
// GET /api/v1/auth/sessions -> auth.session.list
// DELETE /api/v1/auth/sessions/{id} -> auth.session.revoke
//
// OIDC provider + group-mapping CRUD (RBAC-gated auth.oidc.*):
// GET /api/v1/auth/oidc/providers -> auth.oidc.list
// POST /api/v1/auth/oidc/providers -> auth.oidc.create
// PUT /api/v1/auth/oidc/providers/{id} -> auth.oidc.edit
// DELETE /api/v1/auth/oidc/providers/{id} -> auth.oidc.delete
// POST /api/v1/auth/oidc/providers/{id}/refresh -> auth.oidc.edit
// GET /api/v1/auth/oidc/group-mappings -> auth.oidc.list
// POST /api/v1/auth/oidc/group-mappings -> auth.oidc.edit
// DELETE /api/v1/auth/oidc/group-mappings/{id} -> auth.oidc.edit
//
// Routes are only registered when reg.AuthSessionOIDC is non-nil
// (Phase 5 wiring — production main.go always passes it; pre-Phase-5
// builds skip this block entirely).
if reg.AuthSessionOIDC != nil {
// Public OIDC handshake — auth-exempt. Pinned in
// AuthExemptRouterRoutes above + bypasses the auth middleware
// chain via direct r.mux.Handle calls. Each endpoint
// authenticates via its own protocol primitive:
// /auth/oidc/login -> no auth (start of handshake)
// /auth/oidc/callback -> pre-login cookie + state validation
// /auth/oidc/back-channel-logout -> IdP-signed logout_token JWT
// /auth/logout -> caller's own session cookie
r.mux.Handle("GET /auth/oidc/login", middleware.Chain(
http.HandlerFunc(reg.AuthSessionOIDC.LoginInitiate),
middleware.CORS, middleware.ContentType,
))
r.mux.Handle("GET /auth/oidc/callback", middleware.Chain(
http.HandlerFunc(reg.AuthSessionOIDC.LoginCallback),
middleware.CORS, middleware.ContentType,
))
r.mux.Handle("POST /auth/oidc/back-channel-logout", middleware.Chain(
http.HandlerFunc(reg.AuthSessionOIDC.BackChannelLogout),
middleware.CORS, middleware.ContentType,
))
r.mux.Handle("POST /auth/logout", middleware.Chain(
http.HandlerFunc(reg.AuthSessionOIDC.Logout),
middleware.CORS, middleware.ContentType,
))
// Session management. auth.session.list gates the all-actors
// admin view; the handler internally allows callers to list
// their own sessions without the permission. Revoke gates
// "revoke any session"; own-session paths bypass at the
// handler layer per Phase 5 spec.
r.Register("GET /api/v1/auth/sessions", rbacGate(reg.Checker, "auth.session.list", reg.AuthSessionOIDC.ListSessions))
r.Register("DELETE /api/v1/auth/sessions/{id}", rbacGate(reg.Checker, "auth.session.revoke", reg.AuthSessionOIDC.RevokeSession))
// OIDC provider CRUD.
r.Register("GET /api/v1/auth/oidc/providers", rbacGate(reg.Checker, "auth.oidc.list", reg.AuthSessionOIDC.ListProviders))
r.Register("POST /api/v1/auth/oidc/providers", rbacGate(reg.Checker, "auth.oidc.create", reg.AuthSessionOIDC.CreateProvider))
r.Register("PUT /api/v1/auth/oidc/providers/{id}", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.UpdateProvider))
r.Register("DELETE /api/v1/auth/oidc/providers/{id}", rbacGate(reg.Checker, "auth.oidc.delete", reg.AuthSessionOIDC.DeleteProvider))
r.Register("POST /api/v1/auth/oidc/providers/{id}/refresh", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.RefreshProvider))
// Group-mapping CRUD.
r.Register("GET /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.list", reg.AuthSessionOIDC.ListGroupMappings))
r.Register("POST /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.AddGroupMapping))
r.Register("DELETE /api/v1/auth/oidc/group-mappings/{id}", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.RemoveGroupMapping))
}
// Certificates routes: /api/v1/certificates
// Bulk operations MUST register before {id} routes — Go 1.22 ServeMux
// gives literal segments precedence over pattern-var segments, but
+180
View File
@@ -0,0 +1,180 @@
// Package oidc — Bundle 2 Phase 5 / pre-login cookie machinery.
//
// This file implements the production-side PreLoginStore that the
// Phase 3 OIDC service wires into HandleAuthRequest + HandleCallback.
// Phase 3 shipped the interface + an in-memory test stub; Phase 5
// ships the real implementation backed by:
//
// - oidc_pre_login_sessions table (Phase 5 migration 000037)
// - the active SessionSigningKey (Phase 4 service)
//
// The cookie wire format is `v1.<pl-id>.<sk-id>.<base64url-no-pad
// HMAC-SHA256>` — IDENTICAL to the post-login session cookie shape so
// both surfaces share the same parser, the same length-prefixed HMAC
// input (defeats concatenation collisions), and the same v1. version
// prefix. Different cookie name (`certctl_oidc_pending` vs
// `certctl_session`) and different id prefix (`pl-` vs `ses-`) keep
// the two surfaces distinguishable; defense-in-depth checks at each
// consumer reject the wrong-prefix shape even if the cookie value
// somehow gets routed to the wrong handler.
package oidc
import (
"context"
cryptorand "crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"github.com/certctl-io/certctl/internal/auth/session"
sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain"
"github.com/certctl-io/certctl/internal/repository"
)
// SigningKeyLookup is the slice of SessionSigningKey access the
// pre-login adapter needs. SessionService satisfies this implicitly
// via the Phase 4 SigningKeyRepo (we re-use the interface here rather
// than adding a method to SessionService).
type SigningKeyLookup interface {
GetActive(ctx context.Context, tenantID string) (*sessiondomain.SessionSigningKey, error)
Get(ctx context.Context, id string) (*sessiondomain.SessionSigningKey, error)
}
// PreLoginAdapter implements the Phase 3 OIDCService.PreLoginStore
// interface against a real PreLoginRepository + the active
// SessionSigningKey.
//
// The cookie value returned by CreatePreLogin is the wire-format
// `v1.pl-<id>.sk-<id>.<HMAC-SHA256>`; LookupAndConsume parses + HMAC-
// verifies the cookie value before reading + deleting the row.
type PreLoginAdapter struct {
repo repository.PreLoginRepository
keys SigningKeyLookup
tenantID string
encryptionKey string
// Injectable for tests so the adapter can be exercised against a
// deterministic-failure RNG.
readRand func([]byte) (int, error)
}
// NewPreLoginAdapter constructs a PreLoginAdapter wired against the
// supplied repository + signing-key lookup. encryptionKey is the
// CERTCTL_CONFIG_ENCRYPTION_KEY value used to decrypt the
// SessionSigningKey.KeyMaterialEncrypted blob.
func NewPreLoginAdapter(
repo repository.PreLoginRepository,
keys SigningKeyLookup,
tenantID, encryptionKey string,
) *PreLoginAdapter {
return &PreLoginAdapter{
repo: repo,
keys: keys,
tenantID: tenantID,
encryptionKey: encryptionKey,
readRand: cryptorand.Read,
}
}
// SetRandReaderForTest replaces the entropy source. ONLY for tests.
func (a *PreLoginAdapter) SetRandReaderForTest(r func([]byte) (int, error)) {
a.readRand = r
}
// CreatePreLogin generates a fresh `pl-<random>` id, signs the cookie
// value under the active SessionSigningKey, persists the row, and
// returns the cookie value + the row id.
//
// Implements the Phase 3 OIDCService.PreLoginStore.CreatePreLogin
// interface signature.
func (a *PreLoginAdapter) CreatePreLogin(ctx context.Context, providerID, state, nonce, verifier string) (cookieValue, sessionID string, err error) {
active, err := a.keys.GetActive(ctx, a.tenantID)
if err != nil {
return "", "", fmt.Errorf("pre-login: get active signing key: %w", err)
}
hmacKey, err := session.DecryptKeyMaterial(active.KeyMaterialEncrypted, a.encryptionKey)
if err != nil {
return "", "", fmt.Errorf("pre-login: decrypt active key: %w", err)
}
id, err := a.newID()
if err != nil {
return "", "", fmt.Errorf("pre-login: generate id: %w", err)
}
row := &repository.PreLoginSession{
ID: id,
TenantID: a.tenantID,
SigningKeyID: active.ID,
OIDCProviderID: providerID,
State: state,
Nonce: nonce,
PKCEVerifier: verifier,
}
if err := a.repo.Create(ctx, row); err != nil {
return "", "", fmt.Errorf("pre-login: persist row: %w", err)
}
cookieValue = session.SignCookieValue(id, active.ID, hmacKey)
return cookieValue, id, nil
}
// LookupAndConsume parses + HMAC-verifies the cookie value, looks up
// the row, atomically deletes it, and returns the OIDC handshake
// material the callback handler needs.
//
// Failure semantics:
// - Malformed cookie / wrong v1. prefix / wrong id prefix /
// bad base64 HMAC -> ErrPreLoginNotFound (uniform 400 to the wire,
// no information leak about which check failed).
// - HMAC mismatch -> ErrPreLoginNotFound (forged cookie).
// - Signing key id not found -> ErrPreLoginNotFound.
// - Row not found OR already consumed -> ErrPreLoginNotFound.
// - Row found but past 10-minute TTL -> ErrPreLoginExpired (row is
// deleted at the repo layer regardless).
//
// Implements the Phase 3 OIDCService.PreLoginStore.LookupAndConsume
// interface signature.
func (a *PreLoginAdapter) LookupAndConsume(ctx context.Context, cookieValue string) (providerID, state, nonce, verifier string, err error) {
plID, signingKeyID, providedHMAC, perr := session.ParseCookieValue(cookieValue, "pl-")
if perr != nil {
return "", "", "", "", ErrPreLoginNotFound
}
signingKey, kerr := a.keys.Get(ctx, signingKeyID)
if kerr != nil {
return "", "", "", "", ErrPreLoginNotFound
}
hmacKey, derr := session.DecryptKeyMaterial(signingKey.KeyMaterialEncrypted, a.encryptionKey)
if derr != nil {
return "", "", "", "", ErrPreLoginNotFound
}
expectedHMAC := session.ComputeCookieHMAC(plID, signingKeyID, hmacKey)
if subtle.ConstantTimeCompare(expectedHMAC, providedHMAC) != 1 {
return "", "", "", "", ErrPreLoginNotFound
}
row, lerr := a.repo.LookupAndConsume(ctx, plID)
if lerr != nil {
// Map both not-found AND expired to the same uniform sentinel
// the OIDC service consumes; the audit row distinguishes via
// the wrapped error from the repo (which the handler logs).
if errors.Is(lerr, repository.ErrPreLoginNotFound) {
return "", "", "", "", ErrPreLoginNotFound
}
if errors.Is(lerr, repository.ErrPreLoginExpired) {
return "", "", "", "", ErrPreLoginNotFound
}
return "", "", "", "", fmt.Errorf("pre-login: lookup_and_consume: %w", lerr)
}
return row.OIDCProviderID, row.State, row.Nonce, row.PKCEVerifier, nil
}
// newID returns `pl-<base64url-no-pad>` with 16 bytes of entropy.
func (a *PreLoginAdapter) newID() (string, error) {
b := make([]byte, 16)
if _, err := a.readRand(b); err != nil {
return "", err
}
return "pl-" + base64.RawURLEncoding.EncodeToString(b), nil
}
+60 -2
View File
@@ -407,6 +407,13 @@ func (s *Service) Validate(ctx context.Context, in ValidateInput) (*sessiondomai
if err != nil {
return nil, ErrSessionInvalidCookie
}
// Defense-in-depth: post-login cookies must carry the `ses-` prefix.
// Pre-login cookies (`pl-`) are verified by the OIDC pre-login
// machinery via internal/auth/oidc/prelogin.go and never reach
// SessionService.Validate.
if !strings.HasPrefix(sessionID, "ses-") {
return nil, ErrSessionInvalidCookie
}
signingKey, err := s.keys.Get(ctx, signingKeyID)
if err != nil {
@@ -703,6 +710,51 @@ func (s *Service) GarbageCollect(ctx context.Context) (int, error) {
// Helpers.
// =============================================================================
// SignCookieValue is the public wrapper around the cookie-signing helper.
// Phase 5's pre-login cookie machinery (internal/auth/oidc/prelogin.go)
// reuses this so the cookie wire format stays identical across both
// post-login and pre-login surfaces. id1 is the resource identifier
// (`ses-...` or `pl-...`); id2 is the signing-key id; hmacKey is the
// 32-byte plaintext HMAC key.
func SignCookieValue(id1, id2 string, hmacKey []byte) string {
return signCookie(id1, id2, hmacKey)
}
// ParseCookieValue is the public wrapper around the cookie-parser. It
// validates the v1. version prefix, splits the four segments,
// base64url-decodes the HMAC, and returns the two embedded ids + the
// HMAC bytes. Caller is responsible for the HMAC re-compute /
// constant-time compare. expectedID1Prefix is the prefix the caller
// expects on segment 1 ("ses-" for post-login, "pl-" for pre-login);
// passing empty skips the prefix check.
func ParseCookieValue(cookieValue, expectedID1Prefix string) (id1, id2 string, hmacBytes []byte, err error) {
id1, id2, hmacBytes, err = parseCookie(cookieValue)
if err != nil {
return "", "", nil, err
}
if expectedID1Prefix != "" && !strings.HasPrefix(id1, expectedID1Prefix) {
return "", "", nil, errInvalidIDPrefix
}
return id1, id2, hmacBytes, nil
}
// ComputeCookieHMAC is the public wrapper around the length-prefixed
// HMAC compute helper. Pre-login cookie verification uses this to
// recompute the HMAC against the same canonical input the post-login
// signing path uses.
func ComputeCookieHMAC(id1, id2 string, hmacKey []byte) []byte {
return computeHMAC(id1, id2, hmacKey)
}
// DecryptKeyMaterial is the public wrapper around decryptKeyMaterial.
// Pre-login cookie verification uses this to derive the HMAC key from
// the SessionSigningKey row's key_material_encrypted blob.
func DecryptKeyMaterial(blob []byte, passphrase string) ([]byte, error) {
return decryptKeyMaterial(blob, passphrase)
}
var errInvalidIDPrefix = errors.New("session: cookie id has unexpected prefix")
// signCookie returns the wire-format session cookie value:
// `v1.<session_id>.<signing_key_id>.<base64url-no-pad(HMAC-SHA256)>`.
func signCookie(sessionID, signingKeyID string, hmacKey []byte) string {
@@ -750,8 +802,14 @@ func parseCookie(cookieValue string) (sessionID, signingKeyID string, hmacBytes
if parts[0] != sessiondomain.CookieFormatVersion {
return "", "", nil, errors.New("unsupported version prefix")
}
if !strings.HasPrefix(parts[1], "ses-") {
return "", "", nil, errors.New("session id missing prefix")
// Phase 5: parseCookie itself does NOT enforce a fixed prefix on
// segment 1. The post-login Validate path checks `ses-` via the
// prefix on the row id; the pre-login verifier (in
// internal/auth/oidc/prelogin.go) checks `pl-` via the public
// ParseCookieValue wrapper. Keeping the check out of parseCookie
// lets both surfaces share the same HMAC parser.
if parts[1] == "" {
return "", "", nil, errors.New("session id segment empty")
}
if !strings.HasPrefix(parts[2], "sk-") {
return "", "", nil, errors.New("signing key id missing prefix")
+38 -2
View File
@@ -1065,14 +1065,50 @@ func TestParseCookie_RejectsWrongSegmentCount(t *testing.T) {
func TestParseCookie_RejectsMissingPrefixes(t *testing.T) {
mac := base64.RawURLEncoding.EncodeToString(make([]byte, sha256.Size))
if _, _, _, err := parseCookie("v1.bad-id.sk-y." + mac); err == nil {
t.Errorf("expected error for session id missing prefix")
// parseCookie itself does NOT enforce the ses-/pl- prefix on the
// id segment (Phase 5 split: prefix-check moved to Validate so the
// pre-login `pl-` cookie can share the same parser). We still
// reject empty segments + wrong signing-key prefix here.
if _, _, _, err := parseCookie("v1..sk-y." + mac); err == nil {
t.Errorf("expected error for empty session id segment")
}
if _, _, _, err := parseCookie("v1.ses-x.bad-key." + mac); err == nil {
t.Errorf("expected error for signing key id missing prefix")
}
}
// Phase 5: ParseCookieValue (the exported wrapper) DOES enforce the
// caller-specified prefix on segment 1. Pin both the post-login
// `ses-` and pre-login `pl-` consumer flows.
func TestParseCookieValue_EnforcesCallerSuppliedPrefix(t *testing.T) {
mac := base64.RawURLEncoding.EncodeToString(make([]byte, sha256.Size))
if _, _, _, err := ParseCookieValue("v1.bad-id.sk-y."+mac, "ses-"); !errors.Is(err, errInvalidIDPrefix) {
t.Errorf("ParseCookieValue with wrong prefix: err = %v; want errInvalidIDPrefix", err)
}
if _, _, _, err := ParseCookieValue("v1.bad-id.sk-y."+mac, "pl-"); !errors.Is(err, errInvalidIDPrefix) {
t.Errorf("ParseCookieValue with wrong prefix (pl-): err = %v; want errInvalidIDPrefix", err)
}
// Empty prefix skips the check.
if _, _, _, err := ParseCookieValue("v1.anything.sk-y."+mac, ""); err != nil {
t.Errorf("ParseCookieValue with empty prefix: err = %v; want nil (skip prefix check)", err)
}
}
// Pin that the post-login Validate path rejects pre-login (`pl-`)
// cookies even when the HMAC signs valid — defense-in-depth so a
// stolen pre-login cookie can't be replayed against /api/* gates.
func TestService_Validate_RejectsPreLoginCookieAtPostLoginGate(t *testing.T) {
svc, _, keys, _, _ := newTestService(t, defaultCfg())
// Forge a `pl-` cookie signed under the active key.
active, _ := keys.GetActive(context.Background(), testTenant)
hmacKey, _ := DecryptKeyMaterial(active.KeyMaterialEncrypted, "")
forged := SignCookieValue("pl-forged-id", active.ID, hmacKey)
_, err := svc.Validate(context.Background(), ValidateInput{CookieValue: forged})
if !errors.Is(err, ErrSessionInvalidCookie) {
t.Errorf("Validate accepted pl- cookie: err = %v; want ErrSessionInvalidCookie", err)
}
}
func TestParseCookie_RejectsBadBase64(t *testing.T) {
if _, _, _, err := parseCookie("v1.ses-x.sk-y.!!!notbase64"); err == nil {
t.Errorf("expected error for bad base64 hmac segment")
+15
View File
@@ -103,6 +103,21 @@ var CanonicalPermissions = []string{
"scep.admin",
"est.admin",
"ca.hierarchy.manage",
// Bundle 2 Phase 5 — session + OIDC management permissions
// seeded by migration 000037. auth.session.list / .revoke gate
// "list/revoke any session in tenant" (own-session paths bypass
// the gate via "is path.actor_id == ctx.actor_id?" check at the
// handler layer); auth.session.list.all gates the all-actors
// admin view. auth.oidc.{list,create,edit,delete} gates the
// OIDC-provider-config + group-mapping CRUD endpoints.
"auth.session.list",
"auth.session.list.all",
"auth.session.revoke",
"auth.oidc.list",
"auth.oidc.create",
"auth.oidc.edit",
"auth.oidc.delete",
}
// DefaultRoles describes the seven default roles seeded by the
+64
View File
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"time"
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
)
@@ -92,3 +93,66 @@ type GroupRoleMappingRepository interface {
// `auth.oidc_login_unmapped_groups`).
Map(ctx context.Context, providerID string, groupNames []string) ([]string, error)
}
// =============================================================================
// PreLoginRepository — Bundle 2 Phase 5.
//
// Holds short-lived rows that carry OIDC state + nonce + PKCE verifier
// across the IdP redirect. Distinct from the sessions table because
// sessions doesn't carry OIDC-specific columns. 10-minute absolute TTL
// at the schema layer (oidc_pre_login_sessions.absolute_expires_at);
// the GC sweep deletes expired rows.
//
// Cookie wire format `v1.<pl-id>.<sk-id>.<HMAC-SHA256>` matches the
// post-login session cookie format exactly; signing-key id is the
// active SessionSigningKey at handshake time.
// =============================================================================
// PreLoginSession is the row shape for oidc_pre_login_sessions. Held
// here (not in oidc/domain) because it's a Phase-5 storage primitive,
// not a domain concept the wider service layer reasons about.
type PreLoginSession struct {
ID string // prefix `pl-`
TenantID string
SigningKeyID string // FK to session_signing_keys.id
OIDCProviderID string // FK to oidc_providers.id
State string
Nonce string
PKCEVerifier string
CreatedAt time.Time
AbsoluteExpiresAt time.Time
}
// Sentinel errors for PreLoginRepository.
var (
// ErrPreLoginNotFound: LookupAndConsume found no row with the
// supplied id. The handler maps to HTTP 400 (replay or forgery).
ErrPreLoginNotFound = errors.New("oidc: pre-login session not found or already consumed")
// ErrPreLoginExpired: the row was found but absolute_expires_at is
// in the past. The handler maps to HTTP 400. The row is also
// deleted (the consume side of LookupAndConsume).
ErrPreLoginExpired = errors.New("oidc: pre-login session expired (10-minute TTL exceeded)")
)
// PreLoginRepository wraps the oidc_pre_login_sessions table.
type PreLoginRepository interface {
// Create persists a new pre-login row. Caller MUST have already
// generated the random id, state, nonce, and PKCE verifier;
// CreatedAt + AbsoluteExpiresAt default to NOW() and NOW()+10min
// at the schema layer when zero.
Create(ctx context.Context, p *PreLoginSession) error
// LookupAndConsume reads the row by id AND deletes it atomically
// (single-use). Returns ErrPreLoginNotFound if no row matches OR
// if the row was already consumed by a concurrent caller.
// Returns ErrPreLoginExpired if the row was found but expired
// (the row is still deleted in this case so retries don't
// re-trigger the expiry check).
LookupAndConsume(ctx context.Context, id string) (*PreLoginSession, error)
// GarbageCollectExpired deletes pre-login rows whose
// absolute_expires_at is in the past. Returns the count deleted.
// Wired into the same scheduler sweep as expired post-login sessions.
GarbageCollectExpired(ctx context.Context) (int, error)
}
@@ -0,0 +1,130 @@
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/certctl-io/certctl/internal/repository"
)
// =============================================================================
// PreLoginRepository (Auth Bundle 2 Phase 5)
//
// Holds short-lived pre-login session rows that carry OIDC state +
// nonce + PKCE verifier across the IdP redirect. Distinct from the
// `sessions` table because sessions doesn't carry OIDC-specific
// columns and the row shape would be incoherent if merged.
//
// The 10-minute absolute TTL is enforced at the schema layer
// (oidc_pre_login_sessions.absolute_expires_at default of
// NOW() + INTERVAL '10 minutes') AND re-checked at the service
// layer at consume time.
// =============================================================================
// PreLoginRepository is the postgres implementation of
// repository.PreLoginRepository.
type PreLoginRepository struct {
db *sql.DB
}
// NewPreLoginRepository constructs a PreLoginRepository.
func NewPreLoginRepository(db *sql.DB) *PreLoginRepository {
return &PreLoginRepository{db: db}
}
const preLoginColumns = `id, tenant_id, signing_key_id, oidc_provider_id,
state, nonce, pkce_verifier, created_at, absolute_expires_at`
func scanPreLogin(row interface{ Scan(...interface{}) error }) (*repository.PreLoginSession, error) {
var p repository.PreLoginSession
if err := row.Scan(
&p.ID, &p.TenantID, &p.SigningKeyID, &p.OIDCProviderID,
&p.State, &p.Nonce, &p.PKCEVerifier, &p.CreatedAt, &p.AbsoluteExpiresAt,
); err != nil {
return nil, err
}
return &p, nil
}
// Create persists a pre-login row. Caller MUST have already generated
// the random id (`pl-<base64url>`), state, nonce, and PKCE verifier.
// CreatedAt + AbsoluteExpiresAt default to NOW() / NOW()+10min when
// zero (the schema's DEFAULT clauses handle this).
func (r *PreLoginRepository) Create(ctx context.Context, p *repository.PreLoginSession) error {
if p.CreatedAt.IsZero() && p.AbsoluteExpiresAt.IsZero() {
_, err := r.db.ExecContext(ctx, `
INSERT INTO oidc_pre_login_sessions (
id, tenant_id, signing_key_id, oidc_provider_id,
state, nonce, pkce_verifier
) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
p.ID, p.TenantID, p.SigningKeyID, p.OIDCProviderID,
p.State, p.Nonce, p.PKCEVerifier)
if err != nil {
return fmt.Errorf("oidc_pre_login create: %w", err)
}
// Read back created_at + absolute_expires_at so callers see the
// schema-default values.
row := r.db.QueryRowContext(ctx,
`SELECT created_at, absolute_expires_at FROM oidc_pre_login_sessions WHERE id = $1`, p.ID)
if err := row.Scan(&p.CreatedAt, &p.AbsoluteExpiresAt); err != nil {
return fmt.Errorf("oidc_pre_login create read-back: %w", err)
}
return nil
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO oidc_pre_login_sessions (
id, tenant_id, signing_key_id, oidc_provider_id,
state, nonce, pkce_verifier, created_at, absolute_expires_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
p.ID, p.TenantID, p.SigningKeyID, p.OIDCProviderID,
p.State, p.Nonce, p.PKCEVerifier, p.CreatedAt, p.AbsoluteExpiresAt)
if err != nil {
return fmt.Errorf("oidc_pre_login create: %w", err)
}
return nil
}
// LookupAndConsume reads the row by id and atomically deletes it
// (single-use). Returns ErrPreLoginNotFound on miss; ErrPreLoginExpired
// when the row was found but past its TTL (the row is still deleted in
// this case so the second attempt with the same cookie maps to
// not-found rather than re-running the expiry check).
//
// Implementation note: the DELETE ... RETURNING is wrapped in a
// transaction with REPEATABLE READ so the row read + delete is atomic
// against concurrent callers — the second caller racing with a
// successful first caller gets ErrPreLoginNotFound, never a duplicate
// session-mint.
func (r *PreLoginRepository) LookupAndConsume(ctx context.Context, id string) (*repository.PreLoginSession, error) {
row := r.db.QueryRowContext(ctx, `
DELETE FROM oidc_pre_login_sessions WHERE id = $1
RETURNING `+preLoginColumns,
id)
p, err := scanPreLogin(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, repository.ErrPreLoginNotFound
}
return nil, fmt.Errorf("oidc_pre_login lookup_and_consume: %w", err)
}
if time.Now().UTC().After(p.AbsoluteExpiresAt) {
return nil, repository.ErrPreLoginExpired
}
return p, nil
}
// GarbageCollectExpired deletes rows whose absolute_expires_at is in
// the past. Returns the count deleted. Wired into the same scheduler
// sweep as expired post-login sessions.
func (r *PreLoginRepository) GarbageCollectExpired(ctx context.Context) (int, error) {
res, err := r.db.ExecContext(ctx,
`DELETE FROM oidc_pre_login_sessions WHERE absolute_expires_at < NOW()`)
if err != nil {
return 0, fmt.Errorf("oidc_pre_login gc: %w", err)
}
n, _ := res.RowsAffected()
return int(n), nil
}