UNPKG

everything-dev

Version:

A consolidated product package for building Module Federation apps with oRPC APIs.

208 lines (206 loc) • 8.96 kB
import { SiteClient, authPost, getJson, parseSiteErrorDetail, rpcPost } from "./site-client.mjs"; import { Context, Effect, Layer } from "effect"; import { execa } from "execa"; import { platform } from "node:os"; import { BOS_CLI_CLIENT_ID } from "better-near-auth"; //#region src/services/auth/device-login.ts const realSiteClient = { authPost, rpcPost, getJson }; const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; const DEVICE_LINK_CLIENT_ID = BOS_CLI_CLIENT_ID; const DEFAULT_CLIENT_ID = DEVICE_LINK_CLIENT_ID; const DEFAULT_POLL_INTERVAL_SECONDS = 5; const SLOW_DOWN_PENALTY_SECONDS = 5; /** * CLI login via the OAuth 2.0 Device Flow (RFC 8628) — the same flow the * site's QR pairing uses. The CLI requests a device code, the user approves * at `/login/device` in any browser (same machine or not), and the CLI polls * the token endpoint until the grant completes. All HTTP goes through the * site client; the returned handle drives the poll loop from the handler. */ async function startDeviceLogin(opts, client = realSiteClient) { const siteUrl = opts.siteUrl.replace(/\/$/, ""); const clientId = DEFAULT_CLIENT_ID; const codeResponse = await client.authPost(siteUrl, "/device/code", { client_id: clientId }); if (codeResponse.status >= 400) throw new Error(`Device login could not start (${codeResponse.status}) — is ${siteUrl} running the platform auth plugin?`); const code = codeResponse.json; const verificationUrl = buildVerificationUrl(siteUrl, code, opts); let abortReason = null; let rejectPending; const pendingReject = new Promise((_, reject) => { rejectPending = reject; }); const waitForApproval = (timeoutMs = 6e5) => { const deadline = Date.now() + timeoutMs; let intervalSeconds = code.interval ?? DEFAULT_POLL_INTERVAL_SECONDS; const poll = async () => { if (abortReason) throw new Error(abortReason); if (Date.now() > deadline) throw new Error("Login timed out — the device code expired before approval"); const { status, json } = await client.authPost(siteUrl, "/device/token", { grant_type: DEVICE_CODE_GRANT_TYPE, device_code: code.device_code, client_id: clientId }); if (status === 200) { const { access_token: sessionToken } = json; return await completeLogin(siteUrl, sessionToken, opts, client); } const error = json.error; if (error === "authorization_pending") {} else if (error === "slow_down") intervalSeconds += SLOW_DOWN_PENALTY_SECONDS; else if (error === "expired_token") throw new Error("Login timed out — the device code expired before approval"); else if (error === "access_denied") throw new Error("Login request was denied on the approval page"); else throw new Error(json.error_description ?? `Login failed (${error ?? status})`); await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1e3)); return poll(); }; return Promise.race([pendingReject, poll()]); }; return { userCode: code.user_code, verificationUrl, waitForApproval, abort: (reason) => { abortReason = reason ?? "Login aborted"; rejectPending(new Error(abortReason)); } }; } /** The device login as a Context.Service tag (ticket #423) — the login * handler yields it; tests provide fake site clients. */ var DeviceLoginService = class DeviceLoginService extends Context.Service()("everything-dev/auth/device-login") { static Live = Layer.effect(this, Effect.gen(function* () { const client = yield* SiteClient; return DeviceLoginService.of({ start: (opts) => Effect.tryPromise(() => startDeviceLogin(opts, client)) }); })); }; function buildVerificationUrl(siteUrl, code, opts) { const base = code.verification_uri_complete ?? code.verification_uri ?? "/login/device"; const url = new URL(base, siteUrl); const params = url.searchParams; params.set("user_code", code.user_code); if (opts.account) params.set("account", opts.account); if (opts.device) params.set("device", opts.device); if (opts.delegate) { params.set("pubKey", opts.delegate.pubKey); params.set("contract", opts.delegate.contract); params.set("network", opts.delegate.network); } if (opts.custody) { params.set("custody", "1"); params.set("network", opts.custody.network); } return url.toString(); } async function completeLogin(siteUrl, sessionToken, opts, client) { const claim = await client.authPost(siteUrl, "/device-link/claim", { token: sessionToken, client_id: DEFAULT_CLIENT_ID }); if (claim.status !== 200) { const detail = parseSiteErrorDetail(claim.status, claim.json); throw new Error(`Approved session could not be claimed${detail ? `: ${detail}` : ` (${claim.status})`}`); } const cookiePair = claim.setCookie?.split(";")[0]; if (!cookiePair?.includes("=")) throw new Error("Session claim returned no session cookie"); const apiKey = await (opts.deployKey ? mintDeployKey(siteUrl, cookiePair, opts, client) : mintApiKey(siteUrl, cookiePair, opts, client)); return { sessionToken, accountId: await resolveNearAccount(siteUrl, cookiePair, client), apiKey, custodyWallet: opts.custody ? await fetchCustodyCredential(siteUrl, cookiePair, opts.custody.network, client).catch(() => null) : null }; } async function fetchCustodyCredential(siteUrl, cookiePair, network, client) { const { status, json } = await client.authPost(siteUrl, "/custody/credential", { network }, cookiePair); if (status !== 200) { const detail = parseSiteErrorDetail(status, json); throw new Error(`Custody wallet credential could not be fetched${detail ? `: ${detail}` : ` (${status})`}`); } const data = json; if (!data.apiKey?.startsWith("wk_")) throw new Error("Custody wallet credential returned no API key"); if (data.network !== "mainnet" && data.network !== "testnet") throw new Error(`Custody wallet credential returned unknown network "${data.network}"`); return { apiKey: data.apiKey, walletId: data.walletId, nearAccountId: data.nearAccountId, network: data.network, handoffUrl: data.handoffUrl ?? null, registeredAt: data.registeredAt ?? (/* @__PURE__ */ new Date()).toISOString() }; } async function mintApiKey(siteUrl, cookiePair, opts, client) { const { status, json } = await client.authPost(siteUrl, "/api-key/create", { configId: "user-keys", name: `bos login — ${opts.device ?? "cli"} — ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ")}`, ...opts.expiresIn ? { expiresIn: opts.expiresIn } : {} }, cookiePair); if (status !== 200) { const detail = parseSiteErrorDetail(status, json); throw new Error(`CLI credential could not be created${detail ? `: ${detail}` : ` (${status})`}`); } const created = json; if (!created.key) throw new Error("API key creation returned no key"); return { key: created.key, id: created.id }; } /** * Deploy-key mint: only the auth plugin's server-side route may set * `permissions` (better-auth rejects client-set permissions with * SERVER_ONLY_PROPERTY), so the scoped bundle-write key is minted through the * plugin's oRPC route instead of better-auth's client endpoint. The oRPC * envelope error body (`{ json: { code, message, data } }`) is parsed by the * site client — the server's FORBIDDEN detail surfaces verbatim. */ async function mintDeployKey(siteUrl, cookiePair, opts, client) { const deployKey = opts.deployKey; if (!deployKey) throw new Error("Deploy-key mint requested without account scope"); const response = await client.rpcPost(siteUrl, "/api/rpc/auth/createDeployKey", { account: deployKey.account, gateway: deployKey.gateway, name: `bos deploy — ${opts.device ?? "cli"} — ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ")}`, ...opts.expiresIn ? { expiresIn: opts.expiresIn } : {} }, cookiePair); if (response.status !== 200) { const detail = parseSiteErrorDetail(response.status, response.json); throw new Error(`Deploy key could not be created${detail ? `: ${detail}` : ` (${response.status})`}`); } const created = response.json.json; if (!created?.key) throw new Error("Deploy key creation returned no key"); return { key: created.key, id: created.id }; } async function resolveNearAccount(siteUrl, cookiePair, client) { try { return (await client.getJson(`${siteUrl.replace(/\/$/, "")}/api/auth/near/list-accounts`, cookiePair)).activeAccount?.accountId ?? null; } catch { return null; } } async function openInBrowser(url) { const os = platform(); try { if (os === "darwin") await execa("open", [url], { stdio: "ignore" }); else if (os === "win32") await execa("cmd", [ "/c", "start", "", url ], { stdio: "ignore", windowsVerbatimArguments: true }); else await execa("xdg-open", [url], { stdio: "ignore" }); } catch { throw new Error(`Failed to open browser. Open this URL manually:\n ${url}`); } } //#endregion export { DEVICE_LINK_CLIENT_ID, DeviceLoginService, openInBrowser, startDeviceLogin }; //# sourceMappingURL=device-login.mjs.map