UNPKG

accounts

Version:

Tempo Accounts SDK

175 lines (160 loc) 6.05 kB
import type { Context } from 'hono' import { setCookie as core_setCookie } from 'hono/cookie' import { Hex } from 'ox' /** * Shared session helpers used by SDK handlers that issue server-side * sessions (e.g. `auth`, `webAuthn`). Each handler is responsible for its * own session payload shape and storage; this module only provides the * token-extraction, cookie-issuance, and token-generation primitives so * the conventions stay consistent. */ /** Default `Set-Cookie` attributes for handler-issued session cookies. */ export const defaults = { httpOnly: true, sameSite: 'Lax', path: '/', } as const /** * Parse a `Bearer <token>` value out of an `Authorization` header. Returns * `undefined` when the header is missing, doesn't use the `Bearer` * scheme, or contains an empty token. */ export function bearerToken(authorization: string | null): string | undefined { if (!authorization) return undefined if (!authorization.toLowerCase().startsWith('bearer ')) return undefined return authorization.slice(7).trim() || undefined } /** * Extract the value of a single cookie from a raw `Cookie` header. * Returns `undefined` when the cookie is absent. */ export function parseCookieValue(header: string, name: string): string | undefined { for (const part of header.split(';')) { const trimmed = part.trim() const eq = trimmed.indexOf('=') if (eq === -1) continue if (trimmed.slice(0, eq) === name) return decodeURIComponent(trimmed.slice(eq + 1)) } return undefined } /** * Minimal request interface accepted by `tokenFromRequest` and * `getSession`. Compatible with both the Fetch API `Request` and * Node.js `http.IncomingMessage` so callers in Express, Fastify, or * plain `http.createServer` don't need to construct a synthetic * `Request` just to read a session. */ export type SessionRequest = | Request | { headers: Record<string, string | string[] | undefined> } /** * Read a single header value from a `SessionRequest`. Handles both * the Fetch API `Headers` (`.get()`) and the Node.js record shape * where values may be `string | string[] | undefined`. */ function getHeader(req: SessionRequest, name: string): string | null { if ('get' in req.headers && typeof req.headers.get === 'function') return req.headers.get(name) const value = (req.headers as Record<string, string | string[] | undefined>)[name] if (value === undefined) return null return Array.isArray(value) ? value.join('; ') : value } /** * Resolve the session token for a request. Prefers `Authorization: Bearer * <token>` over the cookie. When `cookie: false`, the cookie is ignored * even if present so callers cannot opt back into cookie mode by sending * a stale `Set-Cookie` value. * * Accepts both Fetch API `Request` and Node.js `IncomingMessage`-shaped * objects (see {@link SessionRequest}). */ export function tokenFromRequest( req: SessionRequest, options: { /** Whether cookie issuance is enabled for this handler. */ cookie: boolean /** Cookie name when cookie mode is enabled. */ cookieName: string }, ): string | undefined { const bearer = bearerToken(getHeader(req, 'authorization')) if (bearer) return bearer if (!options.cookie) return undefined const cookieHeader = getHeader(req, 'cookie') return cookieHeader ? parseCookieValue(cookieHeader, options.cookieName) : undefined } /** * Build the raw `Set-Cookie` header value for a session cookie. Use this * when the route handler returns a freshly-constructed `Response` (which * bypasses Hono's context header merging) — append the returned string * to the response's `Set-Cookie` header directly. */ export function serializeCookie(options: { /** Cookie name. */ name: string /** Token value. */ value: string /** Cookie max-age in seconds. */ ttl: number /** Resolved request protocol — drives the `Secure` attribute. */ protocol: string }): string { const parts = [`${options.name}=${encodeURIComponent(options.value)}`] parts.push(`Max-Age=${options.ttl}`) parts.push(`Path=${defaults.path}`) parts.push(`SameSite=${defaults.sameSite}`) if (defaults.httpOnly) parts.push('HttpOnly') if (options.protocol === 'https:') parts.push('Secure') return parts.join('; ') } /** * Build the raw `Set-Cookie` header value that clears a previously * issued session cookie. */ export function clearCookieHeader(name: string): string { return `${name}=; Max-Age=0; Path=${defaults.path}` } /** * Clear a previously-issued session cookie by writing an empty value with * `Max-Age=0`. */ export function clearCookie(c: Context, name: string): void { core_setCookie(c, name, '', { path: '/', maxAge: 0 }) } /** * Generate a 256-bit cryptographically-random session token, encoded as * lowercase hex without the `0x` prefix. */ export function generateToken(): string { return Hex.fromBytes(crypto.getRandomValues(new Uint8Array(32))).slice(2) } /** * Build the final JSON response for a verify/login route, merging an * optional hook `Response` (extra body fields, status, custom headers) * with the handler's own JSON and an optional `Set-Cookie` header. * * The hook contract — return a `Response` whose body fields and status * are folded onto the default response — is shared by `auth` and * `webAuthn`. Hook fields take precedence over the handler's defaults * via spread order. */ export async function mergeResponse( json: Record<string, unknown>, hook?: Response | undefined, cookieHeader?: string | undefined, ): Promise<Response> { const headers = hook ? new Headers(hook.headers) : new Headers() headers.set('content-type', 'application/json') if (cookieHeader) headers.append('set-cookie', cookieHeader) if (!hook) return new Response(JSON.stringify(json), { headers, status: 200, }) const extra = (await hook.json().catch(() => ({}))) as Record<string, unknown> return new Response(JSON.stringify({ ...json, ...extra }), { headers, status: hook.status, }) }