UNPKG

accounts

Version:

Tempo Accounts SDK

286 lines 12.8 kB
import { Hex } from 'ox'; 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 { 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 }, }; const challengeKey = (nonce) => `challenge:${nonce}`; const sessionKey = (token) => `session:${token}`; /** Zod schemas for the auth handler's request and response payloads. */ export var schema; (function (schema) { /** Schemas for `POST {path}/challenge`. */ let challenge; (function (challenge) { /** Request body schema. */ challenge.parameters = z.object({ chainId: z.optional(z.number()), }); /** Response body schema. */ challenge.returns = z.object({ message: z.string(), }); })(challenge = schema.challenge || (schema.challenge = {})); /** Schemas for `POST {path}` (verify). */ let verify; (function (verify) { /** Request body schema. */ verify.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. */ verify.returns = z.object({ token: z.optional(z.string()), }); })(verify = schema.verify || (schema.verify = {})); })(schema || (schema = {})); /** * 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 = {}) { 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) { if (store.take) return store.take(key); const value = await store.get(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) => 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; 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 = { 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; 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 = async (req) => { if (!session) return undefined; const token = Session.tokenFromRequest(req, { cookie, cookieName }); if (!token) return undefined; return await store.get(sessionKey(token)); }; return Object.assign(router, { getSession }); } /** * 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, options) { 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() { return (typeof globalThis.navigator !== 'undefined' && globalThis.navigator.userAgent === 'Cloudflare-Workers'); } //# sourceMappingURL=auth.js.map