UNPKG

accounts

Version:

Tempo Accounts SDK

919 lines (840 loc) 29.6 kB
import { Address, Base64, Bytes, Hex, PublicKey } from 'ox' import { KeyAuthorization as TempoKeyAuthorization, SignatureEnvelope } from 'ox/tempo' import { createClient, http, type Chain, type Client, type Transport } 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' import type { MaybePromise } from '../internal/types.js' import type { Kv } from './Kv.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<SignatureEnvelope.SignatureEnvelopeRpc>(), }) /** 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'), }), ]) /** Shared CLI auth helper with pre-bound defaults and cached clients. */ export type CliAuth = { /** Creates and stores a new device code. */ createDeviceCode: (options: createDeviceCode.Parameters) => Promise<createDeviceCode.ReturnType> /** Looks up a pending device code for browser approval UIs. */ pending: (options: pending.Parameters) => Promise<pending.ReturnType> /** Polls a device code with PKCE verification. */ poll: (options: poll.Parameters) => Promise<poll.ReturnType> /** Authorizes a pending device code after validating the signed key authorization. */ authorize: (options: authorize.Parameters) => Promise<authorize.ReturnType> } /** Stored device-code entry. */ export type Entry = z.output<typeof entry> /** Device-code storage contract. */ export type Store = { /** Saves a new pending device-code entry. */ create: (entry: Entry.Pending) => MaybePromise<void> /** Loads a device-code entry by verification code. */ get: (code: string) => MaybePromise<Entry | undefined> /** Marks a pending device-code as authorized. */ authorize: (options: Store.authorize.Options) => MaybePromise<Entry.Authorized | undefined> /** Consumes an authorized device-code exactly once. */ consume: (code: string) => MaybePromise<Entry.Authorized | undefined> /** Deletes a device-code entry. */ delete: (code: string) => MaybePromise<void> } /** Host validation and sanitization for requested CLI auth defaults. */ export type Policy = { /** Validates and optionally rewrites requested defaults before the entry is stored. */ validate: (options: Policy.validate.Options) => MaybePromise<Policy.validate.ReturnType> } /** Request rate limiter used by CLI auth handlers. */ export type RateLimit = { /** Returns whether the request is allowed to continue. */ limit: (options: RateLimit.limit.Options) => MaybePromise<RateLimit.limit.ReturnType> } export declare namespace Entry { /** Pending device-code entry. */ export type Pending = Extract<z.output<typeof entry>, { status: 'pending' }> /** Authorized device-code entry. */ export type Authorized = Extract<z.output<typeof entry>, { status: 'authorized' }> /** Consumed device-code entry. */ export type Consumed = Extract<z.output<typeof entry>, { status: 'consumed' }> } export declare namespace Store { export namespace authorize { export type Options = { /** Root account that approved the access key. */ accountAddress: Address.Address /** Signed key authorization. */ keyAuthorization: z.output<typeof keyAuthorization> /** Verification code to authorize. */ code: string } } export namespace kv { export type Options = { /** Prefix used for KV keys. @default "cli-auth" */ key?: string | undefined } } } export declare namespace Policy { export namespace validate { export type Options = { /** Requested root account restriction. */ account?: Address.Address | undefined /** Requested chain ID. */ chainId: bigint /** Requested access-key expiry timestamp. Omit to let the server choose one. */ expiry?: number | undefined /** Requested key type. */ keyType: z.output<typeof keyType> /** Requested spending limits. */ limits?: readonly { token: Address.Address; limit: bigint }[] | undefined /** Requested access-key public key. */ pubKey: Hex.Hex } export type ReturnType = { /** Suggested access-key expiry timestamp. */ expiry: number /** Suggested spending limits. */ limits?: readonly { token: Address.Address; limit: bigint }[] | undefined } } } export declare namespace RateLimit { export namespace limit { export type Options = { /** Rate-limit key derived from the request. */ key: string /** Incoming request being rate-limited. */ request: Request } export type ReturnType = { /** Whether the request is allowed to continue. */ success: boolean } } export namespace memory { export type Options = { /** Maximum requests per key in a window. */ max: number /** Window duration in milliseconds. */ windowMs: number } } export namespace cloudflare { export type Limiter = { /** Cloudflare Rate Limit binding method. */ limit: (options: { key: string }) => MaybePromise<{ success: boolean }> } export type Options = { /** Prefix added to the derived request key. @default "cli-auth" */ key?: string | undefined } } } /** Error thrown when pending device-code lookup cannot return a pending request. */ export class PendingError extends Error { /** HTTP status returned by handler surfaces. */ status: 400 | 404 constructor(message: string, status: 400 | 404) { 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(): Store { const entries = new Map<string, Entry>() 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', } satisfies Entry.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', } satisfies Entry.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: Kv, options: Store.kv.Options = {}): Store { const key = options.key ?? 'cli-auth' function toKey(code: string) { 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', } satisfies Entry.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', } satisfies Entry.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<z.input<typeof entry>>(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(): Policy { return { validate({ expiry, limits }) { return { expiry: expiry ?? Math.floor(Date.now() / 1000) + 60 * 60 * 24, ...(limits ? { limits } : {}), } }, } }, /** Returns the provided policy unchanged. */ from(policy: Policy): 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: RateLimit.cloudflare.Limiter, options: RateLimit.cloudflare.Options = {}, ): RateLimit { 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: RateLimit.memory.Options): RateLimit { const entries = new Map<string, { count: number; resetAt: number }>() 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: from.Options = {}): CliAuth { 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, } satisfies z.output<typeof keyAuthorization> 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: string | undefined 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', } }, } } export declare namespace from { /** Shared CLI auth helper configuration. */ export type Options = { /** Default chain ID embedded into created device codes. @default tempo.id */ chainId?: bigint | number | undefined /** * Preconfigured chains used to build and cache viem clients. * * Unknown chain IDs are cached lazily using a tempo-shaped chain object so * standalone helpers can still verify signatures without a full chain list. * * @default [tempo] */ chains?: readonly [Chain, ...Chain[]] | undefined /** Time source used for TTL evaluation. */ now?: (() => number) | undefined /** Policy used to validate requested expiry and limits. */ policy?: Policy | undefined /** Random byte generator used for verification code allocation. */ random?: ((size: number) => Uint8Array) | undefined /** Device-code store. */ store?: Store | undefined /** Pending entry TTL in milliseconds. @default 600000 */ ttlMs?: number | undefined /** Transports keyed by chain ID. Defaults to `http()` for each chain. */ transports?: Record<number, Transport> | undefined } } /** * 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: createDeviceCode.Options, ): Promise<createDeviceCode.ReturnType> { const { request, ...rest } = options return from(rest).createDeviceCode({ request }) } export declare namespace createDeviceCode { /** Parameters for creating a new device code. */ export type Parameters = { /** Incoming device-code creation request. */ request: z.output<typeof createRequest> } /** Shared CLI auth defaults plus create-device-code parameters. */ export type Options = from.Options & Parameters /** Created device-code response body. */ export type ReturnType = z.output<typeof createResponse> } /** * 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: pending.Options): Promise<pending.ReturnType> { const { code, ...rest } = options return from(rest).pending({ code }) } export declare namespace pending { /** Parameters for looking up a pending device code. */ export type Parameters = { /** Verification code from the route path. */ code: string } /** Shared CLI auth defaults plus pending lookup parameters. */ export type Options = from.Options & Parameters /** Pending device-code response body. */ export type ReturnType = z.output<typeof pendingResponse> } /** * 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: poll.Options): Promise<poll.ReturnType> { const { code, request, ...rest } = options return from(rest).poll({ code, request }) } export declare namespace poll { /** Parameters for polling a device code. */ export type Parameters = { /** Verification code from the route path. */ code: string /** Poll request body. */ request: z.output<typeof pollRequest> } /** Shared CLI auth defaults plus poll parameters. */ export type Options = from.Options & Parameters /** Poll response body. */ export type ReturnType = z.output<typeof pollResponse> } /** * 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: authorize.Options): Promise<authorize.ReturnType> { const { client, request, ...rest } = options return from(rest).authorize({ ...(client ? { client } : {}), request, }) } export declare namespace authorize { /** Parameters for authorizing a pending device code. */ export type Parameters = { /** Client used to verify the signed key authorization. */ client?: Client<Transport, Chain | undefined> | undefined /** Authorize request body. */ request: z.output<typeof authorizeRequest> } /** Shared CLI auth defaults plus authorization parameters. */ export type Options = from.Options & Parameters /** Authorization response body. */ export type ReturnType = z.output<typeof authorizeResponse> } /** @internal */ function randomBytes(size: number) { return Bytes.random(size) } /** @internal */ function createCode(random: (size: number) => Uint8Array) { const bytes = random(8) let code = '' for (const byte of bytes) code += alphabet[byte % alphabet.length] return code } /** @internal */ function createClientCache(options: from.Options = {}) { const chains = options.chains ?? [tempo] const [defaultChain] = chains const transports = options.transports ?? {} const clients = new Map<number, Client<Transport, Chain | undefined>>() 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: bigint | number = 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: string) { return code.replaceAll('-', '').toUpperCase() } /** @internal */ function expectedKeyAuthorization(entry: Entry.Pending) { 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: Entry, now: () => number) { return now() > entry.expiresAt } /** @internal */ function normalizeKeyAuthorization(value: z.output<typeof keyAuthorization>) { return { ...value, expiry: value.expiry ?? undefined, limits: value.limits ?? undefined, } } /** @internal */ async function verifyCodeChallenge(codeVerifier: string, codeChallenge: string) { const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)) return Base64.fromBytes(new Uint8Array(hash), { pad: false, url: true }) === codeChallenge }