/** * UTF-8-safe base64url encoding for OAuth `state` payloads. * * The /accept-invite flow round-trips an invite code + invited email through * the OAuth provider's `state` parameter. Internationalized email addresses * (e.g., `user@münchen.de`) contain code points > 255, which raw `btoa` / * `atob` cannot represent — they throw `DOMException: The string to be * encoded contains characters outside of the Latin1 range`. * * The classic `unescape(encodeURIComponent(...))` trick maps a UTF-16 string * through its UTF-8 byte representation into a Latin-1 string that `btoa` * accepts. The decode side reverses the transformation. */ export interface OAuthStatePayload { csrf: string accountInviteCode: string invitedEmail: string } export interface DecodedOAuthState { csrf: string accountInviteCode?: string invitedEmail?: string } /** Encode an OAuth state payload as URL-safe base64. UTF-8 safe. */ export function encodeOAuthState(payload: OAuthStatePayload): string { const json = JSON.stringify(payload) // unescape(encodeURIComponent(...)) converts UTF-16 -> UTF-8 -> Latin-1 // string so btoa can encode it without throwing on non-Latin-1 chars. const b64 = btoa(unescape(encodeURIComponent(json))) return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } /** Best-effort base64url-decode. Returns null on legacy random-hex states or * malformed input so the caller can fall back to a simple equality check. */ export function decodeOAuthState(raw: string | null): DecodedOAuthState | null { if (!raw) return null try { const padded = raw.replace(/-/g, '+').replace(/_/g, '/') const b64 = padded + '='.repeat((4 - (padded.length % 4)) % 4) // decodeURIComponent(escape(...)) reverses the encode-side transform. const json = decodeURIComponent(escape(atob(b64))) const parsed = JSON.parse(json) as Partial if (typeof parsed?.csrf === 'string') { return { csrf: parsed.csrf, accountInviteCode: typeof parsed.accountInviteCode === 'string' ? parsed.accountInviteCode : undefined, invitedEmail: typeof parsed.invitedEmail === 'string' ? parsed.invitedEmail : undefined, } } return null } catch { return null } }