// OAuth state token for the Slack workspace install handshake. // Format: base64url(payloadJson).base64url(HMAC-SHA256 signature). The signature // binds the payload (acting userId, optional pinned redirectUri, expiresAt) to a // shared `signingSecret` the host supplies on both the begin and complete calls, // so a forged or tampered state is rejected — the state is the CSRF / integrity // control for the OAuth callback, not just an expiry envelope. export interface StateTokenPayload { userId?: string; redirectUri?: string; expiresAt: string; } function bytesToBase64Url(bytes: Uint8Array): string { let binary = ""; for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function toBase64Url(input: string): string { return bytesToBase64Url(new TextEncoder().encode(input)); } function base64UrlToBytes(input: string): Uint8Array { const pad = (4 - (input.length % 4)) % 4; const b64 = input.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad); const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < bytes.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } function fromBase64Url(input: string): string { return new TextDecoder().decode(base64UrlToBytes(input)); } const STATE_TTL_MS = 1000 * 60 * 10; function importHmacKey(signingSecret: string): Promise { return crypto.subtle.importKey( "raw", new TextEncoder().encode(signingSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"], ); } /** Issue a signed state token. The `signingSecret` must match the one passed to {@link parseStateToken} on the callback. */ export async function createStateToken( payload: Omit, signingSecret: string, ): Promise { const expiresAt = new Date(Date.now() + STATE_TTL_MS).toISOString(); const body = toBase64Url(JSON.stringify({ ...payload, expiresAt })); const key = await importHmacKey(signingSecret); const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)); return `${body}.${bytesToBase64Url(new Uint8Array(signature))}`; } /** * Verify and decode a state token. Returns `null` when the token is malformed, * the signature does not verify against `signingSecret` (forged / tampered / * wrong key), or it has expired. Signature verification (`crypto.subtle.verify`) * is constant-time and runs before the payload is trusted. */ export async function parseStateToken( token: string, signingSecret: string, ): Promise { try { const dot = token.indexOf("."); if (dot <= 0 || dot === token.length - 1) return null; const body = token.slice(0, dot); const signature = base64UrlToBytes(token.slice(dot + 1)); const key = await importHmacKey(signingSecret); const valid = await crypto.subtle.verify( "HMAC", key, signature, new TextEncoder().encode(body), ); if (!valid) return null; const parsed = JSON.parse(fromBase64Url(body)) as StateTokenPayload; if (!parsed.expiresAt || new Date(parsed.expiresAt).getTime() < Date.now()) { return null; } return parsed; } catch { return null; } }