Browsers (Accept: text/html) refused for state_not_bound, state_already_launched or state_already_used get the handoff page linking to return_to, re-checked against the app's allowed origins; other callers keep the JSON code. A callback with no binding denies the attempt its signed state names (pending only). The memory store prunes a use record at exp + 30 s, as the state itself is accepted that long; the schema comment says the same. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
export const CREATE_SCHEMA_SQL = `
|
|
-- Runtime app registration (POST /v1/apps) minted a client_secret_hash that
|
|
-- nothing ever verified, and the registered_apps table it fed was
|
|
-- unreachable dead code even after that writer was deleted: every HTTP route
|
|
-- already enumerates the four built-in app slugs in its own JSON schema, so a
|
|
-- registered row could never be resolved from a request (R21.3). Drop the
|
|
-- table on any store built before this schema too.
|
|
DROP TABLE IF EXISTS registered_apps;
|
|
|
|
CREATE TABLE IF NOT EXISTS accounts (
|
|
id TEXT PRIMARY KEY,
|
|
created_at TIMESTAMPTZ NOT NULL,
|
|
last_seen_at TIMESTAMPTZ NOT NULL,
|
|
display_name TEXT,
|
|
primary_email TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS linked_identities (
|
|
id TEXT PRIMARY KEY,
|
|
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
provider TEXT NOT NULL,
|
|
provider_user_id TEXT NOT NULL,
|
|
username TEXT,
|
|
display_name TEXT,
|
|
primary_email TEXT,
|
|
access_token_encrypted TEXT,
|
|
refresh_token_encrypted TEXT,
|
|
token_expires_at TIMESTAMPTZ,
|
|
scopes TEXT NOT NULL DEFAULT '',
|
|
profile_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at TIMESTAMPTZ NOT NULL,
|
|
updated_at TIMESTAMPTZ NOT NULL,
|
|
UNIQUE(provider, provider_user_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS linked_identities_account_id_idx
|
|
ON linked_identities(account_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
app_slug TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL,
|
|
last_seen_at TIMESTAMPTZ NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
claims_json JSONB NOT NULL,
|
|
access_revision INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS sessions_account_id_idx
|
|
ON sessions(account_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth_attempts (
|
|
handle TEXT PRIMARY KEY,
|
|
app_slug TEXT NOT NULL,
|
|
provider TEXT NOT NULL,
|
|
mode TEXT NOT NULL,
|
|
return_to TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
completed_at TIMESTAMPTZ,
|
|
consumed_at TIMESTAMPTZ,
|
|
denial_code TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS auth_attempts_lookup_idx
|
|
ON auth_attempts(app_slug, expires_at, status);
|
|
|
|
CREATE TABLE IF NOT EXISTS private_command_receipts (
|
|
app_slug TEXT NOT NULL,
|
|
idempotency_key TEXT NOT NULL,
|
|
request_fingerprint TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
content_schema TEXT NOT NULL,
|
|
envelope_base64 TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
PRIMARY KEY (app_slug, idempotency_key)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS private_command_receipts_expiry_idx
|
|
ON private_command_receipts(expires_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS pending_links (
|
|
id TEXT PRIMARY KEY,
|
|
app_slug TEXT NOT NULL,
|
|
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
provider TEXT NOT NULL,
|
|
provider_user_id TEXT NOT NULL,
|
|
identity_json JSONB NOT NULL,
|
|
return_to TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
consumed_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth_completions (
|
|
code TEXT PRIMARY KEY,
|
|
app_slug TEXT NOT NULL,
|
|
provider TEXT NOT NULL,
|
|
mode TEXT NOT NULL,
|
|
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
return_to TEXT NOT NULL,
|
|
payload_json JSONB NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
consumed_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS auth_completions_lookup_idx
|
|
ON auth_completions(app_slug, expires_at, consumed_at);
|
|
|
|
-- Correlation handle for the browser_completion handoff. Before the
|
|
-- caller-identity cut, the completion code itself was the caller-chosen
|
|
-- attemptId (an unauthenticated account-takeover primitive); the code is now
|
|
-- always minted by the store, and attempt_id lets the private command plane
|
|
-- redeem the completion it created without ever needing the code.
|
|
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS attempt_id TEXT;
|
|
|
|
-- The connection target the signed state named (connection.targetId). Redeem
|
|
-- compares it against the target the redeeming app states.
|
|
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS target_id TEXT;
|
|
|
|
-- One attempt handle may bind at most one unconsumed completion (R21.1): a
|
|
-- second completion for the same handle would let the by-attempt UPDATE in
|
|
-- consumeAuthCompletionByAttempt match more than one row, silently consuming
|
|
-- and discarding all but the one it returns. The predicate is partial rather
|
|
-- than a plain UNIQUE(app_slug, attempt_id) because a NULL attempt_id (no
|
|
-- correlation handle) and a *consumed*, already-settled completion must not
|
|
-- collide with a later one.
|
|
DROP INDEX IF EXISTS auth_completions_attempt_idx;
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx
|
|
ON auth_completions(app_slug, attempt_id)
|
|
WHERE attempt_id IS NOT NULL AND consumed_at IS NULL;
|
|
|
|
-- One use per (state jti, phase): a signed OAuth state launches once and
|
|
-- completes once. The first INSERT for a pair wins; ON CONFLICT DO NOTHING
|
|
-- reports every later claim as no row. The Postgres store deletes nothing, so rows
|
|
-- stay like consumed auth_completions; a state is accepted until expires_at + 30 s
|
|
-- of skew, so a row may be pruned only after that and is never consulted again.
|
|
CREATE TABLE IF NOT EXISTS oauth_state_uses (
|
|
jti TEXT NOT NULL,
|
|
phase TEXT NOT NULL,
|
|
app_slug TEXT NOT NULL,
|
|
used_at TIMESTAMPTZ NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
PRIMARY KEY (jti, phase)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS capability_grants (
|
|
id TEXT PRIMARY KEY,
|
|
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
scope_type TEXT NOT NULL,
|
|
scope_id TEXT,
|
|
capability TEXT NOT NULL,
|
|
source TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
expires_at TIMESTAMPTZ,
|
|
note TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL,
|
|
updated_at TIMESTAMPTZ NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS capability_grants_lookup_idx
|
|
ON capability_grants(account_id, scope_type, scope_id, status);
|
|
|
|
CREATE TABLE IF NOT EXISTS entitlement_snapshots (
|
|
id TEXT PRIMARY KEY,
|
|
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
provider TEXT NOT NULL,
|
|
scope TEXT NOT NULL,
|
|
evaluated_at TIMESTAMPTZ NOT NULL,
|
|
is_allowed BOOLEAN NOT NULL,
|
|
reason_code TEXT NOT NULL,
|
|
reason_detail TEXT,
|
|
raw_summary_json JSONB NOT NULL,
|
|
UNIQUE(account_id, provider, scope)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS audit_events (
|
|
id TEXT PRIMARY KEY,
|
|
account_id TEXT,
|
|
session_id TEXT,
|
|
app_slug TEXT,
|
|
event_type TEXT NOT NULL,
|
|
event_payload_json JSONB NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS audit_events_lookup_idx
|
|
ON audit_events(account_id, session_id, app_slug, created_at);
|
|
`;
|
|
|
|
const schemaText = CREATE_SCHEMA_SQL.replace(/\r\n/g, "\n");
|
|
const createdTables = [...schemaText.matchAll(/^CREATE TABLE IF NOT EXISTS (\w+) \(\n([\s\S]*?)\n\);/gm)].map((match) => ({
|
|
table: match[1]!,
|
|
lines: match[2]!.split("\n").map((line) => line.trim().replace(/,$/, "")),
|
|
}));
|
|
|
|
/** Sorted and comma-separated, the form the schema check compares key columns in. */
|
|
function columnList(text: string): string {
|
|
return text.split(",").map((column) => column.trim()).sort().join(",");
|
|
}
|
|
|
|
/** The columns of a `PRIMARY KEY (a, b)` or `UNIQUE(a, b)` line, as a column list. */
|
|
function keyColumns(line: string, keyword: "PRIMARY KEY" | "UNIQUE"): string | undefined {
|
|
const match = new RegExp(`^${keyword}\\s*\\(([^)]*)\\)`).exec(line);
|
|
return match ? columnList(match[1]!) : undefined;
|
|
}
|
|
|
|
/*
|
|
* What every start must find before it serves, read from the SQL above so the
|
|
* two cannot drift. A start that applies the schema checks as well, because
|
|
* CREATE ... IF NOT EXISTS leaves a damaged table as it is. Indexes that only
|
|
* speed lookups are not required; the unique ones are, by shape, in
|
|
* REQUIRED_KEYS.
|
|
*
|
|
* Out of scope, each able to make a write fail on a database that passes the
|
|
* check: CHECK constraints; triggers; row-level security; unique indexes
|
|
* beyond the required keys, or stricter than them, including the leftovers of
|
|
* an interrupted concurrent index build (a valid extra one refuses rows the
|
|
* store expects to write, and even an invalid one that is ready is enforced
|
|
* on writes); NOT NULL columns the schema does not declare and that have no
|
|
* default, which the store's INSERTs leave out; and SELECT granted column by
|
|
* column when a statement reads `SELECT *` or `RETURNING *`, which needs
|
|
* SELECT on every column the table has, not only the declared ones.
|
|
*/
|
|
|
|
/** Every table the schema creates. */
|
|
export const REQUIRED_TABLES = createdTables.map(({ table }) => table);
|
|
|
|
/**
|
|
* Every column a created table declares and every column the schema adds to
|
|
* an existing table, each with its declared type, and whether it is nullable:
|
|
* the store reads and writes values as that type, and leaves a nullable
|
|
* column null, so a column retyped or made NOT NULL under it fails its writes.
|
|
*/
|
|
export const REQUIRED_COLUMNS = [
|
|
...createdTables.flatMap(({ table, lines }) =>
|
|
lines
|
|
.filter((line) => !/^(?:PRIMARY KEY|UNIQUE)\b/.test(line))
|
|
.map((line) => {
|
|
const [, column, type] = /^(\w+) (\w+)/.exec(line)!;
|
|
return { table, column: column!, type: type!, nullable: !/\b(?:NOT NULL|PRIMARY KEY)\b/.test(line) };
|
|
})
|
|
),
|
|
...[...schemaText.matchAll(/^ALTER TABLE (\w+) ADD COLUMN IF NOT EXISTS (\w+) (\w+)([^;]*);/gm)].map((match) => ({
|
|
table: match[1]!,
|
|
column: match[2]!,
|
|
type: match[3]!,
|
|
nullable: !/\b(?:NOT NULL|PRIMARY KEY)\b/.test(match[4]!),
|
|
})),
|
|
];
|
|
|
|
/**
|
|
* Every unique key the schema makes: the primary keys and unique constraints
|
|
* of the created tables, and each unique index it creates, with that index's
|
|
* predicate as the schema writes it (empty for none). Any index of the right
|
|
* shape serves, whatever its name; PostgresStore.checkSchema says what the
|
|
* right shape is.
|
|
*/
|
|
export const REQUIRED_KEYS = [
|
|
...createdTables.flatMap(({ table, lines }) =>
|
|
lines.flatMap((line) => {
|
|
const columns =
|
|
/^(\w+) .*\bPRIMARY KEY\b/.exec(line)?.[1] ?? keyColumns(line, "PRIMARY KEY") ?? keyColumns(line, "UNIQUE");
|
|
return columns ? [{ table, columns, predicate: "" }] : [];
|
|
})
|
|
),
|
|
...[...schemaText.matchAll(/^CREATE UNIQUE INDEX IF NOT EXISTS \w+\s+ON (\w+)\(([^)]*)\)(?:\s+WHERE ([^;]+))?;/gm)].map((match) => ({
|
|
table: match[1]!,
|
|
columns: columnList(match[2]!),
|
|
predicate: (match[3] ?? "").replace(/\s+/g, " ").trim(),
|
|
})),
|
|
];
|