import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { spawn } from "node:child_process"; import { Agent } from "@atproto/api"; import { NodeOAuthClient, type NodeSavedSession, type NodeSavedState, } from "@atproto/oauth-client-node"; const SESSION_DIR = join(homedir(), ".sitebase"); const SESSION_FILE = join(SESSION_DIR, "session.json"); const CLIENT_ID = "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto+include%3Asite.standard.authFull"; interface StoredSession { did: string; session: NodeSavedSession; } let verbose = false; export function setVerbose(v: boolean): void { verbose = v; } function debug(...args: unknown[]): void { if (verbose) { console.error("[debug]", ...args); } } async function readStoredSession(): Promise { try { const data = await readFile(SESSION_FILE, "utf-8"); debug("Read session from", SESSION_FILE); return JSON.parse(data) as StoredSession; } catch { debug("No stored session found at", SESSION_FILE); return null; } } async function writeStoredSession(stored: StoredSession): Promise { await mkdir(SESSION_DIR, { recursive: true }); await writeFile(SESSION_FILE, JSON.stringify(stored, null, 2), "utf-8"); debug("Wrote session to", SESSION_FILE); } function createClient( sessionStore: { get: (key: string) => Promise; set: (key: string, value: NodeSavedSession) => Promise; del: (key: string) => Promise; }, redirectUri?: string, ): NodeOAuthClient { const stateStore = new Map(); const redirect_uris = redirectUri ? [redirectUri] : ["http://127.0.0.1/callback"]; debug("Creating OAuth client with client_id:", CLIENT_ID); debug("redirect_uris:", redirect_uris); return new NodeOAuthClient({ clientMetadata: { client_id: CLIENT_ID, redirect_uris: redirect_uris as [string], application_type: "native", token_endpoint_auth_method: "none", dpop_bound_access_tokens: true, grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], scope: "atproto include:site.standard.authFull", }, stateStore: { get: async (key: string) => { debug("stateStore.get:", key); return stateStore.get(key); }, set: async (key: string, value: NodeSavedState) => { debug("stateStore.set:", key); stateStore.set(key, value); }, del: async (key: string) => { debug("stateStore.del:", key); stateStore.delete(key); }, }, sessionStore: { get: async (key: string) => { debug("sessionStore.get:", key); return sessionStore.get(key); }, set: async (key: string, value: NodeSavedSession) => { debug("sessionStore.set:", key); return sessionStore.set(key, value); }, del: async (key: string) => { debug("sessionStore.del:", key); return sessionStore.del(key); }, }, }); } function openBrowser(url: string): void { const cmd = process.platform === "darwin" ? "open" : "xdg-open"; debug("Opening browser with command:", cmd, url); spawn(cmd, [url], { stdio: "ignore", detached: true }).unref(); } export async function login(handle: string): Promise { let stored: StoredSession | null = null; const sessionStore = { get: async (_key: string) => stored?.session, set: async (key: string, value: NodeSavedSession) => { stored = { did: key, session: value }; await writeStoredSession(stored); }, del: async (_key: string) => { stored = null; }, }; // Start callback server first to get the ephemeral port const { promise: callbackPromise, resolve: resolveCallback } = Promise.withResolvers(); const server = Bun.serve({ port: 0, async fetch(req) { const url = new URL(req.url); debug("Received request:", url.pathname, url.search); if (url.pathname === "/callback") { resolveCallback(url.searchParams); return new Response( "

Login successful!

You can close this tab.

", { headers: { "Content-Type": "text/html" } }, ); } return new Response("Not found", { status: 404 }); }, }); const port = server.port; const redirectUri = `http://127.0.0.1:${port}/callback`; debug("Callback server listening on port", port); debug("Redirect URI:", redirectUri); // Create client with port-specific redirect_uri in metadata const client = createClient(sessionStore, redirectUri); try { debug("Calling client.authorize with handle:", handle); const authUrl = await client.authorize(handle); debug("Got auth URL:", authUrl.toString()); console.log("Opening browser for authentication..."); openBrowser(authUrl.toString()); console.log(`If the browser doesn't open, visit: ${authUrl.toString()}`); // Wait for callback debug("Waiting for OAuth callback..."); const params = await callbackPromise; debug("Received callback params:", Object.fromEntries(params.entries())); debug("Calling client.callback..."); await client.callback(params); console.log(`\nLogged in as ${stored!.did}`); } finally { server.stop(); debug("Callback server stopped"); } } export async function logout(): Promise { try { await unlink(SESSION_FILE); console.log("Logged out successfully."); } catch { console.log("No active session."); } } export async function getAuthenticatedAgent(): Promise<{ agent: Agent; did: string; }> { const stored = await readStoredSession(); if (!stored) { throw new Error( "Not logged in. Run `sitebase auth login ` first.", ); } const sessionStore = { get: async (_key: string) => stored.session, set: async (key: string, value: NodeSavedSession) => { stored.session = value; await writeStoredSession(stored); }, del: async (_key: string) => { await unlink(SESSION_FILE).catch(() => {}); }, }; debug("Restoring session for DID:", stored.did); const client = createClient(sessionStore); const session = await client.restore(stored.did); debug("Session restored successfully"); const agent = new Agent(session); return { agent, did: stored.did }; }