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
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}