import { parseExactOrigin } from "./origins"; /** Schemes that could run script or read local files if we redirected to them. */ const DANGEROUS_SCHEMES = new Set(["javascript:", "data:", "vbscript:", "file:", "blob:"]); const MAX_CALLBACK_LENGTH = 512; const MAX_STATE_LENGTH = 256; export type NativeStartQuery = | { ok: true; origin: string; callback: string; state: string | null } | { ok: false; reason: string }; /** * `GET /app/start?origin=&callback=&state=` * * `callback` is checked for shape here and for registration against the app's * OAuth client metadata by the caller. Both checks must pass before the worker * hands a token to that URL. */ export function parseNativeStartQuery(url: URL): NativeStartQuery { const origin = url.searchParams.get("origin") ?? ""; const callback = url.searchParams.get("callback") ?? ""; const state = url.searchParams.get("state"); if (!origin) return { ok: false, reason: "origin is required" }; const parsedOrigin = parseExactOrigin(origin); if (!parsedOrigin.ok) return { ok: false, reason: parsedOrigin.reason }; const parsedCallback = parseCallback(callback); if (!parsedCallback.ok) return { ok: false, reason: parsedCallback.reason }; if (state !== null && state.length > MAX_STATE_LENGTH) { return { ok: false, reason: "state is too long" }; } return { ok: true, origin: parsedOrigin.origin, callback: parsedCallback.callback, state }; } function parseCallback( callback: string, ): { ok: true; callback: string } | { ok: false; reason: string } { if (!callback) return { ok: false, reason: "callback is required" }; if (callback.length > MAX_CALLBACK_LENGTH) return { ok: false, reason: "callback is too long" }; let url: URL; try { url = new URL(callback); } catch { return { ok: false, reason: "callback is not a URL" }; } if (DANGEROUS_SCHEMES.has(url.protocol.toLowerCase())) { return { ok: false, reason: "callback scheme is not allowed" }; } if (url.hash) return { ok: false, reason: "callback must not contain a fragment" }; return { ok: true, callback }; } /** * A callback is only trusted when the app already published it in * `redirect_uris`. Exact match: we append our own parameters afterwards, so * prefix or host matching would let a registered URI authorize a longer, * attacker-chosen one. */ export function isRegisteredCallback(callback: string, redirectUris: string[] | undefined): boolean { if (!redirectUris || redirectUris.length === 0) return false; return redirectUris.includes(callback); } export function buildCallbackUrl(callback: string, params: Record): string { const url = new URL(callback); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); } return url.toString(); }