forked from
nekomimi.pet/wisp.place-monorepo
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
1import { NodeOAuthClient, type NodeSavedSession, type NodeSavedState, type NodeSavedStateStore, type NodeSavedSessionStore } from "@atproto/oauth-client-node";
2import { Agent, CredentialSession } from "@atproto/api";
3import { resolvePdsFromHandle } from "@wispplace/atproto-utils";
4import { Hono } from "hono";
5import { serve as honoNodeServe } from "@hono/node-server";
6import open from "open";
7import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
8import { dirname, join } from "path";
9import { homedir } from "os";
10import { isBun } from "@wispplace/bun-firehose";
11
12// OAuth scope for CLI
13const OAUTH_SCOPE = 'atproto repo:place.wisp.fs repo:place.wisp.subfs repo:place.wisp.settings blob:*/*';
14
15// Default session store path
16const DEFAULT_STORE_PATH = join(homedir(), '.wisp', 'oauth-session.json');
17
18// Loopback server config
19const LOOPBACK_PORT = 4000;
20const LOOPBACK_HOST = '127.0.0.1';
21
22interface StoredData {
23 states: Record<string, NodeSavedState>;
24 sessions: Record<string, NodeSavedSession>;
25}
26
27function ensureDir(filePath: string) {
28 const dir = dirname(filePath);
29 if (!existsSync(dir)) {
30 mkdirSync(dir, { recursive: true });
31 }
32}
33
34function loadStore(storePath: string): StoredData {
35 if (!existsSync(storePath)) {
36 return { states: {}, sessions: {} };
37 }
38 try {
39 const content = readFileSync(storePath, 'utf-8');
40 return JSON.parse(content);
41 } catch {
42 return { states: {}, sessions: {} };
43 }
44}
45
46function saveStore(storePath: string, data: StoredData) {
47 ensureDir(storePath);
48 writeFileSync(storePath, JSON.stringify(data, null, 2));
49}
50
51function createStateStore(storePath: string): NodeSavedStateStore {
52 return {
53 async set(key: string, state: NodeSavedState) {
54 const data = loadStore(storePath);
55 data.states[key] = state;
56 saveStore(storePath, data);
57 },
58 async get(key: string) {
59 const data = loadStore(storePath);
60 return data.states[key];
61 },
62 async del(key: string) {
63 const data = loadStore(storePath);
64 delete data.states[key];
65 saveStore(storePath, data);
66 }
67 };
68}
69
70function createSessionStore(storePath: string): NodeSavedSessionStore {
71 return {
72 async set(sub: string, session: NodeSavedSession) {
73 const data = loadStore(storePath);
74 data.sessions[sub] = session;
75 saveStore(storePath, data);
76 },
77 async get(sub: string) {
78 const data = loadStore(storePath);
79 return data.sessions[sub];
80 },
81 async del(sub: string) {
82 const data = loadStore(storePath);
83 delete data.sessions[sub];
84 saveStore(storePath, data);
85 }
86 };
87}
88
89export interface AuthOptions {
90 storePath?: string;
91 appPassword?: string;
92}
93
94/**
95 * Authenticate with AT Protocol using OAuth loopback flow
96 */
97export async function authenticateOAuth(
98 handle: string,
99 options: AuthOptions = {}
100): Promise<{ agent: Agent; did: string }> {
101 const storePath = options.storePath || DEFAULT_STORE_PATH;
102
103 // Build loopback client metadata
104 const redirectUri = `http://${LOOPBACK_HOST}:${LOOPBACK_PORT}/oauth/callback`;
105 const clientIdParams = new URLSearchParams();
106 clientIdParams.append('redirect_uri', redirectUri);
107 clientIdParams.append('scope', OAUTH_SCOPE);
108
109 const client = new NodeOAuthClient({
110 clientMetadata: {
111 client_id: `http://localhost?${clientIdParams.toString()}`,
112 client_name: "Wisp CLI",
113 client_uri: "https://wisp.place",
114 redirect_uris: [redirectUri],
115 grant_types: ['authorization_code', 'refresh_token'],
116 response_types: ['code'],
117 application_type: 'web',
118 token_endpoint_auth_method: 'none',
119 scope: OAUTH_SCOPE,
120 dpop_bound_access_tokens: false,
121 },
122 stateStore: createStateStore(storePath),
123 sessionStore: createSessionStore(storePath),
124 });
125
126 // Try to restore existing session
127 const data = loadStore(storePath);
128 const existingSessions = Object.keys(data.sessions);
129
130 // Check if we have a session for this handle's DID
131 for (const sub of existingSessions) {
132 try {
133 const session = await client.restore(sub);
134 if (session) {
135 // Verify session is still valid
136 const agent = new Agent(session);
137 const profile = await agent.getProfile({ actor: sub });
138
139 // Check if this is the handle we want
140 if (profile.data.handle === handle || sub === handle) {
141 console.log(`Restored existing session for ${profile.data.handle}`);
142 return { agent, did: sub };
143 }
144 }
145 } catch {
146 // Session invalid, continue
147 }
148 }
149
150 // Start new OAuth flow
151 console.log(`Starting OAuth flow for ${handle}...`);
152
153 // Create loopback server to receive callback
154 const callbackPromise = new Promise<{ params: URLSearchParams }>((resolve, reject) => {
155 const app = new Hono();
156 let serverHandle: { close: () => void } | null = null;
157
158 const successHtml = `
159 <html>
160 <head><title>Wisp CLI - Authentication Successful</title></head>
161 <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
162 <div style="text-align: center;">
163 <h1>Authentication Successful</h1>
164 <p>You can close this window and return to the CLI.</p>
165 </div>
166 </body>
167 </html>
168 `;
169
170 app.get('/oauth/callback', (c) => {
171 const params = new URLSearchParams(c.req.url.split('?')[1] || '');
172
173 // Close server after receiving callback
174 setTimeout(() => serverHandle?.close(), 100);
175
176 resolve({ params });
177
178 return c.html(successHtml);
179 });
180
181 app.all('*', (c) => c.text('Not found', 404));
182
183 // Start server based on runtime
184 if (isBun) {
185 // @ts-ignore - Bun global
186 const bunServer = Bun.serve({
187 port: LOOPBACK_PORT,
188 hostname: LOOPBACK_HOST,
189 fetch: app.fetch,
190 });
191 serverHandle = { close: () => bunServer.stop() };
192 } else {
193 const nodeServer = honoNodeServe({
194 fetch: app.fetch,
195 port: LOOPBACK_PORT,
196 hostname: LOOPBACK_HOST,
197 });
198 serverHandle = { close: () => nodeServer.close() };
199 }
200
201 // Timeout after 5 minutes
202 setTimeout(() => {
203 serverHandle?.close();
204 reject(new Error('OAuth callback timeout'));
205 }, 5 * 60 * 1000);
206 });
207
208 // Get authorization URL
209 const authUrl = await client.authorize(handle, {
210 scope: OAUTH_SCOPE,
211 });
212
213 // Open browser
214 console.log(`Opening browser for authentication...`);
215 console.log(`If browser doesn't open, visit: ${authUrl}`);
216 await open(authUrl.toString());
217
218 // Wait for callback
219 const { params } = await callbackPromise;
220
221 // Handle callback
222 const { session } = await client.callback(params);
223
224 const agent = new Agent(session);
225 const did = session.did;
226
227 console.log(`Successfully authenticated as ${did}`);
228
229 return { agent, did };
230}
231
232/**
233 * Authenticate with AT Protocol using app password (for CI/headless)
234 */
235export async function authenticateAppPassword(
236 identifier: string,
237 password: string,
238 pdsUrl?: string
239): Promise<{ agent: Agent; did: string }> {
240 let serviceUrl = pdsUrl;
241
242 if (!serviceUrl) {
243 // Resolve the handle to find the correct PDS
244 console.log(`Resolving PDS for ${identifier}...`);
245 serviceUrl = await resolvePdsFromHandle(identifier);
246 console.log(`Found PDS: ${serviceUrl}`);
247 }
248
249 const credSession = new CredentialSession(new URL(serviceUrl));
250 await credSession.login({ identifier, password });
251
252 const agent = new Agent(credSession);
253 const did = credSession.did!;
254
255 console.log(`Successfully authenticated as ${did}`);
256
257 return { agent, did };
258}
259
260/**
261 * Authenticate - tries OAuth if no password provided, otherwise uses app password
262 */
263export async function authenticate(
264 handle: string,
265 options: AuthOptions = {}
266): Promise<{ agent: Agent; did: string }> {
267 if (options.appPassword) {
268 return authenticateAppPassword(handle, options.appPassword);
269 }
270 return authenticateOAuth(handle, options);
271}
272
273/**
274 * Clear stored OAuth sessions
275 */
276export function clearSessions(storePath?: string) {
277 const path = storePath || DEFAULT_STORE_PATH;
278 if (existsSync(path)) {
279 unlinkSync(path);
280 console.log('Cleared stored OAuth sessions');
281 }
282}