File
Blob: src/worker/api/connected-apps.ts
| 1 | import { APIError } from "better-auth"; |
| 2 | import { and, desc, eq } from "drizzle-orm"; |
| 3 | |
| 4 | import { requiredParam } from "@/worker/api/request"; |
| 5 | import { oauthAccessTokens, oauthClients, oauthConsents, oauthRefreshTokens } from "@/worker/db/schema"; |
| 6 | import type { AppContext } from "@/worker/hono"; |
| 7 | import { HttpError } from "@/worker/http"; |
| 8 | import { requireSession } from "@/worker/middleware/auth"; |
| 9 | import { 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. |
| 15 | export 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 | |
| 49 | const 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 | |
| 68 | const 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. |
| 85 | export 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 | }; |