Something went wrong. Try again.
Something went wrong. Try again.
TypeScript
at design-docs
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222import { 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<StoredSession | null> { 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<void> { 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<NodeSavedSession | undefined>; set: (key: string, value: NodeSavedSession) => Promise<void>; del: (key: string) => Promise<void>; }, redirectUri?: string,): NodeOAuthClient { const stateStore = new Map<string, NodeSavedState>();
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<void> { 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<URLSearchParams>();
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( "<html><body><h1>Login successful!</h1><p>You can close this tab.</p></body></html>", { 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<void> { 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 <handle>` 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 };}