Something went wrong. Try again.
A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558import * as CRYPTO from 'node:crypto';import * as HTTP from 'node:http';import { once } from 'node:events';import { resolveIdentity, type Identity } from './identity.ts';import { fetchJson, type Authorizer } from './xrpc.ts';import { AuthError, CliError } from './errors.ts';import { setting } from './config.ts';import { packageInfo } from './version.ts';
// Signing in to an account's own PDS the way atproto means it: OAuth 2.1 with// discovery from the identity, pushed authorization requests (PAR), PKCE// (S256), and DPoP-bound tokens — after the client in tangled, which in turn// follows bundle's.//// The session is kept: the refresh token and the DPoP private key go into the// system credential store, so later commands carry on without a browser.//// The client is public — there is no secret a CLI could keep — and is one of// two kinds://// * hosted: a client metadata document at an https URL, which is the// `client_id`. Sessions signed in with a hosted client keep using it after// an upgrade, for as long as its document is there.// * loopback: atproto's development client, a `client_id` of// `http://localhost?…` that carries its own redirect URI and scope and needs// nothing hosted. Authorization servers give it shorter sessions.//// Signing in takes the first of these whose document would pass an// authorization server's checks (status 200 without a redirect, JSON served as// JSON, naming itself, our redirect URI and our scope)://// 1. the oauth-client setting, when it names a document;// 2. the documents package.json lists in `atcli.oauthClients`, in order:// this release's own, attached to the release on tangled (its URL is// known from the version alone), then fallbacks hosted elsewhere;// 3. the loopback client.//// The candidates are checked at once, so a slow or missing one costs no more// than the slowest check. `atcli config set oauth-client loopback` skips// straight to the loopback client.//// Two ways through://// * one step: a loopback server on 127.0.0.1 waits for the browser to come// back. If the browser runs somewhere else (SSH, a container), the address// it lands on can be pasted into the terminal instead.// * two steps, for agents: `start` returns the URL and keeps what the flow// needs; whoever has the browser signs in, and `finish` is handed the// address the browser ended up at.
/** Everything a session needs to make requests, and to refresh. */export interface OAuthSecret { kind: 'oauth'; issuer: string; clientId: string; accessToken: string; refreshToken: string | undefined; /** Seconds since the epoch. */ expiresAt: number; scope: string; /** The DPoP key, as a JWK with its private part. */ dpopKey: CRYPTO.webcrypto.JsonWebKey; /** The last DPoP nonce each origin handed out. */ dpopNonces: Record<string, string>;}
/** What `finish` needs from `start`. Kept in the credential store meanwhile: it holds the DPoP key. */export interface PendingOAuth { identifier: string; did: string; pds: string; issuer: string; clientId: string; redirectUri: string; scope: string; verifier: string; state: string; dpopKey: CRYPTO.webcrypto.JsonWebKey; dpopNonces: Record<string, string>; created: number;}
export interface OAuthResult { identity: Identity; secret: OAuthSecret;}
/** * Full access to the account through the Bluesky app view, and to direct * messages. `--scope` asks for something narrower. */export const DEFAULT_SCOPE = 'atproto transition:generic transition:chat.bsky';
interface ServerMetadata { issuer: string; authorization_endpoint: string; token_endpoint: string; pushed_authorization_request_endpoint?: string; revocation_endpoint?: string; code_challenge_methods_supported?: string[]; dpop_signing_alg_values_supported?: string[];}
interface TokenResponse { access_token: string; token_type: string; sub: string; scope: string; refresh_token?: string; expires_in?: number;}
export const REDIRECT_URI = 'http://127.0.0.1/callback';
/** The loopback development client for `scope`. */export function loopbackClientId(scope: string): string { // The port is not part of the match for a loopback redirect, which is what // lets one client_id serve every ephemeral port. return `http://localhost?${new URLSearchParams({ redirect_uri: REDIRECT_URI, scope }).toString()}`;}
/** This release's own hosted client: the first package.json lists, for this version. */export function releaseClientId(): string | undefined { return packageInfo().oauthClients[0];}
/** The hosted clients to try, in order: the setting's, then package.json's. */export function clientCandidates(): string[] { const wanted = setting('oauth-client'); const named = wanted !== 'auto' && wanted !== 'loopback' ? [wanted] : []; return [...new Set([...named, ...packageInfo().oauthClients])];}
/** The client metadata document for a hosted client at `clientId`: what a release attaches. */export function clientMetadata(clientId: string, scope = DEFAULT_SCOPE): Record<string, unknown> { return { client_id: clientId, client_name: 'atcli', client_uri: new URL(clientId).origin === 'https://tangled.org' ? clientId.replace(/\/tags\/.*$/, '') : new URL(clientId).origin, application_type: 'native', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], redirect_uris: [REDIRECT_URI], scope, token_endpoint_auth_method: 'none', dpop_bound_access_tokens: true, };}
/** * Whether the document at `clientId` would pass an authorization server's * checks for `scope`. Says what is wrong, or nothing when it is fine. */export async function checkHostedClient(clientId: string, scope: string, signal?: AbortSignal): Promise<string | undefined> { let response: Response; try { const timeout = AbortSignal.timeout(10_000); response = await fetch(clientId, { redirect: 'manual', headers: { accept: 'application/json' }, signal: signal ? AbortSignal.any([signal, timeout]) : timeout }); } catch (error) { if (signal?.aborted) return 'not needed'; return `it could not be fetched (${(error as Error & { cause?: Error }).cause?.message ?? (error as Error).message})`; } if (response.status !== 200) return response.status === 404 ? 'it is not there (this version may not be released yet)' : `it answers ${response.status}, not 200`; const type = response.headers.get('content-type') ?? ''; // A missing file is often a 200 web page: say so, rather than quote its type. if (/^text\/html\b/i.test(type)) return 'it is not there (the server answered with a web page; this version may not be released yet)'; if (!/^application\/json\b/i.test(type)) return `it is served as ${type || 'nothing'}, not application/json`; let document: Record<string, unknown>; try { document = await response.json() as Record<string, unknown>; } catch { return 'it is not JSON'; } if (document['client_id'] !== clientId) return `it calls itself ${String(document['client_id'])}`; if (!(document['redirect_uris'] as unknown[] | undefined)?.includes(REDIRECT_URI)) return `it does not allow the redirect ${REDIRECT_URI}`; const allowed = new Set(String(document['scope'] ?? '').split(' ')); const missing = scope.split(' ').filter((part) => !allowed.has(part)); if (missing.length) return `it does not allow the scope ${missing.join(' ')}`; if (document['dpop_bound_access_tokens'] !== true) return 'it does not ask for DPoP-bound tokens'; return undefined;}
/** * Say which client is signing in when it is not the first choice. A release's * own document is expected to be missing until it is published, so passing it * over is only worth a line when nothing hosted is left. */function reportChoice(choice: ClientChoice, log: (line: string) => void): void { const named = setting('oauth-client'); const asked = named !== 'auto' && named !== 'loopback' ? choice.skipped.find((each) => each.clientId === named) : undefined; if (asked) log(`the oauth-client setting's ${asked.clientId} is not usable: ${asked.problem}`); if (!choice.hosted && choice.skipped.length) { for (const each of choice.skipped) if (each !== asked) log(`${each.clientId} is not usable: ${each.problem}`); log('signing in as the loopback client, whose sessions are shorter'); }}
export interface ClientChoice { clientId: string; hosted: boolean; /** The candidates passed over before it, and why each was. */ skipped: Array<{ clientId: string; problem: string }>;}
/** Which client to sign in as, for `scope`: the first candidate that passes, else loopback. */export async function chooseClient(scope: string, candidates: string[] = setting('oauth-client') === 'loopback' ? [] : clientCandidates()): Promise<ClientChoice> { // All are asked at once; the first that passes, in order, wins, and the // checks after it are called off rather than waited for. const stop = new AbortController(); const checks = candidates.map((clientId) => checkHostedClient(clientId, scope, stop.signal)); const skipped: ClientChoice['skipped'] = []; try { for (const [at, clientId] of candidates.entries()) { const problem = await checks[at]; if (!problem) return { clientId, hosted: true, skipped }; skipped.push({ clientId, problem }); } } finally { stop.abort(); } return { clientId: loopbackClientId(scope), hosted: false, skipped };}
export interface LoginOptions { scope?: string | undefined; log?: (line: string) => void; /** Open the browser (default: when there plausibly is one). */ open?: ((url: string) => void) | undefined; /** Read a pasted callback address from here, as well as waiting for the loopback. */ paste?: (() => Promise<string>) | undefined; timeout?: number | undefined;}
/** Start a sign-in: push the request, and say where to send the browser. */export async function startOAuth(identifier: string, { scope = DEFAULT_SCOPE, port = 0, log = () => {} }: { scope?: string; port?: number; log?: (line: string) => void } = {}): Promise<{ url: string; pending: PendingOAuth }> { const identity = await resolveIdentity(identifier); const server = await authorizationServer(identity.pds); const dpop = CRYPTO.generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey; const nonces: Record<string, string> = {}; const redirectUri = port ? REDIRECT_URI.replace('127.0.0.1', `127.0.0.1:${port}`) : REDIRECT_URI; const verifier = CRYPTO.randomBytes(32).toString('base64url'); const state = CRYPTO.randomBytes(16).toString('base64url'); const hint = identity.handle ?? identity.did;
let client = await chooseClient(scope); reportChoice(client, log); let clientId = client.clientId; let pushed = await pushAuthorization(server, dpop, nonces, { clientId, redirectUri, scope, verifier, state, hint }); if ('error' in pushed && pushed.error === 'invalid_scope' && scope !== 'atproto transition:generic') { log(`${new URL(server.issuer).host} does not offer '${scope}'; asking for general access instead`); scope = 'atproto transition:generic'; client = await chooseClient(scope); reportChoice(client, log); clientId = client.clientId; pushed = await pushAuthorization(server, dpop, nonces, { clientId, redirectUri, scope, verifier, state, hint }); } if ('error' in pushed) throw new AuthError(`${server.issuer} refused the sign-in request: ${pushed.error}${pushed.description ? ` — ${pushed.description}` : ''}`, 'OAuthRefused');
const url = new URL(server.authorization_endpoint); url.search = new URLSearchParams({ client_id: clientId, request_uri: pushed.requestUri }).toString(); return { url: url.toString(), pending: { identifier, did: identity.did, pds: identity.pds, issuer: server.issuer, clientId, redirectUri, scope, verifier, state, dpopKey: dpop.export({ format: 'jwk' }), dpopNonces: nonces, created: Date.now(), }, };}
/** Finish a sign-in with the address the browser was sent back to. */export async function finishOAuth(pending: PendingOAuth, callbackUrl: string): Promise<OAuthResult> { const code = codeFrom(new URL(callbackUrl.trim()).searchParams, pending.state, pending.issuer); return await exchange(pending, code);}
/** Sign in in one go: a loopback server waits for the browser, or a pasted address. */export async function loginOAuth(identifier: string, options: LoginOptions = {}): Promise<OAuthResult> { const log = options.log ?? (() => {}); const loopback = HTTP.createServer(); loopback.listen(0, '127.0.0.1'); await once(loopback, 'listening'); const address = loopback.address(); if (!address || typeof address === 'string') throw new Error('the loopback server did not bind a port'); try { const { url, pending } = await startOAuth(identifier, { scope: options.scope ?? DEFAULT_SCOPE, port: address.port, log }); log(`Sign in to ${new URL(pending.issuer).host} as ${identifier} in your browser.`); log(`If it does not open, visit:\n\n ${url}\n`); if (options.paste) log('If the browser is on another machine, paste the address it ends up at here.'); options.open?.(url);
const waiting: Promise<string>[] = [loopbackCallback(loopback, pending, options.timeout ?? 10 * 60_000)]; if (options.paste) { waiting.push(options.paste().then((pasted) => codeFrom(new URL(pasted.trim()).searchParams, pending.state, pending.issuer))); } const code = await Promise.race(waiting); return await exchange(pending, code); } finally { loopback.close(); loopback.closeAllConnections(); }}
async function exchange(pending: PendingOAuth, code: string): Promise<OAuthResult> { const server = await serverMetadata(pending.issuer); const dpop = keyOf(pending.dpopKey); const nonces = { ...pending.dpopNonces }; const tokens = await tokenRequest(server.token_endpoint, dpop, nonces, { grant_type: 'authorization_code', code, redirect_uri: pending.redirectUri, client_id: pending.clientId, code_verifier: pending.verifier, }); // The server was found through the account's own PDS, so a different `sub` // is a server speaking for someone else. if (tokens.sub !== pending.did) throw new AuthError(`${pending.issuer} signed in ${String(tokens.sub)}, not ${pending.did}`, 'OAuthWrongAccount'); const identity = await resolveIdentity(pending.did); return { identity, secret: { kind: 'oauth', issuer: pending.issuer, clientId: pending.clientId, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 300), scope: tokens.scope, dpopKey: pending.dpopKey, dpopNonces: nonces, }, };}
/** Exchange the refresh token for new tokens. Refresh tokens are single-use. */export async function refreshOAuth(secret: OAuthSecret, did: string): Promise<OAuthSecret> { if (!secret.refreshToken) throw new AuthError('the session cannot be refreshed; sign in again', 'SessionExpired'); const server = await serverMetadata(secret.issuer); const nonces = { ...secret.dpopNonces }; const tokens = await tokenRequest(server.token_endpoint, keyOf(secret.dpopKey), nonces, { grant_type: 'refresh_token', refresh_token: secret.refreshToken, client_id: secret.clientId, }); if (tokens.sub !== did) throw new AuthError(`refreshing signed in ${tokens.sub}, not ${did}`, 'OAuthWrongAccount'); return { ...secret, scope: tokens.scope, accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? secret.refreshToken, expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 300), dpopNonces: nonces, };}
/** Revoke the session, best effort: the access token lapses on its own soon after. */export async function revokeOAuth(secret: OAuthSecret): Promise<void> { try { const server = await serverMetadata(secret.issuer); if (!server.revocation_endpoint || !secret.refreshToken) return; await dpopFetch(server.revocation_endpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: secret.refreshToken, client_id: secret.clientId }).toString(), }, keyOf(secret.dpopKey), { ...secret.dpopNonces }, undefined); } catch { // Nothing more to do. }}
/** Headers for a request to a resource server: the token, and a proof bound to it and to this request. */export function oauthAuthorizer(secret: OAuthSecret): Authorizer { const key = keyOf(secret.dpopKey); return ({ method, url }) => ({ authorization: `DPoP ${secret.accessToken}`, dpop: dpopProof(key, method, url, secret.dpopNonces[new URL(url).origin], secret.accessToken), });}
/** Note the nonce a response handed out. Returns whether it is one not seen before. */export function learnNonce(secret: { dpopNonces: Record<string, string> }, url: string, headers: Headers | undefined): boolean { const nonce = headers?.get('dpop-nonce'); if (!nonce) return false; const origin = new URL(url).origin; if (secret.dpopNonces[origin] === nonce) return false; secret.dpopNonces[origin] = nonce; return true;}
// -------------------------------------------------------------- discovery ---
async function authorizationServer(pds: string): Promise<ServerMetadata> { const resource = await fetchJson<{ authorization_servers?: string[] }>(`${pds}/.well-known/oauth-protected-resource`); const issuer = resource.authorization_servers?.[0]; if (!issuer) throw new AuthError(`${pds} names no authorization server; sign in with --password instead`, 'OAuthUnsupported'); const metadata = await serverMetadata(issuer); if (!metadata.pushed_authorization_request_endpoint) throw new AuthError(`${metadata.issuer} does not take pushed authorization requests`, 'OAuthUnsupported'); if (!(metadata.code_challenge_methods_supported ?? []).includes('S256')) throw new AuthError(`${metadata.issuer} does not support PKCE S256`, 'OAuthUnsupported'); if (!(metadata.dpop_signing_alg_values_supported ?? []).includes('ES256')) throw new AuthError(`${metadata.issuer} does not support ES256 DPoP`, 'OAuthUnsupported'); return metadata;}
const servers = new Map<string, Promise<ServerMetadata>>();
function serverMetadata(issuer: string): Promise<ServerMetadata> { let pending = servers.get(issuer); if (!pending) { pending = (async () => { const origin = new URL(issuer); if (origin.protocol !== 'https:' && origin.hostname !== 'localhost' && origin.hostname !== '127.0.0.1') { throw new AuthError(`authorization server ${issuer} is not https`, 'OAuthInsecure'); } if (origin.origin !== issuer.replace(/\/$/, '')) throw new AuthError(`authorization server ${issuer} is not a bare origin`, 'OAuthInvalid'); const metadata = await fetchJson<ServerMetadata>(`${origin.origin}/.well-known/oauth-authorization-server`); if (metadata.issuer !== origin.origin) throw new AuthError(`${origin.origin} describes itself as ${String(metadata.issuer)}`, 'OAuthInvalid'); return metadata; })(); pending.catch(() => servers.delete(issuer)); servers.set(issuer, pending); } return pending;}
async function pushAuthorization(server: ServerMetadata, dpop: CRYPTO.KeyObject, nonces: Record<string, string>, request: { clientId: string; redirectUri: string; scope: string; verifier: string; state: string; hint: string;}): Promise<{ requestUri: string } | { error: string; description?: string | undefined }> { const response = await dpopFetch(server.pushed_authorization_request_endpoint!, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: request.clientId, response_type: 'code', redirect_uri: request.redirectUri, scope: request.scope, state: request.state, code_challenge: CRYPTO.createHash('sha256').update(request.verifier).digest('base64url'), code_challenge_method: 'S256', login_hint: request.hint, }).toString(), }, dpop, nonces, undefined); const body = await response.json().catch(() => ({})) as { request_uri?: string; error?: string; error_description?: string }; if (!response.ok || !body.request_uri) return { error: body.error ?? `HTTP ${response.status}`, description: body.error_description }; return { requestUri: body.request_uri };}
/** The code in a callback, once its `state` and `iss` are the ones expected. */function codeFrom(params: URLSearchParams, state: string, issuer: string): string { if (params.get('state') !== state) throw new AuthError('sign-in failed: the state parameter does not match this sign-in', 'OAuthStateMismatch'); const error = params.get('error'); if (error) throw new AuthError(`sign-in failed: ${params.get('error_description') || error}`, 'OAuthDenied'); if (params.get('iss') !== issuer) throw new AuthError(`sign-in failed: the answer came from ${String(params.get('iss'))}, not ${issuer}`, 'OAuthWrongIssuer'); const code = params.get('code'); if (!code) throw new AuthError('sign-in failed: no authorization code in the callback', 'OAuthNoCode'); return code;}
function loopbackCallback(server: HTTP.Server, pending: PendingOAuth, timeout: number): Promise<string> { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new AuthError('timed out waiting for the browser sign-in to complete', 'OAuthTimeout')), timeout); timer.unref(); server.on('request', (req, res) => { const url = new URL(req.url ?? '/', 'http://127.0.0.1'); const respond = (status: number, message: string) => { res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', connection: 'close' }); res.end(`<!doctype html><meta charset="utf-8"><title>atcli</title><body style="font:16px system-ui;margin:4em auto;max-width:32em"><p>${message}</p></body>\n`); }; if (url.pathname !== '/callback') return respond(404, 'Not found.'); clearTimeout(timer); try { const code = codeFrom(url.searchParams, pending.state, pending.issuer); respond(200, 'Signed in to atcli. You can close this tab and return to the terminal.'); resolve(code); } catch (err) { respond(400, String((err as Error).message)); reject(err); } }); server.on('error', reject); });}
async function tokenRequest(endpoint: string, dpop: CRYPTO.KeyObject, nonces: Record<string, string>, params: Record<string, string>): Promise<TokenResponse> { const response = await dpopFetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(params).toString(), }, dpop, nonces, undefined); const body = await response.json().catch(() => ({})) as Partial<TokenResponse> & { error?: string; error_description?: string }; if (!response.ok) { const ended = body.error === 'invalid_grant'; throw new AuthError(`${ended ? 'the session has ended' : 'token request refused'}: ${body.error ?? response.status}${body.error_description ? ` — ${body.error_description}` : ''}`, ended ? 'SessionExpired' : 'OAuthRefused'); } if (body.token_type?.toLowerCase() !== 'dpop') throw new AuthError(`token is not DPoP-bound (${String(body.token_type)})`, 'OAuthInvalid'); if (!body.access_token || !body.sub) throw new AuthError('token response carries no access token or subject', 'OAuthInvalid'); if (!body.scope?.split(' ').includes('atproto')) throw new AuthError('token response does not grant the atproto scope', 'OAuthInvalid'); return body as TokenResponse;}
// ------------------------------------------------------------------ DPoP ---
function keyOf(jwk: CRYPTO.webcrypto.JsonWebKey): CRYPTO.KeyObject { return CRYPTO.createPrivateKey({ key: jwk, format: 'jwk' });}
// A server hands out a nonce on first contact and rotates it; a request it// rejects for want of the current one is sent once more with it.async function dpopFetch(url: string, init: RequestInit, dpop: CRYPTO.KeyObject, nonces: Record<string, string>, accessToken: string | undefined): Promise<Response> { const origin = new URL(url).origin; const attempt = async () => { const proof = dpopProof(dpop, init.method ?? 'GET', url, nonces[origin], accessToken); let response: Response; try { response = await fetch(url, { ...init, headers: { ...(init.headers as Record<string, string> | undefined), dpop: proof }, signal: AbortSignal.timeout(30_000) }); } catch (cause) { throw new CliError(`could not reach ${new URL(url).host}: ${(cause as Error & { cause?: Error }).cause?.message ?? (cause as Error).message}`, 'NetworkError'); } const nonce = response.headers.get('dpop-nonce'); if (nonce) nonces[origin] = nonce; return response; }; const response = await attempt(); if (await wantsNonce(response)) return await attempt(); return response;}
// An authorization server says so in a 400's JSON body; a resource server in a// 401's WWW-Authenticate header.async function wantsNonce(response: Response): Promise<boolean> { if (response.status === 401) return /use_dpop_nonce/.test(response.headers.get('www-authenticate') ?? ''); if (response.status !== 400) return false; try { return (await response.clone().json() as { error?: string }).error === 'use_dpop_nonce'; } catch { return false; }}
/** A DPoP proof: a JWT signed with the session key, for one request. */export function dpopProof(dpop: CRYPTO.KeyObject, method: string, url: string, nonce?: string | undefined, accessToken?: string | undefined): string { const { kty, crv, x, y } = CRYPTO.createPublicKey(dpop).export({ format: 'jwk' }); const target = new URL(url); const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: { kty, crv, x, y } }; const payload = { jti: CRYPTO.randomBytes(16).toString('base64url'), htm: method.toUpperCase(), htu: `${target.origin}${target.pathname}`, iat: Math.floor(Date.now() / 1000), ...(nonce ? { nonce } : {}), ...(accessToken ? { ath: CRYPTO.createHash('sha256').update(accessToken).digest('base64url') } : {}), }; const input = `${encode(header)}.${encode(payload)}`; const signature = CRYPTO.sign('sha256', Buffer.from(input), { key: dpop, dsaEncoding: 'ieee-p1363' }); return `${input}.${signature.toString('base64url')}`;}
function encode(value: unknown): string { return Buffer.from(JSON.stringify(value)).toString('base64url');}