From 8e6084f293e48305f33b62915fa67a9ad9c70e3c Mon Sep 17 00:00:00 2001 From: Ryuz Date: Sat, 24 Oct 2026 16:47:01 +0200 Subject: [PATCH 1/2] Adopt Better Auth-shaped auth schema in a fresh 0001 (reset 2026-10-24) user/session/account/verification/apikey/oauthClient/oauthConsent/ oauthAccessToken/oauthRefreshToken/oauthClientAssertion replace account/web_session/email_code/api_key/oauth_client/oauth_grant/ oauth_token; oauth_request, beta_allowlist and waitlist stay Outer-shaped with userId. INTEGER-ms dates and sha256-only secret columns are documented departures. All code paths and fixtures re-pointed; plan/quota/metrics/prune/console behavior unchanged. --- crates/outer/migrations/0001_init.sql | 387 ++++++++++++++---- crates/outer/migrations/0002_app_cron.sql | 10 - crates/outer/migrations/0003_rollback_day.sql | 9 - crates/outer/migrations/0004_agent_action.sql | 18 - .../migrations/0005_agent_action_created.sql | 2 - crates/outer/migrations/0006_visitor_day.sql | 12 - crates/outer/migrations/0007_d1_migration.sql | 20 - crates/outer/src/apps/mod.rs | 13 +- crates/outer/src/auth/cimd.rs | 23 +- crates/outer/src/auth/mod.rs | 218 ++++++---- crates/outer/src/auth/oauth.rs | 231 +++++++---- crates/outer/src/auth/tests.rs | 89 ++-- crates/outer/src/db/mod.rs | 15 +- crates/outer/src/deploy/pipeline.rs | 4 +- crates/outer/src/fleet/manager.rs | 4 +- crates/outer/src/http/console.rs | 7 +- crates/outer/src/http/proxy.rs | 2 +- crates/outer/src/mcp/actions.rs | 26 +- crates/outer/src/mcp/tests.rs | 56 ++- crates/outer/src/mcp/tools/mod.rs | 10 +- crates/outer/src/moderation/gate.rs | 2 +- crates/outer/src/observe/metrics.rs | 8 +- crates/outer/src/ops.rs | 2 +- crates/outer/src/secrets.rs | 2 +- 24 files changed, 728 insertions(+), 442 deletions(-) delete mode 100644 crates/outer/migrations/0002_app_cron.sql delete mode 100644 crates/outer/migrations/0003_rollback_day.sql delete mode 100644 crates/outer/migrations/0004_agent_action.sql delete mode 100644 crates/outer/migrations/0005_agent_action_created.sql delete mode 100644 crates/outer/migrations/0006_visitor_day.sql delete mode 100644 crates/outer/migrations/0007_d1_migration.sql diff --git a/crates/outer/migrations/0001_init.sql b/crates/outer/migrations/0001_init.sql index e94e93a..1b61e61 100644 --- a/crates/outer/migrations/0001_init.sql +++ b/crates/outer/migrations/0001_init.sql @@ -1,20 +1,100 @@ --- Outer's platform state (spec §17). Timestamps are unix milliseconds. --- Shipped migrations are never edited; changes go in a new file. +-- Outer's platform state (spec §17). Schema reset on 2026-10-24: the auth +-- tables now follow the Better Auth database structure (table names and +-- column names from https://better-auth.com/docs/concepts/database and the +-- api-key / admin / oauth-provider / mcp plugin schemas), so the schema is +-- the one the Better Auth ecosystem knows. Migrations are append-only from +-- this 0001 on; earlier history was squashed because production restarts +-- from a wiped database. +-- +-- Auth tables follow Better Auth (`user`, `session`, `account`, +-- `verification`, `apikey`, `oauth*`); `oauth_request`, `beta_allowlist` +-- and `waitlist` stay Outer-shaped. Secret columns hold ONLY sha256 hashes. +-- +-- Deliberate departures from Better Auth, documented where used: +-- 1. Dates are INTEGER unix milliseconds everywhere (Outer convention), +-- not the `date` affinity (ISO-8601 text) Better Auth's SQLite DDL +-- emits. Every timestamp comparison in the codebase is integer math. +-- 2. Secret columns hold ONLY sha256 hex digests, never raw secrets: +-- `session.token`, `verification.value`, `apikey.key`, +-- `oauthAccessToken.token`, `oauthRefreshToken.token`. Better Auth +-- stores session tokens raw and hashes API keys; Outer hashes both. +-- 3. `*_uris` / `scopes`-style `string[]` fields and `json`/`metadata` +-- fields are TEXT holding JSON (same storage as Better Auth on SQLite). +-- `oauthConsent.scopes`/`resources` hold Outer's single scope/resource +-- string verbatim (e.g. `apps`), not a JSON array. +-- 4. Outer-specific additional fields (Better Auth's additional-fields +-- mechanism): `user.plan/planStatus/stripe*/graceUntil/terms*/lastActiveAt`, +-- `apikey.clientName/clientVersion`, `oauthClient.kind/host/verified/ +-- fetchedAt/lastTokenAt`, `oauthConsent.clientName/clientVersion/ +-- lastUsedAt/revokedAt`, `verification.attempts`. OAuth token rows point +-- at their consent via `referenceId`. +-- 5. No Better Auth equivalent exists for the pending-authorization row, so +-- `oauthRequest` stays Outer-shaped (only `account_id` renamed to +-- `userId`). `beta_allowlist` and `waitlist` (closed-beta state) also +-- stay Outer-shaped. The `account` table (social/credential logins) is +-- created for ecosystem familiarity; Outer only does email OTP and never +-- writes it. + +CREATE TABLE user ( + id TEXT PRIMARY KEY, + name TEXT, + email TEXT NOT NULL UNIQUE, + emailVerified INTEGER NOT NULL DEFAULT 0, + image TEXT, + role TEXT, + banned INTEGER NOT NULL DEFAULT 0, + banReason TEXT, + banExpires INTEGER, + plan TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'pro')), + planStatus TEXT CHECK (planStatus IN ('active', 'past_due', 'canceled')), + stripeCustomerId TEXT UNIQUE, + stripeSubscriptionId TEXT, + graceUntil INTEGER, + termsVersion TEXT, + termsAcceptedAt INTEGER, + lastActiveAt INTEGER NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL +); + +CREATE TABLE session ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + expiresAt INTEGER NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + ipAddress TEXT, + userAgent TEXT, + userId TEXT NOT NULL REFERENCES user (id) ON DELETE CASCADE, + impersonatedBy TEXT +); +CREATE INDEX session_userId ON session (userId); CREATE TABLE account ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - plan TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'pro')), - plan_status TEXT CHECK (plan_status IN ('active', 'past_due', 'canceled')), - stripe_customer_id TEXT UNIQUE, - stripe_subscription_id TEXT, - grace_until INTEGER, - terms_version TEXT, - terms_accepted_at INTEGER, - last_active_at INTEGER NOT NULL, - banned_at INTEGER, - ban_reason TEXT, - created_at INTEGER NOT NULL + id TEXT PRIMARY KEY, + accountId TEXT NOT NULL, + providerId TEXT NOT NULL, + userId TEXT NOT NULL REFERENCES user (id) ON DELETE CASCADE, + accessToken TEXT, + refreshToken TEXT, + idToken TEXT, + accessTokenExpiresAt INTEGER, + refreshTokenExpiresAt INTEGER, + scope TEXT, + password TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL +); +CREATE INDEX account_userId ON account (userId); + +CREATE TABLE verification ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL UNIQUE, + value TEXT NOT NULL, + expiresAt INTEGER NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE beta_allowlist ( @@ -29,39 +109,78 @@ CREATE TABLE waitlist ( attempts INTEGER NOT NULL DEFAULT 1 ); -CREATE TABLE email_code ( - email TEXT PRIMARY KEY, - code_hash TEXT NOT NULL, - expires_at INTEGER NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL +CREATE TABLE apikey ( + id TEXT PRIMARY KEY, + name TEXT, + start TEXT, + prefix TEXT, + key TEXT NOT NULL UNIQUE, + referenceId TEXT NOT NULL REFERENCES user (id) ON DELETE CASCADE, + refillInterval INTEGER, + refillAmount INTEGER, + lastRefillAt INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + rateLimitEnabled INTEGER NOT NULL DEFAULT 1, + rateLimitTimeWindow INTEGER, + rateLimitMax INTEGER, + requestCount INTEGER NOT NULL DEFAULT 0, + remaining INTEGER, + lastRequest INTEGER, + expiresAt INTEGER, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + permissions TEXT, + metadata TEXT, + configId TEXT NOT NULL DEFAULT 'default', + clientName TEXT, + clientVersion TEXT ); +CREATE INDEX apikey_reference ON apikey (referenceId); -CREATE TABLE api_key ( - id TEXT PRIMARY KEY, - account_id TEXT NOT NULL REFERENCES account (id) ON DELETE CASCADE, - name TEXT, - prefix TEXT NOT NULL, - key_hash TEXT NOT NULL UNIQUE, - client_name TEXT, - client_version TEXT, - created_at INTEGER NOT NULL, - last_used_at INTEGER, - revoked_at INTEGER -); -CREATE INDEX api_key_account ON api_key (account_id); - -CREATE TABLE oauth_client ( - client_id TEXT PRIMARY KEY, - kind TEXT NOT NULL CHECK (kind IN ('cimd', 'dcr')), - name TEXT, - host TEXT, - redirect_uris TEXT NOT NULL, - verified INTEGER NOT NULL DEFAULT 0, - fetched_at INTEGER, - created_at INTEGER NOT NULL, - last_token_at INTEGER +CREATE TABLE oauthClient ( + id TEXT PRIMARY KEY, + clientId TEXT NOT NULL UNIQUE, + clientSecret TEXT, + clientDiscoveryId TEXT, + disabled INTEGER, + skipConsent INTEGER, + enableEndSession INTEGER, + subjectType TEXT, + scopes TEXT, + clientCredentialsScopes TEXT, + userId TEXT REFERENCES user (id) ON DELETE CASCADE, + createdAt INTEGER, + updatedAt INTEGER, + name TEXT, + uri TEXT, + icon TEXT, + contacts TEXT, + tos TEXT, + policy TEXT, + softwareId TEXT, + softwareVersion TEXT, + softwareStatement TEXT, + redirectUris TEXT NOT NULL, + postLogoutRedirectUris TEXT, + backchannelLogoutUri TEXT, + backchannelLogoutSessionRequired INTEGER, + tokenEndpointAuthMethod TEXT, + applicationType TEXT, + jwks TEXT, + jwksUri TEXT, + grantTypes TEXT, + responseTypes TEXT, + requirePKCE INTEGER, + dpopBoundAccessTokens INTEGER NOT NULL DEFAULT 0, + referenceId TEXT, + metadata TEXT, + kind TEXT NOT NULL CHECK (kind IN ('cimd', 'dcr')), + host TEXT, + verified INTEGER NOT NULL DEFAULT 0, + fetchedAt INTEGER, + lastTokenAt INTEGER ); +CREATE INDEX oauthClient_user ON oauthClient (userId); CREATE TABLE oauth_request ( id TEXT PRIMARY KEY, @@ -71,45 +190,88 @@ CREATE TABLE oauth_request ( code_challenge TEXT NOT NULL, scope TEXT, resource TEXT, - account_id TEXT REFERENCES account (id) ON DELETE CASCADE, + userId TEXT REFERENCES user (id) ON DELETE CASCADE, csrf TEXT NOT NULL, code_hash TEXT UNIQUE, expires_at INTEGER NOT NULL ); -CREATE TABLE oauth_grant ( - id TEXT PRIMARY KEY, - account_id TEXT NOT NULL REFERENCES account (id) ON DELETE CASCADE, - client_id TEXT NOT NULL, - scope TEXT NOT NULL, - resource TEXT NOT NULL, - client_name TEXT, - client_version TEXT, - created_at INTEGER NOT NULL, - last_used_at INTEGER, - revoked_at INTEGER +CREATE TABLE oauthConsent ( + id TEXT PRIMARY KEY, + clientId TEXT NOT NULL REFERENCES oauthClient (clientId), + userId TEXT NOT NULL REFERENCES user (id) ON DELETE CASCADE, + referenceId TEXT, + resources TEXT, + requestedUserInfoClaims TEXT, + scopes TEXT NOT NULL, + createdAt INTEGER, + updatedAt INTEGER, + clientName TEXT, + clientVersion TEXT, + lastUsedAt INTEGER, + revokedAt INTEGER +); +CREATE INDEX oauthConsent_user ON oauthConsent (userId); +CREATE INDEX oauthConsent_client ON oauthConsent (clientId); + +CREATE TABLE oauthAccessToken ( + id TEXT PRIMARY KEY, + token TEXT UNIQUE, + clientId TEXT NOT NULL REFERENCES oauthClient (clientId), + sessionId TEXT REFERENCES session (id) ON DELETE SET NULL, + userId TEXT REFERENCES user (id) ON DELETE CASCADE, + referenceId TEXT, + authorizationCodeId TEXT, + resources TEXT, + requestedUserInfoClaims TEXT, + refreshId TEXT REFERENCES oauthRefreshToken (id), + expiresAt INTEGER, + createdAt INTEGER, + revoked INTEGER, + confirmation TEXT, + scopes TEXT NOT NULL ); -CREATE INDEX oauth_grant_account ON oauth_grant (account_id); +CREATE INDEX oauthAccessToken_client ON oauthAccessToken (clientId); +CREATE INDEX oauthAccessToken_user ON oauthAccessToken (userId); +CREATE INDEX oauthAccessToken_reference ON oauthAccessToken (referenceId); -CREATE TABLE oauth_token ( - token_hash TEXT PRIMARY KEY, - grant_id TEXT NOT NULL REFERENCES oauth_grant (id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK (kind IN ('access', 'refresh')), - expires_at INTEGER NOT NULL, - rotated_at INTEGER +CREATE TABLE oauthRefreshToken ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + clientId TEXT NOT NULL REFERENCES oauthClient (clientId), + sessionId TEXT REFERENCES session (id) ON DELETE SET NULL, + userId TEXT NOT NULL REFERENCES user (id) ON DELETE CASCADE, + referenceId TEXT, + authorizationCodeId TEXT, + resources TEXT, + requestedUserInfoClaims TEXT, + expiresAt INTEGER, + createdAt INTEGER, + revoked INTEGER, + rotatedAt INTEGER, + rotationReplayResponse TEXT, + rotationReplayExpiresAt INTEGER, + authTime INTEGER, + confirmation TEXT, + scopes TEXT NOT NULL ); -CREATE INDEX oauth_token_grant ON oauth_token (grant_id); +CREATE INDEX oauthRefreshToken_client ON oauthRefreshToken (clientId); +CREATE INDEX oauthRefreshToken_user ON oauthRefreshToken (userId); +CREATE INDEX oauthRefreshToken_reference ON oauthRefreshToken (referenceId); -CREATE TABLE web_session ( - id_hash TEXT PRIMARY KEY, - account_id TEXT NOT NULL REFERENCES account (id) ON DELETE CASCADE, - expires_at INTEGER NOT NULL +CREATE TABLE oauthClientAssertion ( + id TEXT PRIMARY KEY, + expiresAt INTEGER NOT NULL ); +-- The MCP plugin uses the same tables as the OAuth Provider plugin +-- (`oauthClient`, `oauthAccessToken`, `oauthRefreshToken`, `oauthConsent`, +-- `oauthClientAssertion`); no extra tables. + CREATE TABLE app ( id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, - owner_id TEXT NOT NULL REFERENCES account (id), + owner_id TEXT NOT NULL REFERENCES user (id), name TEXT, uid INTEGER NOT NULL UNIQUE, port INTEGER UNIQUE, @@ -131,11 +293,11 @@ CREATE INDEX app_owner ON app (owner_id); CREATE TABLE app_collaborator ( app_id TEXT NOT NULL REFERENCES app (id) ON DELETE CASCADE, email TEXT NOT NULL, - account_id TEXT REFERENCES account (id) ON DELETE SET NULL, + userId TEXT REFERENCES user (id) ON DELETE SET NULL, added_at INTEGER NOT NULL, PRIMARY KEY (app_id, email) ); -CREATE INDEX app_collaborator_account ON app_collaborator (account_id); +CREATE INDEX app_collaborator_user ON app_collaborator (userId); CREATE TABLE deploy ( id TEXT PRIMARY KEY, @@ -216,21 +378,21 @@ CREATE TABLE error_issue ( ); CREATE TABLE usage_day ( - account_id TEXT NOT NULL, - day TEXT NOT NULL, - code_changes INTEGER NOT NULL DEFAULT 0, - build_ms INTEGER NOT NULL DEFAULT 0, - codemode_runs INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (account_id, day) + userId TEXT NOT NULL, + day TEXT NOT NULL, + code_changes INTEGER NOT NULL DEFAULT 0, + build_ms INTEGER NOT NULL DEFAULT 0, + codemode_runs INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (userId, day) ); CREATE TABLE usage_month ( - account_id TEXT NOT NULL, + userId TEXT NOT NULL, month TEXT NOT NULL, requests INTEGER NOT NULL DEFAULT 0, storage_peak_bytes INTEGER NOT NULL DEFAULT 0, egress_bytes INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (account_id, month) + PRIMARY KEY (userId, month) ); CREATE TABLE rate_limit_events ( @@ -268,3 +430,68 @@ CREATE TABLE stripe_event ( type TEXT NOT NULL, received_at INTEGER NOT NULL ); + +CREATE TABLE app_cron ( + app_id TEXT NOT NULL REFERENCES app (id) ON DELETE CASCADE, + expr TEXT NOT NULL, + next_at INTEGER NOT NULL, + PRIMARY KEY (app_id, expr) +); +CREATE INDEX app_cron_due ON app_cron (next_at); + +CREATE TABLE rollback_day ( + app_id TEXT NOT NULL REFERENCES app (id) ON DELETE CASCADE, + day TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (app_id, day) +); + +-- Console agent action log (spec §13.1, §17): one row per MCP tool call +-- that targets an app. `user_id` is the acting user, `consent_id` the +-- `oauthConsent` row (OAuth calls) or NULL, `apikey_id` the `apikey` row +-- (API-key calls) or NULL. Pruned with the plan's log retention (7 d Free, +-- 30 d Pro); the app row's CASCADE drops an app's log on delete. +CREATE TABLE agent_action ( + id TEXT NOT NULL PRIMARY KEY, + app_id TEXT NOT NULL REFERENCES app (id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + consent_id TEXT NULL, + apikey_id TEXT NULL, + tool TEXT NOT NULL, + summary TEXT NOT NULL, + outcome TEXT NOT NULL, + error_code TEXT NULL, + duration_ms INTEGER NOT NULL, + created_at INTEGER NOT NULL +); +CREATE INDEX idx_agent_action_app_created + ON agent_action (app_id, created_at DESC); +CREATE INDEX idx_agent_action_created ON agent_action (created_at); + +CREATE TABLE visitor_day ( + app_id TEXT NOT NULL REFERENCES app (id) ON DELETE CASCADE, + day INTEGER NOT NULL, + visitors INTEGER NOT NULL, + PRIMARY KEY (app_id, day) +); + +-- D1 migrations applied by the deploy pipeline (spec §10.1 step 7): one +-- row per migration file Outer applied to an app's database, so an +-- edited-after-apply file fails the deploy instead of silently drifting +-- from what celld recorded in the app's own `d1_migrations` table. +-- celld skips by name; this table pins the content hash Outer saw. +-- The row is keyed by the effective identity celld applies to: +-- `database_id` when the wrangler declaration has one, else +-- `database_name` (celld uses the id as the stable identity when present). +-- `database_name` stays alongside for humans. Changing the id under the +-- same name therefore starts clean against the new (empty) database +-- instead of skipping as "already applied". +CREATE TABLE d1_migration ( + app_id TEXT NOT NULL REFERENCES app (id) ON DELETE CASCADE, + database_name TEXT NOT NULL, + database_identity TEXT NOT NULL, + name TEXT NOT NULL, + sha256 TEXT NOT NULL, + applied_at INTEGER NOT NULL, + PRIMARY KEY (app_id, database_identity, name) +); diff --git a/crates/outer/migrations/0002_app_cron.sql b/crates/outer/migrations/0002_app_cron.sql deleted file mode 100644 index 6e0c228..0000000 --- a/crates/outer/migrations/0002_app_cron.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Cron schedules from each deploy's wrangler config (spec §12.3). --- Outer wakes the app shortly before each due time; the running fleet --- then fires the Worker's `scheduled` handler itself. -CREATE TABLE app_cron ( - app_id TEXT NOT NULL REFERENCES app (id) ON DELETE CASCADE, - expr TEXT NOT NULL, - next_at INTEGER NOT NULL, - PRIMARY KEY (app_id, expr) -); -CREATE INDEX app_cron_due ON app_cron (next_at); diff --git a/crates/outer/migrations/0003_rollback_day.sql b/crates/outer/migrations/0003_rollback_day.sql deleted file mode 100644 index 171a30a..0000000 --- a/crates/outer/migrations/0003_rollback_day.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Per-app daily rollback budget (spec §8.3 throttle): at most one rollback --- runs per app at a time (in-process lock), and each app gets --- ROLLBACKS_PER_DAY per UTC day, counted here. -CREATE TABLE rollback_day ( - app_id TEXT NOT NULL REFERENCES app (id) ON DELETE CASCADE, - day TEXT NOT NULL, - count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (app_id, day) -); diff --git a/crates/outer/migrations/0004_agent_action.sql b/crates/outer/migrations/0004_agent_action.sql deleted file mode 100644 index 019366c..0000000 --- a/crates/outer/migrations/0004_agent_action.sql +++ /dev/null @@ -1,18 +0,0 @@ --- Console agent action log (spec §13.1, §17): one row per MCP tool call --- that targets an app. Pruned with the plan's log retention (7 d Free, --- 30 d Pro); the app row's CASCADE drops an app's log on delete. -CREATE TABLE agent_action ( - id TEXT NOT NULL PRIMARY KEY, - app_id TEXT NOT NULL REFERENCES app (id) ON DELETE CASCADE, - account_id TEXT NOT NULL, - grant_id TEXT NULL, - api_key_id TEXT NULL, - tool TEXT NOT NULL, - summary TEXT NOT NULL, - outcome TEXT NOT NULL, - error_code TEXT NULL, - duration_ms INTEGER NOT NULL, - created_at INTEGER NOT NULL -); -CREATE INDEX idx_agent_action_app_created - ON agent_action (app_id, created_at DESC); diff --git a/crates/outer/migrations/0005_agent_action_created.sql b/crates/outer/migrations/0005_agent_action_created.sql deleted file mode 100644 index 59b5fea..0000000 --- a/crates/outer/migrations/0005_agent_action_created.sql +++ /dev/null @@ -1,2 +0,0 @@ --- The agent action log's daily prune deletes by age across all apps. -CREATE INDEX idx_agent_action_created ON agent_action (created_at); diff --git a/crates/outer/migrations/0006_visitor_day.sql b/crates/outer/migrations/0006_visitor_day.sql deleted file mode 100644 index bd327c7..0000000 --- a/crates/outer/migrations/0006_visitor_day.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Per-app daily distinct visitors (console §13.1): one row per --- (app, UTC day) holding that day's distinct client-IP count. IPs are --- hashed with the day's process-random salt in memory and never stored; --- only the count reaches SQLite. `day` counts UTC days since the epoch. --- The flush upserts with max() so a restart mid-day (which resets the --- in-memory set) keeps the count monotonic; restarts can undercount. -CREATE TABLE visitor_day ( - app_id TEXT NOT NULL REFERENCES app (id) ON DELETE CASCADE, - day INTEGER NOT NULL, - visitors INTEGER NOT NULL, - PRIMARY KEY (app_id, day) -); diff --git a/crates/outer/migrations/0007_d1_migration.sql b/crates/outer/migrations/0007_d1_migration.sql deleted file mode 100644 index feaf2a0..0000000 --- a/crates/outer/migrations/0007_d1_migration.sql +++ /dev/null @@ -1,20 +0,0 @@ --- D1 migrations applied by the deploy pipeline (spec §10.1 step 7): one --- row per migration file Outer applied to an app's database, so an --- edited-after-apply file fails the deploy instead of silently drifting --- from what celld recorded in the app's own `d1_migrations` table. --- celld skips by name; this table pins the content hash Outer saw. --- The row is keyed by the effective identity celld applies to: --- `database_id` when the wrangler declaration has one, else --- `database_name` (celld uses the id as the stable identity when present). --- `database_name` stays alongside for humans. Changing the id under the --- same name therefore starts clean against the new (empty) database --- instead of skipping as "already applied". -CREATE TABLE d1_migration ( - app_id TEXT NOT NULL REFERENCES app (id) ON DELETE CASCADE, - database_name TEXT NOT NULL, - database_identity TEXT NOT NULL, - name TEXT NOT NULL, - sha256 TEXT NOT NULL, - applied_at INTEGER NOT NULL, - PRIMARY KEY (app_id, database_identity, name) -); diff --git a/crates/outer/src/apps/mod.rs b/crates/outer/src/apps/mod.rs index f656b1b..d99bb8e 100644 --- a/crates/outer/src/apps/mod.rs +++ b/crates/outer/src/apps/mod.rs @@ -161,12 +161,12 @@ pub async fn ensure_account(pool: &SqlitePool, email: &str) -> anyhow::Result("SELECT id FROM account WHERE email = ?1") + if let Some(id) = sqlx::query_scalar::<_, String>("SELECT id FROM user WHERE email = ?1") .bind(&email) .fetch_optional(pool) .await? { - sqlx::query("UPDATE account SET last_active_at = ?1 WHERE id = ?2") + sqlx::query("UPDATE user SET lastActiveAt = ?1, updatedAt = ?1 WHERE id = ?2") .bind(now) .bind(&id) .execute(pool) @@ -175,7 +175,8 @@ pub async fn ensure_account(pool: &SqlitePool, email: &str) -> anyhow::Result anyhow::Result Ok(id), Err(e) if is_unique_violation(&e) => { // A concurrent caller inserted the same email first. - let id = sqlx::query_scalar::<_, String>("SELECT id FROM account WHERE email = ?1") + let id = sqlx::query_scalar::<_, String>("SELECT id FROM user WHERE email = ?1") .bind(&email) .fetch_one(pool) .await?; - sqlx::query("UPDATE account SET last_active_at = ?1 WHERE id = ?2") + sqlx::query("UPDATE user SET lastActiveAt = ?1, updatedAt = ?1 WHERE id = ?2") .bind(now) .bind(&id) .execute(pool) @@ -479,7 +480,7 @@ mod tests { let second = ensure_account(&pool, " ANA@example.com ").await.unwrap(); assert_eq!(first, second, "same email lowercases to the same account"); let (email, last_active_at): (String, i64) = - sqlx::query_as("SELECT email, last_active_at FROM account WHERE id = ?1") + sqlx::query_as("SELECT email, lastActiveAt FROM user WHERE id = ?1") .bind(&first) .fetch_one(&pool) .await diff --git a/crates/outer/src/auth/cimd.rs b/crates/outer/src/auth/cimd.rs index d5d5e19..0cf8031 100644 --- a/crates/outer/src/auth/cimd.rs +++ b/crates/outer/src/auth/cimd.rs @@ -2,7 +2,7 @@ //! //! The `client_id` is either an HTTPS URL (a client-instance metadata //! document, fetched with HTTPS only to public IPs only, 3 s timeout, -//! ≤ 16 KiB, cached in `oauth_client`) or a `dcr_…` id from `POST +//! ≤ 16 KiB, cached in `oauthClient`) or a `dcr_…` id from `POST //! /oauth/register`. Known clients only get a friendlier label; their //! redirects always come from their own document. @@ -12,7 +12,7 @@ use std::time::Duration; use sqlx::SqlitePool; use url::Url; -use super::{now_ms, AuthConfig}; +use super::{now_ms, tokens, AuthConfig}; /// ≤ 16 KiB per spec. /// @@ -213,8 +213,8 @@ fn check_redirect(mut doc: ClientDoc, redirect_uri: &str) -> Result anyhow::Result> { let row: Option<(String, Option, Option, String)> = sqlx::query_as( - "SELECT client_id, name, host, redirect_uris FROM oauth_client \ - WHERE client_id = ? AND kind = 'dcr'", + "SELECT clientId, name, host, redirectUris FROM oauthClient \ + WHERE clientId = ? AND kind = 'dcr'", ) .bind(client_id) .fetch_optional(pool) @@ -331,17 +331,20 @@ async fn fetch_cimd( // Backdate so `fetched_at + 24 h` is the exact expiry. let fetched_at = now - (DOC_TTL_MS - ttl_ms); sqlx::query( - "INSERT INTO oauth_client (client_id, kind, name, host, redirect_uris, verified, fetched_at, created_at) \ - VALUES (?, 'cimd', ?, ?, ?, 0, ?, ?) \ - ON CONFLICT(client_id) DO UPDATE SET name = excluded.name, host = excluded.host, \ - redirect_uris = excluded.redirect_uris, fetched_at = excluded.fetched_at", + "INSERT INTO oauthClient (id, clientId, kind, name, host, redirectUris, verified, fetchedAt, createdAt, updatedAt) \ + VALUES (?, ?, 'cimd', ?, ?, ?, 0, ?, ?, ?) \ + ON CONFLICT(clientId) DO UPDATE SET name = excluded.name, host = excluded.host, \ + redirectUris = excluded.redirectUris, fetchedAt = excluded.fetchedAt, \ + updatedAt = excluded.updatedAt", ) + .bind(tokens::new_id()) .bind(client_id) .bind(&name) .bind(&host) .bind(serde_json::to_string(&redirect_uris).unwrap_or_default()) .bind(fetched_at) .bind(now) + .bind(now) .execute(pool) .await .map_err(|err| ClientError::Fetch(err.to_string()))?; @@ -361,8 +364,8 @@ async fn cached_doc( now: i64, ) -> anyhow::Result> { let row: Option = sqlx::query_as( - "SELECT name, host, redirect_uris, fetched_at FROM oauth_client \ - WHERE client_id = ? AND kind = 'cimd'", + "SELECT name, host, redirectUris, fetchedAt FROM oauthClient \ + WHERE clientId = ? AND kind = 'cimd'", ) .bind(client_id) .fetch_optional(pool) diff --git a/crates/outer/src/auth/mod.rs b/crates/outer/src/auth/mod.rs index 244e469..0bb66d5 100644 --- a/crates/outer/src/auth/mod.rs +++ b/crates/outer/src/auth/mod.rs @@ -1,8 +1,9 @@ //! Accounts, sign-in (OAuth 2.1 + email OTP) and API keys. //! -//! This module owns `account`, `beta_allowlist`, `waitlist`, `email_code`, -//! `api_key`, `oauth_*` and `web_session`. The MCP handler only calls the -//! functions below; names and shapes are binding. +//! This module owns the Better Auth-shaped `user`, `session`, `account`, +//! `verification`, `apikey` and `oauth*` tables plus Outer's `oauthRequest`, +//! `beta_allowlist` and `waitlist`. Secret columns hold ONLY sha256 hashes. +//! The MCP handler only calls the functions below; names and shapes are binding. pub mod cimd; pub mod email; @@ -169,7 +170,7 @@ pub struct AccountRow { pub id: String, pub email: String, pub terms_version: Option, - pub banned_at: Option, + pub banned: bool, } impl AccountRow { @@ -180,19 +181,25 @@ impl AccountRow { } async fn account_by_email(pool: &SqlitePool, email: &str) -> anyhow::Result> { - let row: Option<(String, String, Option, Option)> = - sqlx::query_as("SELECT id, email, terms_version, banned_at FROM account WHERE email = ?") + let row: Option<(String, String, Option, i64)> = + sqlx::query_as("SELECT id, email, termsVersion, banned FROM user WHERE email = ?") .bind(email) .fetch_optional(pool) .await?; - Ok(row.map(|(id, email, terms_version, banned_at)| AccountRow { + Ok(row.map(|(id, email, terms_version, banned)| AccountRow { id, email, terms_version, - banned_at, + banned: banned != 0, })) } +/// Better Auth email-OTP identifier for an address: the `verification` +/// table is shared, so the kind namespaces the row. +fn otp_identifier(email: &str) -> String { + format!("email-verification:{email}") +} + /// Closed-beta gate: open signup, admin emails and allowlisted emails pass; /// anyone else lands on the waitlist (counted) with the exact closed-beta /// user message. Idempotent: repeat sends bump the waitlist count. @@ -253,7 +260,7 @@ pub async fn send_code( .await .map_err(|err| AuthError::new("db", format!("account lookup failed: {err}")))? { - if account.banned_at.is_some() { + if account.banned { return Err(AuthError::new( "forbidden", "this account is banned".to_string(), @@ -285,15 +292,17 @@ pub async fn send_code( } let code = tokens::new_otp_code(); sqlx::query( - "INSERT INTO email_code (email, code_hash, expires_at, attempts, created_at) \ - VALUES (?, ?, ?, 0, ?) \ - ON CONFLICT(email) DO UPDATE SET code_hash = excluded.code_hash, \ - expires_at = excluded.expires_at, attempts = 0, created_at = excluded.created_at", + "INSERT INTO verification (id, identifier, value, expiresAt, createdAt, updatedAt) \ + VALUES (?, ?, ?, ?, ?, ?) \ + ON CONFLICT(identifier) DO UPDATE SET value = excluded.value, \ + expiresAt = excluded.expiresAt, attempts = 0, updatedAt = excluded.updatedAt", ) - .bind(&email) + .bind(tokens::new_id()) + .bind(otp_identifier(&email)) .bind(tokens::sha256_hex(&code)) .bind(now + (EXPIRES_IN_S as i64) * 1000) .bind(now) + .bind(now) .execute(pool) .await .map_err(|err| AuthError::new("db", format!("code store failed: {err}")))?; @@ -333,9 +342,10 @@ pub async fn verify_code_core( let db = |err: sqlx::Error| AuthError::new("db", format!("{err}")); let now = now_ms(); let email = normalize_email(email); + let identifier = otp_identifier(&email); let row: Option<(String, i64, i64)> = - sqlx::query_as("SELECT code_hash, expires_at, attempts FROM email_code WHERE email = ?") - .bind(&email) + sqlx::query_as("SELECT value, expiresAt, attempts FROM verification WHERE identifier = ?") + .bind(&identifier) .fetch_optional(pool) .await .map_err(db)?; @@ -346,8 +356,8 @@ pub async fn verify_code_core( )); }; if now > expires_at { - sqlx::query("DELETE FROM email_code WHERE email = ?") - .bind(&email) + sqlx::query("DELETE FROM verification WHERE identifier = ?") + .bind(&identifier) .execute(pool) .await .map_err(db)?; @@ -357,8 +367,8 @@ pub async fn verify_code_core( )); } if attempts >= 5 { - sqlx::query("DELETE FROM email_code WHERE email = ?") - .bind(&email) + sqlx::query("DELETE FROM verification WHERE identifier = ?") + .bind(&identifier) .execute(pool) .await .map_err(db)?; @@ -370,8 +380,8 @@ pub async fn verify_code_core( if !tokens::ct_eq(&tokens::sha256_hex(code.trim()), &code_hash) { let attempts = attempts + 1; if attempts >= 5 { - sqlx::query("DELETE FROM email_code WHERE email = ?") - .bind(&email) + sqlx::query("DELETE FROM verification WHERE identifier = ?") + .bind(&identifier) .execute(pool) .await .map_err(db)?; @@ -380,9 +390,10 @@ pub async fn verify_code_core( "too many wrong codes, ask for a new one".to_string(), )); } - sqlx::query("UPDATE email_code SET attempts = ? WHERE email = ?") + sqlx::query("UPDATE verification SET attempts = ?, updatedAt = ? WHERE identifier = ?") .bind(attempts) - .bind(&email) + .bind(now) + .bind(&identifier) .execute(pool) .await .map_err(db)?; @@ -391,8 +402,8 @@ pub async fn verify_code_core( "that code doesn't match".to_string(), )); } - sqlx::query("DELETE FROM email_code WHERE email = ?") - .bind(&email) + sqlx::query("DELETE FROM verification WHERE identifier = ?") + .bind(&identifier) .execute(pool) .await .map_err(db)?; @@ -419,12 +430,14 @@ pub async fn verify_code_core( } let id = tokens::new_id(); sqlx::query( - "INSERT INTO account (id, email, last_active_at, created_at) VALUES (?, ?, ?, ?)", + "INSERT INTO user (id, email, emailVerified, lastActiveAt, createdAt, updatedAt) \ + VALUES (?, ?, 1, ?, ?, ?)", ) .bind(&id) .bind(&email) .bind(now) .bind(now) + .bind(now) .execute(pool) .await .map_err(db)?; @@ -432,17 +445,18 @@ pub async fn verify_code_core( id, email: email.clone(), terms_version: None, - banned_at: None, + banned: false, } } }; - if account.banned_at.is_some() { + if account.banned { return Err(AuthError::new( "forbidden", "this account is banned".to_string(), )); } - sqlx::query("UPDATE account SET last_active_at = ? WHERE id = ?") + sqlx::query("UPDATE user SET lastActiveAt = ?, updatedAt = ? WHERE id = ?") + .bind(now) .bind(now) .bind(&account.id) .execute(pool) @@ -459,14 +473,19 @@ pub async fn record_terms_acceptance( cfg: &AuthConfig, account_id: &str, ) -> anyhow::Result<()> { - sqlx::query("UPDATE account SET terms_version = ?, terms_accepted_at = ? WHERE id = ?") - .bind(&cfg.terms_version) - .bind(now_ms()) - .bind(account_id) - .execute(pool) - .await?; - sqlx::query("UPDATE account SET last_active_at = ? WHERE id = ?") - .bind(now_ms()) + let now = now_ms(); + sqlx::query( + "UPDATE user SET termsVersion = ?, termsAcceptedAt = ?, updatedAt = ? WHERE id = ?", + ) + .bind(&cfg.terms_version) + .bind(now) + .bind(now) + .bind(account_id) + .execute(pool) + .await?; + sqlx::query("UPDATE user SET lastActiveAt = ?, updatedAt = ? WHERE id = ?") + .bind(now) + .bind(now) .bind(account_id) .execute(pool) .await?; @@ -538,14 +557,15 @@ pub async fn create_api_key( let (key, prefix, key_hash) = tokens::new_api_key(); let id = tokens::new_id(); sqlx::query( - "INSERT INTO api_key (id, account_id, name, prefix, key_hash, created_at) \ - VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO apikey (id, name, prefix, key, referenceId, createdAt, updatedAt) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) - .bind(account_id) .bind(&name) .bind(&prefix) .bind(&key_hash) + .bind(account_id) + .bind(now) .bind(now) .execute(pool) .await @@ -553,7 +573,7 @@ pub async fn create_api_key( Ok(NewApiKey { id, prefix, key }) } -/// One API key row: id, name, prefix, created_at, last_used_at, client name/version. +/// One API key row: id, name, prefix, createdAt, lastRequest, clientName/clientVersion. type ApiKeyRow = ( String, Option, @@ -566,8 +586,8 @@ type ApiKeyRow = ( pub async fn list_api_keys(pool: &SqlitePool, account_id: &str) -> anyhow::Result> { let rows: Vec = sqlx::query_as( - "SELECT id, name, prefix, created_at, last_used_at, client_name, client_version \ - FROM api_key WHERE account_id = ? AND revoked_at IS NULL ORDER BY created_at", + "SELECT id, name, prefix, createdAt, lastRequest, clientName, clientVersion \ + FROM apikey WHERE referenceId = ? AND enabled != 0 ORDER BY createdAt", ) .bind(account_id) .fetch_all(pool) @@ -598,10 +618,10 @@ pub async fn revoke_api_key( // Exactly one live key per prefix, or nothing: a (vanishingly rare) // prefix collision must never revoke a key the caller didn't mean. let result = sqlx::query( - "UPDATE api_key SET revoked_at = ? \ - WHERE account_id = ? AND prefix = ? AND revoked_at IS NULL \ - AND (SELECT COUNT(*) FROM api_key \ - WHERE account_id = ? AND prefix = ? AND revoked_at IS NULL) = 1", + "UPDATE apikey SET enabled = 0, updatedAt = ? \ + WHERE referenceId = ? AND prefix = ? AND enabled != 0 \ + AND (SELECT COUNT(*) FROM apikey \ + WHERE referenceId = ? AND prefix = ? AND enabled != 0) = 1", ) .bind(now_ms()) .bind(account_id) @@ -614,7 +634,7 @@ pub async fn revoke_api_key( Ok(result.rows_affected() > 0) } -/// One grant row with the client label: grant id, client_id, grant/client names, host, scope, created, last used. +/// One consent row with the client label: consent id, clientId, consent/client names, host, scopes, created, last used. type ConnectionRow = ( String, String, @@ -632,9 +652,9 @@ pub async fn list_connections( ) -> anyhow::Result> { let rows: Vec = sqlx::query_as( - "SELECT g.id, g.client_id, g.client_name, c.name, c.host, g.scope, g.created_at, g.last_used_at \ - FROM oauth_grant g LEFT JOIN oauth_client c ON c.client_id = g.client_id \ - WHERE g.account_id = ? AND g.revoked_at IS NULL ORDER BY g.created_at", + "SELECT c.id, c.clientId, c.clientName, o.name, o.host, c.scopes, c.createdAt, c.lastUsedAt \ + FROM oauthConsent c LEFT JOIN oauthClient o ON o.clientId = c.clientId \ + WHERE c.userId = ? AND c.revokedAt IS NULL ORDER BY c.createdAt", ) .bind(account_id) .fetch_all(pool) @@ -674,9 +694,10 @@ pub async fn revoke_connection( let db = |err: sqlx::Error| AuthError::new("db", format!("{err}")); let now = now_ms(); let result = sqlx::query( - "UPDATE oauth_grant SET revoked_at = ? WHERE id = ? AND account_id = ? AND revoked_at IS NULL", + "UPDATE oauthConsent SET revokedAt = ?, updatedAt = ? WHERE id = ? AND userId = ? AND revokedAt IS NULL", ) .bind(now) + .bind(now) .bind(grant_id) .bind(account_id) .execute(pool) @@ -685,7 +706,12 @@ pub async fn revoke_connection( if result.rows_affected() == 0 { return Ok(false); } - sqlx::query("DELETE FROM oauth_token WHERE grant_id = ?") + sqlx::query("DELETE FROM oauthAccessToken WHERE referenceId = ?") + .bind(grant_id) + .execute(pool) + .await + .map_err(db)?; + sqlx::query("DELETE FROM oauthRefreshToken WHERE referenceId = ?") .bind(grant_id) .execute(pool) .await @@ -704,17 +730,21 @@ pub async fn record_client_info( let version = version.trim().chars().take(100).collect::(); match credential { Credential::ApiKey { key_id } => { - sqlx::query("UPDATE api_key SET client_name = ?, client_version = ? WHERE id = ?") - .bind(&name) - .bind(&version) - .bind(key_id) - .execute(pool) - .await?; + sqlx::query( + "UPDATE apikey SET clientName = ?, clientVersion = ?, updatedAt = ? WHERE id = ?", + ) + .bind(&name) + .bind(&version) + .bind(now_ms()) + .bind(key_id) + .execute(pool) + .await?; } Credential::OAuth { grant_id } => { - sqlx::query("UPDATE oauth_grant SET client_name = ?, client_version = ? WHERE id = ?") + sqlx::query("UPDATE oauthConsent SET clientName = ?, clientVersion = ?, updatedAt = ? WHERE id = ?") .bind(&name) .bind(&version) + .bind(now_ms()) .bind(grant_id) .execute(pool) .await?; @@ -747,11 +777,11 @@ pub async fn resolve_bearer( } let hash = tokens::sha256_hex(token); let row: Option<(String, String, String, i64, Option)> = sqlx::query_as( - "SELECT g.id, g.account_id, a.email, g.created_at, g.last_used_at \ - FROM oauth_token t JOIN oauth_grant g ON g.id = t.grant_id \ - JOIN account a ON a.id = g.account_id \ - WHERE t.token_hash = ? AND t.kind = 'access' AND t.expires_at > ? \ - AND g.revoked_at IS NULL AND a.banned_at IS NULL", + "SELECT c.id, c.userId, u.email, c.createdAt, c.lastUsedAt \ + FROM oauthAccessToken t JOIN oauthConsent c ON c.id = t.referenceId \ + JOIN user u ON u.id = c.userId \ + WHERE t.token = ? AND t.expiresAt > ? \ + AND c.revokedAt IS NULL AND u.banned = 0", ) .bind(&hash) .bind(now) @@ -779,9 +809,9 @@ pub async fn resolve_bearer( } let hash = tokens::sha256_hex(token); let row: Option<(String, String, String, Option)> = sqlx::query_as( - "SELECT k.id, k.account_id, a.email, k.last_used_at \ - FROM api_key k JOIN account a ON a.id = k.account_id \ - WHERE k.key_hash = ? AND k.revoked_at IS NULL AND a.banned_at IS NULL", + "SELECT k.id, k.referenceId, u.email, k.lastRequest \ + FROM apikey k JOIN user u ON u.id = k.referenceId \ + WHERE k.key = ? AND k.enabled != 0 AND u.banned = 0", ) .bind(&hash) .fetch_optional(pool) @@ -790,7 +820,8 @@ pub async fn resolve_bearer( return Ok(None); }; if last_used_at.is_none_or(|last| now - last >= 60_000) { - sqlx::query("UPDATE api_key SET last_used_at = ? WHERE id = ?") + sqlx::query("UPDATE apikey SET lastRequest = ?, updatedAt = ? WHERE id = ?") + .bind(now) .bind(now) .bind(&key_id) .execute(pool) @@ -811,7 +842,8 @@ async fn touch_grant( now: i64, ) -> anyhow::Result<()> { if last_used_at.is_none_or(|last| now - last >= 60_000) { - sqlx::query("UPDATE oauth_grant SET last_used_at = ? WHERE id = ?") + sqlx::query("UPDATE oauthConsent SET lastUsedAt = ?, updatedAt = ? WHERE id = ?") + .bind(now) .bind(now) .bind(grant_id) .execute(pool) @@ -841,12 +873,18 @@ pub const SESSION_TTL_MS: i64 = 30 * 86_400_000; pub async fn create_session(pool: &SqlitePool, account_id: &str) -> anyhow::Result { let now = now_ms(); let (id, hash) = tokens::new_session_id(); - sqlx::query("INSERT INTO web_session (id_hash, account_id, expires_at) VALUES (?, ?, ?)") - .bind(&hash) - .bind(account_id) - .bind(now + SESSION_TTL_MS) - .execute(pool) - .await?; + sqlx::query( + "INSERT INTO session (id, token, userId, expiresAt, createdAt, updatedAt) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(tokens::new_id()) + .bind(&hash) + .bind(account_id) + .bind(now + SESSION_TTL_MS) + .bind(now) + .bind(now) + .execute(pool) + .await?; Ok(id) } @@ -856,20 +894,20 @@ pub async fn account_from_session( ) -> anyhow::Result> { let now = now_ms(); let hash = tokens::sha256_hex(session_id.trim()); - let row: Option<(String, String, Option, Option)> = sqlx::query_as( - "SELECT a.id, a.email, a.terms_version, a.banned_at FROM web_session s \ - JOIN account a ON a.id = s.account_id \ - WHERE s.id_hash = ? AND s.expires_at > ? AND a.banned_at IS NULL", + let row: Option<(String, String, Option, i64)> = sqlx::query_as( + "SELECT u.id, u.email, u.termsVersion, u.banned FROM session s \ + JOIN user u ON u.id = s.userId \ + WHERE s.token = ? AND s.expiresAt > ? AND u.banned = 0", ) .bind(&hash) .bind(now) .fetch_optional(pool) .await?; - Ok(row.map(|(id, email, terms_version, banned_at)| AccountRow { + Ok(row.map(|(id, email, terms_version, banned)| AccountRow { id, email, terms_version, - banned_at, + banned: banned != 0, })) } @@ -893,8 +931,10 @@ pub fn session_from_headers(headers: &axum::http::HeaderMap) -> Option { }) } -/// The `web_session.id_hash` for a presented session id (sign-out deletes -/// by hash; the raw id never reaches storage). +/// The `session.token` hash for a presented session id (sign-out deletes +/// by hash; the raw id never reaches storage). The column holds ONLY the +/// sha256 hex digest, never the raw session id (a deliberate departure +/// from Better Auth, which stores the raw session token). pub fn session_hash(session_id: &str) -> String { tokens::sha256_hex(session_id.trim()) } @@ -905,17 +945,13 @@ pub fn clear_session_cookie() -> String { format!("{SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax") } -/// Delete expired email codes and web sessions (the daily prune). Only rows -/// no reader can still accept go (`expires_at < now`, matching the -/// `now > expires_at` checks on the verify paths). Returns -/// `(email_codes, sessions)` removed. pub async fn prune_expired(pool: &SqlitePool, now: i64) -> anyhow::Result<(u64, u64)> { - let codes = sqlx::query("DELETE FROM email_code WHERE expires_at < ?") + let codes = sqlx::query("DELETE FROM verification WHERE expiresAt < ?") .bind(now) .execute(pool) .await? .rows_affected(); - let sessions = sqlx::query("DELETE FROM web_session WHERE expires_at < ?") + let sessions = sqlx::query("DELETE FROM session WHERE expiresAt < ?") .bind(now) .execute(pool) .await? diff --git a/crates/outer/src/auth/oauth.rs b/crates/outer/src/auth/oauth.rs index 8f55937..3a4060f 100644 --- a/crates/outer/src/auth/oauth.rs +++ b/crates/outer/src/auth/oauth.rs @@ -186,7 +186,7 @@ struct PendingRequest { } /// One pending authorization row: id, client_id, redirect_uri, state, -/// code_challenge, scope, resource, account_id, csrf, expires_at. +/// code_challenge, scope, resource, userId, csrf, expires_at. type PendingRow = ( String, String, @@ -200,7 +200,7 @@ type PendingRow = ( i64, ); /// One issued authorization code row: id, client_id, redirect_uri, state, -/// code_challenge, scope, resource, account_id, expires_at. +/// code_challenge, scope, resource, userId, expires_at. type CodeRow = ( String, String, @@ -212,10 +212,18 @@ type CodeRow = ( Option, i64, ); -/// One refresh token row: grant_id, account_id, client_id, scope, resource, -/// expires_at, rotated_at. -type RefreshRow = (String, String, String, String, String, i64, Option); - +/// One refresh token row: consent id, userId, clientId, scopes, resources, +/// expiresAt, rotatedAt. `resources`/`expiresAt` arrive `None` only from rows +/// Outer never writes (it always sets both); `None` expires immediately. +type RefreshRow = ( + String, + String, + String, + String, + Option, + Option, + Option, +); async fn load_request( pool: &SqlitePool, id: &str, @@ -223,7 +231,7 @@ async fn load_request( ) -> anyhow::Result> { let row: Option = sqlx::query_as( - "SELECT id, client_id, redirect_uri, state, code_challenge, scope, resource, account_id, csrf, expires_at \ + "SELECT id, client_id, redirect_uri, state, code_challenge, scope, resource, userId, csrf, expires_at \ FROM oauth_request WHERE id = ?", ) .bind(id) @@ -334,7 +342,7 @@ async fn authorize_get( // Returning session: the sign-in already happened, skip email and code. if let Some(session_id) = super::session_from_headers(&headers) { if let Ok(Some(account)) = super::account_from_session(&state.pool, &session_id).await { - sqlx::query("UPDATE oauth_request SET account_id = ? WHERE id = ?") + sqlx::query("UPDATE oauth_request SET userId = ? WHERE id = ?") .bind(&account.id) .bind(&id) .execute(&state.pool) @@ -460,7 +468,7 @@ async fn authorize_post( .await { Ok((account_id, terms_ok)) => { - sqlx::query("UPDATE oauth_request SET account_id = ? WHERE id = ?") + sqlx::query("UPDATE oauth_request SET userId = ? WHERE id = ?") .bind(&account_id) .bind(&pending.id) .execute(&state.pool) @@ -630,7 +638,7 @@ async fn register( .into_response(); } sqlx::query( - "DELETE FROM oauth_client WHERE kind = 'dcr' AND last_token_at IS NULL AND created_at < ?", + "DELETE FROM oauthClient WHERE kind = 'dcr' AND lastTokenAt IS NULL AND createdAt < ?", ) .bind(now - STALE_DCR_MS) .execute(&state.pool) @@ -691,14 +699,16 @@ async fn register( .unwrap_or_default(); let client_id = format!("dcr_{}", hex::encode(rand::random::<[u8; 16]>())); sqlx::query( - "INSERT INTO oauth_client (client_id, kind, name, host, redirect_uris, verified, created_at) \ - VALUES (?, 'dcr', ?, ?, ?, 0, ?)", + "INSERT INTO oauthClient (id, clientId, kind, name, host, redirectUris, verified, createdAt, updatedAt) \ + VALUES (?, ?, 'dcr', ?, ?, ?, 0, ?, ?)", ) + .bind(tokens::new_id()) .bind(&client_id) .bind(&name) .bind(&host) .bind(serde_json::to_string(&redirect_uris).unwrap_or_default()) .bind(now) + .bind(now) .execute(&state.pool) .await .ok(); @@ -733,7 +743,12 @@ async fn token( ) -> Response { let _ = request_ip(&headers, state.config.origin_locked, Some(peer)); let now = now_ms(); - sqlx::query("DELETE FROM oauth_token WHERE expires_at < ?") + sqlx::query("DELETE FROM oauthAccessToken WHERE expiresAt < ?") + .bind(now) + .execute(&state.pool) + .await + .ok(); + sqlx::query("DELETE FROM oauthRefreshToken WHERE expiresAt < ?") .bind(now) .execute(&state.pool) .await @@ -771,7 +786,7 @@ async fn exchange_code(state: &AuthState, form: HashMap, now: i6 let hash = tokens::sha256_hex(code); let row: Option = sqlx::query_as( - "SELECT id, client_id, redirect_uri, state, code_challenge, scope, resource, account_id, expires_at \ + "SELECT id, client_id, redirect_uri, state, code_challenge, scope, resource, userId, expires_at \ FROM oauth_request WHERE code_hash = ?", ) .bind(&hash) @@ -832,15 +847,23 @@ async fn exchange_code(state: &AuthState, form: HashMap, now: i6 issue_grant(state, &client_id, &account_id, &scope, &resource, now).await } -/// Refresh-token reuse: revoke the grant and drop all of its tokens. +/// Refresh-token reuse: revoke the consent and drop all of its tokens. async fn revoke_grant_tokens(pool: &sqlx::SqlitePool, grant_id: &str, now: i64) { - sqlx::query("UPDATE oauth_grant SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL") - .bind(now) + sqlx::query( + "UPDATE oauthConsent SET revokedAt = ?, updatedAt = ? WHERE id = ? AND revokedAt IS NULL", + ) + .bind(now) + .bind(now) + .bind(grant_id) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM oauthAccessToken WHERE referenceId = ?") .bind(grant_id) .execute(pool) .await .ok(); - sqlx::query("DELETE FROM oauth_token WHERE grant_id = ?") + sqlx::query("DELETE FROM oauthRefreshToken WHERE referenceId = ?") .bind(grant_id) .execute(pool) .await @@ -855,9 +878,9 @@ async fn exchange_refresh(state: &AuthState, form: HashMap, now: }; let hash = tokens::sha256_hex(refresh); let row: Option = sqlx::query_as( - "SELECT t.grant_id, g.account_id, g.client_id, g.scope, g.resource, t.expires_at, t.rotated_at \ - FROM oauth_token t JOIN oauth_grant g ON g.id = t.grant_id \ - WHERE t.token_hash = ? AND t.kind = 'refresh'", + "SELECT t.referenceId, c.userId, c.clientId, c.scopes, c.resources, t.expiresAt, t.rotatedAt \ + FROM oauthRefreshToken t JOIN oauthConsent c ON c.id = t.referenceId \ + WHERE t.token = ?", ) .bind(&hash) .fetch_optional(&state.pool) @@ -875,13 +898,17 @@ async fn exchange_refresh(state: &AuthState, form: HashMap, now: else { return bad("invalid_grant", "the refresh token is unknown"); }; + // Outer always writes `resources` and `expiresAt`; `None` comes only + // from a foreign row and expires immediately. + let grant_resource = grant_resource.unwrap_or_else(|| state.config.resource.clone()); + let expires_at = expires_at.unwrap_or(0); // Reusing a rotated refresh token revokes the whole grant. if rotated_at.is_some() { revoke_grant_tokens(&state.pool, &grant_id, now).await; return bad("invalid_grant", "the refresh token was already used"); } if now > expires_at { - sqlx::query("DELETE FROM oauth_token WHERE token_hash = ?") + sqlx::query("DELETE FROM oauthRefreshToken WHERE token = ?") .bind(&hash) .execute(&state.pool) .await @@ -915,7 +942,7 @@ async fn exchange_refresh(state: &AuthState, form: HashMap, now: // Claim the rotation atomically: of two concurrent uses only one // updates the row; the other is reuse and revokes the grant. let claimed = sqlx::query( - "UPDATE oauth_token SET rotated_at = ? WHERE token_hash = ? AND rotated_at IS NULL", + "UPDATE oauthRefreshToken SET rotatedAt = ? WHERE token = ? AND rotatedAt IS NULL", ) .bind(now) .bind(&hash) @@ -927,21 +954,41 @@ async fn exchange_refresh(state: &AuthState, form: HashMap, now: revoke_grant_tokens(&state.pool, &grant_id, now).await; return bad("invalid_grant", "the refresh token was already used"); } - sqlx::query("INSERT INTO oauth_token (token_hash, grant_id, kind, expires_at) VALUES (?, ?, 'access', ?)") - .bind(&access_hash) - .bind(&grant_id) - .bind(now + access_ttl_ms) - .execute(&state.pool) - .await - .ok(); - sqlx::query("INSERT INTO oauth_token (token_hash, grant_id, kind, expires_at) VALUES (?, ?, 'refresh', ?)") - .bind(&refresh_hash) - .bind(&grant_id) - .bind(now + refresh_ttl_ms) - .execute(&state.pool) - .await - .ok(); - sqlx::query("UPDATE oauth_client SET last_token_at = ? WHERE client_id = ?") + let refresh_id = tokens::new_id(); + sqlx::query( + "INSERT INTO oauthRefreshToken (id, token, clientId, userId, referenceId, scopes, resources, expiresAt, createdAt) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&refresh_id) + .bind(&refresh_hash) + .bind(&client_id) + .bind(&account_id) + .bind(&grant_id) + .bind(&scope) + .bind(&grant_resource) + .bind(now + refresh_ttl_ms) + .bind(now) + .execute(&state.pool) + .await + .ok(); + sqlx::query( + "INSERT INTO oauthAccessToken (id, token, clientId, userId, referenceId, refreshId, scopes, resources, expiresAt, createdAt) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(tokens::new_id()) + .bind(&access_hash) + .bind(&client_id) + .bind(&account_id) + .bind(&grant_id) + .bind(&refresh_id) + .bind(&scope) + .bind(&grant_resource) + .bind(now + access_ttl_ms) + .bind(now) + .execute(&state.pool) + .await + .ok(); + sqlx::query("UPDATE oauthClient SET lastTokenAt = ? WHERE clientId = ?") .bind(now) .bind(&client_id) .execute(&state.pool) @@ -958,7 +1005,7 @@ async fn exchange_refresh(state: &AuthState, form: HashMap, now: } async fn grant_revoked(pool: &SqlitePool, grant_id: &str) -> bool { - sqlx::query_as::<_, (Option,)>("SELECT revoked_at FROM oauth_grant WHERE id = ?") + sqlx::query_as::<_, (Option,)>("SELECT revokedAt FROM oauthConsent WHERE id = ?") .bind(grant_id) .fetch_optional(pool) .await @@ -978,46 +1025,69 @@ async fn issue_grant( ) -> Response { let grant_id = tokens::new_id(); sqlx::query( - "INSERT INTO oauth_grant (id, account_id, client_id, scope, resource, created_at) \ - VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO oauthConsent (id, clientId, userId, scopes, resources, createdAt, updatedAt) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(&grant_id) - .bind(account_id) .bind(client_id) + .bind(account_id) .bind(scope) .bind(resource) .bind(now) + .bind(now) .execute(&state.pool) .await .ok(); let (access, access_hash) = tokens::new_access_token(); let access_ttl_ms = ttl_ms(state.config.access_ttl, 3_600_000); - sqlx::query("INSERT INTO oauth_token (token_hash, grant_id, kind, expires_at) VALUES (?, ?, 'access', ?)") - .bind(&access_hash) - .bind(&grant_id) - .bind(now + access_ttl_ms) - .execute(&state.pool) - .await - .ok(); let mut response = json!({ "access_token": access, "token_type": "Bearer", "expires_in": access_ttl_ms / 1000, "scope": scope, }); + let mut refresh_id: Option = None; if scope.split(' ').any(|part| part == "offline_access") { let (refresh, refresh_hash) = tokens::new_refresh_token(); let refresh_ttl_ms = ttl_ms(state.config.refresh_ttl, 90 * 86_400_000); - sqlx::query("INSERT INTO oauth_token (token_hash, grant_id, kind, expires_at) VALUES (?, ?, 'refresh', ?)") - .bind(&refresh_hash) - .bind(&grant_id) - .bind(now + refresh_ttl_ms) - .execute(&state.pool) - .await - .ok(); + let id = tokens::new_id(); + sqlx::query( + "INSERT INTO oauthRefreshToken (id, token, clientId, userId, referenceId, scopes, resources, expiresAt, createdAt) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&refresh_hash) + .bind(client_id) + .bind(account_id) + .bind(&grant_id) + .bind(scope) + .bind(resource) + .bind(now + refresh_ttl_ms) + .bind(now) + .execute(&state.pool) + .await + .ok(); response["refresh_token"] = json!(refresh); + refresh_id = Some(id); } - sqlx::query("UPDATE oauth_client SET last_token_at = ? WHERE client_id = ?") + sqlx::query( + "INSERT INTO oauthAccessToken (id, token, clientId, userId, referenceId, refreshId, scopes, resources, expiresAt, createdAt) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(tokens::new_id()) + .bind(&access_hash) + .bind(client_id) + .bind(account_id) + .bind(&grant_id) + .bind(&refresh_id) + .bind(scope) + .bind(resource) + .bind(now + access_ttl_ms) + .bind(now) + .execute(&state.pool) + .await + .ok(); + sqlx::query("UPDATE oauthClient SET lastTokenAt = ? WHERE clientId = ?") .bind(now) .bind(client_id) .execute(&state.pool) @@ -1042,29 +1112,24 @@ async fn revoke( ) -> Response { if let Some(token) = form.get("token") { let hash = tokens::sha256_hex(token.trim()); - let grant: Option<(String,)> = - sqlx::query_as("SELECT grant_id FROM oauth_token WHERE token_hash = ?") + let mut grant: Option<(String,)> = + sqlx::query_as("SELECT referenceId FROM oauthAccessToken WHERE token = ?") .bind(&hash) .fetch_optional(&state.pool) .await .unwrap_or(None); - if let Some((grant_id,)) = grant { - sqlx::query( - "UPDATE oauth_grant SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL", - ) - .bind(now_ms()) - .bind(&grant_id) - .execute(&state.pool) - .await - .ok(); - sqlx::query("DELETE FROM oauth_token WHERE grant_id = ?") - .bind(&grant_id) - .execute(&state.pool) + if grant.is_none() { + grant = sqlx::query_as("SELECT referenceId FROM oauthRefreshToken WHERE token = ?") + .bind(&hash) + .fetch_optional(&state.pool) .await - .ok(); + .unwrap_or(None); + } + if let Some((grant_id,)) = grant { + revoke_grant_tokens(&state.pool, &grant_id, now_ms()).await; } else { sqlx::query( - "UPDATE api_key SET revoked_at = ? WHERE key_hash = ? AND revoked_at IS NULL", + "UPDATE apikey SET enabled = 0, updatedAt = ? WHERE key = ? AND enabled != 0", ) .bind(now_ms()) .bind(&hash) @@ -1083,10 +1148,11 @@ async fn revoke( /// Delete expired authorization leftovers (the daily prune): unclaimed /// `oauth_request` rows past `expires_at` (claimed ones are deleted by the -/// exchange), expired `oauth_token` rows (also deleted opportunistically on -/// every token call), and DCR clients with no token issued in 30 days (also -/// deleted opportunistically on register). Only rows no reader can still -/// accept go (`expires_at < now`, matching the `now > expires_at` checks). +/// exchange), expired token rows in `oauthAccessToken`/`oauthRefreshToken` +/// (also deleted opportunistically on every token call), and DCR clients +/// with no token issued in 30 days (also deleted opportunistically on +/// register). Only rows no reader can still accept go (`expiresAt < now`, +/// matching the `now > expiresAt` checks). /// Returns `(requests, tokens, stale_clients)` removed. pub async fn prune_expired(pool: &sqlx::SqlitePool, now: i64) -> anyhow::Result<(u64, u64, u64)> { let requests = sqlx::query("DELETE FROM oauth_request WHERE expires_at < ?") @@ -1094,17 +1160,22 @@ pub async fn prune_expired(pool: &sqlx::SqlitePool, now: i64) -> anyhow::Result< .execute(pool) .await? .rows_affected(); - let tokens = sqlx::query("DELETE FROM oauth_token WHERE expires_at < ?") + let access = sqlx::query("DELETE FROM oauthAccessToken WHERE expiresAt < ?") + .bind(now) + .execute(pool) + .await? + .rows_affected(); + let refresh = sqlx::query("DELETE FROM oauthRefreshToken WHERE expiresAt < ?") .bind(now) .execute(pool) .await? .rows_affected(); let stale_clients = sqlx::query( - "DELETE FROM oauth_client WHERE kind = 'dcr' AND last_token_at IS NULL AND created_at < ?", + "DELETE FROM oauthClient WHERE kind = 'dcr' AND lastTokenAt IS NULL AND createdAt < ?", ) .bind(now - STALE_DCR_MS) .execute(pool) .await? .rows_affected(); - Ok((requests, tokens, stale_clients)) + Ok((requests, access + refresh, stale_clients)) } diff --git a/crates/outer/src/auth/tests.rs b/crates/outer/src/auth/tests.rs index 6071e8d..360b11a 100644 --- a/crates/outer/src/auth/tests.rs +++ b/crates/outer/src/auth/tests.rs @@ -792,7 +792,7 @@ async fn otp_attempts_expiry_and_limits() { .await .unwrap(); let code = captured(&codes, "ana@example.com"); - sqlx::query("UPDATE email_code SET expires_at = 0 WHERE email = 'ana@example.com'") + sqlx::query("UPDATE verification SET expiresAt = 0 WHERE identifier = 'email-verification:ana@example.com'") .execute(&pool) .await .unwrap(); @@ -920,12 +920,11 @@ async fn api_keys_create_list_revoke_and_resolve() { assert!(verified.api_key.starts_with("outer_")); // Terms are stored on first verify. - let (terms,): (Option,) = - sqlx::query_as("SELECT terms_version FROM account WHERE id = ?") - .bind(&verified.account_id) - .fetch_one(&pool) - .await - .unwrap(); + let (terms,): (Option,) = sqlx::query_as("SELECT termsVersion FROM user WHERE id = ?") + .bind(&verified.account_id) + .fetch_one(&pool) + .await + .unwrap(); assert_eq!(terms.as_deref(), Some("2026-10-23")); let principal = auth::resolve_bearer(&pool, &state.config, &verified.api_key) @@ -1433,7 +1432,7 @@ fn consent_page_never_warns_and_notes_only_self_registered_apps() { async fn prune_expired_keeps_live_and_boundary_rows() { let pool = test_pool().await; sqlx::query( - "INSERT INTO account (id, email, last_active_at, created_at) VALUES ('a1', 'a@example.com', 0, 0)", + "INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('a1', 'a@example.com', 0, 0, 0)", ) .execute(&pool) .await @@ -1444,9 +1443,10 @@ async fn prune_expired_keeps_live_and_boundary_rows() { ("edge@example.com", 100), ] { sqlx::query( - "INSERT INTO email_code (email, code_hash, expires_at, created_at) VALUES (?, 'h', ?, 0)", + "INSERT INTO verification (id, identifier, value, expiresAt, createdAt, updatedAt) VALUES (?, ?, 'h', ?, 0, 0)", ) .bind(email) + .bind(format!("email-verification:{email}")) .bind(expires_at) .execute(&pool) .await @@ -1454,9 +1454,10 @@ async fn prune_expired_keeps_live_and_boundary_rows() { } for (hash, expires_at) in [("dead", 99), ("live", 200), ("edge", 100)] { sqlx::query( - "INSERT INTO web_session (id_hash, account_id, expires_at) VALUES (?, 'a1', ?)", + "INSERT INTO session (id, token, userId, expiresAt, createdAt, updatedAt) VALUES (?, ?, 'a1', ?, 0, 0)", ) .bind(hash) + .bind(hash) .bind(expires_at) .execute(&pool) .await @@ -1464,16 +1465,22 @@ async fn prune_expired_keeps_live_and_boundary_rows() { } let (codes, sessions) = auth::prune_expired(&pool, 100).await.unwrap(); assert_eq!((codes, sessions), (1, 1)); - let kept_codes: Vec = sqlx::query_scalar("SELECT email FROM email_code ORDER BY email") - .fetch_all(&pool) - .await - .unwrap(); - assert_eq!(kept_codes, vec!["edge@example.com", "live@example.com"]); - let kept_sessions: Vec = - sqlx::query_scalar("SELECT id_hash FROM web_session ORDER BY id_hash") + let kept_codes: Vec = + sqlx::query_scalar("SELECT identifier FROM verification ORDER BY identifier") .fetch_all(&pool) .await .unwrap(); + assert_eq!( + kept_codes, + vec![ + "email-verification:edge@example.com", + "email-verification:live@example.com" + ] + ); + let kept_sessions: Vec = sqlx::query_scalar("SELECT token FROM session ORDER BY token") + .fetch_all(&pool) + .await + .unwrap(); assert_eq!(kept_sessions, vec!["edge", "live"]); } @@ -1487,7 +1494,7 @@ async fn oauth_prune_expired_keeps_live_and_other_kinds() { // would make every client "fresh". let now: i64 = 3_000_000_000; sqlx::query( - "INSERT INTO account (id, email, last_active_at, created_at) VALUES ('a1', 'a@example.com', 0, 0)", + "INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('a1', 'a@example.com', 0, 0, 0)", ) .execute(&pool) .await @@ -1507,9 +1514,27 @@ async fn oauth_prune_expired_keeps_live_and_other_kinds() { .await .unwrap(); } + for (client_id, kind, created_at) in [ + ("c", "cimd", 100), + ("dcr-old", "dcr", 100), + ("dcr-fresh", "dcr", now - 1_000), + ("cimd-old", "cimd", 100), + ] { + sqlx::query( + "INSERT INTO oauthClient (id, clientId, kind, redirectUris, verified, createdAt, updatedAt) VALUES (?, ?, ?, '[]', 0, ?, ?)", + ) + .bind(format!("oc-{client_id}")) + .bind(client_id) + .bind(kind) + .bind(created_at) + .bind(created_at) + .execute(&pool) + .await + .unwrap(); + } sqlx::query( - "INSERT INTO oauth_grant (id, account_id, client_id, scope, resource, created_at) \ - VALUES ('g1', 'a1', 'c', 'apps', 'r', 0)", + "INSERT INTO oauthConsent (id, clientId, userId, scopes, resources, createdAt, updatedAt) \ + VALUES ('g1', 'c', 'a1', 'apps', 'r', 0, 0)", ) .execute(&pool) .await @@ -1520,29 +1545,15 @@ async fn oauth_prune_expired_keeps_live_and_other_kinds() { ("t-edge", now), ] { sqlx::query( - "INSERT INTO oauth_token (token_hash, grant_id, kind, expires_at) VALUES (?, 'g1', 'access', ?)", + "INSERT INTO oauthAccessToken (id, token, clientId, userId, referenceId, scopes, expiresAt, createdAt) VALUES (?, ?, 'c', 'a1', 'g1', 'apps', ?, 0)", ) .bind(hash) + .bind(hash) .bind(expires_at) .execute(&pool) .await .unwrap(); } - for (client_id, kind, created_at) in [ - ("dcr-old", "dcr", 100), - ("dcr-fresh", "dcr", now - 1_000), - ("cimd-old", "cimd", 100), - ] { - sqlx::query( - "INSERT INTO oauth_client (client_id, kind, redirect_uris, created_at) VALUES (?, ?, '[]', ?)", - ) - .bind(client_id) - .bind(kind) - .bind(created_at) - .execute(&pool) - .await - .unwrap(); - } let (requests, tokens, clients) = auth::oauth::prune_expired(&pool, now).await.unwrap(); assert_eq!((requests, tokens, clients), (1, 1, 1)); let kept_requests: Vec = sqlx::query_scalar("SELECT id FROM oauth_request ORDER BY id") @@ -1551,15 +1562,15 @@ async fn oauth_prune_expired_keeps_live_and_other_kinds() { .unwrap(); assert_eq!(kept_requests, vec!["r-edge", "r-live"]); let kept_tokens: Vec = - sqlx::query_scalar("SELECT token_hash FROM oauth_token ORDER BY token_hash") + sqlx::query_scalar("SELECT token FROM oauthAccessToken ORDER BY token") .fetch_all(&pool) .await .unwrap(); assert_eq!(kept_tokens, vec!["t-edge", "t-live"]); let kept_clients: Vec = - sqlx::query_scalar("SELECT client_id FROM oauth_client ORDER BY client_id") + sqlx::query_scalar("SELECT clientId FROM oauthClient ORDER BY clientId") .fetch_all(&pool) .await .unwrap(); - assert_eq!(kept_clients, vec!["cimd-old", "dcr-fresh"]); + assert_eq!(kept_clients, vec!["c", "cimd-old", "dcr-fresh"]); } diff --git a/crates/outer/src/db/mod.rs b/crates/outer/src/db/mod.rs index e61c22d..1163c7c 100644 --- a/crates/outer/src/db/mod.rs +++ b/crates/outer/src/db/mod.rs @@ -34,14 +34,14 @@ mod tests { let path = dir.path().join("outer.sqlite"); let pool = open(&path).await.unwrap(); - sqlx::query("INSERT INTO account (id, email, last_active_at, created_at) VALUES ('a1', 'ana@example.com', 0, 0)") + sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('a1', 'ana@example.com', 0, 0, 0)") .execute(&pool) .await .unwrap(); pool.close().await; let pool = open(&path).await.unwrap(); - let (emails,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM account") + let (emails,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM user") .fetch_one(&pool) .await .unwrap(); @@ -55,6 +55,15 @@ mod tests { ) .execute(&pool) .await; - assert!(orphan.is_err(), "an app must reference an existing account"); + assert!(orphan.is_err(), "an app must reference an existing user"); + + let violations: Vec<(i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") + .fetch_all(&pool) + .await + .unwrap(); + assert!( + violations.is_empty(), + "a fresh database has no foreign-key violations: {violations:?}" + ); } } diff --git a/crates/outer/src/deploy/pipeline.rs b/crates/outer/src/deploy/pipeline.rs index 44d5767..9748772 100644 --- a/crates/outer/src/deploy/pipeline.rs +++ b/crates/outer/src/deploy/pipeline.rs @@ -2985,7 +2985,7 @@ mod tests { let pool = crate::db::open(&dir.path().join("outer.sqlite")) .await .unwrap(); - sqlx::query("INSERT INTO account (id, email, last_active_at, created_at) VALUES ('owner1', 'o@example.com', 0, 0)") + sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('owner1', 'o@example.com', 0, 0, 0)") .execute(&pool) .await .unwrap(); @@ -5469,7 +5469,7 @@ exit 0 let pool = crate::db::open(&dir.path().join("outer.sqlite")) .await .unwrap(); - sqlx::query("INSERT INTO account (id, email, last_active_at, created_at) VALUES ('a1', 'o@example.com', 0, 0)") + sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('a1', 'o@example.com', 0, 0, 0)") .execute(&pool) .await .unwrap(); diff --git a/crates/outer/src/fleet/manager.rs b/crates/outer/src/fleet/manager.rs index 9cc8d4c..7274c7f 100644 --- a/crates/outer/src/fleet/manager.rs +++ b/crates/outer/src/fleet/manager.rs @@ -772,7 +772,7 @@ mod tests { let pool = crate::db::open(&dir.path().join("outer.sqlite")) .await .unwrap(); - sqlx::query("INSERT INTO account (id, email, last_active_at, created_at) VALUES ('a1', 'w@example.com', 0, 0)") + sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('a1', 'w@example.com', 0, 0, 0)") .execute(&pool) .await .unwrap(); @@ -941,7 +941,7 @@ mod tests { let pool = crate::db::open(&dir.path().join("outer.sqlite")) .await .unwrap(); - sqlx::query("INSERT INTO account (id, email, last_active_at, created_at) VALUES ('a1', 'o@example.com', 0, 0)") + sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('a1', 'o@example.com', 0, 0, 0)") .execute(&pool) .await .unwrap(); diff --git a/crates/outer/src/http/console.rs b/crates/outer/src/http/console.rs index 4a2e1af..8bd8dc4 100644 --- a/crates/outer/src/http/console.rs +++ b/crates/outer/src/http/console.rs @@ -655,7 +655,7 @@ async fn api_sign_out(State(state): State, headers: HeaderMap) ); } let hash = crate::auth::session_hash(&session); - sqlx::query("DELETE FROM web_session WHERE id_hash = ?") + sqlx::query("DELETE FROM session WHERE token = ?") .bind(&hash) .execute(&mcp.pool) .await @@ -1133,12 +1133,13 @@ mod tests { /// Seed one account plus one app with one finished deploy. async fn seed_app(pool: &SqlitePool, owner: &str, slug: &str, now: i64) { sqlx::query( - "INSERT INTO account (id, email, last_active_at, created_at) VALUES (?, ?, ?, ?)", + "INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)", ) .bind(format!("acct-{owner}")) .bind(owner) .bind(now) .bind(now) + .bind(now) .execute(pool) .await .unwrap(); @@ -1416,7 +1417,7 @@ mod tests { created_at: i64, ) { sqlx::query( - "INSERT INTO agent_action (id, app_id, account_id, grant_id, api_key_id, \ + "INSERT INTO agent_action (id, app_id, user_id, consent_id, apikey_id, \ tool, summary, outcome, error_code, duration_ms, created_at) \ VALUES (?1, ?2, 'acct-ana@example.com', NULL, NULL, ?3, 'did a thing', ?4, NULL, 120, ?5)", ) diff --git a/crates/outer/src/http/proxy.rs b/crates/outer/src/http/proxy.rs index 71a4a5b..d7b93cf 100644 --- a/crates/outer/src/http/proxy.rs +++ b/crates/outer/src/http/proxy.rs @@ -501,7 +501,7 @@ mod tests { let pool = crate::db::open(&dir.path().join("outer.sqlite")) .await .unwrap(); - sqlx::query("INSERT INTO account (id, email, last_active_at, created_at) VALUES ('a1', 'o@example.com', 0, 0)") + sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('a1', 'o@example.com', 0, 0, 0)") .execute(&pool) .await .unwrap(); diff --git a/crates/outer/src/mcp/actions.rs b/crates/outer/src/mcp/actions.rs index ed90830..33c415b 100644 --- a/crates/outer/src/mcp/actions.rs +++ b/crates/outer/src/mcp/actions.rs @@ -318,7 +318,7 @@ pub async fn record( Credential::ApiKey { key_id } => (None, Some(key_id.as_str())), }; if let Err(err) = sqlx::query( - "INSERT INTO agent_action (id, app_id, account_id, grant_id, api_key_id, \ + "INSERT INTO agent_action (id, app_id, user_id, consent_id, apikey_id, \ tool, summary, outcome, error_code, duration_ms, created_at) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", ) @@ -348,16 +348,16 @@ pub async fn prune(pool: &SqlitePool, now: i64) -> anyhow::Result { // Set-based, on the `created_at` index: Pro apps keep 30 d, the rest 7 d. let pro = sqlx::query( "DELETE FROM agent_action WHERE created_at < ?1 AND app_id IN \ - (SELECT app.id FROM app JOIN account ON account.id = app.owner_id \ - WHERE account.plan = 'pro')", + (SELECT app.id FROM app JOIN user ON user.id = app.owner_id \ + WHERE user.plan = 'pro')", ) .bind(pro_cutoff) .execute(pool) .await?; let rest = sqlx::query( "DELETE FROM agent_action WHERE created_at < ?1 AND app_id NOT IN \ - (SELECT app.id FROM app JOIN account ON account.id = app.owner_id \ - WHERE account.plan = 'pro')", + (SELECT app.id FROM app JOIN user ON user.id = app.owner_id \ + WHERE user.plan = 'pro')", ) .bind(free_cutoff) .execute(pool) @@ -431,11 +431,11 @@ pub async fn list( let rows: Vec = match before { Some((before_ts, before_id)) => sqlx::query_as( "SELECT a.id, a.tool, a.summary, a.outcome, a.error_code, a.duration_ms, a.created_at, \ - g.client_name, c.name, c.host, k.name, k.prefix \ + g.clientName, c.name, c.host, k.name, k.prefix \ FROM agent_action AS a \ - LEFT JOIN oauth_grant AS g ON g.id = a.grant_id \ - LEFT JOIN oauth_client AS c ON c.client_id = g.client_id \ - LEFT JOIN api_key AS k ON k.id = a.api_key_id \ + LEFT JOIN oauthConsent AS g ON g.id = a.consent_id \ + LEFT JOIN oauthClient AS c ON c.clientId = g.clientId \ + LEFT JOIN apikey AS k ON k.id = a.apikey_id \ WHERE a.app_id = ?1 AND (a.created_at, a.id) < (?2, ?3) \ ORDER BY a.created_at DESC, a.id DESC LIMIT ?4", ) @@ -447,11 +447,11 @@ pub async fn list( .await?, None => sqlx::query_as( "SELECT a.id, a.tool, a.summary, a.outcome, a.error_code, a.duration_ms, a.created_at, \ - g.client_name, c.name, c.host, k.name, k.prefix \ + g.clientName, c.name, c.host, k.name, k.prefix \ FROM agent_action AS a \ - LEFT JOIN oauth_grant AS g ON g.id = a.grant_id \ - LEFT JOIN oauth_client AS c ON c.client_id = g.client_id \ - LEFT JOIN api_key AS k ON k.id = a.api_key_id \ + LEFT JOIN oauthConsent AS g ON g.id = a.consent_id \ + LEFT JOIN oauthClient AS c ON c.clientId = g.clientId \ + LEFT JOIN apikey AS k ON k.id = a.apikey_id \ WHERE a.app_id = ?1 \ ORDER BY a.created_at DESC, a.id DESC LIMIT ?2", ) diff --git a/crates/outer/src/mcp/tests.rs b/crates/outer/src/mcp/tests.rs index 6cd0018..d954b15 100644 --- a/crates/outer/src/mcp/tests.rs +++ b/crates/outer/src/mcp/tests.rs @@ -735,13 +735,11 @@ async fn free_plan_caps_changes_per_day() { let account_id = crate::apps::ensure_account(&state.pool, "ana@example.com") .await .unwrap(); - sqlx::query( - "INSERT INTO usage_day (account_id, day, code_changes) VALUES (?1, date('now'), 49)", - ) - .bind(&account_id) - .execute(&state.pool) - .await - .unwrap(); + sqlx::query("INSERT INTO usage_day (userId, day, code_changes) VALUES (?1, date('now'), 49)") + .bind(&account_id) + .execute(&state.pool) + .await + .unwrap(); let (_, fiftieth) = call( &state, @@ -1254,19 +1252,17 @@ async fn commit_quota_claim_is_atomic_and_refunded() { .await .unwrap(); async fn used(state: &McpState, account_id: &str) -> i64 { - sqlx::query_scalar("SELECT code_changes FROM usage_day WHERE account_id = ?1") + sqlx::query_scalar("SELECT code_changes FROM usage_day WHERE userId = ?1") .bind(account_id) .fetch_one(&state.pool) .await .unwrap() } - sqlx::query( - "INSERT INTO usage_day (account_id, day, code_changes) VALUES (?1, date('now'), 10)", - ) - .bind(&account_id) - .execute(&state.pool) - .await - .unwrap(); + sqlx::query("INSERT INTO usage_day (userId, day, code_changes) VALUES (?1, date('now'), 10)") + .bind(&account_id) + .execute(&state.pool) + .await + .unwrap(); // A malformed base never claims: validation runs before the UPSERT. let (_, bad_shape) = call( &state, @@ -1290,7 +1286,7 @@ async fn commit_quota_claim_is_atomic_and_refunded() { assert_eq!(is_error(&unknown)["error"], "conflict"); assert_eq!(used(&state, &account_id).await, 10); // Racing at the boundary: exactly one claim fits under 50. - sqlx::query("UPDATE usage_day SET code_changes = 49 WHERE account_id = ?1") + sqlx::query("UPDATE usage_day SET code_changes = 49 WHERE userId = ?1") .bind(&account_id) .execute(&state.pool) .await @@ -1914,14 +1910,14 @@ async fn agent_action_commit_logged_with_redacted_summary() { rows[0].client_label ); let key_ids: Vec> = - sqlx::query_scalar("SELECT api_key_id FROM agent_action ORDER BY created_at") + sqlx::query_scalar("SELECT apikey_id FROM agent_action ORDER BY created_at") .fetch_all(&state.pool) .await .unwrap(); assert_eq!(key_ids.len(), 2); assert!(key_ids.iter().all(|id| id.is_some()), "{key_ids:?}"); let grants: Vec> = - sqlx::query_scalar("SELECT grant_id FROM agent_action ORDER BY created_at") + sqlx::query_scalar("SELECT consent_id FROM agent_action ORDER BY created_at") .fetch_all(&state.pool) .await .unwrap(); @@ -2006,7 +2002,7 @@ async fn agent_action_oauth_caller_resolves_client_label() { let token = api_key_for(&state, "ana@example.com").await; create_app(&state, &token, "groceries").await; let account_id: String = - sqlx::query_scalar("SELECT id FROM account WHERE email = 'ana@example.com'") + sqlx::query_scalar("SELECT id FROM user WHERE email = 'ana@example.com'") .fetch_one(&state.pool) .await .unwrap(); @@ -2014,16 +2010,16 @@ async fn agent_action_oauth_caller_resolves_client_label() { let (access, hash) = crate::auth::tokens::new_access_token(); let now = crate::auth::now_ms(); sqlx::query( - "INSERT INTO oauth_client (client_id, kind, name, host, redirect_uris, created_at) \ - VALUES ('cid-claude', 'dcr', 'Claude', 'claude.ai', '[\"https://claude.ai/cb\"]', ?1)", + "INSERT INTO oauthClient (id, clientId, kind, name, host, redirectUris, verified, createdAt, updatedAt) \ + VALUES ('ocid-1', 'cid-claude', 'dcr', 'Claude', 'claude.ai', '[\"https://claude.ai/cb\"]', 0, ?1, ?1)", ) .bind(now) .execute(&state.pool) .await .unwrap(); sqlx::query( - "INSERT INTO oauth_grant (id, account_id, client_id, scope, resource, client_name, created_at) \ - VALUES ('gid-1', ?1, 'cid-claude', 'apps', 'http://outer.localhost/mcp', 'Claude', ?2)", + "INSERT INTO oauthConsent (id, clientId, userId, scopes, resources, clientName, createdAt, updatedAt) \ + VALUES ('gid-1', 'cid-claude', ?1, 'apps', 'http://outer.localhost/mcp', 'Claude', ?2, ?2)", ) .bind(&account_id) .bind(now) @@ -2031,11 +2027,13 @@ async fn agent_action_oauth_caller_resolves_client_label() { .await .unwrap(); sqlx::query( - "INSERT INTO oauth_token (token_hash, grant_id, kind, expires_at) \ - VALUES (?1, 'gid-1', 'access', ?2)", + "INSERT INTO oauthAccessToken (id, token, clientId, userId, referenceId, scopes, expiresAt, createdAt) \ + VALUES ('oat-1', ?1, 'cid-claude', ?2, 'gid-1', 'apps', ?3, ?4)", ) .bind(&hash) + .bind(&account_id) .bind(now + 3_600_000) + .bind(now) .execute(&state.pool) .await .unwrap(); @@ -2054,7 +2052,7 @@ async fn agent_action_oauth_caller_resolves_client_label() { assert_eq!(rows[0].tool, "get_app"); assert_eq!(rows[0].client_label, "Claude (claude.ai)"); let grant_id: Option = - sqlx::query_scalar("SELECT grant_id FROM agent_action WHERE tool = 'get_app'") + sqlx::query_scalar("SELECT consent_id FROM agent_action WHERE tool = 'get_app'") .fetch_one(&state.pool) .await .unwrap(); @@ -2068,7 +2066,7 @@ async fn agent_action_pagination_prune_and_delete_cascade() { create_app(&state, &token, "groceries").await; let id = app_id(&state, "groceries").await; let account_id: String = - sqlx::query_scalar("SELECT id FROM account WHERE email = 'ana@example.com'") + sqlx::query_scalar("SELECT id FROM user WHERE email = 'ana@example.com'") .fetch_one(&state.pool) .await .unwrap(); @@ -2082,7 +2080,7 @@ async fn agent_action_pagination_prune_and_delete_cascade() { ("c", "commit", 300), ] { sqlx::query( - "INSERT INTO agent_action (id, app_id, account_id, tool, summary, outcome, duration_ms, created_at) \ + "INSERT INTO agent_action (id, app_id, user_id, tool, summary, outcome, duration_ms, created_at) \ VALUES (?1, ?2, ?3, ?4, 's', 'ok', 1, ?5)", ) .bind(row_id) @@ -2135,7 +2133,7 @@ async fn agent_action_pagination_prune_and_delete_cascade() { assert_eq!(kept, vec!["b".to_string(), "c".to_string()]); // Pro accounts keep 30 days: an 8-day-old row survives, a 31-day one goes. - sqlx::query("UPDATE account SET plan = 'pro' WHERE id = ?1") + sqlx::query("UPDATE user SET plan = 'pro' WHERE id = ?1") .bind(&account_id) .execute(&state.pool) .await diff --git a/crates/outer/src/mcp/tools/mod.rs b/crates/outer/src/mcp/tools/mod.rs index d8946ad..ef9cb8d 100644 --- a/crates/outer/src/mcp/tools/mod.rs +++ b/crates/outer/src/mcp/tools/mod.rs @@ -140,7 +140,7 @@ pub fn resets_at_utc() -> String { /// The account's plan (`free` unless the row says `pro`). pub async fn plan_of(pool: &sqlx::SqlitePool, account_id: &str) -> Result { - let plan: Option = sqlx::query_scalar("SELECT plan FROM account WHERE id = ?1") + let plan: Option = sqlx::query_scalar("SELECT plan FROM user WHERE id = ?1") .bind(account_id) .fetch_optional(pool) .await @@ -169,7 +169,7 @@ pub async fn changes_used_today( account_id: &str, ) -> Result { let used: Option = - sqlx::query_scalar("SELECT code_changes FROM usage_day WHERE account_id = ?1 AND day = ?2") + sqlx::query_scalar("SELECT code_changes FROM usage_day WHERE userId = ?1 AND day = ?2") .bind(account_id) .bind(today_utc()) .fetch_optional(pool) @@ -194,8 +194,8 @@ pub async fn claim_change(pool: &sqlx::SqlitePool, account_id: &str) -> Result Result anyhow::Result<()> { sqlx::query( "UPDATE usage_day SET code_changes = code_changes - 1 \ - WHERE account_id = ?1 AND day = ?2 AND code_changes > 0", + WHERE userId = ?1 AND day = ?2 AND code_changes > 0", ) .bind(account_id) .bind(today_utc()) diff --git a/crates/outer/src/moderation/gate.rs b/crates/outer/src/moderation/gate.rs index 73b5bbd..7ca4eae 100644 --- a/crates/outer/src/moderation/gate.rs +++ b/crates/outer/src/moderation/gate.rs @@ -497,7 +497,7 @@ mod tests { let pool = crate::db::open(&dir.path().join("outer.sqlite")) .await .unwrap(); - sqlx::query("INSERT INTO account (id, email, last_active_at, created_at) VALUES ('o', 'o@x.com', 0, 0)") + sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('o', 'o@x.com', 0, 0, 0)") .execute(&pool) .await .unwrap(); diff --git a/crates/outer/src/observe/metrics.rs b/crates/outer/src/observe/metrics.rs index 9778752..7216901 100644 --- a/crates/outer/src/observe/metrics.rs +++ b/crates/outer/src/observe/metrics.rs @@ -724,7 +724,7 @@ pub async fn prune(pool: &SqlitePool, now_ms: i64) -> anyhow::Result<(u64, u64, for (cutoff, plan) in [(free_cutoff, "free"), (pro_cutoff, "pro")] { minutes += sqlx::query( "DELETE FROM metric_minute WHERE minute < ?1 AND app_id IN \ - (SELECT a.id FROM app a JOIN account c ON c.id = a.owner_id WHERE c.plan = ?2)", + (SELECT a.id FROM app a JOIN user c ON c.id = a.owner_id WHERE c.plan = ?2)", ) .bind(cutoff) .bind(plan) @@ -736,7 +736,7 @@ pub async fn prune(pool: &SqlitePool, now_ms: i64) -> anyhow::Result<(u64, u64, for (cutoff, plan) in [(free_cutoff, "free"), (pro_cutoff, "pro")] { paths += sqlx::query( "DELETE FROM path_hour WHERE hour < ?1 AND app_id IN \ - (SELECT a.id FROM app a JOIN account c ON c.id = a.owner_id WHERE c.plan = ?2)", + (SELECT a.id FROM app a JOIN user c ON c.id = a.owner_id WHERE c.plan = ?2)", ) .bind(cutoff) .bind(plan) @@ -748,7 +748,7 @@ pub async fn prune(pool: &SqlitePool, now_ms: i64) -> anyhow::Result<(u64, u64, for (cutoff, plan) in [(day_of(free_cutoff), "free"), (day_of(pro_cutoff), "pro")] { days += sqlx::query( "DELETE FROM visitor_day WHERE day < ?1 AND app_id IN \ - (SELECT a.id FROM app a JOIN account c ON c.id = a.owner_id WHERE c.plan = ?2)", + (SELECT a.id FROM app a JOIN user c ON c.id = a.owner_id WHERE c.plan = ?2)", ) .bind(cutoff) .bind(plan) @@ -940,7 +940,7 @@ mod tests { } async fn app(pool: &SqlitePool, id: &str, plan: &str) { - sqlx::query("INSERT INTO account (id, email, plan, last_active_at, created_at) VALUES (?1, ?2, ?3, 0, 0)") + sqlx::query("INSERT INTO user (id, email, plan, lastActiveAt, createdAt, updatedAt) VALUES (?1, ?2, ?3, 0, 0, 0)") .bind(format!("acct-{id}")) .bind(format!("{id}@example.com")) .bind(plan) diff --git a/crates/outer/src/ops.rs b/crates/outer/src/ops.rs index 6e32656..bf3d1b6 100644 --- a/crates/outer/src/ops.rs +++ b/crates/outer/src/ops.rs @@ -48,7 +48,7 @@ pub async fn create_app( let template = templates::materialize(&p.store, &tree).await?; let owner_id = apps::ensure_account(&p.pool, owner_email).await?; - let plan: Option = sqlx::query_scalar("SELECT plan FROM account WHERE id = ?1") + let plan: Option = sqlx::query_scalar("SELECT plan FROM user WHERE id = ?1") .bind(&owner_id) .fetch_optional(&p.pool) .await?; diff --git a/crates/outer/src/secrets.rs b/crates/outer/src/secrets.rs index 18d42ab..5cd7487 100644 --- a/crates/outer/src/secrets.rs +++ b/crates/outer/src/secrets.rs @@ -381,7 +381,7 @@ mod tests { let pool = crate::db::open(&dir.path().join("outer.sqlite")) .await .unwrap(); - sqlx::query("INSERT INTO account (id, email, last_active_at, created_at) VALUES ('owner1', 'o@example.com', 0, 0)") + sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('owner1', 'o@example.com', 0, 0, 0)") .execute(&pool) .await .unwrap(); From 3963106b75218336a948172cefb5e2a62c2005b6 Mon Sep 17 00:00:00 2001 From: Ryuz Date: Sat, 24 Oct 2026 17:03:05 +0200 Subject: [PATCH 2/2] Close OAuth token-endpoint gaps: client binding, rotation tx, ban/expiry/revoked checks, consent FK, strict resource 1. exchange_code/exchange_refresh require client_id (invalid_client when absent, invalid_grant on mismatch) before any state change. 2. Refresh rotation (consent-live recheck, claim, mint) and reuse revocation run in single transactions. 3. Banned (banExpires-aware) and missing users fail both grant types. 4/5. resolve_bearer enforces apikey.expiresAt and token revoked flag. 6. oauthAccessToken/oauthRefreshToken.referenceId FK to oauthConsent with cascade. 7. Token resource must equal the authorize request's (no downgrade to server default). Tests: 5 new finding-pinning tests; existing token tests send client_id. --- crates/outer/migrations/0001_init.sql | 4 +- crates/outer/src/auth/mod.rs | 5 +- crates/outer/src/auth/oauth.rs | 183 ++++++--- crates/outer/src/auth/tests.rs | 537 +++++++++++++++++++++++++- 4 files changed, 676 insertions(+), 53 deletions(-) diff --git a/crates/outer/migrations/0001_init.sql b/crates/outer/migrations/0001_init.sql index 1b61e61..a174227 100644 --- a/crates/outer/migrations/0001_init.sql +++ b/crates/outer/migrations/0001_init.sql @@ -220,7 +220,7 @@ CREATE TABLE oauthAccessToken ( clientId TEXT NOT NULL REFERENCES oauthClient (clientId), sessionId TEXT REFERENCES session (id) ON DELETE SET NULL, userId TEXT REFERENCES user (id) ON DELETE CASCADE, - referenceId TEXT, + referenceId TEXT REFERENCES oauthConsent (id) ON DELETE CASCADE, authorizationCodeId TEXT, resources TEXT, requestedUserInfoClaims TEXT, @@ -241,7 +241,7 @@ CREATE TABLE oauthRefreshToken ( clientId TEXT NOT NULL REFERENCES oauthClient (clientId), sessionId TEXT REFERENCES session (id) ON DELETE SET NULL, userId TEXT NOT NULL REFERENCES user (id) ON DELETE CASCADE, - referenceId TEXT, + referenceId TEXT REFERENCES oauthConsent (id) ON DELETE CASCADE, authorizationCodeId TEXT, resources TEXT, requestedUserInfoClaims TEXT, diff --git a/crates/outer/src/auth/mod.rs b/crates/outer/src/auth/mod.rs index 0bb66d5..bd86e8f 100644 --- a/crates/outer/src/auth/mod.rs +++ b/crates/outer/src/auth/mod.rs @@ -781,6 +781,7 @@ pub async fn resolve_bearer( FROM oauthAccessToken t JOIN oauthConsent c ON c.id = t.referenceId \ JOIN user u ON u.id = c.userId \ WHERE t.token = ? AND t.expiresAt > ? \ + AND (t.revoked IS NULL OR t.revoked = 0) \ AND c.revokedAt IS NULL AND u.banned = 0", ) .bind(&hash) @@ -811,9 +812,11 @@ pub async fn resolve_bearer( let row: Option<(String, String, String, Option)> = sqlx::query_as( "SELECT k.id, k.referenceId, u.email, k.lastRequest \ FROM apikey k JOIN user u ON u.id = k.referenceId \ - WHERE k.key = ? AND k.enabled != 0 AND u.banned = 0", + WHERE k.key = ? AND k.enabled != 0 AND u.banned = 0 \ + AND (k.expiresAt IS NULL OR k.expiresAt > ?)", ) .bind(&hash) + .bind(now) .fetch_optional(pool) .await?; let Some((key_id, account_id, email, last_used_at)) = row else { diff --git a/crates/outer/src/auth/oauth.rs b/crates/outer/src/auth/oauth.rs index 3a4060f..f8d440e 100644 --- a/crates/outer/src/auth/oauth.rs +++ b/crates/outer/src/auth/oauth.rs @@ -213,7 +213,8 @@ type CodeRow = ( i64, ); /// One refresh token row: consent id, userId, clientId, scopes, resources, -/// expiresAt, rotatedAt. `resources`/`expiresAt` arrive `None` only from rows +/// expiresAt, rotatedAt, the user's banned flag and ban expiry. +/// `resources`/`expiresAt` arrive `None` only from rows /// Outer never writes (it always sets both); `None` expires immediately. type RefreshRow = ( String, @@ -223,6 +224,8 @@ type RefreshRow = ( Option, Option, Option, + i64, + Option, ); async fn load_request( pool: &SqlitePool, @@ -731,10 +734,10 @@ async fn register( } /// `POST /oauth/token` (form): authorization_code (single use, 60 s TTL, -/// PKCE S256 required, redirect_uri match, `resource` checked when sent) and -/// refresh_token (rotated every use, 90-day sliding; reusing a rotated token -/// revokes the whole grant). Proper OAuth JSON errors + `Cache-Control: -/// no-store`. +/// PKCE S256 required, `client_id`/redirect_uri/`resource` bound to the +/// authorize request) and refresh_token (rotated every use in one +/// transaction, 90-day sliding; reusing a rotated token revokes the whole +/// grant). Proper OAuth JSON errors + `Cache-Control: no-store`. async fn token( State(state): State, headers: HeaderMap, @@ -767,6 +770,12 @@ async fn token( async fn exchange_code(state: &AuthState, form: HashMap, now: i64) -> Response { let bad = |code: &str, description: &str| oauth_error(StatusCode::BAD_REQUEST, code, description); + // Public clients authenticate by `client_id` alone (RFC 6749 §4.1.3, + // RFC 8414: `token_endpoint_auth_methods_supported: ["none"]`), so a + // missing id is an authentication failure, not a bad grant. + let Some(form_client) = form.get("client_id").filter(|id| !id.is_empty()) else { + return bad("invalid_client", "client_id is required for public clients"); + }; let (Some(code), Some(redirect_uri), Some(verifier)) = ( form.get("code"), form.get("redirect_uri"), @@ -819,6 +828,12 @@ async fn exchange_code(state: &AuthState, form: HashMap, now: i6 if !claimed { return bad("invalid_grant", "the authorization code was already used"); } + if form_client != &client_id { + return bad( + "invalid_grant", + "client_id does not match the authorize request", + ); + } if now > expires_at { return bad("invalid_grant", "the authorization code expired"); } @@ -831,8 +846,12 @@ async fn exchange_code(state: &AuthState, form: HashMap, now: i6 if !tokens::ct_eq(&tokens::pkce_s256(verifier), &challenge) { return bad("invalid_grant", "PKCE verification failed"); } + // The token request is bound to the authorize request's resource (RFC + // 8707): omitting it keeps the stored value, sending a different one — + // including the server default — is a downgrade and fails. if let Some(resource) = form.get("resource") { - if stored_resource.as_ref() != Some(resource) && *resource != state.config.resource { + let expected = stored_resource.as_ref().unwrap_or(&state.config.resource); + if resource != expected { return bad( "invalid_target", "resource does not match the authorize request", @@ -842,44 +861,89 @@ async fn exchange_code(state: &AuthState, form: HashMap, now: i6 let Some(account_id) = account_id else { return bad("invalid_grant", "the authorization was never completed"); }; + if user_banned(&state.pool, &account_id, now).await { + return bad("invalid_grant", "the account is no longer active"); + } let scope = scope.unwrap_or_else(|| "apps".to_string()); let resource = stored_resource.unwrap_or_else(|| state.config.resource.clone()); issue_grant(state, &client_id, &account_id, &scope, &resource, now).await } -/// Refresh-token reuse: revoke the consent and drop all of its tokens. +/// Refresh-token reuse: revoke the consent and drop all of its tokens. One +/// transaction, so a concurrent rotation either lands fully before the +/// revoke (and is wiped with everything else) or fails closed after it — +/// never a fresh token minted onto a revoked consent. async fn revoke_grant_tokens(pool: &sqlx::SqlitePool, grant_id: &str, now: i64) { - sqlx::query( + let Ok(mut tx) = pool.begin().await else { + return; + }; + let done = sqlx::query( "UPDATE oauthConsent SET revokedAt = ?, updatedAt = ? WHERE id = ? AND revokedAt IS NULL", ) .bind(now) .bind(now) .bind(grant_id) - .execute(pool) + .execute(&mut *tx) .await - .ok(); - sqlx::query("DELETE FROM oauthAccessToken WHERE referenceId = ?") - .bind(grant_id) - .execute(pool) - .await - .ok(); - sqlx::query("DELETE FROM oauthRefreshToken WHERE referenceId = ?") - .bind(grant_id) - .execute(pool) - .await - .ok(); + .is_ok() + && sqlx::query("DELETE FROM oauthAccessToken WHERE referenceId = ?") + .bind(grant_id) + .execute(&mut *tx) + .await + .is_ok() + && sqlx::query("DELETE FROM oauthRefreshToken WHERE referenceId = ?") + .bind(grant_id) + .execute(&mut *tx) + .await + .is_ok(); + if done { + tx.commit().await.ok(); + } +} + +/// A banned user (or a code/refresh pointing at a deleted user) must stop +/// authenticating. An expired temporary ban no longer counts. +async fn user_banned(pool: &sqlx::SqlitePool, user_id: &str, now: i64) -> bool { + let row: Option<(i64, Option)> = + sqlx::query_as("SELECT banned, banExpires FROM user WHERE id = ?") + .bind(user_id) + .fetch_optional(pool) + .await + .unwrap_or(None); + match row { + None => true, + Some((banned, expires)) => banned != 0 && expires.is_none_or(|until| now < until), + } } async fn exchange_refresh(state: &AuthState, form: HashMap, now: i64) -> Response { let bad = |code: &str, description: &str| oauth_error(StatusCode::BAD_REQUEST, code, description); + let unavailable = || { + oauth_error( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "the token store is unavailable", + ) + }; + // Same public-client binding as the code exchange (RFC 6749 §6): a + // missing id fails authentication, a wrong one fails the grant. Both + // checks run before any state change, so a mismatched id never revokes + // someone else's grant. + let Some(form_client) = form.get("client_id").filter(|id| !id.is_empty()) else { + return bad("invalid_client", "client_id is required for public clients"); + }; let Some(refresh) = form.get("refresh_token") else { return bad("invalid_request", "refresh_token is required"); }; let hash = tokens::sha256_hex(refresh); + // The `user` join rejects banned accounts and deleted users (a missing + // user yields no row: `invalid_grant`, same as an unknown token). let row: Option = sqlx::query_as( - "SELECT t.referenceId, c.userId, c.clientId, c.scopes, c.resources, t.expiresAt, t.rotatedAt \ + "SELECT t.referenceId, c.userId, c.clientId, c.scopes, c.resources, t.expiresAt, t.rotatedAt, \ + u.banned, u.banExpires \ FROM oauthRefreshToken t JOIN oauthConsent c ON c.id = t.referenceId \ + JOIN user u ON u.id = c.userId \ WHERE t.token = ?", ) .bind(&hash) @@ -894,10 +958,18 @@ async fn exchange_refresh(state: &AuthState, form: HashMap, now: grant_resource, expires_at, rotated_at, + banned, + ban_expires, )) = row else { return bad("invalid_grant", "the refresh token is unknown"); }; + if form_client != &client_id { + return bad("invalid_grant", "client_id does not match the grant"); + } + if banned != 0 && ban_expires.is_none_or(|until| now < until) { + return bad("invalid_grant", "the account is no longer active"); + } // Outer always writes `resources` and `expiresAt`; `None` comes only // from a foreign row and expires immediately. let grant_resource = grant_resource.unwrap_or_else(|| state.config.resource.clone()); @@ -934,28 +1006,45 @@ async fn exchange_refresh(state: &AuthState, form: HashMap, now: } None => grant_scope.clone(), }; - // Rotate: the old refresh dies, the sliding window restarts now. + // Rotate inside one transaction: re-check the consent is live, claim the + // old token, then mint the new pair. The claim UPDATE takes the write + // lock, so a concurrent reuse either lands fully before this commit (and + // the reuse path revokes everything, this pair included) or blocks until + // after it and sees `rotatedAt` set. Either way no fresh token survives + // on a revoked consent — and `resolve_bearer` requires + // `revokedAt IS NULL` as a second barrier. let (access, access_hash) = tokens::new_access_token(); let (refresh, refresh_hash) = tokens::new_refresh_token(); let access_ttl_ms = ttl_ms(state.config.access_ttl, 3_600_000); let refresh_ttl_ms = ttl_ms(state.config.refresh_ttl, 90 * 86_400_000); - // Claim the rotation atomically: of two concurrent uses only one - // updates the row; the other is reuse and revokes the grant. + let Ok(mut tx) = state.pool.begin().await else { + return unavailable(); + }; + let live: Option<(Option,)> = + sqlx::query_as("SELECT revokedAt FROM oauthConsent WHERE id = ?") + .bind(&grant_id) + .fetch_optional(&mut *tx) + .await + .unwrap_or(None); + if live.is_none_or(|(revoked,)| revoked.is_some()) { + return bad("invalid_grant", "the grant was revoked"); + } let claimed = sqlx::query( "UPDATE oauthRefreshToken SET rotatedAt = ? WHERE token = ? AND rotatedAt IS NULL", ) .bind(now) .bind(&hash) - .execute(&state.pool) + .execute(&mut *tx) .await .map(|done| done.rows_affected() == 1) .unwrap_or(false); if !claimed { + tx.rollback().await.ok(); revoke_grant_tokens(&state.pool, &grant_id, now).await; return bad("invalid_grant", "the refresh token was already used"); } let refresh_id = tokens::new_id(); - sqlx::query( + let inserted = sqlx::query( "INSERT INTO oauthRefreshToken (id, token, clientId, userId, referenceId, scopes, resources, expiresAt, createdAt) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) @@ -968,33 +1057,35 @@ async fn exchange_refresh(state: &AuthState, form: HashMap, now: .bind(&grant_resource) .bind(now + refresh_ttl_ms) .bind(now) - .execute(&state.pool) + .execute(&mut *tx) .await - .ok(); - sqlx::query( - "INSERT INTO oauthAccessToken (id, token, clientId, userId, referenceId, refreshId, scopes, resources, expiresAt, createdAt) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(tokens::new_id()) - .bind(&access_hash) - .bind(&client_id) - .bind(&account_id) - .bind(&grant_id) - .bind(&refresh_id) - .bind(&scope) - .bind(&grant_resource) - .bind(now + access_ttl_ms) - .bind(now) - .execute(&state.pool) - .await - .ok(); + .is_ok() + && sqlx::query( + "INSERT INTO oauthAccessToken (id, token, clientId, userId, referenceId, refreshId, scopes, resources, expiresAt, createdAt) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(tokens::new_id()) + .bind(&access_hash) + .bind(&client_id) + .bind(&account_id) + .bind(&grant_id) + .bind(&refresh_id) + .bind(&scope) + .bind(&grant_resource) + .bind(now + access_ttl_ms) + .bind(now) + .execute(&mut *tx) + .await + .is_ok(); + if !inserted || tx.commit().await.is_err() { + return unavailable(); + } sqlx::query("UPDATE oauthClient SET lastTokenAt = ? WHERE clientId = ?") .bind(now) .bind(&client_id) .execute(&state.pool) .await .ok(); - let _ = (account_id, grant_resource); json_nostore(json!({ "access_token": access, "token_type": "Bearer", diff --git a/crates/outer/src/auth/tests.rs b/crates/outer/src/auth/tests.rs index 360b11a..1f26eef 100644 --- a/crates/outer/src/auth/tests.rs +++ b/crates/outer/src/auth/tests.rs @@ -324,6 +324,7 @@ async fn allow( async fn exchange( flow: &Flow, + client_id: &str, params: &HashMap, redirect_uri: &str, ) -> serde_json::Value { @@ -333,6 +334,7 @@ async fn exchange( "/oauth/token", &form(&[ ("grant_type", "authorization_code"), + ("client_id", client_id), ("code", ¶ms["code"]), ("redirect_uri", redirect_uri), ("code_verifier", VERIFIER), @@ -372,7 +374,7 @@ async fn full_authorize_flow_mints_a_working_token() { assert!(body.contains("expired") || body.contains("Start over")); let params = allow(&flow, &request_id, &csrf, &session).await; - let tokens: serde_json::Value = exchange(&flow, ¶ms, redirect_uri).await; + let tokens: serde_json::Value = exchange(&flow, &client_id, ¶ms, redirect_uri).await; assert_eq!(tokens["token_type"], "Bearer"); assert_eq!(tokens["scope"], SCOPE); assert!(tokens["refresh_token"] @@ -401,6 +403,7 @@ async fn full_authorize_flow_mints_a_working_token() { "/oauth/token", &form(&[ ("grant_type", "refresh_token"), + ("client_id", &client_id), ("refresh_token", tokens["refresh_token"].as_str().unwrap()), ]), ), @@ -417,6 +420,7 @@ async fn full_authorize_flow_mints_a_working_token() { "/oauth/token", &form(&[ ("grant_type", "refresh_token"), + ("client_id", &client_id), ("refresh_token", tokens["refresh_token"].as_str().unwrap()), ]), ), @@ -476,6 +480,7 @@ async fn concurrent_code_and_refresh_reuse_cannot_double_spend() { "/oauth/token", &form(&[ ("grant_type", "authorization_code"), + ("client_id", &client_id), ("code", ¶ms["code"]), ("redirect_uri", redirect_uri), ("code_verifier", VERIFIER), @@ -497,7 +502,11 @@ async fn concurrent_code_and_refresh_reuse_cannot_double_spend() { &flow.app, post_form( "/oauth/token", - &form(&[("grant_type", "refresh_token"), ("refresh_token", &refresh)]), + &form(&[ + ("grant_type", "refresh_token"), + ("client_id", &client_id), + ("refresh_token", &refresh), + ]), ), ) }; @@ -588,6 +597,7 @@ async fn codes_are_single_use_expiring_and_pkce_bound() { "/oauth/token", &form(&[ ("grant_type", "authorization_code"), + ("client_id", &client_id), ("code", ¶ms["code"]), ("redirect_uri", redirect_uri), ("code_verifier", "wrong-verifier"), @@ -605,6 +615,7 @@ async fn codes_are_single_use_expiring_and_pkce_bound() { "/oauth/token", &form(&[ ("grant_type", "authorization_code"), + ("client_id", &client_id), ("code", ¶ms["code"]), ("redirect_uri", "https://evil.example/cb"), ("code_verifier", VERIFIER), @@ -624,6 +635,7 @@ async fn codes_are_single_use_expiring_and_pkce_bound() { "/oauth/token", &form(&[ ("grant_type", "authorization_code"), + ("client_id", &client_id), ("code", ¶ms["code"]), ("redirect_uri", redirect_uri), ("code_verifier", VERIFIER), @@ -649,6 +661,7 @@ async fn codes_are_single_use_expiring_and_pkce_bound() { "/oauth/token", &form(&[ ("grant_type", "authorization_code"), + ("client_id", &client_id), ("code", ¶ms["code"]), ("redirect_uri", redirect_uri), ("code_verifier", VERIFIER), @@ -660,6 +673,522 @@ async fn codes_are_single_use_expiring_and_pkce_bound() { assert!(body.contains("invalid_grant")); } +#[tokio::test] +async fn token_endpoint_binds_client_id() { + let flow = flow().await; + let redirect_uri = "https://app.example/cb"; + let client_a = dcr_client(&flow.app, redirect_uri).await; + let client_b = dcr_client(&flow.app, redirect_uri).await; + let (request_id, csrf, session) = + sign_in(&flow, &client_a, redirect_uri, "ana@example.com").await; + let params = allow(&flow, &request_id, &csrf, &session).await; + let code = params["code"].clone(); + + // Missing client_id fails closed as invalid_client and does NOT consume + // the code: the id check runs before the row is even loaded. + let (status, _, body) = send( + &flow.app, + post_form( + "/oauth/token", + &form(&[ + ("grant_type", "authorization_code"), + ("code", &code), + ("redirect_uri", redirect_uri), + ("code_verifier", VERIFIER), + ]), + ), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("invalid_client"), "{body}"); + + // Another client's id fails as invalid_grant and consumes the code: + // single use survives the theft attempt. + let (status, _, body) = send( + &flow.app, + post_form( + "/oauth/token", + &form(&[ + ("grant_type", "authorization_code"), + ("client_id", &client_b), + ("code", &code), + ("redirect_uri", redirect_uri), + ("code_verifier", VERIFIER), + ]), + ), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("invalid_grant"), "{body}"); + + // The right client now finds the code already spent. + let (status, _, body) = send( + &flow.app, + post_form( + "/oauth/token", + &form(&[ + ("grant_type", "authorization_code"), + ("client_id", &client_a), + ("code", &code), + ("redirect_uri", redirect_uri), + ("code_verifier", VERIFIER), + ]), + ), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("invalid_grant"), "{body}"); + + // Refresh binding: a live grant for the mismatch probes. + let (request_id, csrf, session) = + sign_in(&flow, &client_a, redirect_uri, "bob@example.com").await; + let params = allow(&flow, &request_id, &csrf, &session).await; + let tokens: serde_json::Value = exchange(&flow, &client_a, ¶ms, redirect_uri).await; + let refresh = tokens["refresh_token"].as_str().unwrap().to_string(); + + // Missing client_id: invalid_client, grant untouched. + let (status, _, body) = send( + &flow.app, + post_form( + "/oauth/token", + &form(&[("grant_type", "refresh_token"), ("refresh_token", &refresh)]), + ), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("invalid_client"), "{body}"); + + // Wrong client_id: invalid_grant, and the grant is NOT revoked — a + // mismatched id never counts as reuse. + let (status, _, body) = send( + &flow.app, + post_form( + "/oauth/token", + &form(&[ + ("grant_type", "refresh_token"), + ("client_id", &client_b), + ("refresh_token", &refresh), + ]), + ), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("invalid_grant"), "{body}"); + + // The grant survives both probes: the real client rotates fine. + let (status, _, body) = send( + &flow.app, + post_form( + "/oauth/token", + &form(&[ + ("grant_type", "refresh_token"), + ("client_id", &client_a), + ("refresh_token", &refresh), + ]), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); +} + +/// Two concurrent refreshes of one token: at most one succeeds, and once +/// the consent is revoked nothing from the family authenticates — not even +/// a token planted directly on the revoked row (`resolve_bearer` joins +/// `revokedAt IS NULL`, the second barrier behind the rotation +/// transaction). +#[tokio::test] +async fn refresh_race_and_revocation_leave_no_survivors() { + let flow = flow().await; + let redirect_uri = "https://app.example/cb"; + let client_id = dcr_client(&flow.app, redirect_uri).await; + let (request_id, csrf, session) = + sign_in(&flow, &client_id, redirect_uri, "ana@example.com").await; + let params = allow(&flow, &request_id, &csrf, &session).await; + let tokens: serde_json::Value = exchange(&flow, &client_id, ¶ms, redirect_uri).await; + let first_access = tokens["access_token"].as_str().unwrap().to_string(); + let refresh = tokens["refresh_token"].as_str().unwrap().to_string(); + let principal = auth::resolve_bearer(&flow.state.pool, &flow.state.config, &first_access) + .await + .unwrap() + .expect("fresh access resolves"); + let grant_id = match &principal.credential { + auth::Credential::OAuth { grant_id } => grant_id.clone(), + _ => panic!("expected an OAuth grant"), + }; + + // Race the same refresh twice: at most one rotation commits. + let use_refresh = || { + send( + &flow.app, + post_form( + "/oauth/token", + &form(&[ + ("grant_type", "refresh_token"), + ("client_id", &client_id), + ("refresh_token", &refresh), + ]), + ), + ) + }; + let (a, b) = tokio::join!(use_refresh(), use_refresh()); + let winners: Vec = [&a, &b] + .into_iter() + .filter(|r| r.0 == StatusCode::OK) + .map(|r| r.2.parse().unwrap()) + .collect(); + assert!( + winners.len() <= 1, + "a refresh token rotates once: {a:?} {b:?}" + ); + let mut family = vec![first_access.clone()]; + for won in &winners { + family.push(won["access_token"].as_str().unwrap().to_string()); + } + + // Explicit revocation wipes the family: no survivor authenticates, the + // refresh is dead, and the token rows are gone. (When the race had a + // winner, the loser already revoked the grant — `revoke_connection` + // then reports false, which is fine: the end state is identical.) + let _ = auth::revoke_connection(&flow.state.pool, &principal.account_id, &grant_id).await; + for token in &family { + assert!( + auth::resolve_bearer(&flow.state.pool, &flow.state.config, token) + .await + .unwrap() + .is_none(), + "revoked consent stops resolving" + ); + } + let (status, _, body) = send( + &flow.app, + post_form( + "/oauth/token", + &form(&[ + ("grant_type", "refresh_token"), + ("client_id", &client_id), + ("refresh_token", &refresh), + ]), + ), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("invalid_grant"), "{body}"); + let (left,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM oauthAccessToken WHERE referenceId = ?") + .bind(&grant_id) + .fetch_one(&flow.state.pool) + .await + .unwrap(); + assert_eq!(left, 0, "revocation deletes the family's tokens"); + + // Even a token minted behind the revocation's back (the rotation-race + // shape: committed onto a revoked consent) cannot authenticate: the + // `revokedAt IS NULL` join blocks it. + let (planted, planted_hash) = auth::tokens::new_access_token(); + sqlx::query( + "INSERT INTO oauthAccessToken (id, token, clientId, userId, referenceId, scopes, expiresAt, createdAt) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(auth::tokens::new_id()) + .bind(&planted_hash) + .bind(&client_id) + .bind(&principal.account_id) + .bind(&grant_id) + .bind("apps offline_access") + .bind(auth::now_ms() + 3_600_000) + .bind(auth::now_ms()) + .execute(&flow.state.pool) + .await + .unwrap(); + assert!( + auth::resolve_bearer(&flow.state.pool, &flow.state.config, &planted) + .await + .unwrap() + .is_none(), + "token on a revoked consent does not resolve" + ); +} +/// Banned users fail closed at both grant types; an expired temporary ban +/// does not. The access token stops resolving while banned, and deleting +/// the user kills the grant (the join finds no row: `invalid_grant`). +#[tokio::test] +async fn banned_users_cannot_redeem_or_refresh() { + let flow = flow().await; + let redirect_uri = "https://app.example/cb"; + let client_id = dcr_client(&flow.app, redirect_uri).await; + // A live grant for the refresh probes, minted before the ban. + let (request_id, csrf, session) = + sign_in(&flow, &client_id, redirect_uri, "bob@example.com").await; + let params = allow(&flow, &request_id, &csrf, &session).await; + let tokens: serde_json::Value = exchange(&flow, &client_id, ¶ms, redirect_uri).await; + let access = tokens["access_token"].as_str().unwrap().to_string(); + let mut refresh = tokens["refresh_token"].as_str().unwrap().to_string(); + fn refresh_post(client_id: &str, refresh: &str) -> Request { + post_form( + "/oauth/token", + &form(&[ + ("grant_type", "refresh_token"), + ("client_id", client_id), + ("refresh_token", refresh), + ]), + ) + } + + sqlx::query("UPDATE user SET banned = 1 WHERE email = 'bob@example.com'") + .execute(&flow.state.pool) + .await + .unwrap(); + let (status, _, body) = send(&flow.app, refresh_post(&client_id, &refresh)).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("invalid_grant"), "{body}"); + assert!( + auth::resolve_bearer(&flow.state.pool, &flow.state.config, &access) + .await + .unwrap() + .is_none(), + "banned user's access token stops resolving" + ); + + // An expired temporary ban no longer counts: rotation works again. + sqlx::query("UPDATE user SET banExpires = 0 WHERE email = 'bob@example.com'") + .execute(&flow.state.pool) + .await + .unwrap(); + let (status, _, body) = send(&flow.app, refresh_post(&client_id, &refresh)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + refresh = body.parse::().unwrap()["refresh_token"] + .as_str() + .unwrap() + .to_string(); + + // An active temporary ban fails again. + sqlx::query("UPDATE user SET banExpires = 9999999999999 WHERE email = 'bob@example.com'") + .execute(&flow.state.pool) + .await + .unwrap(); + let (status, _, body) = send(&flow.app, refresh_post(&client_id, &refresh)).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("invalid_grant"), "{body}"); + + // A deleted user is an unknown grant: the join finds no row. + sqlx::query("DELETE FROM user WHERE email = 'bob@example.com'") + .execute(&flow.state.pool) + .await + .unwrap(); + let (status, _, body) = send(&flow.app, refresh_post(&client_id, &refresh)).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("invalid_grant"), "{body}"); + + // Code redemption fails closed for banned users too. + // Fresh code for ana, banned before redeeming: + let (request_id, csrf, session) = + sign_in(&flow, &client_id, redirect_uri, "ana@example.com").await; + let params = allow(&flow, &request_id, &csrf, &session).await; + sqlx::query("UPDATE user SET banned = 1, banExpires = NULL WHERE email = 'ana@example.com'") + .execute(&flow.state.pool) + .await + .unwrap(); + let (status, _, body) = send( + &flow.app, + post_form( + "/oauth/token", + &form(&[ + ("grant_type", "authorization_code"), + ("client_id", &client_id), + ("code", ¶ms["code"]), + ("redirect_uri", redirect_uri), + ("code_verifier", VERIFIER), + ]), + ), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("invalid_grant"), "{body}"); +} + +/// `apikey.expiresAt` is enforced and the token `revoked` flag is honored: +/// expired keys and revoked-flagged access tokens do not resolve, live ones +/// do. +#[tokio::test] +async fn bearer_rejects_expired_keys_and_revoked_tokens() { + let flow = flow().await; + let redirect_uri = "https://app.example/cb"; + let client_id = dcr_client(&flow.app, redirect_uri).await; + let (request_id, csrf, session) = + sign_in(&flow, &client_id, redirect_uri, "ana@example.com").await; + let params = allow(&flow, &request_id, &csrf, &session).await; + let tokens: serde_json::Value = exchange(&flow, &client_id, ¶ms, redirect_uri).await; + let access = tokens["access_token"].as_str().unwrap().to_string(); + let principal = auth::resolve_bearer(&flow.state.pool, &flow.state.config, &access) + .await + .unwrap() + .expect("fresh access resolves"); + let access_hash = auth::tokens::sha256_hex(&access); + + // A set `revoked` flag blocks the access token; clearing it restores it. + sqlx::query("UPDATE oauthAccessToken SET revoked = 1 WHERE token = ?") + .bind(&access_hash) + .execute(&flow.state.pool) + .await + .unwrap(); + assert!( + auth::resolve_bearer(&flow.state.pool, &flow.state.config, &access) + .await + .unwrap() + .is_none(), + "revoked-flagged access token does not resolve" + ); + sqlx::query("UPDATE oauthAccessToken SET revoked = 0 WHERE token = ?") + .bind(&access_hash) + .execute(&flow.state.pool) + .await + .unwrap(); + assert!( + auth::resolve_bearer(&flow.state.pool, &flow.state.config, &access) + .await + .unwrap() + .is_some(), + "unflagged access token resolves again" + ); + + // API keys: no expiry resolves, a past expiry does not, a future one + // does. + let created = auth::create_api_key(&flow.state.pool, &principal.account_id, None) + .await + .unwrap(); + let key_hash = auth::tokens::sha256_hex(&created.key); + assert!( + auth::resolve_bearer(&flow.state.pool, &flow.state.config, &created.key) + .await + .unwrap() + .is_some(), + "unexpiring key resolves" + ); + sqlx::query("UPDATE apikey SET expiresAt = 0 WHERE key = ?") + .bind(&key_hash) + .execute(&flow.state.pool) + .await + .unwrap(); + assert!( + auth::resolve_bearer(&flow.state.pool, &flow.state.config, &created.key) + .await + .unwrap() + .is_none(), + "expired key does not resolve" + ); + sqlx::query("UPDATE apikey SET expiresAt = 9999999999999 WHERE key = ?") + .bind(&key_hash) + .execute(&flow.state.pool) + .await + .unwrap(); + assert!( + auth::resolve_bearer(&flow.state.pool, &flow.state.config, &created.key) + .await + .unwrap() + .is_some(), + "unexpired key resolves" + ); +} + +/// The token request cannot change the authorize request's resource (RFC +/// 8707): omitting it binds the stored value, sending anything else — +/// including the server default over a non-default stored value — fails +/// with `invalid_target`. +#[tokio::test] +async fn token_resource_must_match_authorize_request() { + let flow = flow().await; + let redirect_uri = "https://app.example/cb"; + let client_id = dcr_client(&flow.app, redirect_uri).await; + // Four distinct users up front: account creation is limited to 3/day + // per IP, and the OTP flow enforces it. + for n in 1..=4u32 { + sqlx::query( + "INSERT INTO user (id, email, emailVerified, lastActiveAt, createdAt, updatedAt) \ + VALUES (?, ?, 1, 0, 0, 0)", + ) + .bind(format!("user-{n}")) + .bind(format!("ana{n}@example.com")) + .execute(&flow.state.pool) + .await + .unwrap(); + } + async fn mint_code(flow: &Flow, client_id: &str, redirect_uri: &str, n: u32) -> String { + let email = format!("ana{n}@example.com"); + let (request_id, csrf, session) = sign_in(flow, client_id, redirect_uri, &email).await; + allow(flow, &request_id, &csrf, &session).await["code"].clone() + } + let redeem = |code: &str, resource: Option<&str>| { + let mut pairs = vec![ + ("grant_type", "authorization_code"), + ("client_id", client_id.as_str()), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", VERIFIER), + ]; + if let Some(resource) = resource { + pairs.push(("resource", resource)); + } + send(&flow.app, post_form("/oauth/token", &form(&pairs))) + }; + + // A resource the authorize request never saw is rejected. + let code = mint_code(&flow, &client_id, redirect_uri, 1).await; + let (status, _, body) = redeem(&code, Some("https://other.example/mcp")).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("invalid_target"), "{body}"); + + // Omitting it binds the stored value and succeeds. + let code = mint_code(&flow, &client_id, redirect_uri, 2).await; + let (status, _, body) = redeem(&code, None).await; + assert_eq!(status, StatusCode::OK, "{body}"); + + // Stored non-default + server default sent: a downgrade, rejected. (The + // authorize page only issues the default, so plant the foreign shape + // directly.) + const FOREIGN: &str = "https://foreign.example/mcp"; + let code = mint_code(&flow, &client_id, redirect_uri, 3).await; + sqlx::query("UPDATE oauth_request SET resource = ? WHERE code_hash = ?") + .bind(FOREIGN) + .bind(auth::tokens::sha256_hex(&code)) + .execute(&flow.state.pool) + .await + .unwrap(); + let (status, _, body) = redeem(&code, Some(RESOURCE)).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(body.contains("invalid_target"), "{body}"); + + // The matching stored value succeeds and the grant carries it. + let code = mint_code(&flow, &client_id, redirect_uri, 4).await; + sqlx::query("UPDATE oauth_request SET resource = ? WHERE code_hash = ?") + .bind(FOREIGN) + .bind(auth::tokens::sha256_hex(&code)) + .execute(&flow.state.pool) + .await + .unwrap(); + let (status, _, body) = redeem(&code, Some(FOREIGN)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let issued: serde_json::Value = body.parse().unwrap(); + let principal = auth::resolve_bearer( + &flow.state.pool, + &flow.state.config, + issued["access_token"].as_str().unwrap(), + ) + .await + .unwrap() + .expect("bound token resolves"); + let grant_id = match &principal.credential { + auth::Credential::OAuth { grant_id } => grant_id.clone(), + _ => panic!("expected an OAuth grant"), + }; + let (resources,): (Option,) = + sqlx::query_as("SELECT resources FROM oauthConsent WHERE id = ?") + .bind(&grant_id) + .fetch_one(&flow.state.pool) + .await + .unwrap(); + assert_eq!(resources.as_deref(), Some(FOREIGN)); +} + #[tokio::test] async fn dcr_validates_and_rate_limits() { let flow = flow().await; @@ -1081,7 +1610,7 @@ async fn revoke_endpoint_kills_tokens() { let (request_id, csrf, session) = sign_in(&flow, &client_id, redirect_uri, "ana@example.com").await; let params = allow(&flow, &request_id, &csrf, &session).await; - let tokens: serde_json::Value = exchange(&flow, ¶ms, redirect_uri).await; + let tokens: serde_json::Value = exchange(&flow, &client_id, ¶ms, redirect_uri).await; let access = tokens["access_token"].as_str().unwrap().to_string(); let (status, _, _) = send( @@ -1113,7 +1642,7 @@ async fn connections_list_record_and_revoke() { let (request_id, csrf, session) = sign_in(&flow, &client_id, redirect_uri, "ana@example.com").await; let params = allow(&flow, &request_id, &csrf, &session).await; - let tokens: serde_json::Value = exchange(&flow, ¶ms, redirect_uri).await; + let tokens: serde_json::Value = exchange(&flow, &client_id, ¶ms, redirect_uri).await; let access = tokens["access_token"].as_str().unwrap(); let principal = auth::resolve_bearer(&flow.state.pool, &flow.state.config, access)