UNPKG

accounts

Version:

Tempo Accounts SDK

646 lines 23.5 kB
import { Address, Base64, Bytes, Hex, PublicKey } from 'ox'; import { KeyAuthorization as TempoKeyAuthorization, SignatureEnvelope } from 'ox/tempo'; import { createClient, http } from 'viem'; import { verifyHash } from 'viem/actions'; import { tempo } from 'viem/chains'; import * as z from 'zod/mini'; import * as u from '../core/zod/utils.js'; const maxLimits = 10; const limit = z.object({ token: u.address(), limit: u.bigint() }); const limits = z.readonly(z.array(limit).check(z.maxLength(maxLimits))); const defaultTtlMs = 10 * 60 * 1_000; const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; /** Supported access-key types for CLI bootstrap. */ export const keyType = z.union([z.literal('secp256k1'), z.literal('p256'), z.literal('webAuthn')]); /** Signed key authorization returned by the device-code flow. */ export const keyAuthorization = z.object({ address: u.address(), chainId: u.bigint(), expiry: z.union([u.number(), z.null(), z.undefined()]), keyId: u.address(), keyType, limits: z.optional(limits), signature: z.custom(), }); /** CLI auth device-code creation request body. */ export const createRequest = z.object({ account: z.optional(u.address()), chainId: z.optional(u.bigint()), codeChallenge: z.string(), expiry: z.optional(z.number()), keyType: z.optional(keyType), limits: z.optional(limits), pubKey: u.hex(), }); /** Response body for `POST /cli-auth/device-code`. */ export const createResponse = z.object({ code: z.string(), }); /** Request body for `POST /auth/pkce/poll/:code`. */ export const pollRequest = z.object({ codeVerifier: z.string(), }); /** Response body for `POST /auth/pkce/poll/:code`. */ export const pollResponse = u.oneOf([ z.object({ status: z.literal('pending'), }), z.object({ status: z.literal('authorized'), accountAddress: u.address(), keyAuthorization: keyAuthorization, }), z.object({ status: z.literal('expired'), }), ]); /** Response body for `GET /auth/pkce/pending/:code`. */ export const pendingResponse = z.object({ accessKeyAddress: u.address(), account: z.optional(u.address()), chainId: u.bigint(), code: z.string(), expiry: z.number(), keyType, limits: z.optional(limits), pubKey: u.hex(), status: z.literal('pending'), }); /** Request body for `POST /auth/pkce`. */ export const authorizeRequest = z.object({ accountAddress: u.address(), code: z.string(), keyAuthorization: keyAuthorization, }); /** Response body for `POST /cli-auth/authorize`. */ export const authorizeResponse = z.object({ status: z.literal('authorized'), }); /** Stored device-code entry schema. */ export const entry = u.oneOf([ z.object({ account: z.optional(u.address()), chainId: u.bigint(), code: z.string(), codeChallenge: z.string(), createdAt: z.number(), expiresAt: z.number(), expiry: z.number(), keyType, limits: z.optional(limits), pubKey: u.hex(), status: z.literal('pending'), }), z.object({ account: z.optional(u.address()), accountAddress: u.address(), authorizedAt: z.number(), chainId: u.bigint(), code: z.string(), codeChallenge: z.string(), createdAt: z.number(), expiresAt: z.number(), expiry: z.number(), keyAuthorization, keyType, limits: z.optional(limits), pubKey: u.hex(), status: z.literal('authorized'), }), z.object({ account: z.optional(u.address()), accountAddress: u.address(), authorizedAt: z.number(), chainId: u.bigint(), code: z.string(), codeChallenge: z.string(), consumedAt: z.number(), createdAt: z.number(), expiresAt: z.number(), expiry: z.number(), keyAuthorization, keyType, limits: z.optional(limits), pubKey: u.hex(), status: z.literal('consumed'), }), ]); /** Error thrown when pending device-code lookup cannot return a pending request. */ export class PendingError extends Error { /** HTTP status returned by handler surfaces. */ status; constructor(message, status) { super(message); this.name = 'PendingError'; this.status = status; } } /** Built-in device-code store helpers. */ export const Store = { /** * Creates an in-memory device-code store. * * Useful for tests and single-process servers. */ memory() { const entries = new Map(); return { async authorize(options) { const current = entries.get(options.code); if (!current || current.status !== 'pending') return undefined; const next = { ...current, accountAddress: options.accountAddress, authorizedAt: Date.now(), keyAuthorization: options.keyAuthorization, status: 'authorized', }; entries.set(options.code, next); return next; }, async consume(code) { const current = entries.get(code); if (!current || current.status !== 'authorized') return undefined; entries.set(code, { ...current, consumedAt: Date.now(), status: 'consumed', }); return current; }, async create(entry_) { entries.set(entry_.code, entry_); }, async delete(code) { entries.delete(code); }, async get(code) { return entries.get(code); }, }; }, /** * Creates a key-value backed device-code store. * * Stored values are encoded through the shared entry schema so they remain * JSON-safe across KV implementations. */ kv(kv, options = {}) { const key = options.key ?? 'cli-auth'; function toKey(code) { return `${key}:${code}`; } return { async authorize(options) { const current = await this.get(options.code); if (!current || current.status !== 'pending') return undefined; const next = { ...current, accountAddress: options.accountAddress, authorizedAt: Date.now(), keyAuthorization: options.keyAuthorization, status: 'authorized', }; await kv.set(toKey(options.code), z.encode(entry, next)); return next; }, async consume(code) { const current = await this.get(code); if (!current || current.status !== 'authorized') return undefined; await kv.set(toKey(code), z.encode(entry, { ...current, consumedAt: Date.now(), status: 'consumed', })); return current; }, async create(entry_) { await kv.set(toKey(entry_.code), z.encode(entry, entry_)); }, async delete(code) { await kv.delete(toKey(code)); }, async get(code) { const value = await kv.get(toKey(code)); if (!value) return undefined; return z.decode(entry, value); }, }; }, }; /** Built-in policy helpers. */ export const Policy = { /** Creates an allow-all policy with a default 24-hour expiry when omitted. */ allow() { return { validate({ expiry, limits }) { return { expiry: expiry ?? Math.floor(Date.now() / 1000) + 60 * 60 * 24, ...(limits ? { limits } : {}), }; }, }; }, /** Returns the provided policy unchanged. */ from(policy) { return policy; }, }; /** Built-in CLI auth rate-limit helpers. */ export const RateLimit = { /** * Creates a Cloudflare Rate Limit binding adapter. * * Uses the request-derived key for all CLI auth endpoints so one budget is * shared across create, pending, poll, and authorize requests. */ cloudflare(limiter, options = {}) { const key = options.key ?? 'cli-auth'; return { async limit(options) { return limiter.limit({ key: `${key}:${options.key}` }); }, }; }, /** Creates an in-memory fixed-window limiter for dev and single-process servers. */ memory(options) { const entries = new Map(); return { limit({ key }) { const now = Date.now(); const current = entries.get(key); if (!current || now >= current.resetAt) { entries.set(key, { count: 1, resetAt: now + options.windowMs }); return { success: true }; } if (current.count >= options.max) return { success: false }; current.count++; return { success: true }; }, }; }, }; /** * Instantiates a CLI auth helper with shared defaults and cached clients. * * * @param {from.Options} options - Shared CLI auth defaults. * @returns {CliAuth} CLI auth helper. * * @example * ```ts * import { CliAuth } from 'accounts/server' * * const cli = CliAuth.from({ * store: CliAuth.Store.memory(), * }) * * const created = await cli.createDeviceCode({ request }) * const authorized = await cli.authorize({ request }) * const polled = await cli.poll({ request }) * const pending = await cli.pending({ code }) * ``` */ export function from(options = {}) { const cache = createClientCache(options); const { chainId, now = Date.now, policy = Policy.allow(), random = randomBytes, store = Store.memory(), ttlMs = defaultTtlMs, } = options; return { async authorize(options) { const code = normalizeCode(options.request.code); const current = await store.get(code); if (!current) throw new Error('Unknown device code.'); if (isExpired(current, now)) { await store.delete(code); throw new Error('Expired device code.'); } if (current.status !== 'pending') throw new Error('Device code already completed.'); if (current.account && current.account.toLowerCase() !== options.request.accountAddress.toLowerCase()) throw new Error('Account does not match requested account.'); const expected = expectedKeyAuthorization(current); const actual = normalizeKeyAuthorization(options.request.keyAuthorization); if (actual.keyId.toLowerCase() !== expected.address.toLowerCase()) throw new Error('Key authorization key does not match the device-code request.'); if (actual.address.toLowerCase() !== expected.address.toLowerCase()) throw new Error('Key authorization address does not match the device-code request.'); if (actual.keyType !== expected.type) throw new Error('Key authorization key type does not match the device-code request.'); if (actual.chainId !== expected.chainId) throw new Error('Key authorization chain does not match the device-code request.'); const signed = TempoKeyAuthorization.from({ address: actual.address, chainId: actual.chainId, expiry: actual.expiry, ...(actual.limits ? { limits: actual.limits } : {}), type: actual.keyType, }); const client = options.client ?? cache.get(current.chainId); const valid = await verifyHash(client, { address: options.request.accountAddress, hash: TempoKeyAuthorization.getSignPayload(signed), signature: SignatureEnvelope.serialize(SignatureEnvelope.fromRpc(actual.signature), { magic: actual.signature.type === 'webAuthn', }), }); if (!valid) throw new Error('Key authorization signature is invalid.'); const signedKeyAuthorization = { address: options.request.keyAuthorization.address, chainId: options.request.keyAuthorization.chainId, expiry: actual.expiry, keyId: options.request.keyAuthorization.keyId, keyType: options.request.keyAuthorization.keyType, ...(actual.limits ? { limits: actual.limits } : {}), signature: options.request.keyAuthorization.signature, }; const authorized = await store.authorize({ accountAddress: options.request.accountAddress, code, keyAuthorization: signedKeyAuthorization, }); if (!authorized) throw new Error('Unable to authorize device code.'); return { status: 'authorized' }; }, async createDeviceCode(options) { const nextChainId = options.request.chainId ?? chainId ?? cache.defaultChainId; const { account, codeChallenge, pubKey } = options.request; const keyType = options.request.keyType ?? 'secp256k1'; PublicKey.assert(PublicKey.from(pubKey)); const approved = await policy.validate({ ...(account ? { account } : {}), chainId: typeof nextChainId === 'bigint' ? nextChainId : BigInt(nextChainId), expiry: options.request.expiry, keyType, ...(options.request.limits ? { limits: options.request.limits } : {}), pubKey, }); let code; for (let i = 0; i < 10; i++) { const candidate = createCode(random); if (await store.get(candidate)) continue; code = candidate; break; } if (!code) throw new Error('Unable to allocate device code.'); const createdAt = now(); await store.create({ ...(account ? { account } : {}), chainId: typeof nextChainId === 'bigint' ? nextChainId : BigInt(nextChainId), code, codeChallenge, createdAt, expiresAt: createdAt + ttlMs, expiry: approved.expiry, keyType, ...(approved.limits ? { limits: approved.limits } : {}), pubKey, status: 'pending', }); return { code }; }, async pending(options) { const normalized = normalizeCode(options.code); const current = await store.get(normalized); if (!current) throw new PendingError('Unknown device code.', 404); if (isExpired(current, now)) { await store.delete(normalized); throw new PendingError('Expired device code.', 404); } if (current.status !== 'pending') throw new PendingError('Device code already completed.', 400); return { accessKeyAddress: Address.fromPublicKey(PublicKey.from(current.pubKey)), ...(current.account ? { account: current.account } : {}), chainId: current.chainId, code: current.code, expiry: current.expiry, keyType: current.keyType, ...(current.limits ? { limits: current.limits } : {}), pubKey: current.pubKey, status: 'pending', }; }, async poll(options) { const normalized = normalizeCode(options.code); const current = await store.get(normalized); if (!current) return { status: 'expired' }; if (isExpired(current, now)) { await store.delete(normalized); return { status: 'expired' }; } if (!(await verifyCodeChallenge(options.request.codeVerifier, current.codeChallenge))) throw new Error('Invalid code verifier.'); if (current.status === 'pending') return { status: 'pending' }; if (current.status === 'consumed') { await store.delete(normalized); return { status: 'expired' }; } const authorized = await store.consume(normalized); if (!authorized) return { status: 'expired' }; return { accountAddress: authorized.accountAddress, keyAuthorization: authorized.keyAuthorization, status: 'authorized', }; }, }; } /** * Creates and stores a new device code. * * @param {createDeviceCode.Options} options - Shared defaults plus the incoming request. * @returns {Promise<createDeviceCode.ReturnType>} Created device code. * * @example * ```ts * import { Hono } from 'hono' * import { CliAuth } from 'accounts/server' * import { zValidator } from '@hono/zod-validator' * * export default new Hono<{ Bindings: Cloudflare.Env }>() * // ... other routes (`/authorize`, `/poll:code`, `/pending:code`) * .post('/code', * zValidator('json', CliAuth.createRequest), * async (c) => { * const request = c.req.valid('json') * const result = await CliAuth.createDeviceCode({ request }) * return c.json(z.encode(CliAuth.createResponse, result)) * }) * ``` */ export async function createDeviceCode(options) { const { request, ...rest } = options; return from(rest).createDeviceCode({ request }); } /** * Looks up a pending device code for browser approval UIs. * * @param {pending.Options} options - Shared defaults plus the pending lookup parameters. * @returns {Promise<pending.ReturnType>} Pending device-code payload. * * @example * ```ts * import { Hono } from 'hono' * import { CliAuth } from 'accounts/server' * import { zValidator } from '@hono/zod-validator' * * export default new Hono<{ Bindings: Cloudflare.Env }>() * // ... other routes (`/code`, `/authorize`, `/poll:code`) * .get('/pending:code', * zValidator('param', z.object({ code: z.string() })), * async (c) => { * const code = c.req.param('code') * const result = await CliAuth.pending({ code }) * return c.json(z.encode(CliAuth.pendingResponse, result)) * }) */ export async function pending(options) { const { code, ...rest } = options; return from(rest).pending({ code }); } /** * Polls a device code with PKCE verification. * * @param {poll.Options} options - Shared defaults plus the poll parameters. * @returns {Promise<poll.ReturnType>} Pending, authorized, or expired poll response. * * @example * ```ts * import { Hono } from 'hono' * import { CliAuth } from 'accounts/server' * import { zValidator } from '@hono/zod-validator' * * export default new Hono<{ Bindings: Cloudflare.Env }>() * // ... other routes (`/code`, `/authorize`, `/pending:code`) * .post('/poll:code', * zValidator('json', CliAuth.pollRequest), * async (c) => { * const request = c.req.valid('json') * const result = await CliAuth.poll({ request }) * return c.json(z.encode(CliAuth.pollResponse, result)) * }) * ``` */ export async function poll(options) { const { code, request, ...rest } = options; return from(rest).poll({ code, request }); } /** * Authorizes a pending device code after validating the signed key authorization. * * @param {authorize.Options} options - Shared defaults plus the authorization request. * @returns {Promise<authorize.ReturnType>} Authorized response body. * * @example * ```ts * import { Hono } from 'hono' * import { CliAuth } from 'accounts/server' * import { zValidator } from '@hono/zod-validator' * * export default new Hono<{ Bindings: Cloudflare.Env }>() * // ... other routes (`/code`, `/poll:code`, `/pending:code`) * .post('/authorize', * zValidator('json', CliAuth.authorizeRequest), * async (c) => { * const request = c.req.valid('json') * const result = await CliAuth.authorize({ request }) * return c.json(z.encode(CliAuth.authorizeResponse, result)) * }) * ``` */ export async function authorize(options) { const { client, request, ...rest } = options; return from(rest).authorize({ ...(client ? { client } : {}), request, }); } /** @internal */ function randomBytes(size) { return Bytes.random(size); } /** @internal */ function createCode(random) { const bytes = random(8); let code = ''; for (const byte of bytes) code += alphabet[byte % alphabet.length]; return code; } /** @internal */ function createClientCache(options = {}) { const chains = options.chains ?? [tempo]; const [defaultChain] = chains; const transports = options.transports ?? {}; const clients = new Map(); for (const chain of chains) { const transport = transports[chain.id] ?? http(); clients.set(chain.id, createClient({ chain, transport })); } const defaultChainId = options.chainId ?? defaultChain.id; return { defaultChainId, get(chainId = defaultChainId) { const id = typeof chainId === 'bigint' ? Number(chainId) : chainId; const current = clients.get(id); if (current) return current; const client = createClient({ chain: { ...tempo, id, }, transport: transports[id] ?? http(), }); clients.set(id, client); return client; }, }; } /** @internal */ function normalizeCode(code) { return code.replaceAll('-', '').toUpperCase(); } /** @internal */ function expectedKeyAuthorization(entry) { return TempoKeyAuthorization.from({ address: Address.fromPublicKey(PublicKey.from(entry.pubKey)), chainId: entry.chainId, expiry: entry.expiry, ...(entry.limits ? { limits: entry.limits } : {}), type: entry.keyType, }); } /** @internal */ function isExpired(entry, now) { return now() > entry.expiresAt; } /** @internal */ function normalizeKeyAuthorization(value) { return { ...value, expiry: value.expiry ?? undefined, limits: value.limits ?? undefined, }; } /** @internal */ async function verifyCodeChallenge(codeVerifier, codeChallenge) { const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)); return Base64.fromBytes(new Uint8Array(hash), { pad: false, url: true }) === codeChallenge; } //# sourceMappingURL=CliAuth.js.map