Skip to content
File

Blob: src/worker/api/connected-apps.ts

typescript104 lines
1import { APIError } from "better-auth";
2import { and, desc, eq } from "drizzle-orm";
3 
4import { requiredParam } from "@/worker/api/request";
5import { oauthAccessTokens, oauthClients, oauthConsents, oauthRefreshTokens } from "@/worker/db/schema";
6import type { AppContext } from "@/worker/hono";
7import { HttpError } from "@/worker/http";
8import { requireSession } from "@/worker/middleware/auth";
9import { isSafeHttpUrl } from "@/worker/services/url";
10 
11// User-facing list of OAuth clients the signed-in user has explicitly
12// granted scopes to. Apps with `skip_consent: true` bypass the consent
13// endpoint entirely, so they never write a row to oauthConsents — the
14// WHERE userId clause excludes them by construction.
15export const handleListConnectedApps = async (c: AppContext): Promise<Response> => {
16 const session = requireSession(c);
17 // Single Drizzle query keyed on the session user id. The leftJoin
18 // against oauthClients pulls display fields (name, uri) without a
19 // separate round-trip and without re-running the cookie session lookup
20 // that auth.api.getOAuthConsents performs internally.
21 const rows = await c.var.db
22 .select({
23 id: oauthConsents.id,
24 clientId: oauthConsents.clientId,
25 scopes: oauthConsents.scopes,
26 createdAt: oauthConsents.createdAt,
27 updatedAt: oauthConsents.updatedAt,
28 clientName: oauthClients.name,
29 clientUri: oauthClients.uri,
30 })
31 .from(oauthConsents)
32 .leftJoin(oauthClients, eq(oauthConsents.clientId, oauthClients.clientId))
33 .where(eq(oauthConsents.userId, session.user.id))
34 .orderBy(desc(oauthConsents.createdAt));
35 
36 return c.json(
37 rows.map((row) => ({
38 id: row.id,
39 client_id: row.clientId,
40 client_name: row.clientName ?? null,
41 client_uri: typeof row.clientUri === "string" && isSafeHttpUrl(row.clientUri) ? row.clientUri : null,
42 scopes: normaliseScopes(row.scopes),
43 granted_at: row.createdAt,
44 updated_at: row.updatedAt,
45 })),
46 );
47};
48 
49const getOwnedConsent = async (c: AppContext, consentId: string) => {
50 try {
51 return await c.var.auth.api.getOAuthConsent({
52 query: { id: consentId },
53 headers: c.req.raw.headers,
54 });
55 } catch (e) {
56 // 404 not 403 on cross-user attempts — don't leak the existence of
57 // someone else's grant.
58 if (
59 e instanceof APIError &&
60 (e.status === "NOT_FOUND" || e.status === "UNAUTHORIZED" || e.statusCode === 404 || e.statusCode === 401)
61 ) {
62 throw new HttpError(404, "consent_not_found", "Consent not found.");
63 }
64 throw e;
65 }
66};
67 
68const normaliseScopes = (raw: unknown): string[] => {
69 if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === "string");
70 if (typeof raw === "string") {
71 try {
72 const parsed = JSON.parse(raw);
73 return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : [];
74 } catch {
75 // Fallback: treat as a space-separated scope string ("openid profile email").
76 return raw.split(/\s+/).filter(Boolean);
77 }
78 }
79 return [];
80};
81 
82// Revoke a single connected app. Clears the consent row plus any active
83// refresh/access tokens for the (user, client) pair so the RP can't
84// silently keep using its tokens until they expire on their own.
85export const handleRevokeConnectedApp = async (c: AppContext): Promise<Response> => {
86 const session = requireSession(c);
87 const logger = c.var.log.child({ component: "connected-apps" });
88 const consentId = requiredParam(c, "id", { status: 400, code: "invalid_id", message: "Consent id required." });
89 const consent = await getOwnedConsent(c, consentId);
90 
91 await c.var.db.batch([
92 c.var.db
93 .delete(oauthAccessTokens)
94 .where(and(eq(oauthAccessTokens.userId, session.user.id), eq(oauthAccessTokens.clientId, consent.clientId))),
95 c.var.db
96 .delete(oauthRefreshTokens)
97 .where(and(eq(oauthRefreshTokens.userId, session.user.id), eq(oauthRefreshTokens.clientId, consent.clientId))),
98 c.var.db.delete(oauthConsents).where(eq(oauthConsents.id, consentId)),
99 ]);
100 
101 logger.info("connected_app_revoked", { consentId, clientId: consent.clientId, userId: session.user.id });
102 return c.body(null, 204);
103};