From 7f4b665759dfe0c3675b2423a7ec8a496fd1061a Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sun, 17 May 2026 16:47:47 +0200 Subject: [PATCH 1/9] fix: revoke oauth refresh tokens on session and password lifecycle --- lib/auth.ts | 35 ++++++++++++++++++++++++++++++++++- lib/data/oauth-session.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/auth.ts b/lib/auth.ts index 996a56d..a9262f6 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -5,6 +5,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import * as authSchema from "@/lib/db/auth-schema"; import { authDb } from "@/lib/db/connection"; import { clearOrgMembershipArtifacts } from "@/lib/data/account"; +import { clearUserOAuthArtifacts } from "@/lib/data/oauth-session"; import { ac, owner, admin, member as memberRole } from "@/lib/auth/permissions"; import { findOrgMemberUserIdsAsAdmin } from "@/lib/data/membership"; import { grantOrgAccess, revokeOrgAccess } from "@/lib/realtime/access"; @@ -116,7 +117,8 @@ export const auth = betterAuth({ loginPage: "/sign-in", consentPage: "/consent", allowDynamicClientRegistration: true, - allowUnauthenticatedClientRegistration: true, + allowUnauthenticatedClientRegistration: false, + refreshTokenExpiresIn: 60 * 60 * 24 * 7, // 7 days, matches session expiresIn validAudiences: process.env.BETTER_AUTH_URL ? [ process.env.BETTER_AUTH_URL, @@ -139,6 +141,37 @@ export const auth = betterAuth({ silenceWarnings: { oauthAuthServerConfig: true }, }), ], + databaseHooks: { + session: { + delete: { + after: async (session) => { + try { + await clearUserOAuthArtifacts(session.userId); + } catch (err) { + console.error("session.delete.after cascade failure", { + userId: session.userId, + err, + }); + } + }, + }, + }, + account: { + update: { + after: async (account) => { + if (account.providerId !== "credential") return; + try { + await clearUserOAuthArtifacts(account.userId); + } catch (err) { + console.error("account.update.after cascade failure", { + userId: account.userId, + err, + }); + } + }, + }, + }, + }, }); export type Session = typeof auth.$Infer.Session; diff --git a/lib/data/oauth-session.ts b/lib/data/oauth-session.ts index 785293e..ed89cb0 100644 --- a/lib/data/oauth-session.ts +++ b/lib/data/oauth-session.ts @@ -137,6 +137,34 @@ export async function revokeOAuthSession( }); } +/** + * Hard-delete every OAuth refresh and access token belonging to a user. + * + * Called from Better Auth `databaseHooks` (`session.delete.after`, + * `account.update.after`) to cascade-clean tokens whenever a session is + * deleted or a credential account is updated. Must remain idempotent: + * Drizzle DELETE WHERE is a no-op on zero rows, so concurrent invocations + * from overlapping hooks are safe. + * + * Hard-delete instead of soft-revoke because `listActiveOAuthSessions` + * already filters on `isNull(revoked)`; row removal is observationally + * equivalent and reclaims space. The user-facing revoke flow keeps using + * `revokeOAuthSession` for its audit-row semantics. + * + * @param userId - Owner of the tokens to remove. + * @returns Resolves once both deletes commit. + */ +export async function clearUserOAuthArtifacts(userId: string): Promise { + await db.transaction(async (tx) => { + await tx + .delete(oauthAccessToken) + .where(eq(oauthAccessToken.userId, userId)); + await tx + .delete(oauthRefreshToken) + .where(eq(oauthRefreshToken.userId, userId)); + }); +} + /** * Check whether a user has previously approved a specific OAuth client. * Drives the consent page's first-time warning. Uses `oauthConsent` rather From f314a19d1e6c533d11a840c7e62d73782aa628cf Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sun, 17 May 2026 17:49:03 +0200 Subject: [PATCH 2/9] fix: pivot to strategy c for mcp client onboarding ux --- lib/auth.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/auth.ts b/lib/auth.ts index a9262f6..4790069 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -117,8 +117,22 @@ export const auth = betterAuth({ loginPage: "/sign-in", consentPage: "/consent", allowDynamicClientRegistration: true, - allowUnauthenticatedClientRegistration: false, + // Anonymous DCR stays open: MCP clients (Claude Code, Codex, Cursor, + // Gemini) onboard before the user has signed in. Strategy C scopes the + // blast radius by short access-token TTL + cascade hooks on session + // and password lifecycle + an explicit scope allowlist below. + allowUnauthenticatedClientRegistration: true, + accessTokenExpiresIn: 60 * 60, // 1h — pinned to BA default for clarity refreshTokenExpiresIn: 60 * 60 * 24 * 7, // 7 days, matches session expiresIn + // Defensive allowlist for newly-registered clients. Functional no-op + // vs the current default (DCR already inherits these scopes), but + // explicit so a future scope addition does not silently widen DCR. + clientRegistrationAllowedScopes: [ + "openid", + "profile", + "email", + "offline_access", + ], validAudiences: process.env.BETTER_AUTH_URL ? [ process.env.BETTER_AUTH_URL, From 6c230bb7b3b5c74a024e0eaf1355e3e3f96aa3cc Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sun, 17 May 2026 17:49:23 +0200 Subject: [PATCH 3/9] feat: add pg_cron janitor for expired oauth tokens --- docker/init-auth.sql | 5 +++++ docker/init-pg-cron.sql | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 docker/init-pg-cron.sql diff --git a/docker/init-auth.sql b/docker/init-auth.sql index e9c451c..2e3c49b 100644 --- a/docker/init-auth.sql +++ b/docker/init-auth.sql @@ -185,3 +185,8 @@ CREATE INDEX IF NOT EXISTS "oauthRefreshToken_clientId_idx" ON "oauthRefreshToke CREATE INDEX IF NOT EXISTS "oauthRefreshToken_userId_idx" ON "oauthRefreshToken"("userId"); CREATE INDEX IF NOT EXISTS "oauthConsent_clientId_idx" ON "oauthConsent"("clientId"); CREATE INDEX IF NOT EXISTS "oauthConsent_userId_idx" ON "oauthConsent"("userId"); + +-- Optional follow-up: docker/init-pg-cron.sql schedules a nightly janitor +-- that purges revoked/expired OAuth tokens. Requires the pg_cron extension +-- to be enabled (Neon console or self-host superuser). Not applied by the +-- testcontainer migrator — the test image ships without pg_cron. diff --git a/docker/init-pg-cron.sql b/docker/init-pg-cron.sql new file mode 100644 index 0000000..9bdd922 --- /dev/null +++ b/docker/init-pg-cron.sql @@ -0,0 +1,26 @@ +-- Requires Neon pg_cron extension enabled in the project console. Apply manually after init-auth.sql. +-- Schedules a nightly janitor that purges revoked or expired OAuth refresh +-- tokens and expired access tokens. Belt-and-braces alongside the cascade +-- hooks in lib/auth.ts: tokens that survive a crashed hook still get +-- collected here. Idempotent — re-running re-schedules the same job. + +CREATE EXTENSION IF NOT EXISTS pg_cron; + +DO $$ +BEGIN + PERFORM cron.unschedule('purge-oauth-tokens') + WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'purge-oauth-tokens'); +EXCEPTION + WHEN OTHERS THEN NULL; +END $$; + +SELECT cron.schedule( + 'purge-oauth-tokens', + '0 3 * * *', + $$ + DELETE FROM neon_auth."oauthRefreshToken" + WHERE revoked IS NOT NULL OR "expiresAt" < now(); + DELETE FROM neon_auth."oauthAccessToken" + WHERE "expiresAt" < now(); + $$ +); From ef6d25db055ef14a184027bc00b9672d24a8a3b8 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sun, 17 May 2026 17:49:47 +0200 Subject: [PATCH 4/9] feat: add revoke-all server action for oauth sessions --- lib/actions/oauth-session.ts | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/lib/actions/oauth-session.ts b/lib/actions/oauth-session.ts index f48f0b4..b6c77b3 100644 --- a/lib/actions/oauth-session.ts +++ b/lib/actions/oauth-session.ts @@ -11,6 +11,7 @@ import { } from "@/lib/actions/team-errors"; import type { OAuthSessionView } from "@/lib/actions/oauth-session-types"; import { + clearUserOAuthArtifacts, listActiveOAuthSessions, revokeOAuthSession, userOwnsActiveSession, @@ -101,3 +102,43 @@ export async function revokeOAuthSessionAction(input: { return teamFail("unknown"); } } + +/** + * Revoke every active OAuth refresh token (and the access tokens minted + * from them) the caller owns. Reuses the same hard-delete helper the BA + * cascade hooks call, so the user-facing bulk revoke and the lifecycle + * cascade share one code path. Rate-limited tighter than single revoke + * because a single click drops every connected agent. + * + * @returns Discriminated result; `ok` on success, `unauthorized` / + * `rate_limited` / `unknown` on failure. + */ +export async function revokeAllOAuthSessionsAction(): Promise { + let userId: string; + try { + const session = await requireSession(); + userId = session.user.id; + } catch { + return teamFail("unauthorized"); + } + + const limit = await checkActionRateLimit( + { + action: "oauth.revoke_all", + windowSeconds: 60, + perUserMax: 3, + perIpMax: 10, + }, + userId, + ); + if (!limit.ok) return teamFail("rate_limited"); + + try { + await clearUserOAuthArtifacts(userId); + revalidatePath("/settings"); + return { ok: true }; + } catch (err) { + console.error("revokeAllOAuthSessionsAction failed", err); + return teamFail("unknown"); + } +} From 79144282858ec7cb85d5199df5627222165cc1d1 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sun, 17 May 2026 17:50:47 +0200 Subject: [PATCH 5/9] feat: add revoke-all button and cascade copy to agents tab --- app/settings/_components/AgentsTab.tsx | 66 +++++++++++++++++++------- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/app/settings/_components/AgentsTab.tsx b/app/settings/_components/AgentsTab.tsx index 4ccf4a0..f5e8385 100644 --- a/app/settings/_components/AgentsTab.tsx +++ b/app/settings/_components/AgentsTab.tsx @@ -3,10 +3,12 @@ import { useCallback, useMemo, useState, useTransition } from "react"; import { listOAuthSessionsAction, + revokeAllOAuthSessionsAction, type OAuthSessionView, } from "@/lib/actions/oauth-session"; import { formatOAuthClientName } from "@/lib/ui/oauth-client-name"; import { AgentSection } from "./AgentSection"; +import { InlineConfirm } from "./InlineConfirm"; interface AgentsTabProps { /** Initial session list, hydrated from the server component. */ @@ -83,6 +85,16 @@ export function AgentsTab({ initialSessions }: AgentsTabProps) { }); }; + const handleRevokeAll = async () => { + setError(null); + const result = await revokeAllOAuthSessionsAction(); + if (result.ok) { + setSessions([]); + } else { + setError(result.message); + } + }; + const { byBrand, otherSessions } = useMemo( () => groupSessions(sessions), [sessions], @@ -96,26 +108,46 @@ export function AgentsTab({ initialSessions }: AgentsTabProps) { Agents & devices

- Sessions authorized to run via MCP. Revoke any time. + Sessions authorized to run via MCP. Signing out or changing your + password revokes every session automatically.

- + } + prompt="Revoke all sessions?" + body="Every connected agent will need to re-authorize." + confirmLabel="Revoke all" + destructive + onConfirm={handleRevokeAll} + /> + ) : null} + + + Refresh + + {error ? ( From bca7af48f103142e24d5b0f092c68d8d4e6b52e8 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sun, 17 May 2026 18:05:53 +0200 Subject: [PATCH 6/9] docs: tighten oauth pr docstrings and drop prod notes --- docker/init-auth.sql | 5 ----- docker/init-pg-cron.sql | 6 ------ lib/actions/oauth-session.ts | 9 ++------- lib/auth.ts | 17 +++++------------ lib/data/oauth-session.ts | 15 ++------------- 5 files changed, 9 insertions(+), 43 deletions(-) diff --git a/docker/init-auth.sql b/docker/init-auth.sql index 2e3c49b..e9c451c 100644 --- a/docker/init-auth.sql +++ b/docker/init-auth.sql @@ -185,8 +185,3 @@ CREATE INDEX IF NOT EXISTS "oauthRefreshToken_clientId_idx" ON "oauthRefreshToke CREATE INDEX IF NOT EXISTS "oauthRefreshToken_userId_idx" ON "oauthRefreshToken"("userId"); CREATE INDEX IF NOT EXISTS "oauthConsent_clientId_idx" ON "oauthConsent"("clientId"); CREATE INDEX IF NOT EXISTS "oauthConsent_userId_idx" ON "oauthConsent"("userId"); - --- Optional follow-up: docker/init-pg-cron.sql schedules a nightly janitor --- that purges revoked/expired OAuth tokens. Requires the pg_cron extension --- to be enabled (Neon console or self-host superuser). Not applied by the --- testcontainer migrator — the test image ships without pg_cron. diff --git a/docker/init-pg-cron.sql b/docker/init-pg-cron.sql index 9bdd922..697ef33 100644 --- a/docker/init-pg-cron.sql +++ b/docker/init-pg-cron.sql @@ -1,9 +1,3 @@ --- Requires Neon pg_cron extension enabled in the project console. Apply manually after init-auth.sql. --- Schedules a nightly janitor that purges revoked or expired OAuth refresh --- tokens and expired access tokens. Belt-and-braces alongside the cascade --- hooks in lib/auth.ts: tokens that survive a crashed hook still get --- collected here. Idempotent — re-running re-schedules the same job. - CREATE EXTENSION IF NOT EXISTS pg_cron; DO $$ diff --git a/lib/actions/oauth-session.ts b/lib/actions/oauth-session.ts index b6c77b3..eaf154b 100644 --- a/lib/actions/oauth-session.ts +++ b/lib/actions/oauth-session.ts @@ -104,14 +104,9 @@ export async function revokeOAuthSessionAction(input: { } /** - * Revoke every active OAuth refresh token (and the access tokens minted - * from them) the caller owns. Reuses the same hard-delete helper the BA - * cascade hooks call, so the user-facing bulk revoke and the lifecycle - * cascade share one code path. Rate-limited tighter than single revoke - * because a single click drops every connected agent. + * Revoke every active OAuth refresh and access token owned by the caller. * - * @returns Discriminated result; `ok` on success, `unauthorized` / - * `rate_limited` / `unknown` on failure. + * @returns Discriminated result. */ export async function revokeAllOAuthSessionsAction(): Promise { let userId: string; diff --git a/lib/auth.ts b/lib/auth.ts index 4790069..0451842 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -11,9 +11,9 @@ import { findOrgMemberUserIdsAsAdmin } from "@/lib/data/membership"; import { grantOrgAccess, revokeOrgAccess } from "@/lib/realtime/access"; /** - * Better Auth server instance. - * Uses Neon Auth's existing schema (neon_auth) via drizzleAdapter. - * Provides email/password auth and organization-based team management. + * Better Auth server instance with email/password auth and + * organization-based team management. Adapts the `neon_auth` schema via + * drizzleAdapter. */ export const auth = betterAuth({ database: drizzleAdapter(authDb, { @@ -117,16 +117,9 @@ export const auth = betterAuth({ loginPage: "/sign-in", consentPage: "/consent", allowDynamicClientRegistration: true, - // Anonymous DCR stays open: MCP clients (Claude Code, Codex, Cursor, - // Gemini) onboard before the user has signed in. Strategy C scopes the - // blast radius by short access-token TTL + cascade hooks on session - // and password lifecycle + an explicit scope allowlist below. allowUnauthenticatedClientRegistration: true, - accessTokenExpiresIn: 60 * 60, // 1h — pinned to BA default for clarity - refreshTokenExpiresIn: 60 * 60 * 24 * 7, // 7 days, matches session expiresIn - // Defensive allowlist for newly-registered clients. Functional no-op - // vs the current default (DCR already inherits these scopes), but - // explicit so a future scope addition does not silently widen DCR. + accessTokenExpiresIn: 60 * 60, // 1h + refreshTokenExpiresIn: 60 * 60 * 24 * 7, // 7 days clientRegistrationAllowedScopes: [ "openid", "profile", diff --git a/lib/data/oauth-session.ts b/lib/data/oauth-session.ts index ed89cb0..c1e8cd2 100644 --- a/lib/data/oauth-session.ts +++ b/lib/data/oauth-session.ts @@ -138,20 +138,9 @@ export async function revokeOAuthSession( } /** - * Hard-delete every OAuth refresh and access token belonging to a user. + * Hard-delete every OAuth refresh and access token owned by a user. * - * Called from Better Auth `databaseHooks` (`session.delete.after`, - * `account.update.after`) to cascade-clean tokens whenever a session is - * deleted or a credential account is updated. Must remain idempotent: - * Drizzle DELETE WHERE is a no-op on zero rows, so concurrent invocations - * from overlapping hooks are safe. - * - * Hard-delete instead of soft-revoke because `listActiveOAuthSessions` - * already filters on `isNull(revoked)`; row removal is observationally - * equivalent and reclaims space. The user-facing revoke flow keeps using - * `revokeOAuthSession` for its audit-row semantics. - * - * @param userId - Owner of the tokens to remove. + * @param userId - Verified user id. * @returns Resolves once both deletes commit. */ export async function clearUserOAuthArtifacts(userId: string): Promise { From 2c8bf52ffb03ec813737102acd6c7b751299b349 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sun, 17 May 2026 18:24:31 +0200 Subject: [PATCH 7/9] fix: drop non-standard sign-out oauth cascade --- lib/auth.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/lib/auth.ts b/lib/auth.ts index 0451842..91c3740 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -149,20 +149,6 @@ export const auth = betterAuth({ }), ], databaseHooks: { - session: { - delete: { - after: async (session) => { - try { - await clearUserOAuthArtifacts(session.userId); - } catch (err) { - console.error("session.delete.after cascade failure", { - userId: session.userId, - err, - }); - } - }, - }, - }, account: { update: { after: async (account) => { From 5a7715fe889a25bfcd5e756d12b0c92adf47d28a Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sun, 17 May 2026 18:24:47 +0200 Subject: [PATCH 8/9] fix: restructure agents tab revoke-all row and brand order --- app/settings/_components/AgentsTab.tsx | 76 +++++++++++++------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/app/settings/_components/AgentsTab.tsx b/app/settings/_components/AgentsTab.tsx index f5e8385..7f89dac 100644 --- a/app/settings/_components/AgentsTab.tsx +++ b/app/settings/_components/AgentsTab.tsx @@ -16,7 +16,7 @@ interface AgentsTabProps { } /** Canonical brands rendered as fixed sections, in display order. */ -const KNOWN_BRANDS = ["Claude Code", "Codex", "Cursor", "Gemini"] as const; +const KNOWN_BRANDS = ["Claude Code", "Codex", "Gemini", "Cursor"] as const; type KnownBrand = (typeof KNOWN_BRANDS)[number]; const KNOWN_BRAND_SET: ReadonlySet = new Set(KNOWN_BRANDS); @@ -34,8 +34,8 @@ function groupSessions(sessions: OAuthSessionView[]): { const byBrand: Record = { "Claude Code": [], Codex: [], - Cursor: [], Gemini: [], + Cursor: [], }; const otherSessions: OAuthSessionView[] = []; @@ -108,17 +108,46 @@ export function AgentsTab({ initialSessions }: AgentsTabProps) { Agents & devices

- Sessions authorized to run via MCP. Signing out or changing your - password revokes every session automatically. + Sessions authorized to run via MCP. Changing your password revokes + every session automatically.

-
- {sessions.length > 0 ? ( + + + + {error ? ( +
+ {error} +
+ ) : null} + +
+ {sessions.length > 0 ? ( +
Revoke all @@ -129,37 +158,8 @@ export function AgentsTab({ initialSessions }: AgentsTabProps) { destructive onConfirm={handleRevokeAll} /> - ) : null} - -
- - - {error ? ( -
- {error} -
- ) : null} - -
+
+ ) : null} {KNOWN_BRANDS.map((brand) => ( Date: Sun, 17 May 2026 18:31:04 +0200 Subject: [PATCH 9/9] fix: drop pg_cron init exception swallow and add header --- docker/init-pg-cron.sql | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docker/init-pg-cron.sql b/docker/init-pg-cron.sql index 697ef33..e8484a9 100644 --- a/docker/init-pg-cron.sql +++ b/docker/init-pg-cron.sql @@ -1,11 +1,13 @@ +-- Nightly purge of expired and revoked OAuth tokens. +-- Apply against the application database. pg_cron schedules in UTC. +-- Idempotent — safe to re-run. + CREATE EXTENSION IF NOT EXISTS pg_cron; DO $$ BEGIN PERFORM cron.unschedule('purge-oauth-tokens') WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'purge-oauth-tokens'); -EXCEPTION - WHEN OTHERS THEN NULL; END $$; SELECT cron.schedule(