Barazo AppView backend
barazo.forum
1import type { FastifyReply, FastifyRequest } from 'fastify'
2import type { SessionService } from './session.js'
3import type { DidDocumentVerifier, DidVerificationResult } from '../lib/did-document-verifier.js'
4import type { Logger } from '../lib/logger.js'
5
6// ---------------------------------------------------------------------------
7// Types
8// ---------------------------------------------------------------------------
9
10/** User info attached to authenticated requests. */
11export interface RequestUser {
12 did: string
13 handle: string
14 sid: string
15}
16
17/** Auth middleware hooks returned by createAuthMiddleware. */
18export interface AuthMiddleware {
19 requireAuth: (request: FastifyRequest, reply: FastifyReply) => Promise<void>
20 optionalAuth: (request: FastifyRequest, reply: FastifyReply) => Promise<void>
21}
22
23// ---------------------------------------------------------------------------
24// Extend Fastify's request type
25// ---------------------------------------------------------------------------
26
27declare module 'fastify' {
28 interface FastifyRequest {
29 /** Authenticated user info (set by requireAuth or optionalAuth middleware). */
30 user?: RequestUser
31 }
32}
33
34// ---------------------------------------------------------------------------
35// Helpers
36// ---------------------------------------------------------------------------
37
38/**
39 * Extract Bearer token from the Authorization header.
40 * Returns the token string if valid, or undefined if missing/malformed.
41 */
42function extractBearerToken(request: FastifyRequest): string | undefined {
43 const authHeader = request.headers.authorization
44 if (!authHeader || !authHeader.startsWith('Bearer ')) {
45 return undefined
46 }
47
48 const token = authHeader.slice('Bearer '.length)
49 if (token.length === 0) {
50 return undefined
51 }
52
53 return token
54}
55
56/** Check if a DID verification failure is a transient resolution error. */
57function isResolutionFailure(result: DidVerificationResult): boolean {
58 return !result.active && result.reason === 'DID document resolution failed'
59}
60
61// ---------------------------------------------------------------------------
62// Factory
63// ---------------------------------------------------------------------------
64
65/**
66 * Create auth middleware hooks for Fastify route preHandler.
67 *
68 * @param sessionService - Session service for token validation
69 * @param didVerifier - DID document verifier for checking DID status
70 * @param logger - Pino logger instance
71 * @returns Object with requireAuth and optionalAuth hooks
72 */
73export function createAuthMiddleware(
74 sessionService: SessionService,
75 didVerifier: DidDocumentVerifier,
76 logger: Logger
77): AuthMiddleware {
78 /**
79 * Require authentication. Returns 401 if no valid token, 502 if service error.
80 * On success, sets `request.user` with the authenticated user info.
81 * Verifies the DID document is still active via the PLC directory (cached).
82 */
83 async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise<void> {
84 const token = extractBearerToken(request)
85 if (token === undefined) {
86 await reply.status(401).send({ error: 'Authentication required' })
87 return
88 }
89
90 try {
91 const session = await sessionService.validateAccessToken(token)
92 if (!session) {
93 await reply.status(401).send({ error: 'Invalid or expired token' })
94 return
95 }
96
97 // Verify DID document is still active
98 const didResult = await didVerifier.verify(session.did)
99 if (!didResult.active) {
100 if (isResolutionFailure(didResult)) {
101 // Transient failure with no cached data -- fail closed
102 logger.error({ did: session.did, reason: didResult.reason }, 'DID verification failed')
103 await reply.status(502).send({ error: 'Service temporarily unavailable' })
104 } else {
105 // DID is definitively deactivated/tombstoned/not found
106 logger.warn({ did: session.did, reason: didResult.reason }, 'DID is no longer active')
107 await reply.status(401).send({ error: 'DID is no longer active' })
108 }
109 return
110 }
111
112 request.user = {
113 did: session.did,
114 handle: session.handle,
115 sid: session.sid,
116 }
117 } catch (err: unknown) {
118 logger.error({ err }, 'Token validation failed in requireAuth')
119 await reply.status(502).send({ error: 'Service temporarily unavailable' })
120 }
121 }
122
123 /**
124 * Optional authentication. If a valid token is present, sets `request.user`.
125 * If no token, invalid token, DID inactive, or service error: continues
126 * with `request.user` undefined.
127 */
128 async function optionalAuth(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
129 const token = extractBearerToken(request)
130 if (token === undefined) {
131 return
132 }
133
134 try {
135 const session = await sessionService.validateAccessToken(token)
136 if (!session) {
137 return
138 }
139
140 // Verify DID document is still active
141 const didResult = await didVerifier.verify(session.did)
142 if (!didResult.active) {
143 logger.warn(
144 { did: session.did, reason: didResult.reason },
145 'DID verification failed in optionalAuth, continuing unauthenticated'
146 )
147 return
148 }
149
150 request.user = {
151 did: session.did,
152 handle: session.handle,
153 sid: session.sid,
154 }
155 } catch (err: unknown) {
156 logger.warn({ err }, 'Token validation failed in optionalAuth, continuing unauthenticated')
157 }
158 }
159
160 return { requireAuth, optionalAuth }
161}