import { Hono } from "hono"; import { setCookie, getCookie, deleteCookie } from "hono/cookie"; import { randomBytes } from "crypto"; import { sql } from "drizzle-orm"; import { Agent } from "@atproto/api"; import type { AppContext } from "../lib/app-context.js"; import { restoreOAuthSession } from "../lib/session.js"; import { createMembershipForUser } from "../lib/membership.js"; import { isProgrammingError } from "../lib/errors.js"; import { users } from "@atbb/db"; /** * Authentication routes for OAuth flow using @atproto/oauth-client-node. * * Flow: * 1. GET /api/auth/login?handle=user.bsky.social → Library resolves PDS and redirects * 2. User approves at their PDS → PDS redirects to /api/auth/callback * 3. GET /api/auth/callback?code=...&state=... → Library exchanges code for tokens * 4. GET /api/auth/session → Check current session * 5. GET /api/auth/logout → Clear session and revoke tokens */ export function createAuthRoutes(ctx: AppContext) { const app = new Hono(); /** * GET /api/auth/login?handle=user.bsky.social * * Initiate OAuth flow using the official library. * The library handles: * - Multi-PDS handle resolution (DNS, .well-known, DID documents) * - PKCE generation and validation * - State generation and CSRF protection * - DPoP key generation */ app.get("/login", async (c) => { const handle = c.req.query("handle"); if (!handle) { return c.json({ error: "Missing required parameter: handle" }, 400); } try { // Generate a random state for our own use (optional, library manages its own state) const state = randomBytes(16).toString("base64url"); // Library handles all OAuth complexities: // - Resolve handle to DID and PDS // - Generate PKCE verifier and challenge // - Build authorization URL with all required parameters const authUrl = await ctx.oauthClient.authorize(handle, { state, }); ctx.logger.info("OAuth login initiated", { handle }); // Redirect to PDS authorization endpoint return c.redirect(authUrl.toString()); } catch (error) { // Distinguish client errors (invalid handle, resolution failure) from server errors const isClientError = error instanceof Error && ( error.message.includes("Invalid handle") || error.message.includes("not found") || error.message.includes("resolve") || error.message.includes("invalid") ); ctx.logger.error("Failed to initiate OAuth login", { operation: "GET /api/auth/login", handle, isClientError, error: error instanceof Error ? error.message : String(error), }); if (isClientError) { return c.json( { error: error instanceof Error ? error.message : "Invalid handle or unable to find your PDS.", }, 400 ); } return c.json( { error: "Failed to initiate login. Please try again.", ...(process.env.NODE_ENV !== "production" && { details: error instanceof Error ? error.message : String(error), }), }, 500 ); } }); /** * GET /api/auth/callback?code=...&state=...&iss=... * * OAuth callback from PDS. Library handles: * - State validation (CSRF protection) * - PKCE verification * - Token exchange with DPoP * - Session storage */ app.get("/callback", async (c) => { const error = c.req.query("error"); // Handle user denial if (error === "access_denied") { ctx.logger.info("OAuth callback denied"); return c.redirect( `/?error=access_denied&message=${encodeURIComponent("You denied access to the forum.")}` ); } try { // Parse callback parameters const params = new URLSearchParams(c.req.url.split("?")[1] || ""); // Library handles all callback processing: // - Validate state and PKCE // - Exchange code for tokens with DPoP // - Store session in sessionStore const { session, state: _state } = await ctx.oauthClient.callback(params); // Fetch user profile to get handle let handle: string | undefined; try { const agent = new Agent(session); const profile = await agent.getProfile({ actor: session.did }); handle = profile.data.handle; } catch (error) { // Handle fetch is critical - fail the login if we can't get it ctx.logger.error("Failed to fetch user handle during callback - failing login", { operation: "GET /api/auth/callback", did: session.did, error: error instanceof Error ? error.message : String(error), }); return c.json( { error: "Failed to retrieve your profile. Please try again.", ...(process.env.NODE_ENV !== "production" && { details: error instanceof Error ? error.message : String(error), }), }, 500 ); } ctx.logger.info("OAuth callback success", { did: session.did, handle }); // Persist handle to users table so posts display handles instead of DIDs. // Non-fatal: login completes even if this fails. if (!handle) { ctx.logger.warn("Persisting null handle — user will appear as DID in posts", { operation: "GET /api/auth/callback", did: session.did, }); } try { await ctx.db .insert(users) .values({ did: session.did, handle: handle ?? null, indexedAt: new Date() }) .onConflictDoUpdate({ target: users.did, set: { handle: sql`COALESCE(${handle ?? null}, ${users.handle})` }, }); } catch (error) { if (isProgrammingError(error)) throw error; ctx.logger.warn("Failed to persist user handle during login", { operation: "GET /api/auth/callback", did: session.did, error: error instanceof Error ? error.message : String(error), }); } // Attempt to create membership record try { const agent = new Agent(session); const result = await createMembershipForUser(ctx, agent, session.did); if (result.created) { ctx.logger.info("OAuth callback membership created", { did: session.did, uri: result.uri }); } else { ctx.logger.info("OAuth callback membership exists", { did: session.did }); } } catch (error) { // CRITICAL: Don't fail login if membership creation fails ctx.logger.warn("OAuth callback membership failed", { did: session.did, error: error instanceof Error ? error.message : String(error) }); // Continue with login flow } // Create a cookie-based session mapping to the OAuth session const cookieToken = randomBytes(32).toString("base64url"); const ttlSeconds = ctx.config.sessionTtlDays * 24 * 60 * 60; const expiresAt = new Date(Date.now() + ttlSeconds * 1000); ctx.cookieSessionStore.set(cookieToken, { did: session.did, handle, expiresAt, createdAt: new Date(), }); // Set HTTP-only cookie setCookie(c, "atbb_session", cookieToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "Lax", maxAge: ttlSeconds, path: "/", }); // Redirect to homepage return c.redirect("/"); } catch (error) { // Check if this is a security validation failure (CSRF, PKCE) const isSecurityError = error instanceof Error && ( error.message.includes("state") || error.message.includes("PKCE") || error.message.includes("CSRF") || error.message.includes("invalid") ); if (isSecurityError) { // Log security validation failures with higher severity ctx.logger.error("OAuth callback security validation failed", { operation: "GET /api/auth/callback", securityError: true, error: error instanceof Error ? error.message : String(error), }); return c.json( { error: "Security validation failed. Please try logging in again.", }, 400 ); } // Server error (token exchange failed, network issues, etc.) ctx.logger.error("Failed to complete OAuth callback", { operation: "GET /api/auth/callback", error: error instanceof Error ? error.message : String(error), }); return c.json( { error: "Failed to complete login. Please try again.", ...(process.env.NODE_ENV !== "production" && { details: error instanceof Error ? error.message : String(error), }), }, 500 ); } }); /** * GET /api/auth/session * * Check current authentication status. * Restores OAuth session from library's store (with automatic token refresh). */ app.get("/session", async (c) => { const cookieToken = getCookie(c, "atbb_session"); if (!cookieToken) { return c.json({ authenticated: false }, 401); } try { const result = await restoreOAuthSession(ctx, cookieToken); if (!result) { deleteCookie(c, "atbb_session"); return c.json({ authenticated: false }, 401); } const { oauthSession, cookieSession } = result; // Get token info (library handles expiration checks and refresh) const tokenInfo = await oauthSession.getTokenInfo(); return c.json({ authenticated: true, did: oauthSession.did, handle: cookieSession.handle || oauthSession.did, sub: tokenInfo.sub, }); } catch (error) { // restoreOAuthSession now throws on unexpected errors only // This is a genuine server error (network failure, etc.) ctx.logger.error("Unexpected error during session check", { operation: "GET /api/auth/session", error: error instanceof Error ? error.message : String(error), }); // Don't delete cookie - might be transient error return c.json( { error: "Failed to check session. Please try again.", ...(process.env.NODE_ENV !== "production" && { details: error instanceof Error ? error.message : String(error), }), }, 500 ); } }); /** * GET /api/auth/logout * * Clear session and revoke tokens. * Library handles token revocation at the PDS. */ app.get("/logout", async (c) => { const cookieToken = getCookie(c, "atbb_session"); if (cookieToken) { const cookieSession = ctx.cookieSessionStore.get(cookieToken); if (cookieSession) { try { // Restore OAuth session const oauthSession = await ctx.oauthClient.restore(cookieSession.did); // Revoke tokens at the PDS await oauthSession.signOut(); ctx.logger.info("OAuth logout", { did: cookieSession.did }); } catch (error) { ctx.logger.error("Failed to revoke tokens during logout", { operation: "GET /api/auth/logout", did: cookieSession.did, error: error instanceof Error ? error.message : String(error), }); // Continue with local cleanup even if revocation fails } // Delete cookie session ctx.cookieSessionStore.delete(cookieToken); } } // Clear cookie deleteCookie(c, "atbb_session"); // Return success or redirect const redirect = c.req.query("redirect"); // Validate redirect to prevent open redirect vulnerability if (redirect && redirect.startsWith("/") && !redirect.startsWith("//")) { return c.redirect(redirect); } return c.json({ success: true, message: "Logged out successfully" }); }); return app; }