Files
certctl/migrations/000040_bcl_replay_cache.up.sql
T
shankar0123 32c97777b5 fix(oidc/bcl): jti replay-cache + iat freshness check (HIGH-3 closure)
Closes HIGH-3 of the 2026-05-10 audit. Pre-fix the BCL handler
accepted any logout_token whose iat + jti were syntactically present
but never checked (a) that iat fell within a skew window or (b) that
jti hadn't been seen before. A captured logout_token was replayable
indefinitely; once CRIT-2 was fixed, every replay would revoke the
user's current sessions — persistent DoS. RFC 9700 §2.7 + OIDC BCL
1.0 §2.5 require jti replay defense.

- Migration 000040_bcl_replay_cache: oidc_bcl_consumed_jtis table with
  composite PK on (jti, issuer_url) — RFC 7519 §4.1.7 per-issuer
  uniqueness — and an expires_at index for the GC sweep.

- repository.BCLReplayRepository interface + ErrBCLJTIAlreadyConsumed
  sentinel. Postgres impl uses INSERT...ON CONFLICT DO NOTHING
  RETURNING true for atomic single-use semantics in one round-trip.

- handler.DefaultBCLVerifier gains WithMaxAge + nowFn clock seam. iat
  freshness check rejects tokens whose iat is in the future beyond
  max-age OR stale beyond it. Verifier signature extended:
  Verify(ctx, jwt) (iss, sub, sid, jti string, iat int64, err error).

- handler.AuthSessionOIDCHandler gains BCLReplayConsumer (interface)
  + WithBCLReplayConsumer(consumer, maxAge) setter. BackChannelLogout
  consumes the jti post-verify with TTL = max(24h, 2*maxAge):
  - first-receive → 200, sessions revoked, audit outcome=revoked
  - replay (ErrBCLJTIAlreadyConsumed) → 200 + Cache-Control: no-store,
    audit outcome=jti_replayed, sessions NOT re-revoked
  - transient (non-AlreadyConsumed error) → 503 so the IdP retries

- internal/scheduler/scheduler.go: SetBCLReplayGarbageCollector wires
  SweepExpired into the existing session-GC tick (no separate ticker
  for short-lived replay rows).

- cmd/server/main.go: bclMaxAge from cfg.Auth.OIDCBCLMaxAgeSeconds
  (default 60s, env CERTCTL_OIDC_BCL_MAX_AGE_SECONDS); bclReplayRepo
  wired into the verifier + handler + scheduler.

- Three regression tests in internal/api/handler/bcl_replay_test.go:
  TestBackChannelLogout_FirstReceiveConsumesJTI,
  TestBackChannelLogout_ReplayedJTIReturns200WithAudit,
  TestBackChannelLogout_TransientConsumeFailureReturns503.

- internal/api/handler/auth_session_oidc_test.go: stubBCLVerifier
  gains jti + iat fields; existing TestBackChannelLogout_* tests
  rewritten for the new Verify return.

Verification gate green: gofmt clean, go vet clean, go test -short
-count=1 on internal/api/handler / internal/api/router /
internal/scheduler / cmd/server / internal/auth/oidc /
internal/auth/breakglass — all pass.

CRIT-1..CRIT-5 + HIGH-1 + HIGH-2 + HIGH-3 of the 2026-05-10 audit
now closed on this branch. Spec at
cowork/auth-bundles-fixes-2026-05-10/07-high-3-bcl-replay-defense.md.

Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-3
2026-05-10 20:53:29 +00:00

37 lines
1.3 KiB
PL/PgSQL

-- 000040_bcl_replay_cache.up.sql
-- Audit 2026-05-10 HIGH-3 closure: BCL logout_token replay defense.
--
-- Pre-fix, the BCL handler (auth_session_oidc.go::BackChannelLogout)
-- required `iat != 0` and `jti != ""` but never (a) checked iat
-- freshness against a skew window, or (b) checked jti against a
-- consumed-set. A captured logout_token was replayable indefinitely;
-- once CRIT-2 was fixed, every replay would revoke the user's current
-- sessions — persistent DoS.
--
-- RFC 9700 §2.7 + OIDC BCL 1.0 §2.5 require jti replay defense.
--
-- This table stores accepted (jti, issuer) pairs with a TTL. The
-- handler's ConsumeJTI call uses INSERT...ON CONFLICT DO NOTHING
-- semantics for atomic single-use. The scheduler GC loop sweeps
-- expired rows.
--
-- Composite PK on (jti, issuer_url) because OIDC `jti` uniqueness is
-- per-issuer per RFC 7519 §4.1.7 — a Keycloak jti=abc and an Auth0
-- jti=abc are distinct events.
BEGIN;
CREATE TABLE IF NOT EXISTS oidc_bcl_consumed_jtis (
jti TEXT NOT NULL,
issuer_url TEXT NOT NULL,
consumed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (jti, issuer_url)
);
-- TTL index for the GC sweep (`WHERE expires_at < now()`).
CREATE INDEX IF NOT EXISTS idx_oidc_bcl_consumed_jtis_expires
ON oidc_bcl_consumed_jtis (expires_at);
COMMIT;