this repo has no description
at design-docs 221 lines 6.2 kB view raw
1import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; 2import { homedir } from "node:os"; 3import { join } from "node:path"; 4import { spawn } from "node:child_process"; 5import { Agent } from "@atproto/api"; 6import { 7 NodeOAuthClient, 8 type NodeSavedSession, 9 type NodeSavedState, 10} from "@atproto/oauth-client-node"; 11 12const SESSION_DIR = join(homedir(), ".sitebase"); 13const SESSION_FILE = join(SESSION_DIR, "session.json"); 14 15const CLIENT_ID = 16 "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto+include%3Asite.standard.authFull"; 17 18interface StoredSession { 19 did: string; 20 session: NodeSavedSession; 21} 22 23let verbose = false; 24 25export function setVerbose(v: boolean): void { 26 verbose = v; 27} 28 29function debug(...args: unknown[]): void { 30 if (verbose) { 31 console.error("[debug]", ...args); 32 } 33} 34 35async function readStoredSession(): Promise<StoredSession | null> { 36 try { 37 const data = await readFile(SESSION_FILE, "utf-8"); 38 debug("Read session from", SESSION_FILE); 39 return JSON.parse(data) as StoredSession; 40 } catch { 41 debug("No stored session found at", SESSION_FILE); 42 return null; 43 } 44} 45 46async function writeStoredSession(stored: StoredSession): Promise<void> { 47 await mkdir(SESSION_DIR, { recursive: true }); 48 await writeFile(SESSION_FILE, JSON.stringify(stored, null, 2), "utf-8"); 49 debug("Wrote session to", SESSION_FILE); 50} 51 52function createClient( 53 sessionStore: { 54 get: (key: string) => Promise<NodeSavedSession | undefined>; 55 set: (key: string, value: NodeSavedSession) => Promise<void>; 56 del: (key: string) => Promise<void>; 57 }, 58 redirectUri?: string, 59): NodeOAuthClient { 60 const stateStore = new Map<string, NodeSavedState>(); 61 62 const redirect_uris = redirectUri 63 ? [redirectUri] 64 : ["http://127.0.0.1/callback"]; 65 66 debug("Creating OAuth client with client_id:", CLIENT_ID); 67 debug("redirect_uris:", redirect_uris); 68 69 return new NodeOAuthClient({ 70 clientMetadata: { 71 client_id: CLIENT_ID, 72 redirect_uris: redirect_uris as [string], 73 application_type: "native", 74 token_endpoint_auth_method: "none", 75 dpop_bound_access_tokens: true, 76 grant_types: ["authorization_code", "refresh_token"], 77 response_types: ["code"], 78 scope: "atproto include:site.standard.authFull", 79 }, 80 stateStore: { 81 get: async (key: string) => { 82 debug("stateStore.get:", key); 83 return stateStore.get(key); 84 }, 85 set: async (key: string, value: NodeSavedState) => { 86 debug("stateStore.set:", key); 87 stateStore.set(key, value); 88 }, 89 del: async (key: string) => { 90 debug("stateStore.del:", key); 91 stateStore.delete(key); 92 }, 93 }, 94 sessionStore: { 95 get: async (key: string) => { 96 debug("sessionStore.get:", key); 97 return sessionStore.get(key); 98 }, 99 set: async (key: string, value: NodeSavedSession) => { 100 debug("sessionStore.set:", key); 101 return sessionStore.set(key, value); 102 }, 103 del: async (key: string) => { 104 debug("sessionStore.del:", key); 105 return sessionStore.del(key); 106 }, 107 }, 108 }); 109} 110 111function openBrowser(url: string): void { 112 const cmd = process.platform === "darwin" ? "open" : "xdg-open"; 113 debug("Opening browser with command:", cmd, url); 114 spawn(cmd, [url], { stdio: "ignore", detached: true }).unref(); 115} 116 117export async function login(handle: string): Promise<void> { 118 let stored: StoredSession | null = null; 119 120 const sessionStore = { 121 get: async (_key: string) => stored?.session, 122 set: async (key: string, value: NodeSavedSession) => { 123 stored = { did: key, session: value }; 124 await writeStoredSession(stored); 125 }, 126 del: async (_key: string) => { 127 stored = null; 128 }, 129 }; 130 131 // Start callback server first to get the ephemeral port 132 const { promise: callbackPromise, resolve: resolveCallback } = 133 Promise.withResolvers<URLSearchParams>(); 134 135 const server = Bun.serve({ 136 port: 0, 137 async fetch(req) { 138 const url = new URL(req.url); 139 debug("Received request:", url.pathname, url.search); 140 if (url.pathname === "/callback") { 141 resolveCallback(url.searchParams); 142 return new Response( 143 "<html><body><h1>Login successful!</h1><p>You can close this tab.</p></body></html>", 144 { headers: { "Content-Type": "text/html" } }, 145 ); 146 } 147 return new Response("Not found", { status: 404 }); 148 }, 149 }); 150 151 const port = server.port; 152 const redirectUri = `http://127.0.0.1:${port}/callback`; 153 debug("Callback server listening on port", port); 154 debug("Redirect URI:", redirectUri); 155 156 // Create client with port-specific redirect_uri in metadata 157 const client = createClient(sessionStore, redirectUri); 158 159 try { 160 debug("Calling client.authorize with handle:", handle); 161 const authUrl = await client.authorize(handle); 162 debug("Got auth URL:", authUrl.toString()); 163 164 console.log("Opening browser for authentication..."); 165 openBrowser(authUrl.toString()); 166 console.log(`If the browser doesn't open, visit: ${authUrl.toString()}`); 167 168 // Wait for callback 169 debug("Waiting for OAuth callback..."); 170 const params = await callbackPromise; 171 debug("Received callback params:", Object.fromEntries(params.entries())); 172 173 debug("Calling client.callback..."); 174 await client.callback(params); 175 176 console.log(`\nLogged in as ${stored!.did}`); 177 } finally { 178 server.stop(); 179 debug("Callback server stopped"); 180 } 181} 182 183export async function logout(): Promise<void> { 184 try { 185 await unlink(SESSION_FILE); 186 console.log("Logged out successfully."); 187 } catch { 188 console.log("No active session."); 189 } 190} 191 192export async function getAuthenticatedAgent(): Promise<{ 193 agent: Agent; 194 did: string; 195}> { 196 const stored = await readStoredSession(); 197 if (!stored) { 198 throw new Error( 199 "Not logged in. Run `sitebase auth login <handle>` first.", 200 ); 201 } 202 203 const sessionStore = { 204 get: async (_key: string) => stored.session, 205 set: async (key: string, value: NodeSavedSession) => { 206 stored.session = value; 207 await writeStoredSession(stored); 208 }, 209 del: async (_key: string) => { 210 await unlink(SESSION_FILE).catch(() => {}); 211 }, 212 }; 213 214 debug("Restoring session for DID:", stored.did); 215 const client = createClient(sessionStore); 216 const session = await client.restore(stored.did); 217 debug("Session restored successfully"); 218 const agent = new Agent(session); 219 220 return { agent, did: stored.did }; 221}