accounts
Version:
Tempo Accounts SDK
175 lines (160 loc) • 6.05 kB
text/typescript
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,
})
}