accounts
Version:
Tempo Accounts SDK
481 lines (437 loc) • 18.6 kB
text/typescript
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'
)
}