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 e6eb7e6497
commit 2896008fd1
15 changed files with 3079 additions and 10 deletions
+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")