mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 19:06:33 +00:00
2a00775481
* feat(oauth): schema + storage layer for OAuth 2.1 server (TASK-1023, sub-PR A of TASK-951)
First of 5 sub-PRs landing the OAuth 2.1 authorization server in
PLAN-943. This one is foundation only — no HTTP exposure, no fosite
import, no public surface change.
Schema (5 tables, parallel SQLite + Postgres migrations):
- oauth_clients — RFC 7591 Dynamic Client Registration; public clients only for v1
- oauth_authorization_codes — short-lived codes for the auth-code grant
- oauth_access_tokens — opaque HMAC; subject denormalized for fast user-bound queries
- oauth_refresh_tokens — same shape; access_token_signature link + request_id chain
- oauth_pkce_requests — PKCE session keyed by auth-code signature
Storage layer (internal/store/oauth.go):
- 12 public methods covering fosite's ClientManager + CoreStorage +
PKCERequestStorage + TokenRevocationStorage interface shapes,
using pad-internal types so the package stays fosite-free.
- Three sentinel errors (ErrOAuthNotFound, ErrOAuthInvalidatedCode,
ErrOAuthInactiveToken) that sub-PR B's adapter maps to the
matching fosite errors.
- request_id IS the chain identifier (fosite preserves it across
rotations — handler/oauth2/flow_refresh.go:86), so family
revocation is a single indexed UPDATE rather than a separate
chain_id column.
14 tests covering: client CRUD + idempotent delete + empty-slice
normalization, auth-code create/get/invalidate (including the
"return payload alongside ErrInvalidatedCode" contract fosite
relies on for family revocation), access-token CRUD + delete,
refresh CRUD + RotateRefreshToken (single-row flip), refresh-token
family revocation (entire chain via request_id, leaves other
chains untouched), access-token family revocation, PKCE CRUD, and
required-field validation.
Both backends share test bodies via testStore(t); set
PAD_TEST_POSTGRES_URL=... to run the same suite against Postgres.
Out of scope for this PR (subsequent sub-PRs):
- fosite import + adapter (sub-PR B / TASK-1024)
- DCR + authorize + token endpoints (sub-PR C / TASK-1025)
- revoke + introspect endpoints (sub-PR D / TASK-1026)
- MCPBearerAuth OAuth integration (sub-PR E / TASK-1027)
* fix(oauth): always insert active=true; drop broken zero-value Active override per Codex review (round 1)
Codex round 1 caught a P1 in insertOAuthRequestRow:
active := defaultActive
if req.Active != defaultActive {
active = req.Active // <- zero-value collides
}
When defaultActive=true and req.Active=false (the zero value), this
branch fires and the row is stored with active=FALSE — silently
producing immediately-revoked tokens. Any sub-PR B adapter that
built an OAuthRequest without explicitly setting Active=true would
ship broken.
Fix: hardcode active=TRUE on insert. Drop the defaultActive
parameter (it's always true for the three flagged tables; PKCE
has no active column). Pre-seeding inactive isn't a supported flow
— fosite never does it, and tests that need a revoked row do
Create + Invalidate / Rotate / RevokeFamily as a two-step.
Regression test TestOAuth_Insert_AlwaysActive constructs an
OAuthRequest with zero-value Active and asserts the row is
readable as active for all three table types (codes, access,
refresh). Without the fix the test fails on the first GetAccessToken
call with ErrOAuthInactiveToken.
* fix(oauth): RotateRefreshToken revokes both refresh + access families per Codex review (round 2)
Codex round 2 caught: my RotateRefreshToken only marked the named
refresh row inactive, but fosite's reference MemoryStore.RotateRefreshToken
(storage/memory.go:497-504) revokes BOTH the refresh family AND the
access family for the grant's request_id. Without this, every access
token issued before a refresh remained active until TTL — defeating
the rotation's invalidation contract.
Fix: RotateRefreshToken now delegates to RevokeRefreshTokenFamily +
RevokeAccessTokenFamily (both already existed). The signatureToRotate
parameter becomes vestigial — fosite passes it but the family revoke
catches every chain member regardless of which row triggered the
rotation. The new pair fosite immediately issues via
CreateAccessTokenSession + CreateRefreshTokenSession inherits the
same request_id (flow_refresh.go:86) and lands active=TRUE per the
round-1 hardcode, so the net post-rotation state is "all old rows
in this grant inactive, the new pair active."
Test rewrite: TestOAuth_RotateRefreshToken_FlipsActiveOnSingleRow
asserted the OPPOSITE behavior (only one row touched) — that was
the original bug. Replaced with TestOAuth_RotateRefreshToken_RevokesEntireGrant
which seeds a refresh + access pair in the same chain, plus a
distinct unrelated grant, then asserts after rotation:
- old refresh + old access both inactive
- unrelated grant untouched (request_id-scoped)
* fix(oauth): DeleteOAuthClient cascades dependent rows in a tx per Codex review (round 3)
Round 3 finding: DeleteOAuthClient errored with FK constraint
violation for any client that had ever issued a grant. The
migrations declare client_id FKs without ON DELETE CASCADE — by
design, so a stray DELETE FROM oauth_clients elsewhere fails
loudly rather than silently nuking grants — but that meant the
"officially supported" delete path was unusable.
Fix: DeleteOAuthClient now runs five sequential DELETEs inside a
single transaction:
1. oauth_pkce_requests
2. oauth_refresh_tokens
3. oauth_access_tokens
4. oauth_authorization_codes
5. oauth_clients
Order matters (children before parent) because the FKs aren't
cascading. The tx makes it atomic — if any step fails, nothing's
deleted, so we never leave a half-deleted client. Idempotent
because every WHERE matches nothing on a non-existent client.
Test TestOAuth_DeleteOAuthClient_CascadesDependentRows seeds a row
in each of the four dependent tables, deletes the client, and
asserts ErrOAuthNotFound on every dependent row + the client itself.
Without the fix this fails on the first DELETE FROM oauth_clients
with an FK constraint violation.
* fix(oauth): SELECT FOR UPDATE row lock in DeleteOAuthClient on Postgres per Codex review (round 4)
Codex round 4 caught a Postgres race in DeleteOAuthClient: the
five-DELETE cascade is atomic, but between the child-row deletes
and the parent delete, a concurrent fosite handler can insert a
fresh grant/token referencing the same client_id. The parent
DELETE then fails with an FK violation and the whole tx rolls
back — the cascade is correct, but unreliable under concurrent
OAuth issuance.
Fix: take SELECT id FROM oauth_clients WHERE id = ? FOR UPDATE
as the very first statement in the tx (Postgres only). The
exclusive row-level lock blocks any concurrent statement that
tries to read the client row — which fosite does on FK resolution
during grant/token inserts — until our tx commits.
Skipped on SQLite because:
(a) BEGIN IMMEDIATE serializes the entire write workload globally
(DSN configures _txlock=immediate per store.go), so the race
doesn't exist.
(b) FOR UPDATE syntax isn't reliably accepted across SQLite
drivers.
ErrNoRows on the lock query is treated as "client doesn't exist
yet" — the subsequent DELETEs match nothing and the call remains
idempotent. Tests still pass on the SQLite path; the Postgres
path's race fix will be exercised by CI's PAD_TEST_POSTGRES_URL
runs and any future concurrency test we add.
169 lines
9.0 KiB
SQL
169 lines
9.0 KiB
SQL
-- Migration 048: OAuth 2.1 server tables (PLAN-943 TASK-951 sub-PR A).
|
|
--
|
|
-- Five tables backing the OAuth authorization server defined in
|
|
-- PLAN-943 TASK-951: registered DCR clients, authorization codes,
|
|
-- access tokens, refresh tokens, PKCE request sessions. Schema
|
|
-- mirrors the storage interfaces fosite expects (handler/oauth2/storage.go,
|
|
-- handler/pkce/storage.go, client_manager.go in github.com/ory/fosite v0.49.0)
|
|
-- without importing fosite — this migration is pure SQL and the
|
|
-- storage layer in internal/store/oauth.go uses pad-internal types.
|
|
-- Sub-PR B wires fosite types over the top via adapter methods.
|
|
--
|
|
-- No table for token revocation: fosite's TokenRevocationStorage
|
|
-- semantics are satisfied by toggling each token row's `active` flag.
|
|
-- RevokeRefreshToken(request_id) walks the chain via the request_id
|
|
-- index (rotations preserve the originating Requester's ID, see
|
|
-- fosite handler/oauth2/flow_refresh.go:86).
|
|
|
|
-- ============================================================
|
|
-- 1. Registered OAuth clients (RFC 7591 Dynamic Client Registration)
|
|
-- ============================================================
|
|
--
|
|
-- Public clients only for v1 (Claude Desktop / Cursor / etc. — they
|
|
-- can't keep a secret). Confidential clients can be added later by
|
|
-- introducing a `client_secret` column; we keep `public` as a flag
|
|
-- now so the schema doesn't need to change.
|
|
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
id TEXT PRIMARY KEY, -- client_id (random ID, opaque)
|
|
name TEXT NOT NULL, -- human-readable, surfaced on consent screen
|
|
redirect_uris TEXT NOT NULL, -- JSON array; OAuth 2.1 requires exact match
|
|
grant_types TEXT NOT NULL, -- JSON array, e.g. ["authorization_code","refresh_token"]
|
|
response_types TEXT NOT NULL, -- JSON array, e.g. ["code"]
|
|
token_endpoint_auth_method TEXT NOT NULL DEFAULT 'none', -- "none" for public clients (PKCE-only)
|
|
scopes TEXT NOT NULL DEFAULT '[]', -- JSON array, the scopes this client is allowed to request
|
|
public INTEGER NOT NULL DEFAULT 1, -- 1=public (no secret), 0=confidential (future)
|
|
logo_url TEXT, -- optional, surfaced on consent screen
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS oauth_clients_created_at_idx
|
|
ON oauth_clients(created_at);
|
|
|
|
-- ============================================================
|
|
-- 2. Authorization codes (one per /authorize → /token exchange)
|
|
-- ============================================================
|
|
--
|
|
-- The `signature` is the HMAC-derived lookup key for the code;
|
|
-- the actual code value is never stored (so a DB read can't replay).
|
|
-- `request_form` is the URL-encoded /authorize query string; fosite
|
|
-- uses it to verify PKCE on /token exchange (the code_challenge is
|
|
-- in here). `session_data` is the marshalled session struct, which
|
|
-- pad will define in sub-PR B (subject = pad user ID, etc.).
|
|
CREATE TABLE IF NOT EXISTS oauth_authorization_codes (
|
|
signature TEXT PRIMARY KEY, -- code's HMAC signature
|
|
request_id TEXT NOT NULL, -- fosite Requester.GetID() — chain root for this grant
|
|
requested_at TEXT NOT NULL, -- ISO8601
|
|
client_id TEXT NOT NULL,
|
|
scopes TEXT NOT NULL DEFAULT '', -- space-separated, requested
|
|
granted_scopes TEXT NOT NULL DEFAULT '', -- space-separated, after consent
|
|
request_form TEXT NOT NULL DEFAULT '', -- URL-encoded form data
|
|
session_data TEXT NOT NULL DEFAULT '', -- JSON-encoded session struct
|
|
audience TEXT NOT NULL DEFAULT '', -- space-separated, requested (RFC 8707 resource= values)
|
|
granted_audience TEXT NOT NULL DEFAULT '', -- space-separated, after binding
|
|
active INTEGER NOT NULL DEFAULT 1, -- 0 once exchanged or invalidated
|
|
FOREIGN KEY (client_id) REFERENCES oauth_clients(id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS oauth_codes_request_id_idx
|
|
ON oauth_authorization_codes(request_id);
|
|
CREATE INDEX IF NOT EXISTS oauth_codes_requested_at_idx
|
|
ON oauth_authorization_codes(requested_at);
|
|
|
|
-- ============================================================
|
|
-- 3. Access tokens (opaque HMAC, audience-bound per RFC 8707)
|
|
-- ============================================================
|
|
--
|
|
-- `subject` is denormalized from session_data so user-bound lookups
|
|
-- (audit, "list active tokens for user X") don't have to JSON-parse
|
|
-- every row. fosite doesn't query by subject itself; the column is
|
|
-- here for our admin / connected-apps surfaces (TASK-954).
|
|
CREATE TABLE IF NOT EXISTS oauth_access_tokens (
|
|
signature TEXT PRIMARY KEY,
|
|
request_id TEXT NOT NULL, -- preserved across refresh rotations (chain identifier)
|
|
requested_at TEXT NOT NULL,
|
|
client_id TEXT NOT NULL,
|
|
scopes TEXT NOT NULL DEFAULT '',
|
|
granted_scopes TEXT NOT NULL DEFAULT '',
|
|
request_form TEXT NOT NULL DEFAULT '',
|
|
session_data TEXT NOT NULL DEFAULT '',
|
|
audience TEXT NOT NULL DEFAULT '',
|
|
granted_audience TEXT NOT NULL DEFAULT '',
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
subject TEXT NOT NULL DEFAULT '', -- denormalized from session_data for fast subject-bound queries
|
|
FOREIGN KEY (client_id) REFERENCES oauth_clients(id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS oauth_access_request_id_idx
|
|
ON oauth_access_tokens(request_id);
|
|
CREATE INDEX IF NOT EXISTS oauth_access_subject_idx
|
|
ON oauth_access_tokens(subject);
|
|
CREATE INDEX IF NOT EXISTS oauth_access_requested_at_idx
|
|
ON oauth_access_tokens(requested_at);
|
|
|
|
-- ============================================================
|
|
-- 4. Refresh tokens (rotated single-use; theft-detection by family)
|
|
-- ============================================================
|
|
--
|
|
-- `access_token_signature` links the refresh to the access token it
|
|
-- was issued alongside. fosite passes both signatures into
|
|
-- CreateRefreshTokenSession so the storage can chain them, useful
|
|
-- when revoking a refresh token to also invalidate its sibling
|
|
-- access token.
|
|
--
|
|
-- `request_id` is the chain identifier for theft detection: every
|
|
-- token in a rotation chain (initial → rotated → rotated-again …)
|
|
-- shares the same request_id. RevokeRefreshToken(request_id) walks
|
|
-- this column and marks every chain member inactive — the "revoke
|
|
-- the whole family on replay" rule from the OAuth 2.1 BCP.
|
|
CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
|
|
signature TEXT PRIMARY KEY,
|
|
request_id TEXT NOT NULL, -- chain root; family revocation walks this index
|
|
access_token_signature TEXT, -- nullable; links to oauth_access_tokens.signature
|
|
requested_at TEXT NOT NULL,
|
|
client_id TEXT NOT NULL,
|
|
scopes TEXT NOT NULL DEFAULT '',
|
|
granted_scopes TEXT NOT NULL DEFAULT '',
|
|
request_form TEXT NOT NULL DEFAULT '',
|
|
session_data TEXT NOT NULL DEFAULT '',
|
|
audience TEXT NOT NULL DEFAULT '',
|
|
granted_audience TEXT NOT NULL DEFAULT '',
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
subject TEXT NOT NULL DEFAULT '',
|
|
FOREIGN KEY (client_id) REFERENCES oauth_clients(id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS oauth_refresh_request_id_idx
|
|
ON oauth_refresh_tokens(request_id);
|
|
CREATE INDEX IF NOT EXISTS oauth_refresh_subject_idx
|
|
ON oauth_refresh_tokens(subject);
|
|
CREATE INDEX IF NOT EXISTS oauth_refresh_requested_at_idx
|
|
ON oauth_refresh_tokens(requested_at);
|
|
|
|
-- ============================================================
|
|
-- 5. PKCE request sessions
|
|
-- ============================================================
|
|
--
|
|
-- fosite's PKCE handler stores the request alongside the auth code
|
|
-- (signature == auth code's signature) so the code_challenge from
|
|
-- /authorize can be verified against the code_verifier from /token.
|
|
-- Same shape as oauth_authorization_codes; separate table because
|
|
-- fosite uses a distinct interface (PKCERequestStorage) and the
|
|
-- lifecycle differs slightly — PKCE rows are deleted on /token
|
|
-- exchange success rather than flagged inactive.
|
|
CREATE TABLE IF NOT EXISTS oauth_pkce_requests (
|
|
signature TEXT PRIMARY KEY,
|
|
request_id TEXT NOT NULL,
|
|
requested_at TEXT NOT NULL,
|
|
client_id TEXT NOT NULL,
|
|
scopes TEXT NOT NULL DEFAULT '',
|
|
granted_scopes TEXT NOT NULL DEFAULT '',
|
|
request_form TEXT NOT NULL DEFAULT '', -- contains code_challenge + code_challenge_method
|
|
session_data TEXT NOT NULL DEFAULT '',
|
|
audience TEXT NOT NULL DEFAULT '',
|
|
granted_audience TEXT NOT NULL DEFAULT '',
|
|
FOREIGN KEY (client_id) REFERENCES oauth_clients(id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS oauth_pkce_request_id_idx
|
|
ON oauth_pkce_requests(request_id);
|