forgejo-mcp-broker/internal/store/migrations/0002_oauth_tables.sql
Ole-Morten Duesund e4a7baa0bc feat(store): OAuth tables migration (forgejo-mcp-broker-cpb)
Adds migrations/0002_oauth_tables.sql per design.md §4.2: clients,
auth_codes, access_tokens, refresh_tokens. Cascading foreign keys
guarantee that revoking a client tears down every dependent row, and
that a refresh token can never outlive its access token.

Storage choices:
- Broker access/refresh tokens stored as hex-encoded SHA-256 hashes;
  plaintext leaves the broker exactly once (when handed to the MCP
  client). Lookups by hash are O(log n) via the PK index.
- Forgejo tokens stored cleartext (subprocess spawning needs them).
  At-rest protection is the volume permissions + optional encrypted
  volume; application-layer encryption is tracked as backlog item -sd4.
- Timestamps are unix epoch INTEGERs, set by the application — keeps
  deadline comparisons trivial and lets phase 5c inject a test clock.
- Tables are not STRICT to stay consistent with the phase-1 broker_meta
  table; converting both is a future cleanup if we want it.

Tests verify column sets via PRAGMA table_info, expected indexes are
present, the FK CASCADE works in both directions (client → tokens, and
access_token → refresh_token), and the oauth_schema_version marker is
written. Existing migration-count assertions parameterised on
embeddedMigrationCount so adding a third migration only needs that
constant bumped.

Closes forgejo-mcp-broker-cpb.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:28:12 +02:00

74 lines
3.6 KiB
SQL

-- 0002_oauth_tables.sql
-- OAuth 2.1 authorization-server state per design.md §4.2.
--
-- Storage rules:
-- * Broker tokens (access, refresh) are stored as hex-encoded SHA-256 hashes.
-- Plaintext leaves the broker exactly once, when handed to the MCP client.
-- * Forgejo tokens are stored cleartext — the broker has to be able to use
-- them to spawn forgejo-mcp subprocesses. The SQLite file is therefore a
-- sensitive secret at rest; protect it via volume permissions and (in
-- production) an encrypted underlying volume.
-- * Timestamps are unix epoch seconds (INTEGER). Comparing deadlines as
-- ints is dramatically simpler than parsing ISO-8601 strings, and SQLite's
-- type affinity makes mixed-type rows easy to misuse.
-- * No DEFAULT clauses for timestamps — the application supplies them so a
-- test clock can be injected later (phase 5c).
CREATE TABLE clients (
client_id TEXT PRIMARY KEY,
client_secret TEXT, -- NULL for public clients (PKCE-only)
redirect_uris TEXT NOT NULL, -- JSON array of strings
metadata_json TEXT, -- raw RFC 7591 client-metadata blob, optional
created_at INTEGER NOT NULL,
last_used INTEGER -- bumped on successful authorize/token use
);
CREATE INDEX idx_clients_last_used ON clients(last_used);
CREATE TABLE auth_codes (
code TEXT PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES clients(client_id) ON DELETE CASCADE,
redirect_uri TEXT NOT NULL,
code_challenge TEXT NOT NULL,
code_challenge_method TEXT NOT NULL, -- only 'S256' accepted by the AS
scopes TEXT NOT NULL, -- space-separated
forgejo_access_token TEXT NOT NULL,
forgejo_refresh_token TEXT,
forgejo_token_expires_at INTEGER NOT NULL,
forgejo_user_id INTEGER NOT NULL,
forgejo_username TEXT NOT NULL,
expires_at INTEGER NOT NULL, -- short TTL (~600s); reaper sweeps expired rows
used_at INTEGER -- non-NULL after exchange; replay defense
);
CREATE INDEX idx_auth_codes_expires_at ON auth_codes(expires_at);
CREATE TABLE access_tokens (
token_hash TEXT PRIMARY KEY, -- hex(sha256(plaintext_token))
client_id TEXT NOT NULL REFERENCES clients(client_id) ON DELETE CASCADE,
forgejo_user_id INTEGER NOT NULL,
forgejo_username TEXT NOT NULL,
scopes TEXT NOT NULL,
forgejo_access_token TEXT NOT NULL,
forgejo_refresh_token TEXT,
forgejo_token_expires_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
revoked_at INTEGER -- NULL = active
);
CREATE INDEX idx_access_tokens_expires_at ON access_tokens(expires_at);
CREATE INDEX idx_access_tokens_forgejo_uid ON access_tokens(forgejo_user_id);
CREATE TABLE refresh_tokens (
token_hash TEXT PRIMARY KEY, -- hex(sha256(plaintext_token))
access_token_hash TEXT NOT NULL REFERENCES access_tokens(token_hash) ON DELETE CASCADE,
client_id TEXT NOT NULL REFERENCES clients(client_id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
revoked_at INTEGER
);
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
INSERT INTO broker_meta (key, value) VALUES ('oauth_schema_version', '1');