Sifa professional network API (Fastify, AT Protocol, Jetstream)
sifa.id/
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}