-- 993_jwt_revocations.sql -- v1.0.10 sécu item 6 — DB-backed JWT revocation ledger. -- -- Background : the platform already had a Redis-based TokenBlacklist -- (services/token_blacklist.go, T0174) that hashes the whole token -- and writes a Redis key with TTL. That works for the happy path — -- logout, refresh rotation — but has two gaps : -- 1. Redis restart wipes the blacklist. A token revoked 10s before -- a Redis crash becomes valid again until its natural expiry. -- 2. No way to revoke "session #3 of user X" from an admin UI : -- the blacklist is keyed by token-hash, not by jti. The admin -- doesn't have the user's token at hand. -- -- This table fixes both : durable per-jti revocations that survive -- Redis restarts, and a stable id (the jti claim) the admin UI can -- target. -- -- The middleware checks Redis first (fast path) and falls back to -- this table on miss (cache miss after restart). On revoke, both -- Redis and the table are written ; the worst case is "double check" -- on a known-revoked token, never "missed" revocation. CREATE TABLE IF NOT EXISTS public.jwt_revocations ( -- The JWT's `jti` claim. Stored as VARCHAR (not UUID) because the -- claim is a string per RFC 7519 ; we generate UUIDs but other -- callers may not. jti VARCHAR(64) PRIMARY KEY, user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, -- The token's natural expiry. Used by the cleanup cron to drop -- rows that no longer matter — once the token would have expired -- on its own, the revocation row is redundant. expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Optional free-text reason (e.g. "user-initiated logout", -- "admin revocation: lost device", "automatic: refresh rotation"). -- Surface in the user's "active sessions" UI so they understand -- why a session ended. reason VARCHAR(255), -- Who triggered the revocation. NULL = system / automatic -- (refresh rotation, expiry-driven), non-NULL = user or admin -- decision worth auditing. revoked_by UUID REFERENCES public.users(id) ON DELETE SET NULL ); COMMENT ON TABLE public.jwt_revocations IS 'Per-jti JWT revocation ledger. Survives Redis restarts. v1.0.10 sécu item 6.'; COMMENT ON COLUMN public.jwt_revocations.jti IS 'JWT ID claim (RFC 7519). Generated as UUID by JWTService.GenerateAccessToken / GenerateRefreshToken.'; COMMENT ON COLUMN public.jwt_revocations.expires_at IS 'Natural token expiry. Cleanup cron drops rows where expires_at < now() - safety_margin.'; -- Lookup by user_id : "list all revocations for this user" — admin -- audit screen. CREATE INDEX IF NOT EXISTS idx_jwt_revocations_user ON public.jwt_revocations(user_id); -- Cleanup index : the cron runs `DELETE FROM jwt_revocations WHERE -- expires_at < $1` ; partial index on already-expired rows would be -- empty during steady state, so a regular B-tree is what we want. CREATE INDEX IF NOT EXISTS idx_jwt_revocations_expires_at ON public.jwt_revocations(expires_at);