/** * Shared constants and types for the plugin OAuth callback handoff. * * The callback page (`/[locale]/plugins/oauth/callback`) parks the provider's * payload in storage and dispatches events; the host relays it to plugins via * the `authHooks.onOAuthCallback` hook bus. Both sides share the storage key, * event name and payload contract from this module. */ export const PLUGIN_OAUTH_STORAGE_KEY = "plugin-oauth-callback"; export const PLUGIN_OAUTH_EVENT = "plugin-oauth-callback"; /** * Payload delivered to plugins through `authHooks.onOAuthCallback`. * * Two shapes, discriminated by which field the provider returned: * - Success: `{ code, state?, receivedAt? }` * - Denial/failure: `{ error, state?, error_description?, receivedAt? }` * * `state` stays optional in both variants because a provider may omit it even * on error; plugins match the flow against the verifier they stashed before * redirecting and ignore payloads whose `state` does not match. Narrow with * `'code' in payload` / `'error' in payload`. */ export type OAuthCallbackPayload = | { code: string; state?: string; receivedAt?: number } | { error: string; state?: string; error_description?: string; receivedAt?: number; }; // ─── State values bound to the plugin that started the flow ─── const STATES_KEY = "plugin-oauth-states"; /** A flow that takes longer than this is abandoned. */ const STATE_TTL_MS = 15 * 60_000; type StateTable = Record; function readStates(): StateTable { try { const raw = localStorage.getItem(STATES_KEY); const parsed = raw ? JSON.parse(raw) as StateTable : {}; const now = Date.now(); for (const [state, entry] of Object.entries(parsed)) { if (!entry || typeof entry.pluginId !== "string" || now - entry.created > STATE_TTL_MS) delete parsed[state]; } return parsed; } catch { return {}; } } function writeStates(table: StateTable): void { try { localStorage.setItem(STATES_KEY, JSON.stringify(table)); } catch { /* storage blocked */ } } /** * A fresh `state` for a plugin's OAuth redirect. The callback is delivered * only to the plugin that asked for this value; before, every plugin with * http:fetch received every code. Kept in localStorage so the tab that gets * the callback event finds it even if another tab started the flow. */ export function createOAuthState(pluginId: string): string { const bytes = new Uint8Array(24); crypto.getRandomValues(bytes); const state = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); const table = readStates(); table[state] = { pluginId, created: Date.now() }; writeStates(table); return state; } /** The plugin a callback belongs to, consumed so it can't be delivered twice. */ export function takeOAuthStateOwner(state: string | undefined): string | null { if (!state) return null; const table = readStates(); const entry = table[state]; if (!entry) return null; delete table[state]; writeStates(table); return entry.pluginId; }