Skip to content
38 changes: 35 additions & 3 deletions app/settings/_components/AgentsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,20 @@
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. */
initialSessions: OAuthSessionView[];
}

/** 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<string> = new Set(KNOWN_BRANDS);

Expand All @@ -32,8 +34,8 @@ function groupSessions(sessions: OAuthSessionView[]): {
const byBrand: Record<KnownBrand, OAuthSessionView[]> = {
"Claude Code": [],
Codex: [],
Cursor: [],
Gemini: [],
Cursor: [],
};
const otherSessions: OAuthSessionView[] = [];

Expand Down Expand Up @@ -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],
Expand All @@ -96,7 +108,8 @@ export function AgentsTab({ initialSessions }: AgentsTabProps) {
Agents &amp; devices
</h1>
<p className="mt-1 text-[13px] text-text-muted">
Sessions authorized to run via MCP. Revoke any time.
Sessions authorized to run via MCP. Changing your password revokes
every session automatically.
</p>
</div>
<button
Expand Down Expand Up @@ -128,6 +141,25 @@ export function AgentsTab({ initialSessions }: AgentsTabProps) {
) : null}

<div className="space-y-3">
{sessions.length > 0 ? (
<div className="flex min-h-9 items-center justify-end">
<InlineConfirm
trigger={
<button
type="button"
className="cursor-pointer rounded-md px-2.5 py-1 text-[12px] font-medium text-text-muted transition-colors hover:bg-surface-hover hover:text-text-primary"
>
Revoke all
</button>
}
prompt="Revoke all sessions?"
body="Every connected agent will need to re-authorize."
confirmLabel="Revoke all"
destructive
onConfirm={handleRevokeAll}
/>
</div>
) : null}
{KNOWN_BRANDS.map((brand) => (
<AgentSection
key={brand}
Expand Down
22 changes: 22 additions & 0 deletions docker/init-pg-cron.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- 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');
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();
$$
);
36 changes: 36 additions & 0 deletions lib/actions/oauth-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "@/lib/actions/team-errors";
import type { OAuthSessionView } from "@/lib/actions/oauth-session-types";
import {
clearUserOAuthArtifacts,
listActiveOAuthSessions,
revokeOAuthSession,
userOwnsActiveSession,
Expand Down Expand Up @@ -101,3 +102,38 @@ export async function revokeOAuthSessionAction(input: {
return teamFail("unknown");
}
}

/**
* Revoke every active OAuth refresh and access token owned by the caller.
*
* @returns Discriminated result.
*/
export async function revokeAllOAuthSessionsAction(): Promise<TeamActionResult> {
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");
}
}
32 changes: 29 additions & 3 deletions lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ 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";

/**
* 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, {
Expand Down Expand Up @@ -117,6 +118,14 @@ export const auth = betterAuth({
consentPage: "/consent",
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
accessTokenExpiresIn: 60 * 60, // 1h
refreshTokenExpiresIn: 60 * 60 * 24 * 7, // 7 days
clientRegistrationAllowedScopes: [
"openid",
"profile",
"email",
"offline_access",
],
validAudiences: process.env.BETTER_AUTH_URL
? [
process.env.BETTER_AUTH_URL,
Expand All @@ -139,6 +148,23 @@ export const auth = betterAuth({
silenceWarnings: { oauthAuthServerConfig: true },
}),
],
databaseHooks: {
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;
17 changes: 17 additions & 0 deletions lib/data/oauth-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,23 @@ export async function revokeOAuthSession(
});
}

/**
* Hard-delete every OAuth refresh and access token owned by a user.
*
* @param userId - Verified user id.
* @returns Resolves once both deletes commit.
*/
export async function clearUserOAuthArtifacts(userId: string): Promise<void> {
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
Expand Down
Loading