mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
86 lines
3.0 KiB
TypeScript
86 lines
3.0 KiB
TypeScript
/**
|
|
* 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<string, { pluginId: string; created: number }>;
|
|
|
|
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;
|
|
}
|