ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
16
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: oauth endpoints to hono

byarielm.fyi fcb5502e 50bace61

verified
+411
+55
packages/api/src/middleware/auth.ts
··· 1 + import { createMiddleware } from "hono/factory"; 2 + import { getCookie } from "hono/cookie"; 3 + import { userSessionStore } from "../infrastructure/oauth"; 4 + import { AuthenticationError, ERROR_MESSAGES } from "../errors"; 5 + 6 + /** 7 + * Authentication middleware for Hono 8 + * Validates session cookies and adds user context to request 9 + */ 10 + export const authMiddleware = createMiddleware(async (c, next) => { 11 + // Extract session from cookies 12 + const sessionId = 13 + getCookie(c, "atlast_session") || getCookie(c, "atlast_session_dev"); 14 + 15 + if (!sessionId) { 16 + throw new AuthenticationError(ERROR_MESSAGES.NO_SESSION_COOKIE); 17 + } 18 + 19 + // Validate session with database 20 + const userSession = await userSessionStore.get(sessionId); 21 + if (!userSession) { 22 + throw new AuthenticationError(ERROR_MESSAGES.INVALID_SESSION); 23 + } 24 + 25 + // Add session context to request 26 + c.set("sessionId", sessionId); 27 + c.set("did", userSession.did); 28 + 29 + await next(); 30 + }); 31 + 32 + /** 33 + * Extract session ID from cookies without validation 34 + * Returns null if no session cookie exists 35 + */ 36 + export function extractSessionId(c: { 37 + req: { header: (name: string) => string | undefined }; 38 + }): string | null { 39 + const cookieHeader = c.req.header("cookie"); 40 + if (!cookieHeader) return null; 41 + 42 + // Simple cookie parsing (no external dependency needed for this case) 43 + const cookies = cookieHeader.split(";").reduce( 44 + (acc, cookie) => { 45 + const [key, value] = cookie.trim().split("="); 46 + if (key && value) { 47 + acc[key] = value; 48 + } 49 + return acc; 50 + }, 51 + {} as Record<string, string>, 52 + ); 53 + 54 + return cookies.atlast_session || cookies.atlast_session_dev || null; 55 + }
+1
packages/api/src/middleware/index.ts
··· 1 1 export * from "./error"; 2 + export * from "./auth";
+283
packages/api/src/routes/auth.ts
··· 1 + import { Hono } from "hono"; 2 + import { setCookie } from "hono/cookie"; 3 + import { createOAuthClient, getOAuthConfig, CONFIG } from "../infrastructure/oauth"; 4 + import { userSessionStore } from "../infrastructure/oauth/stores"; 5 + import { ValidationError, ApiError } from "../errors"; 6 + import { createSecureSessionData } from "../utils/session-security"; 7 + import { extractSessionId } from "../middleware/auth"; 8 + import * as crypto from "crypto"; 9 + 10 + const auth = new Hono(); 11 + 12 + // OAuth public key for JWKS endpoint 13 + const PUBLIC_JWK = { 14 + kty: "EC", 15 + x: "3sVbr4xwN7UtmG1L19vL0x9iN-FRcl7p-Wja_xPbhhk", 16 + y: "Y1XKDaAyDwijp8aEIGHmO46huKjajSQH2cbfpWaWpQ4", 17 + crv: "P-256", 18 + kid: CONFIG.OAUTH_KEY_ID, 19 + use: "sig", 20 + alg: "ES256", 21 + }; 22 + 23 + /** 24 + * POST /api/auth/oauth-start 25 + * Initiates OAuth flow with AT Protocol provider 26 + */ 27 + auth.post("/oauth-start", async (c) => { 28 + const body = await c.req.json<{ login_hint?: string }>(); 29 + const loginHint = body.login_hint; 30 + 31 + if (!loginHint) { 32 + throw new ValidationError("login_hint (handle or DID) is required"); 33 + } 34 + 35 + console.log("[oauth-start] Starting OAuth flow for:", loginHint); 36 + 37 + try { 38 + const client = await createOAuthClient(c); 39 + console.log("[oauth-start] OAuth client created successfully"); 40 + 41 + const authUrl = await client.authorize(loginHint, { 42 + scope: CONFIG.OAUTH_SCOPES, 43 + }); 44 + console.log("[oauth-start] Generated auth URL successfully"); 45 + 46 + return c.json({ 47 + success: true, 48 + data: { url: authUrl.toString() }, 49 + }); 50 + } catch (error) { 51 + console.error( 52 + "[oauth-start] Failed:", 53 + error instanceof Error ? error.message : String(error), 54 + ); 55 + throw new ApiError( 56 + "Failed to start OAuth flow", 57 + 500, 58 + error instanceof Error ? error.message : "Unknown error", 59 + ); 60 + } 61 + }); 62 + 63 + /** 64 + * GET /api/auth/oauth-callback 65 + * OAuth callback endpoint - handles authorization code exchange 66 + */ 67 + auth.get("/oauth-callback", async (c) => { 68 + const config = getOAuthConfig(c); 69 + const isDev = config.clientType === "loopback"; 70 + 71 + // Determine frontend URL 72 + const currentUrl = config.redirectUri.replace("/api/auth/oauth-callback", ""); 73 + 74 + // Parse query parameters 75 + const url = new URL(c.req.url); 76 + const code = url.searchParams.get("code"); 77 + const state = url.searchParams.get("state"); 78 + 79 + console.log( 80 + "[oauth-callback] Processing callback - Mode:", 81 + isDev ? "loopback" : "production", 82 + ); 83 + 84 + if (!code || !state) { 85 + return c.redirect(`${currentUrl}/?error=Missing OAuth parameters`); 86 + } 87 + 88 + try { 89 + const client = await createOAuthClient(c); 90 + const result = await client.callback(url.searchParams); 91 + 92 + console.log( 93 + "[oauth-callback] Successfully authenticated DID:", 94 + result.session.did, 95 + ); 96 + 97 + // Create user session 98 + const sessionId = crypto.randomUUID(); 99 + const secureData = createSecureSessionData(c, result.session.did); 100 + 101 + await userSessionStore.set(sessionId, { 102 + did: secureData.did, 103 + fingerprint: JSON.stringify(secureData.fingerprint), 104 + }); 105 + 106 + console.log("[oauth-callback] Created user session:", sessionId); 107 + 108 + // Determine cookie configuration 109 + const isSecure = currentUrl.startsWith("https://"); 110 + const cookieName = isDev ? "atlast_session_dev" : "atlast_session"; 111 + 112 + // Set secure cookie 113 + setCookie(c, cookieName, sessionId, { 114 + httpOnly: true, 115 + sameSite: "Lax", 116 + maxAge: 2592000, // 30 days 117 + path: "/", 118 + secure: isSecure, 119 + }); 120 + 121 + console.log("[oauth-callback] Set cookie:", cookieName); 122 + 123 + // Redirect back to frontend with session indicator 124 + return c.redirect(`${currentUrl}/?session=${sessionId}`); 125 + } catch (error) { 126 + console.error("[oauth-callback] Failed:", error); 127 + return c.redirect(`${currentUrl}/?error=Authentication failed`); 128 + } 129 + }); 130 + 131 + /** 132 + * GET /api/auth/session 133 + * Get current user session information 134 + */ 135 + auth.get("/session", async (c) => { 136 + const sessionId = c.req.query("session") || extractSessionId(c); 137 + 138 + if (!sessionId) { 139 + return c.json( 140 + { 141 + success: false, 142 + error: "No session cookie", 143 + }, 144 + 401, 145 + ); 146 + } 147 + 148 + const userSession = await userSessionStore.get(sessionId); 149 + if (!userSession) { 150 + return c.json( 151 + { 152 + success: false, 153 + error: "Invalid or expired session", 154 + }, 155 + 401, 156 + ); 157 + } 158 + 159 + // For now, return basic session info 160 + // Later: integrate with AT Protocol to fetch profile 161 + return c.json({ 162 + success: true, 163 + data: { 164 + did: userSession.did, 165 + sessionId, 166 + }, 167 + }); 168 + }); 169 + 170 + /** 171 + * POST /api/auth/logout 172 + * Clear user session and cookies 173 + */ 174 + auth.post("/logout", async (c) => { 175 + console.log("[logout] Starting logout process..."); 176 + 177 + const sessionId = extractSessionId(c); 178 + console.log("[logout] Session ID from cookie:", sessionId); 179 + 180 + if (sessionId) { 181 + await userSessionStore.del(sessionId); 182 + console.log("[logout] Successfully deleted session:", sessionId); 183 + } 184 + 185 + const config = getOAuthConfig(c); 186 + const isDev = config.clientType === "loopback"; 187 + const cookieName = isDev ? "atlast_session_dev" : "atlast_session"; 188 + 189 + // Clear cookie by setting Max-Age to 0 190 + setCookie(c, cookieName, "", { 191 + httpOnly: true, 192 + sameSite: "Lax", 193 + maxAge: 0, 194 + path: "/", 195 + secure: !isDev, 196 + }); 197 + 198 + return c.json({ success: true }); 199 + }); 200 + 201 + /** 202 + * GET /api/auth/client-metadata.json 203 + * OAuth client metadata for AT Protocol discovery 204 + */ 205 + auth.get("/client-metadata.json", (c) => { 206 + const host = c.req.header("host"); 207 + const forwardedHost = c.req.header("x-forwarded-host"); 208 + const requestHost = forwardedHost || host; 209 + 210 + if (!requestHost) { 211 + return c.json({ error: "Missing host header" }, 400); 212 + } 213 + 214 + // Check if this is a loopback/development request 215 + const isLoopback = 216 + requestHost.startsWith("127.0.0.1") || 217 + requestHost.startsWith("[::1]") || 218 + requestHost.includes("localhost"); 219 + 220 + if (isLoopback) { 221 + // For loopback clients, return minimal metadata 222 + const appUrl = `http://${requestHost}`; 223 + const redirectUri = `${appUrl}/api/auth/oauth-callback`; 224 + 225 + return c.json({ 226 + client_id: appUrl, 227 + client_name: "ATlast (Local Dev)", 228 + client_uri: appUrl, 229 + redirect_uris: [redirectUri], 230 + scope: CONFIG.OAUTH_SCOPES, 231 + grant_types: ["authorization_code", "refresh_token"], 232 + response_types: ["code"], 233 + application_type: "web", 234 + token_endpoint_auth_method: "none", 235 + dpop_bound_access_tokens: true, 236 + }); 237 + } 238 + 239 + // Production: Confidential client metadata 240 + const redirectUri = `https://${requestHost}/api/auth/oauth-callback`; 241 + const appUrl = `https://${requestHost}`; 242 + const jwksUri = `https://${requestHost}/api/auth/jwks`; 243 + const clientId = `https://${requestHost}/api/auth/client-metadata.json`; 244 + const logoUri = `https://${requestHost}/favicon.svg`; 245 + 246 + return c.json( 247 + { 248 + client_id: clientId, 249 + client_name: "ATlast", 250 + client_uri: appUrl, 251 + redirect_uris: [redirectUri], 252 + logo_uri: logoUri, 253 + scope: CONFIG.OAUTH_SCOPES, 254 + grant_types: ["authorization_code", "refresh_token"], 255 + response_types: ["code"], 256 + application_type: "web", 257 + token_endpoint_auth_method: "private_key_jwt", 258 + token_endpoint_auth_signing_alg: "ES256", 259 + dpop_bound_access_tokens: true, 260 + jwks_uri: jwksUri, 261 + }, 262 + 200, 263 + { 264 + "Cache-Control": "no-store", 265 + }, 266 + ); 267 + }); 268 + 269 + /** 270 + * GET /api/auth/jwks 271 + * JSON Web Key Set for OAuth client authentication 272 + */ 273 + auth.get("/jwks", (c) => { 274 + return c.json( 275 + { keys: [PUBLIC_JWK] }, 276 + 200, 277 + { 278 + "Cache-Control": "public, max-age=3600", 279 + }, 280 + ); 281 + }); 282 + 283 + export default auth;
+4
packages/api/src/server.ts
··· 4 4 import { secureHeaders } from "hono/secure-headers"; 5 5 import { logger } from "hono/logger"; 6 6 import { errorHandler } from "./middleware/error"; 7 + import authRoutes from "./routes/auth"; 7 8 8 9 const app = new Hono(); 9 10 ··· 42 43 credentials: true, 43 44 }), 44 45 ); 46 + 47 + // Mount routes 48 + app.route("/api/auth", authRoutes); 45 49 46 50 // Health check endpoint 47 51 app.get("/api/health", (c) => {
+1
packages/api/src/utils/index.ts
··· 2 2 export * from "./string.utils"; 3 3 export * from "./encryption.utils"; 4 4 export * from "./validation.utils"; 5 + export * from "./session-security";
+67
packages/api/src/utils/session-security.ts
··· 1 + import { Context } from "hono"; 2 + 3 + export interface SessionFingerprint { 4 + userAgent: string; 5 + ipAddress: string; 6 + createdAt: number; 7 + } 8 + 9 + /** 10 + * Session Security Service 11 + * Provides session replay protection and fingerprinting for Hono 12 + */ 13 + export class SessionSecurityService { 14 + /** 15 + * Generate a session fingerprint from request headers 16 + */ 17 + static generateFingerprint(c: Context): SessionFingerprint { 18 + const userAgent = c.req.header("user-agent") || "unknown"; 19 + const forwardedFor = c.req.header("x-forwarded-for"); 20 + const ipAddress = forwardedFor?.split(",")[0].trim() || c.req.header("client-ip") || "unknown"; 21 + 22 + return { 23 + userAgent, 24 + ipAddress, 25 + createdAt: Date.now(), 26 + }; 27 + } 28 + 29 + /** 30 + * Verify session fingerprint matches current request 31 + * Helps detect session hijacking 32 + */ 33 + static verifyFingerprint( 34 + stored: SessionFingerprint, 35 + current: SessionFingerprint, 36 + ): boolean { 37 + // User agent must match exactly 38 + if (stored.userAgent !== current.userAgent) { 39 + console.warn("Session fingerprint mismatch: User-Agent changed"); 40 + return false; 41 + } 42 + 43 + // IP can change (mobile networks, VPN) but log if it does 44 + if (stored.ipAddress !== current.ipAddress) { 45 + console.info( 46 + `Session IP changed: ${stored.ipAddress} -> ${current.ipAddress}`, 47 + ); 48 + // Don't fail - just log for monitoring 49 + } 50 + 51 + return true; 52 + } 53 + } 54 + 55 + /** 56 + * Add session fingerprint to new sessions 57 + * Call this in oauth-callback when creating session 58 + */ 59 + export function createSecureSessionData( 60 + c: Context, 61 + did: string, 62 + ): { did: string; fingerprint: SessionFingerprint } { 63 + return { 64 + did, 65 + fingerprint: SessionSecurityService.generateFingerprint(c), 66 + }; 67 + }