Sifa professional network API (Fastify, AT Protocol, Jetstream) sifa.id/
at main 104 lines 4.0 kB view raw
1import type { FastifyRequest, FastifyReply } from 'fastify'; 2import type { NodeOAuthClient } from '@atproto/oauth-client-node'; 3import type { OAuthSession } from '@atproto/oauth-client'; 4import * as Sentry from '@sentry/node'; 5import { eq, and, gt, sql } from 'drizzle-orm'; 6import type { Database } from '../db/index.js'; 7import { sessions, profiles } from '../db/schema/index.js'; 8import { isPermanentSessionError } from '../oauth/errors.js'; 9import './types.js'; 10 11/** 12 * Resolves a session cookie value to a DID by looking up the sessions table. 13 * Returns undefined if the cookie is missing, the session doesn't exist, or it's expired. 14 * Used by routes that optionally read the viewer (e.g., profile read). 15 */ 16export async function resolveSessionDid( 17 db: Database, 18 sessionId: string | undefined, 19): Promise<string | undefined> { 20 if (!sessionId) return undefined; 21 22 const [row] = await db 23 .select({ did: sessions.did }) 24 .from(sessions) 25 .where(and(eq(sessions.id, sessionId), gt(sessions.expiresAt, new Date()))) 26 .limit(1); 27 28 return row?.did; 29} 30 31/** 32 * Creates an auth middleware that: 33 * 1. Reads the session cookie (an opaque session ID, not a DID) 34 * 2. Looks up the session in the database to get the DID 35 * 3. Restores the OAuth session via the NodeOAuthClient 36 * 4. Attaches `request.did` and `request.session` for downstream handlers 37 */ 38export function createAuthMiddleware(oauthClient: NodeOAuthClient | null, db: Database) { 39 return async function requireAuth(request: FastifyRequest, reply: FastifyReply) { 40 const sessionId = request.cookies?.session; 41 if (!sessionId) { 42 return reply.status(401).send({ error: 'Unauthorized', message: 'Authentication required' }); 43 } 44 45 if (!oauthClient) { 46 return reply 47 .status(503) 48 .send({ error: 'ServiceUnavailable', message: 'OAuth client not available' }); 49 } 50 51 // Look up session by cookie value from sessions table 52 const did = await resolveSessionDid(db, sessionId); 53 if (!did) { 54 reply.clearCookie('session', { path: '/' }); 55 return reply.status(401).send({ error: 'SessionExpired', message: 'Please sign in again' }); 56 } 57 58 try { 59 const session = await oauthClient.restore(did); 60 request.oauthSession = session; 61 request.did = session.did; 62 63 // Update lastActiveAt at most once per hour (fire-and-forget) 64 const oneHourAgo = new Date(Date.now() - 3600_000); 65 db.update(profiles) 66 .set({ lastActiveAt: new Date() }) 67 .where( 68 sql`${profiles.did} = ${did} AND (${profiles.lastActiveAt} IS NULL OR ${profiles.lastActiveAt} < ${oneHourAgo})`, 69 ) 70 .then(() => {}) 71 .catch(() => {}); 72 } catch (err) { 73 if (isPermanentSessionError(err)) { 74 request.server.log.warn( 75 { err: err instanceof Error ? err.message : err, did }, 76 'Permanent error restoring OAuth session — clearing cookie', 77 ); 78 if (err instanceof Error) Sentry.captureException(err); 79 reply.clearCookie('session', { path: '/' }); 80 return reply.status(401).send({ error: 'SessionExpired', message: 'Please sign in again' }); 81 } 82 request.server.log.warn( 83 { err: err instanceof Error ? err.message : err, did }, 84 'Transient error restoring OAuth session — keeping cookie', 85 ); 86 return reply.status(503).send({ 87 error: 'TemporarilyUnavailable', 88 message: 'Session verification temporarily unavailable. Please retry.', 89 }); 90 } 91 }; 92} 93 94/** 95 * Extracts the authenticated DID and OAuth session from the request. 96 * Only safe to call in routes guarded by requireAuth middleware. 97 */ 98export function getAuthContext(request: FastifyRequest): { did: string; session: OAuthSession } { 99 const { did, oauthSession } = request; 100 if (!did || !oauthSession) { 101 throw new Error('getAuthContext called without auth middleware'); 102 } 103 return { did, session: oauthSession }; 104}