this repo has no description
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}