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