UNPKG

accounts

Version:

Tempo Accounts SDK

218 lines 9.24 kB
import { Store, Wata, deviceCode as core_deviceCode } from 'wata/host'; import * as z from 'zod/mini'; import { from } from '../../Handler.js'; const maxVerifyBodyBytes = 65_536; const verifyRequest = z.object({ action: z.union([z.literal('approve'), z.literal('deny')]), results: z.optional(z.array(z.union([ z.object({ error: z.object({ code: z.number(), data: z.optional(z.json()), message: z.string(), }), id: z.union([z.string(), z.number()]), }), z.object({ id: z.union([z.string(), z.number()]), result: z.json() }), ]))), user_code: z.string(), }); /** * Instantiates a Wata device-code handler for wallet RPC requests. * * The handler implements the OAuth 2.0 Device Authorization Grant endpoints, * accepts browser-submitted RPC results, and returns them to the polling * consumer. The consuming host is responsible for deciding which methods * its approval UI supports. * * @param options - Options. * @returns Request handler. */ export function deviceCode(options) { const { baseUrl, expiresIn, fetch, html, maxBodyBytes = maxVerifyBodyBytes, path = '/auth/device', pollingInterval, store = Store.memory(), validate, ...rest } = options; const router = from(rest); router.all(`${path}/*`, async (c) => { const request = c.req.raw; const origin = typeof baseUrl === 'function' ? baseUrl(request) : baseUrl; const results = new Map(); let settle; const responded = new Promise((resolve, reject) => { settle = (error) => (error ? reject(error) : resolve()); }); const wata = Wata.create({ transports: [ core_deviceCode({ ...(origin ? { baseUrl: origin } : {}), ...(expiresIn !== undefined ? { expiresIn } : {}), ...(fetch !== undefined ? { fetch } : {}), html: { async authenticate({ actions, request }) { const body = await readVerifyBody(request, maxBodyBytes); if (!body.ok) return body.response; const { action, results: submitted, user_code } = body.value; const userCode = normalizeUserCode(user_code); const record = await actions.get(userCode); if (!record) return Response.json({ error: 'unknown_code', error_description: 'Unknown or expired device code.' }, { status: 404 }); if (record.status !== 'pending') return Response.json({ error: 'not_pending', error_description: 'Device code already completed.' }, { status: 409 }); if (action === 'deny') { await actions.deny(userCode); return Response.json({ status: 'denied' }); } const requests = pendingRequests(record); const [pending] = requests; if (requests.length !== 1 || !pending) return Response.json({ error: 'invalid_request', error_description: 'Expected one pending request.', }, { status: 400 }); const response = submitted?.find((entry) => entry.id === pending.id); if (!response) return Response.json({ error: 'invalid_request', error_description: 'Missing response for pending request.', }, { status: 400 }); if ('result' in response) { const validation = await validate({ record, request: pending, result: response.result, userCode, }); if (validation) return validation; } results.set(pending.id, response); await actions.approve(userCode); await responded; return Response.json({ status: 'approved' }); }, render: html.render, }, path, ...(pollingInterval !== undefined ? { pollingInterval } : {}), store: normalizeStore(store), }), ], }); const session = wata.start(); session.onRequest(async (event) => { const response = results.get(event.id); try { if (!response) await event.reject({ code: -32603, message: 'No result supplied for request.' }); else if ('error' in response) await event.reject(response.error); else await event.respond(response.result); settle?.(); } catch (error) { settle?.(error); } }); return await normalizeRegisterResponse(await wata.fetch(request), request, path); }); return router; } function pendingRequests(record) { if (record.message.type !== 'rpc-requests') return []; return record.message.payload.filter((message) => typeof message === 'object' && message !== null && 'id' in message); } function normalizeUserCode(value) { return value.replace(/[\s-]/g, '').toUpperCase(); } function normalizeStore(store) { function key(value) { if (!value.startsWith('user:')) return value; return `user:${normalizeUserCode(value.slice('user:'.length))}`; } return Store.from({ delete: (value) => store.delete(key(value)), get: (name) => store.get(key(name)), set: (name, value, options) => store.set(key(name), value, options), ...(store.take ? { take: (name) => store.take(key(name)) } : {}), }); } async function normalizeRegisterResponse(response, request, path) { if (!response.ok || new URL(request.url).pathname !== `${path}/register`) return response; const body = (await response.clone().json()); if (typeof body.user_code !== 'string') return response; const userCode = normalizeUserCode(body.user_code); const verificationUriComplete = typeof body.verification_uri_complete === 'string' ? (() => { const url = new URL(body.verification_uri_complete); url.searchParams.set('user_code', userCode); return url.toString(); })() : body.verification_uri_complete; const headers = new Headers(response.headers); headers.delete('content-length'); return Response.json({ ...body, user_code: userCode, ...(verificationUriComplete ? { verification_uri_complete: verificationUriComplete } : {}), }, { headers, status: response.status }); } async function readVerifyBody(request, maxBodyBytes) { const invalid = (description) => ({ ok: false, response: Response.json({ error: 'invalid_request', error_description: description }, { status: 400 }), }); let json; try { const body = await readBody(request, maxBodyBytes); if (!body.ok) return invalid('Request body is too large.'); json = JSON.parse(body.text); } catch { return invalid('Expected a JSON body.'); } const parsed = z.safeParse(verifyRequest, json); if (!parsed.success) return invalid('Malformed verify request.'); return { ok: true, value: parsed.data }; } async function readBody(request, maxBodyBytes) { const length = request.headers.get('content-length'); if (length && Number(length) > maxBodyBytes) return { ok: false }; if (!request.body) return { ok: true, text: '' }; const reader = request.body.getReader(); const chunks = []; let size = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > maxBodyBytes) { await reader.cancel().catch(() => undefined); return { ok: false }; } chunks.push(value); } } finally { reader.releaseLock(); } const bytes = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return { ok: true, text: new TextDecoder().decode(bytes) }; } //# sourceMappingURL=deviceCode.js.map