Files
krow_backend/migrations/000014_oauth_tokens.up.sql
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

122 lines
5.6 KiB
SQL

-- ============================================================================
-- Krow — OAuth 2.1 access and refresh tokens
--
-- Phase 3, migration 3 of 3. One row per issued token, access and refresh
-- alike, because they share every lifecycle question worth asking: is it known,
-- has it expired, has it been revoked, whose is it, and what may it reach.
--
-- THE RAW TOKEN IS NEVER STORED. token_hash holds SHA-256, and the CHECK below
-- refuses anything that is not 64 hex characters — so a raw token, which is
-- base64url of random bytes, cannot physically be written to this column. This
-- mirrors sessions.token_hash from 000004 exactly, and for the same reason: a
-- dump of this table must not be replayable as a login.
--
-- TOKEN FAMILIES AND REUSE DETECTION
--
-- Refresh tokens rotate: spending one issues its replacement and consumes the
-- old one. family_id ties a lineage together, which is what makes theft
-- detectable. If a consumed refresh token is presented again, either the
-- legitimate client is retrying or an attacker is replaying a stolen token, and
-- there is no way to tell which. OAuth 2.1's answer is to assume the worse case
-- and revoke the entire family — the attacker loses access, and the legitimate
-- client is forced through a fresh authorization it can complete. Without
-- family_id the best available response is to revoke nothing.
--
-- Target schema: public. No system schema is read or written.
-- ============================================================================
SET search_path = public;
CREATE TABLE oauth_tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- SHA-256 of the token, lowercase hex. Never the token.
token_hash text NOT NULL,
-- 'access' or 'refresh'. One table, because the questions asked of both are
-- the same; a type column rather than two tables, because a lookup that had
-- to try two tables would eventually try only one.
token_type text NOT NULL,
-- Rotation lineage. Every token minted from the same authorization shares a
-- family, so reuse detection can revoke all of them at once.
family_id uuid NOT NULL,
client_id text NOT NULL REFERENCES oauth_clients (client_id) ON DELETE CASCADE,
-- ON DELETE CASCADE: a deleted user must not leave a live token behind.
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
-- Denormalised at issue time for auditing and for cheap per-tenant queries
-- ("what is this organisation's Claude usage"). NEVER the authority on
-- tenancy: identity is rebuilt from the live user row at validation, so a
-- suspended or moved user is caught on their next call rather than at expiry.
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
scopes text[] NOT NULL,
-- RFC 8707. The MCP server this token is for. Validation requires it to
-- match this deployment's canonical resource URI, which is what stops a
-- token minted for another service being spent here.
audience text NOT NULL,
created_date timestamptz NOT NULL DEFAULT now(),
-- Sliding is not a concept here: an access token expires and the client
-- refreshes. expires_at is the only deadline an access token has.
expires_at timestamptz NOT NULL,
-- Set when a refresh token is spent. A consumed refresh token presented
-- again is the reuse signal that revokes the family.
consumed_at timestamptz,
-- Set by revocation: an explicit disconnect, a family revocation, or a
-- suspended account being cleaned up.
revoked_at timestamptz,
revoked_reason text,
last_used_at timestamptz,
CONSTRAINT oauth_tokens_token_hash_key UNIQUE (token_hash),
CONSTRAINT oauth_tokens_token_hash_sha256 CHECK (token_hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT oauth_tokens_type_check CHECK (token_type IN ('access', 'refresh')),
CONSTRAINT oauth_tokens_expires_after_created CHECK (expires_at > created_date),
CONSTRAINT oauth_tokens_scopes_present CHECK (array_length(scopes, 1) >= 1),
CONSTRAINT oauth_tokens_audience_present CHECK (length(btrim(audience)) > 0)
);
-- Validation reads by hash on every single MCP request; the UNIQUE constraint
-- above is that index.
-- Reuse detection and family revocation: given one token, revoke its lineage.
CREATE INDEX oauth_tokens_family_idx ON oauth_tokens (family_id);
-- "Which apps has this user connected", and revoking everything for a user
-- whose account was suspended. Partial, because revoked rows are never the
-- answer to either question.
CREATE INDEX oauth_tokens_user_active_idx ON oauth_tokens (user_id)
WHERE revoked_at IS NULL;
-- The sweep of expired rows.
CREATE INDEX oauth_tokens_expires_idx ON oauth_tokens (expires_at);
COMMENT ON TABLE oauth_tokens IS
'OAuth access and refresh tokens. Stores SHA-256 of each token and never the '
'token itself. family_id ties a rotation lineage together so that replay of a '
'consumed refresh token can revoke the whole family.';
COMMENT ON COLUMN oauth_tokens.token_hash IS
'Lowercase hex SHA-256 of the token. Never the token.';
COMMENT ON COLUMN oauth_tokens.family_id IS
'Rotation lineage. Presenting a consumed refresh token revokes every row '
'sharing this id — the OAuth 2.1 response to a possible stolen token.';
COMMENT ON COLUMN oauth_tokens.audience IS
'RFC 8707 resource indicator. Must match this deployment''s canonical MCP '
'resource URI at validation, or the token is refused.';
COMMENT ON COLUMN oauth_tokens.org_id IS
'The tenant at issue time, for audit and reporting only. Tenancy is re-read '
'from the live user row on every validation and is never taken from here.';