import { APIError } from "better-auth"; import { and, desc, eq } from "drizzle-orm"; import { requiredParam } from "@/worker/api/request"; import { oauthAccessTokens, oauthClients, oauthConsents, oauthRefreshTokens } from "@/worker/db/schema"; import type { AppContext } from "@/worker/hono"; import { HttpError } from "@/worker/http"; import { requireSession } from "@/worker/middleware/auth"; import { isSafeHttpUrl } from "@/worker/services/url"; // User-facing list of OAuth clients the signed-in user has explicitly // granted scopes to. Apps with `skip_consent: true` bypass the consent // endpoint entirely, so they never write a row to oauthConsents — the // WHERE userId clause excludes them by construction. export const handleListConnectedApps = async (c: AppContext): Promise => { const session = requireSession(c); // Single Drizzle query keyed on the session user id. The leftJoin // against oauthClients pulls display fields (name, uri) without a // separate round-trip and without re-running the cookie session lookup // that auth.api.getOAuthConsents performs internally. const rows = await c.var.db .select({ id: oauthConsents.id, clientId: oauthConsents.clientId, scopes: oauthConsents.scopes, createdAt: oauthConsents.createdAt, updatedAt: oauthConsents.updatedAt, clientName: oauthClients.name, clientUri: oauthClients.uri, }) .from(oauthConsents) .leftJoin(oauthClients, eq(oauthConsents.clientId, oauthClients.clientId)) .where(eq(oauthConsents.userId, session.user.id)) .orderBy(desc(oauthConsents.createdAt)); return c.json( rows.map((row) => ({ id: row.id, client_id: row.clientId, client_name: row.clientName ?? null, client_uri: typeof row.clientUri === "string" && isSafeHttpUrl(row.clientUri) ? row.clientUri : null, scopes: normaliseScopes(row.scopes), granted_at: row.createdAt, updated_at: row.updatedAt, })), ); }; const getOwnedConsent = async (c: AppContext, consentId: string) => { try { return await c.var.auth.api.getOAuthConsent({ query: { id: consentId }, headers: c.req.raw.headers, }); } catch (e) { // 404 not 403 on cross-user attempts — don't leak the existence of // someone else's grant. if ( e instanceof APIError && (e.status === "NOT_FOUND" || e.status === "UNAUTHORIZED" || e.statusCode === 404 || e.statusCode === 401) ) { throw new HttpError(404, "consent_not_found", "Consent not found."); } throw e; } }; const normaliseScopes = (raw: unknown): string[] => { if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === "string"); if (typeof raw === "string") { try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : []; } catch { // Fallback: treat as a space-separated scope string ("openid profile email"). return raw.split(/\s+/).filter(Boolean); } } return []; }; // Revoke a single connected app. Clears the consent row plus any active // refresh/access tokens for the (user, client) pair so the RP can't // silently keep using its tokens until they expire on their own. export const handleRevokeConnectedApp = async (c: AppContext): Promise => { const session = requireSession(c); const logger = c.var.log.child({ component: "connected-apps" }); const consentId = requiredParam(c, "id", { status: 400, code: "invalid_id", message: "Consent id required." }); const consent = await getOwnedConsent(c, consentId); await c.var.db.batch([ c.var.db .delete(oauthAccessTokens) .where(and(eq(oauthAccessTokens.userId, session.user.id), eq(oauthAccessTokens.clientId, consent.clientId))), c.var.db .delete(oauthRefreshTokens) .where(and(eq(oauthRefreshTokens.userId, session.user.id), eq(oauthRefreshTokens.clientId, consent.clientId))), c.var.db.delete(oauthConsents).where(eq(oauthConsents.id, consentId)), ]); logger.info("connected_app_revoked", { consentId, clientId: consent.clientId, userId: session.user.id }); return c.body(null, 204); };