UNPKG

accounts

Version:

Tempo Accounts SDK

481 lines (437 loc) 18.6 kB
import { Hex } from 'ox' import type { Address, Transport } from 'viem' import { createClient, http, zeroAddress } from 'viem' import { verifyMessage } from 'viem/actions' import { tempo } from 'viem/chains' import { createSiweMessage, generateSiweNonce, parseSiweMessage } from 'viem/siwe' import * as z from 'zod/mini' import * as u from '../../../core/zod/utils.js' import { type Handler, from } from '../../Handler.js' import * as Kv from '../../Kv.js' import * as Hono from '../hono.js' import * as Session from './session.js' const defaults = { cookieName: 'accounts_auth', ttl: { challenge: 10 * 60, // 10 minutes session: 24 * 60 * 60, // 24 hours }, } as const /** * Session payload persisted in the session store and surfaced via * `getSession`. `address` is the account address that signed the * authentication challenge; `chainId` is the chain echoed in the message. */ export type SessionPayload = { /** Address of the account. */ address: Address /** Chain ID echoed into the challenge message. */ chainId: number /** Unix timestamp (seconds) when the session was issued. */ issuedAt: number /** Unix timestamp (seconds) when the session expires. */ expiresAt: number } /** * Internal challenge-store entry. Tracked separately from the session * payload because challenges are single-use and the address isn't bound at * challenge time — the account supplies the address at verify time and the * server uses the supplied address as the session subject. * * The full issued SIWE message is persisted so verify can require exact * byte-equality between the submitted message and the one we issued. * Without this, the wallet (or anyone who tampered with the message in * flight) could swap fields the server doesn't otherwise check * (`statement`, `resources`, `uri`, `version`, the address placeholder) * while keeping `nonce`/`domain`/`chainId` and still pass verification. */ type ChallengePayload = { /** Echoed for defense-in-depth even though it's also in `message`. */ chainId: number /** Unix seconds. The Kv TTL also enforces this; kept for traceability. */ expiresAt: number /** Verbatim issued SIWE message. Verify rejects any mismatch. */ message: string } const challengeKey = (nonce: string) => `challenge:${nonce}` const sessionKey = (token: string) => `session:${token}` /** Zod schemas for the auth handler's request and response payloads. */ export namespace schema { /** Schemas for `POST {path}/challenge`. */ export namespace challenge { /** Request body schema. */ export const parameters = z.object({ chainId: z.optional(z.number()), }) /** Response body schema. */ export const returns = z.object({ message: z.string(), }) } /** Schemas for `POST {path}` (verify). */ export namespace verify { /** Request body schema. */ export const parameters = z.object({ address: u.address(), message: z.string(), signature: u.hex(), /** * When `true`, the server returns the issued session token in the * response body as `{ token }` and does NOT set a session cookie. * The caller is responsible for sending it as * `Authorization: Bearer <token>` on subsequent requests. */ returnToken: z.optional(z.boolean()), }) /** Response body schema. */ export const returns = z.object({ token: z.optional(z.string()), }) } } /** * Server Authentication request handler. Mounts three routes under `path`: * * - `POST {path}/challenge` → `{ message }` * - `POST {path}` → verify and issue a session (cookie via `Set-Cookie`) * - `POST {path}/logout` → clear the session cookie * * The returned handler also exposes `getSession(req)` for resolving the * current session from a follow-up request's cookie. * * The challenge message is wire-formatted as EIP-4361 (SIWE) for ecosystem * compatibility, but address binding is deferred: the SDK can fold the * challenge digest into the connect ceremony before the account knows * which address it will sign with. The wallet supplies the real address at * verify time and the server uses it as the session subject. */ export function auth(options: auth.Options = {}): auth.ReturnType { const { cookie = true, cookieName = defaults.cookieName, domain, onAuthenticate, path = '/', origin: origin_option, session = true, store = Kv.memory(), transport = http(), // Cloudflare Workers always run behind Cloudflare's edge proxy, which // sets `x-forwarded-proto`/`x-forwarded-host`. Default `trustProxy` to // `true` there so deployments work out of the box. Other runtimes keep // the secure default of `false` (operator must opt in). trustProxy = isCloudflareWorkers(), ttl: { challenge: challengeTtl = defaults.ttl.challenge, session: sessionTtl = defaults.ttl.session, } = {}, ...rest } = options async function take(key: string): Promise<ChallengePayload | undefined> { if (store.take) return store.take<ChallengePayload>(key) const value = await store.get<ChallengePayload>(key) if (value === undefined) return undefined await store.delete(key) return value } // Pre-parse `origin` so a misconfiguration fails loudly at handler // construction time rather than per-request. const pinnedOrigin = (() => { if (!origin_option) return undefined try { const url = new URL(origin_option) return { protocol: url.protocol, host: url.host } } catch { throw new Error(`\`auth({ origin })\` must be a valid absolute URL. Got: ${origin_option}`) } })() const resolveReqOrigin = (req: Request) => resolveOrigin(req, { pinnedOrigin, trustProxy }) const client = createClient({ chain: tempo, transport }) const router = from(rest) const verifyPath = path === '/' ? '/' : path const challengePath = path === '/' ? '/challenge' : `${path}/challenge` const logoutPath = path === '/' ? '/logout' : `${path}/logout` router.post(challengePath, Hono.validate('json', schema.challenge.parameters), async (c) => { const { chainId = 0 } = c.req.valid('json') const { protocol, host: reqHost } = resolveReqOrigin(c.req.raw) const resolvedDomain = domain ?? reqHost const nonce = generateSiweNonce() const issuedAt = new Date() const expirationTime = new Date(issuedAt.getTime() + challengeTtl * 1000) const message = createSiweMessage({ address: zeroAddress, chainId, domain: resolvedDomain, uri: `${protocol}//${resolvedDomain}`, version: '1', nonce, issuedAt, expirationTime, }) await store.set( challengeKey(nonce), { message, chainId, expiresAt: Math.floor(expirationTime.getTime() / 1000), }, { ttl: challengeTtl }, ) return c.json(z.encode(schema.challenge.returns, { message })) }) router.post(verifyPath, Hono.validate('json', schema.verify.parameters), async (c) => { const { address, message, signature, returnToken } = c.req.valid('json') const parsed = parseSiweMessage(message) if (!parsed.nonce) return c.json({ error: 'message missing `nonce`' }, 400) const { protocol, host: reqHost } = resolveReqOrigin(c.req.raw) const resolvedDomain = domain ?? reqHost if (parsed.domain !== resolvedDomain) return c.json({ error: 'domain mismatch' }, 400) const now = Date.now() if (parsed.expirationTime && parsed.expirationTime.getTime() < now) return c.json({ error: 'message expired' }, 400) if (parsed.notBefore && parsed.notBefore.getTime() > now) return c.json({ error: 'message not yet valid' }, 400) const challenge = await take(challengeKey(parsed.nonce)) if (!challenge) return c.json({ error: 'invalid or replayed nonce' }, 409) // Require exact byte-equality between the submitted message and the one // we issued. Without this, the wallet (or anyone tampering with the // message in flight) could swap fields we don't otherwise check // (`statement`, `resources`, `uri`, `version`, the address placeholder) // while keeping `nonce`/`domain`/`chainId` and still pass verification — // enabling a phishing-style takeover where a victim signs a benign-looking // message bound to an attacker's session. if (message !== challenge.message) return c.json({ error: 'message mismatch' }, 400) if (parsed.chainId !== challenge.chainId) return c.json({ error: 'chainId mismatch' }, 400) // Signature verification via viem's `verifyMessage`. Tempo's chain // override unwraps `SignatureEnvelope` for WebAuthn / P256 / keychain // sigs and falls back to ECDSA recovery for plain EOAs. let valid: boolean try { valid = await verifyMessage(client, { address, message, signature }) } catch { return c.json({ error: 'invalid signature' }, 401) } if (!valid) return c.json({ error: 'signature does not match address' }, 401) const issuedAt = Math.floor(now / 1000) const payload: SessionPayload = { address, chainId: parsed.chainId, issuedAt, expiresAt: issuedAt + sessionTtl, } // Hook for side effects (e.g. user provisioning, analytics). Returning // a `Response` merges its JSON body and status onto the verify // response (matches `webAuthn`'s contract). Throwing rejects with // `401` and surfaces the error's message in the response body. let hookResponse: Response | undefined if (onAuthenticate) { try { const result = await onAuthenticate({ address, chainId: parsed.chainId, message, request: c.req.raw, signature, }) if (result) hookResponse = result } catch (error) { return c.json( { error: error instanceof Error ? error.message : 'authentication rejected' }, 401, ) } } // `session: false` short-circuits — verify acts as a stateless // signature check. No token, no cookie, no store write. Useful for // hosts that mint their own session in `onAuthenticate` (e.g. JWTs). if (!session) return Session.mergeResponse({}, hookResponse) const token = Session.generateToken() await store.set(sessionKey(token), payload, { ttl: sessionTtl }) // Token mode: caller sends `Authorization: Bearer <token>`. Forced // when `cookie: false`, opt-in via `returnToken: true` otherwise. // Cookie mode (default): browser carries the cookie automatically. const tokenMode = !cookie || returnToken const cookieHeader = tokenMode ? undefined : Session.serializeCookie({ name: cookieName, protocol, ttl: sessionTtl, value: token, }) return Session.mergeResponse(tokenMode ? { token } : {}, hookResponse, cookieHeader) }) // Logout has no meaning when sessions are disabled — skip mounting the // route entirely so callers get a clean `404` instead of a misleading // `204` no-op. if (session) router.post(logoutPath, async (c) => { const token = Session.tokenFromRequest(c.req.raw, { cookie, cookieName }) if (token) await store.delete(sessionKey(token)) if (cookie) Session.clearCookie(c, cookieName) return c.body(null, 204) }) const getSession: auth.getSession = async (req) => { if (!session) return undefined const token = Session.tokenFromRequest(req, { cookie, cookieName }) if (!token) return undefined return await store.get<SessionPayload>(sessionKey(token)) } return Object.assign(router, { getSession }) } export declare namespace auth { /** Return type of `auth()` — a `Handler` extended with `getSession`. */ type ReturnType = Handler & { getSession: getSession } /** Resolves the current session from a request's cookie or bearer token. */ type getSession = (req: Session.SessionRequest) => Promise<SessionPayload | undefined> /** * Hook invoked after a SIWE signature is verified but before the * session token is issued. Returning a `Response` merges its JSON * body and status onto the verify response. Throwing rejects with * `401` — the thrown error's `message` is surfaced as the response * `error` field — and no session is issued. */ type onAuthenticate = (params: { /** Verified address that signed the SIWE message. */ address: Address /** Chain ID parsed from the SIWE message. */ chainId: number /** Verbatim SIWE message that was signed. */ message: string /** Underlying request — useful for headers, IP, etc. */ request: Request /** Signature provided by the wallet. */ signature: Hex.Hex }) => Response | Promise<Response> | void | Promise<void> type Options = from.Options & { /** * Whether to issue a session cookie on successful verify. When * `false`, the verify response always contains `{ token }` in the * body (the per-request `returnToken` flag is ignored), no * `Set-Cookie` header is sent, logout does not clear a cookie, and * `getSession` ignores any incoming cookie — only * `Authorization: Bearer <token>` is honored. Use this when the SDK * lives in a non-browser context (CLI, server-to-server) or when * the host app already manages auth cookies. * @default true */ cookie?: boolean | undefined /** Cookie name for the session token. @default "accounts_auth" */ cookieName?: string | undefined /** Domain echoed into challenge messages. @default request `Host` header */ domain?: string | undefined /** * Hook invoked after the SIWE signature is verified but before the * session token is issued. Use to provision a user record, emit * analytics, or apply application-level allow/deny rules. Throwing * from this hook rejects the request with `401` and the thrown * error's `message` is surfaced as the response `error` field. */ onAuthenticate?: onAuthenticate | undefined /** Path prefix for the auth endpoints. @default "/" */ path?: string | undefined /** * Pinned canonical public origin (e.g. `'https://app.example.com'`). * When set, the SIWE `domain` and `uri`, and the cookie `Secure` flag, * are derived from this URL — request `Host`, request URL, and * `x-forwarded-*` headers are ignored. This is the recommended setting * for production deployments behind a CDN or reverse proxy: it * prevents a spoofed `x-forwarded-host` from shifting the SIWE domain * and a spoofed `x-forwarded-proto: http` from disabling `Secure`. */ origin?: string | undefined /** * Whether to issue a session on successful verify. When `false`, * verify acts as a stateless signature check — no token is generated, * no entry is written to the session store, and no cookie is sent. * The verify response is `{}`. `getSession` always returns * `undefined` and `/logout` is a no-op (still returns `204`). Use * this when the host application mints its own session token (e.g. * a JWT inside `onAuthenticate`). * @default true */ session?: boolean | undefined /** * Backing store for both single-use challenges (nonces) and issued * sessions. Keys are namespaced internally (`challenge:…`, `session:…`). * @default `Kv.memory()` */ store?: Kv.Kv | undefined /** * Viem transport for the Tempo client used to verify signatures. The * client is always built against the `tempo` chain — Tempo's * `chain.verifyHash` natively understands `SignatureEnvelope` and * falls back to ECDSA recovery for plain EOAs. * @default `http()` */ transport?: Transport | undefined /** * Honor `x-forwarded-proto` / `x-forwarded-host` to derive the public * origin. Required when running behind a trusted reverse proxy that * terminates TLS (OrbStack on `*.tempo.local`, a CDN, etc.). When * `false`, forwarded headers are ignored to prevent spoofing on * deployments that expose the origin server directly. Ignored when * `origin` is set. * @default `true` on Cloudflare Workers (always edge-fronted), `false` elsewhere. */ trustProxy?: boolean | undefined /** TTLs in seconds. */ ttl?: | { /** Challenge (nonce) TTL. @default 600 (10m) */ challenge?: number | undefined /** Session TTL. @default 86400 (24h) */ session?: number | undefined } | undefined } } /** * Resolves the public-facing protocol and host for a request. * * - When `pinnedOrigin` is set (operator passed `auth({ origin })`), * that origin is the source of truth — forwarded headers and request URL * are ignored. This prevents a spoofed `x-forwarded-host` from shifting * SIWE `domain` and a spoofed `x-forwarded-proto: http` from disabling * the cookie `Secure` flag on an HTTPS deployment. * - When `trustProxy` is set, `x-forwarded-proto` / `x-forwarded-host` are * honored (needed behind a reverse proxy like OrbStack or a CDN that * terminates TLS). * - Default falls back to the request `host` header and request URL * protocol — safe even on multi-hop deployments because forwarded * headers are ignored. */ function resolveOrigin( req: Request, options: { pinnedOrigin?: { protocol: string; host: string } | undefined trustProxy?: boolean | undefined }, ): { protocol: string; host: string } { if (options.pinnedOrigin) return options.pinnedOrigin const headers = req.headers const reqUrl = new URL(req.url) if (options.trustProxy) { const forwardedHost = headers.get('x-forwarded-host')?.split(',')[0]?.trim() const forwardedProto = headers.get('x-forwarded-proto')?.split(',')[0]?.trim() return { protocol: forwardedProto ? `${forwardedProto}:` : reqUrl.protocol, host: forwardedHost || headers.get('host') || reqUrl.host, } } return { protocol: reqUrl.protocol, host: headers.get('host') || reqUrl.host, } } /** * Detects whether we're executing inside the Cloudflare Workers runtime, * which is always edge-fronted and forwards `x-forwarded-*` headers from * Cloudflare's proxy. Used to flip the default `trustProxy` to `true`. */ function isCloudflareWorkers(): boolean { return ( typeof globalThis.navigator !== 'undefined' && globalThis.navigator.userAgent === 'Cloudflare-Workers' ) }