// sign-in through the PDS's own OAuth server: a browser tab, consent to the lore permission // set, and a token scoped to it. no app password anywhere. the hosted client document is the // native one under lore.waow.tech/pi; sessions persist under the agent dir and refresh on // their own, keyed by DID. import { execFile } from "node:child_process"; import { createServer, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; import { mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { NodeOAuthClient, type NodeSavedSession, type NodeSavedSessionStore, type NodeSavedState, type NodeSavedStateStore } from "@atproto/oauth-client-node"; import type { OAuthSession } from "@atproto/oauth-client-node"; import { Type, type Static, type TSchema } from "typebox"; import { Parse } from "typebox/value"; import { SPACE_TYPE } from "./lore.ts"; import { type JsonObject, type Query, type Xrpc, XrpcError, bodyOf } from "./xrpc.ts"; export const CLIENT_ID = "https://lore.waow.tech/pi/oauth-client-metadata.json"; export const SCOPE = "atproto include:tech.waow.lore.permissions"; export const PORTS = [41777, 41778, 41779] as const; const HANDLE_RESOLVER = "https://slingshot.microcosm.blue"; /** one JSON file per key under a directory, mode 0600; what the client needs to persist */ class FileStore { constructor(private readonly dir: string) { mkdirSync(dir, { recursive: true, mode: 0o700 }); } private path(key: string): string { return join(this.dir, `${encodeURIComponent(key)}.json`); } async get(key: string): Promise { try { // SAFETY: this file is only ever written by `set` below with a value of type V. return JSON.parse(readFileSync(this.path(key), "utf8")) as V; } catch { return undefined; } } /** write-then-rename, so a crash mid-write never leaves a half token on disk */ async set(key: string, value: V): Promise { const target = this.path(key); const tmp = `${target}.${process.pid}.tmp`; writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); renameSync(tmp, target); } async del(key: string): Promise { try { unlinkSync(this.path(key)); } catch { /* already gone */ } } } // one refresh at a time per session, across every pi process on this machine: refresh tokens // rotate, and two processes refreshing the same one race each other into invalid_grant. // in-process: a promise chain per name. cross-process: a lock directory, stale after 30s. const chains = new Map>(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); // server.address() is a string for pipe sockets, an AddressInfo for tcp; a primitive is never an Object const isAddressInfo = (a: string | AddressInfo | null): a is AddressInfo => a !== null && a instanceof Object; function lockFor(dir: string) { const lockRoot = join(dir, "oauth-locks"); mkdirSync(lockRoot, { recursive: true, mode: 0o700 }); return async (name: string, fn: () => T | PromiseLike): Promise => { const path = join(lockRoot, `${encodeURIComponent(name)}.lock`); const acquire = async () => { for (let attempt = 0; attempt < 300; attempt++) { try { mkdirSync(path); return; } catch { try { const age = Date.now() - Number(readFileSync(join(path, "since"), "utf8")); if (age > 30_000) rmSync(path, { recursive: true, force: true }); } catch { /* no timestamp yet; keep waiting */ } await sleep(100); } } throw new Error(`could not lock ${name}`); }; const run = async () => { await acquire(); writeFileSync(join(path, "since"), String(Date.now())); try { return await fn(); } finally { rmSync(path, { recursive: true, force: true }); } }; const previous = chains.get(name) ?? Promise.resolve(); const next = previous.then(run, run); chains.set(name, next.catch(() => {})); return next; }; } function clientFor(dir: string, port: number): NodeOAuthClient { const stateStore: NodeSavedStateStore = new FileStore(join(dir, "oauth-state")); const sessionStore: NodeSavedSessionStore = new FileStore(join(dir, "oauth-sessions")); return new NodeOAuthClient({ requestLock: lockFor(dir), clientMetadata: { client_id: CLIENT_ID, client_name: "lore (pi extension)", client_uri: "https://lore.waow.tech/", application_type: "native", redirect_uris: [`http://127.0.0.1:${port}/callback`], scope: SCOPE, grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", dpop_bound_access_tokens: true, }, stateStore, sessionStore, handleResolver: HANDLE_RESOLVER, }); } function openBrowser(url: string): void { const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; execFile(cmd, [url], () => {}); } /** the whole sign-in: listen on a loopback port from the hosted document, send the browser to the * PDS, take the code on the way back. resolves to the signed-in DID. */ export async function signIn(dir: string, handle: string, onUrl: (url: string) => void): Promise<{ did: string; session: OAuthSession }> { // an ephemeral port first: hosts that apply the RFC 8252 loopback exception accept it against the // port-less entry in the hosted document. a host that string-matches refuses it, and the fixed // ports listed there are the fallback. for (const wanted of [0, ...PORTS]) { const server = createServer(); const port = await new Promise((resolve) => { server.once("error", () => resolve(null)); server.listen(wanted, "127.0.0.1", () => { const address = server.address(); resolve(isAddressInfo(address) ? address.port : null); }); }); if (port === null) continue; const client = clientFor(dir, port); try { let url: URL; try { url = await client.authorize(handle, { scope: SCOPE, redirect_uri: `http://127.0.0.1:${port}/callback` }); } catch (error) { if (wanted === 0 && /redirect_uri/i.test(error instanceof Error ? error.message : String(error))) continue; throw error; } onUrl(url.href); openBrowser(url.href); const { params, res } = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("sign-in timed out after 5 minutes")), 5 * 60_000); server.on("request", (req, res) => { const u = new URL(req.url ?? "/", `http://127.0.0.1:${port}`); if (u.pathname !== "/callback") { res.writeHead(404).end(); return; } clearTimeout(timer); resolve({ params: u.searchParams, res }); }); }); const page = (html: string) => res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(html); let session: OAuthSession; try { session = (await client.callback(params, { redirect_uri: `http://127.0.0.1:${port}/callback` })).session; } catch (error) { page(outcomePage(false, "sign-in did not complete", "back to pi for the reason.")); throw error; } const grant = await spaceGrant(session); if (grant !== "lore") { const host = new URL(session.serverMetadata.issuer).host; await session.signOut().catch(() => {}); if (grant === "none") { page(outcomePage(false, `${host} does not implement spaces`, "lore needs a PDS that does; nothing was kept.")); throw new NoSpaces(handle, host); } page(outcomePage(false, "that consent is for an older permission set", "sign in again and approve the current one.")); throw new StaleGrant(handle); } page(outcomePage(true, `signed in as ${handle}`, "back to pi. you can close this tab.")); return { did: session.did, session }; } finally { server.close(); } } throw new Error(`could not listen on any of the sign-in ports (${PORTS.join(", ")})`); } type Callback = { params: URLSearchParams; res: ServerResponse }; /** the PDS refused nothing but issued a token without space: grants: it does not implement spaces. * PAR and consent accept the include scope either way, so the token is the only signal. */ export class NoSpaces extends Error { constructor( readonly handle: string, readonly host: string, ) { super(`${handle}'s PDS (${host}) does not implement spaces; lore has nowhere to live there`); } } /** what the token says about spaces. a spaces-capable host expands the permission set into * space: grants at issuance; "other" is a grant for some other space type, which happens when * the sign-in predates the current permission set. */ export type SpaceGrant = "lore" | "other" | "none"; export async function spaceGrant(session: Pick): Promise { const info = await session.getTokenInfo("auto"); if (info.scope.includes(`space:${SPACE_TYPE}?`)) return "lore"; return /(^|\s)space:/.test(info.scope) ? "other" : "none"; } /** the sign-in worked but the token cannot reach lore spaces; the account is dropped. */ export class StaleGrant extends Error { constructor(readonly handle: string) { super(`${handle}'s sign-in predates the current lore permission set; run /lore login ${handle}`); } } const escape = (text: string) => text.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] ?? c); /** what the browser shows after the redirect: one mark, one line, nothing to do */ export function outcomePage(ok: boolean, title: string, line: string): string { const mark = ok ? `` : ``; return `lore

