-- ============================================================================ -- Krow — OAuth 2.1 authorization codes -- -- Phase 3, migration 2 of 3. One row per authorization code issued: the short -- window between a person clicking Approve and the client exchanging the code -- for a token. -- -- A row here is a bearer credential with a fuse. Three properties make it safe, -- and all three are enforced by this schema rather than by the code that uses -- it: -- -- SINGLE-USE consumed_at, set by the same UPDATE that reads the row. A -- code redeemed twice is an attacker replaying a code they -- intercepted, and the second attempt must fail. -- SHORT-LIVED expires_at, minutes not hours. The code is in transit through -- a browser redirect, which is the least trustworthy hop in the -- flow. -- BOUND to client, redirect_uri, user, scope, resource and PKCE -- challenge. Every one of those is re-verified at the token -- endpoint, so a code stolen from one context cannot be spent -- in another. -- -- THE CODE ITSELF IS NEVER STORED. code_hash holds SHA-256, exactly as -- sessions.token_hash does, so a dump of this table cannot be replayed. -- -- Target schema: public. No system schema is read or written. -- ============================================================================ SET search_path = public; CREATE TABLE oauth_grants ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- SHA-256 of the authorization code, lowercase hex. The CHECK pins the -- format so a caller cannot accidentally store a raw code here: a raw code is -- base64url of random bytes and fails this pattern. Same guard, same -- reasoning as sessions_token_hash_sha256 in 000004. code_hash text NOT NULL, client_id text NOT NULL REFERENCES oauth_clients (client_id) ON DELETE CASCADE, -- Who approved. ON DELETE CASCADE: a deleted user must not leave a code -- behind that could still be exchanged for a token authenticating as them. user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE, -- The tenant, denormalised from the user row at issue time. Carried for -- auditing only. It is NEVER read back as the authority on tenancy — -- identity is rebuilt from the live user row at every token validation, so a -- user who moved organisation does not keep the old one. See -- oauth.Authenticator. org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, -- Re-verified at the token endpoint. RFC 6749 requires the redirect_uri -- presented at exchange to match the one presented at authorization; without -- this column there is nothing to match against. redirect_uri text NOT NULL, scopes text[] NOT NULL, -- RFC 8707. The MCP server this code is being obtained for. Carried into the -- access token's audience, which is what makes a token issued for one -- resource unusable at another. resource text NOT NULL, -- PKCE. Mandatory — the column is NOT NULL, so a code without a challenge -- cannot exist. OAuth 2.1 requires PKCE for public clients and this is where -- that requirement stops being advisory. code_challenge text NOT NULL, code_challenge_method text NOT NULL, created_date timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL, -- Set on redemption, in the same statement that reads the row. NULL means -- unspent. consumed_at timestamptz, CONSTRAINT oauth_grants_code_hash_key UNIQUE (code_hash), CONSTRAINT oauth_grants_code_hash_sha256 CHECK (code_hash ~ '^[0-9a-f]{64}$'), -- S256 only. `plain` is permitted by RFC 7636 and forbidden by OAuth 2.1 for -- public clients, because it makes the verifier recoverable from the -- challenge — which is the entire attack PKCE exists to stop. Refused at the -- schema level so no code path can relax it. CONSTRAINT oauth_grants_pkce_s256_only CHECK (code_challenge_method = 'S256'), -- A challenge is base64url of a 32-byte SHA-256 digest: 43 characters, no -- padding. Anything else is malformed. CONSTRAINT oauth_grants_challenge_shape CHECK (code_challenge ~ '^[A-Za-z0-9_-]{43}$'), CONSTRAINT oauth_grants_expires_after_created CHECK (expires_at > created_date), CONSTRAINT oauth_grants_scopes_present CHECK (array_length(scopes, 1) >= 1) ); -- The redemption path: look up by hash, check unspent and unexpired. The UNIQUE -- constraint above already provides this index. -- The sweep of dead rows. CREATE INDEX oauth_grants_expires_idx ON oauth_grants (expires_at); -- Revoking every outstanding code for a user, and the FK's own cascade check. CREATE INDEX oauth_grants_user_idx ON oauth_grants (user_id); COMMENT ON TABLE oauth_grants IS 'OAuth authorization codes: single-use, short-lived, and bound to client, ' 'redirect_uri, user, scope, resource and PKCE challenge. The raw code is ' 'never stored — only SHA-256 of it.'; COMMENT ON COLUMN oauth_grants.code_hash IS 'Lowercase hex SHA-256 of the authorization code. Never the code.'; COMMENT ON COLUMN oauth_grants.consumed_at IS 'Set by the redemption UPDATE itself, so a code cannot be spent twice.'; COMMENT ON COLUMN oauth_grants.org_id IS 'The tenant at issue time, for audit only. Tenancy is re-read from the live ' 'user row on every token validation and is never taken from here.';