WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at root/atb-56-theme-caching-layer 362 lines 12 kB view raw
1import { Hono } from "hono"; 2import { setCookie, getCookie, deleteCookie } from "hono/cookie"; 3import { randomBytes } from "crypto"; 4import { sql } from "drizzle-orm"; 5import { Agent } from "@atproto/api"; 6import type { AppContext } from "../lib/app-context.js"; 7import { restoreOAuthSession } from "../lib/session.js"; 8import { createMembershipForUser } from "../lib/membership.js"; 9import { isProgrammingError } from "../lib/errors.js"; 10import { users } from "@atbb/db"; 11 12/** 13 * Authentication routes for OAuth flow using @atproto/oauth-client-node. 14 * 15 * Flow: 16 * 1. GET /api/auth/login?handle=user.bsky.social → Library resolves PDS and redirects 17 * 2. User approves at their PDS → PDS redirects to /api/auth/callback 18 * 3. GET /api/auth/callback?code=...&state=... → Library exchanges code for tokens 19 * 4. GET /api/auth/session → Check current session 20 * 5. GET /api/auth/logout → Clear session and revoke tokens 21 */ 22export function createAuthRoutes(ctx: AppContext) { 23 const app = new Hono(); 24 25 /** 26 * GET /api/auth/login?handle=user.bsky.social 27 * 28 * Initiate OAuth flow using the official library. 29 * The library handles: 30 * - Multi-PDS handle resolution (DNS, .well-known, DID documents) 31 * - PKCE generation and validation 32 * - State generation and CSRF protection 33 * - DPoP key generation 34 */ 35 app.get("/login", async (c) => { 36 const handle = c.req.query("handle"); 37 38 if (!handle) { 39 return c.json({ error: "Missing required parameter: handle" }, 400); 40 } 41 42 try { 43 // Generate a random state for our own use (optional, library manages its own state) 44 const state = randomBytes(16).toString("base64url"); 45 46 // Library handles all OAuth complexities: 47 // - Resolve handle to DID and PDS 48 // - Generate PKCE verifier and challenge 49 // - Build authorization URL with all required parameters 50 const authUrl = await ctx.oauthClient.authorize(handle, { 51 state, 52 }); 53 54 ctx.logger.info("OAuth login initiated", { handle }); 55 56 // Redirect to PDS authorization endpoint 57 return c.redirect(authUrl.toString()); 58 } catch (error) { 59 // Distinguish client errors (invalid handle, resolution failure) from server errors 60 const isClientError = error instanceof Error && ( 61 error.message.includes("Invalid handle") || 62 error.message.includes("not found") || 63 error.message.includes("resolve") || 64 error.message.includes("invalid") 65 ); 66 67 ctx.logger.error("Failed to initiate OAuth login", { 68 operation: "GET /api/auth/login", 69 handle, 70 isClientError, 71 error: error instanceof Error ? error.message : String(error), 72 }); 73 74 if (isClientError) { 75 return c.json( 76 { 77 error: error instanceof Error 78 ? error.message 79 : "Invalid handle or unable to find your PDS.", 80 }, 81 400 82 ); 83 } 84 85 return c.json( 86 { 87 error: "Failed to initiate login. Please try again.", 88 ...(process.env.NODE_ENV !== "production" && { 89 details: error instanceof Error ? error.message : String(error), 90 }), 91 }, 92 500 93 ); 94 } 95 }); 96 97 /** 98 * GET /api/auth/callback?code=...&state=...&iss=... 99 * 100 * OAuth callback from PDS. Library handles: 101 * - State validation (CSRF protection) 102 * - PKCE verification 103 * - Token exchange with DPoP 104 * - Session storage 105 */ 106 app.get("/callback", async (c) => { 107 const error = c.req.query("error"); 108 109 // Handle user denial 110 if (error === "access_denied") { 111 ctx.logger.info("OAuth callback denied"); 112 113 return c.redirect( 114 `/?error=access_denied&message=${encodeURIComponent("You denied access to the forum.")}` 115 ); 116 } 117 118 try { 119 // Parse callback parameters 120 const params = new URLSearchParams(c.req.url.split("?")[1] || ""); 121 122 // Library handles all callback processing: 123 // - Validate state and PKCE 124 // - Exchange code for tokens with DPoP 125 // - Store session in sessionStore 126 const { session, state: _state } = await ctx.oauthClient.callback(params); 127 128 // Fetch user profile to get handle 129 let handle: string | undefined; 130 try { 131 const agent = new Agent(session); 132 const profile = await agent.getProfile({ actor: session.did }); 133 handle = profile.data.handle; 134 } catch (error) { 135 // Handle fetch is critical - fail the login if we can't get it 136 ctx.logger.error("Failed to fetch user handle during callback - failing login", { 137 operation: "GET /api/auth/callback", 138 did: session.did, 139 error: error instanceof Error ? error.message : String(error), 140 }); 141 return c.json( 142 { 143 error: "Failed to retrieve your profile. Please try again.", 144 ...(process.env.NODE_ENV !== "production" && { 145 details: error instanceof Error ? error.message : String(error), 146 }), 147 }, 148 500 149 ); 150 } 151 152 ctx.logger.info("OAuth callback success", { did: session.did, handle }); 153 154 // Persist handle to users table so posts display handles instead of DIDs. 155 // Non-fatal: login completes even if this fails. 156 if (!handle) { 157 ctx.logger.warn("Persisting null handle — user will appear as DID in posts", { 158 operation: "GET /api/auth/callback", 159 did: session.did, 160 }); 161 } 162 try { 163 await ctx.db 164 .insert(users) 165 .values({ did: session.did, handle: handle ?? null, indexedAt: new Date() }) 166 .onConflictDoUpdate({ 167 target: users.did, 168 set: { handle: sql`COALESCE(${handle ?? null}, ${users.handle})` }, 169 }); 170 } catch (error) { 171 if (isProgrammingError(error)) throw error; 172 ctx.logger.warn("Failed to persist user handle during login", { 173 operation: "GET /api/auth/callback", 174 did: session.did, 175 error: error instanceof Error ? error.message : String(error), 176 }); 177 } 178 179 // Attempt to create membership record 180 try { 181 const agent = new Agent(session); 182 const result = await createMembershipForUser(ctx, agent, session.did); 183 184 if (result.created) { 185 ctx.logger.info("OAuth callback membership created", { did: session.did, uri: result.uri }); 186 } else { 187 ctx.logger.info("OAuth callback membership exists", { did: session.did }); 188 } 189 } catch (error) { 190 // CRITICAL: Don't fail login if membership creation fails 191 ctx.logger.warn("OAuth callback membership failed", { did: session.did, error: error instanceof Error ? error.message : String(error) }); 192 // Continue with login flow 193 } 194 195 // Create a cookie-based session mapping to the OAuth session 196 const cookieToken = randomBytes(32).toString("base64url"); 197 const ttlSeconds = ctx.config.sessionTtlDays * 24 * 60 * 60; 198 const expiresAt = new Date(Date.now() + ttlSeconds * 1000); 199 200 ctx.cookieSessionStore.set(cookieToken, { 201 did: session.did, 202 handle, 203 expiresAt, 204 createdAt: new Date(), 205 }); 206 207 // Set HTTP-only cookie 208 setCookie(c, "atbb_session", cookieToken, { 209 httpOnly: true, 210 secure: process.env.NODE_ENV === "production", 211 sameSite: "Lax", 212 maxAge: ttlSeconds, 213 path: "/", 214 }); 215 216 // Redirect to homepage 217 return c.redirect("/"); 218 } catch (error) { 219 // Check if this is a security validation failure (CSRF, PKCE) 220 const isSecurityError = error instanceof Error && ( 221 error.message.includes("state") || 222 error.message.includes("PKCE") || 223 error.message.includes("CSRF") || 224 error.message.includes("invalid") 225 ); 226 227 if (isSecurityError) { 228 // Log security validation failures with higher severity 229 ctx.logger.error("OAuth callback security validation failed", { 230 operation: "GET /api/auth/callback", 231 securityError: true, 232 error: error instanceof Error ? error.message : String(error), 233 }); 234 return c.json( 235 { 236 error: "Security validation failed. Please try logging in again.", 237 }, 238 400 239 ); 240 } 241 242 // Server error (token exchange failed, network issues, etc.) 243 ctx.logger.error("Failed to complete OAuth callback", { 244 operation: "GET /api/auth/callback", 245 error: error instanceof Error ? error.message : String(error), 246 }); 247 248 return c.json( 249 { 250 error: "Failed to complete login. Please try again.", 251 ...(process.env.NODE_ENV !== "production" && { 252 details: error instanceof Error ? error.message : String(error), 253 }), 254 }, 255 500 256 ); 257 } 258 }); 259 260 /** 261 * GET /api/auth/session 262 * 263 * Check current authentication status. 264 * Restores OAuth session from library's store (with automatic token refresh). 265 */ 266 app.get("/session", async (c) => { 267 const cookieToken = getCookie(c, "atbb_session"); 268 269 if (!cookieToken) { 270 return c.json({ authenticated: false }, 401); 271 } 272 273 try { 274 const result = await restoreOAuthSession(ctx, cookieToken); 275 276 if (!result) { 277 deleteCookie(c, "atbb_session"); 278 return c.json({ authenticated: false }, 401); 279 } 280 281 const { oauthSession, cookieSession } = result; 282 283 // Get token info (library handles expiration checks and refresh) 284 const tokenInfo = await oauthSession.getTokenInfo(); 285 286 return c.json({ 287 authenticated: true, 288 did: oauthSession.did, 289 handle: cookieSession.handle || oauthSession.did, 290 sub: tokenInfo.sub, 291 }); 292 } catch (error) { 293 // restoreOAuthSession now throws on unexpected errors only 294 // This is a genuine server error (network failure, etc.) 295 ctx.logger.error("Unexpected error during session check", { 296 operation: "GET /api/auth/session", 297 error: error instanceof Error ? error.message : String(error), 298 }); 299 300 // Don't delete cookie - might be transient error 301 return c.json( 302 { 303 error: "Failed to check session. Please try again.", 304 ...(process.env.NODE_ENV !== "production" && { 305 details: error instanceof Error ? error.message : String(error), 306 }), 307 }, 308 500 309 ); 310 } 311 }); 312 313 /** 314 * GET /api/auth/logout 315 * 316 * Clear session and revoke tokens. 317 * Library handles token revocation at the PDS. 318 */ 319 app.get("/logout", async (c) => { 320 const cookieToken = getCookie(c, "atbb_session"); 321 322 if (cookieToken) { 323 const cookieSession = ctx.cookieSessionStore.get(cookieToken); 324 325 if (cookieSession) { 326 try { 327 // Restore OAuth session 328 const oauthSession = await ctx.oauthClient.restore(cookieSession.did); 329 330 // Revoke tokens at the PDS 331 await oauthSession.signOut(); 332 333 ctx.logger.info("OAuth logout", { did: cookieSession.did }); 334 } catch (error) { 335 ctx.logger.error("Failed to revoke tokens during logout", { 336 operation: "GET /api/auth/logout", 337 did: cookieSession.did, 338 error: error instanceof Error ? error.message : String(error), 339 }); 340 // Continue with local cleanup even if revocation fails 341 } 342 343 // Delete cookie session 344 ctx.cookieSessionStore.delete(cookieToken); 345 } 346 } 347 348 // Clear cookie 349 deleteCookie(c, "atbb_session"); 350 351 // Return success or redirect 352 const redirect = c.req.query("redirect"); 353 // Validate redirect to prevent open redirect vulnerability 354 if (redirect && redirect.startsWith("/") && !redirect.startsWith("//")) { 355 return c.redirect(redirect); 356 } 357 358 return c.json({ success: true, message: "Logged out successfully" }); 359 }); 360 361 return app; 362}