accounts
Version:
Tempo Accounts SDK
286 lines • 12.8 kB
JavaScript
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