everything-dev
Version:
A consolidated product package for building Module Federation apps with oRPC APIs.
208 lines (206 loc) • 8.96 kB
JavaScript
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