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