Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
at main 282 lines 8.3 kB view raw
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}