${escape(title)}

${escape(line)}

`; } /** a stored session by DID; the client refreshes it as needed */ export async function restore(dir: string, did: string): Promise { return clientFor(dir, PORTS[0]).restore(did, "auto"); } export async function revoke(dir: string, did: string): Promise { await clientFor(dir, PORTS[0]).revoke(did); } const xrpcError = Type.Object({ error: Type.Optional(Type.String()), message: Type.Optional(Type.String()) }); async function failFrom(r: Response): Promise { let code = `HTTP${r.status}`; let message = r.statusText; try { const body = Parse(xrpcError, await r.json()); code = body.error ?? code; message = body.message ?? message; } catch { /* not an xrpc error body */ } throw new XrpcError(r.status, code, message); } /** the Xrpc contract over an OAuth session: DPoP, refresh, and the PDS base are the session's business */ export class OAuthXrpc implements Xrpc { constructor(private readonly session: OAuthSession) {} get did(): string { return this.session.did; } async get(nsid: string, params: Query, output: T): Promise> { const r = await this.session.fetchHandler(`/xrpc/${nsid}?${new URLSearchParams(params)}`); if (!r.ok) await failFrom(r); return bodyOf(r, output); } async post(nsid: string, body: JsonObject, output: T): Promise> { const r = await this.session.fetchHandler(`/xrpc/${nsid}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); if (!r.ok) await failFrom(r); return bodyOf(r, output); } }