@bsv/auth-express-middleware
Version:
BSV Blockchain mutual-authentication express middleware
1,001 lines (907 loc) • 33.7 kB
text/typescript
import { Request, Response, NextFunction } from 'express'
import fs from 'node:fs'
import mime from 'mime-types'
import {
Utils,
VerifiableCertificate,
Peer,
AuthMessage,
RequestedCertificateSet,
Transport,
SessionManager,
AsyncSessionManager,
WalletInterface,
PubKeyHex
} from '@bsv/sdk'
import {
LogLevel,
isLogLevelEnabled,
getLogMethod,
writeUrlToWriter,
writeRequestHeadersToWriter,
writeHeaderPair,
writeBodyToWriter,
convertValueToArray,
makeDebugLogger
} from './authMiddlewareHelpers.js'
export type { LogLevel } from './authMiddlewareHelpers.js'
export { isLogLevelEnabled, getLogMethod } from './authMiddlewareHelpers.js'
export { writeBodyToWriter } from './authMiddlewareHelpers.js'
export interface AuthRequest extends Request {
auth?: {
identityKey: PubKeyHex
}
}
// Developers may optionally provide a handler for incoming certificates.
export interface AuthMiddlewareOptions {
wallet: WalletInterface
// Optional session store. Default is in-process synchronous `SessionManager`.
// Pass an `AsyncSessionManager` (Redis/SQL-backed, etc.) to share state
// across load-balanced instances; Peer awaits internally so both work.
sessionManager?: SessionManager | AsyncSessionManager
allowUnauthenticated?: boolean
certificatesToRequest?: RequestedCertificateSet
onCertificatesReceived?: (
senderPublicKey: string,
certs: VerifiableCertificate[],
req: AuthRequest,
res: Response,
next: NextFunction
) => void
/**
* Optional logger (e.g., console). If not provided, logging is disabled.
*/
logger?: typeof console
/**
* Optional logging level. Defaults to no logging if not provided.
* 'debug' | 'info' | 'warn' | 'error'
*
* - debug: Logs *everything*, including low-level details of the auth process.
* - info: Logs general informational messages about normal operation.
* - warn: Logs potential issues but not necessarily errors.
* - error: Logs only critical issues and errors.
*/
logLevel?: LogLevel
}
/**
* ResponseWriterWrapper buffers response data until signing is complete.
* This pattern matches the Go implementation for cleaner response handling.
*/
class ResponseWriterWrapper {
private statusCode: number = 200
private headers: Record<string, string> = {}
private body: number[] = []
private readonly originalRes: Response
private flushed: boolean = false
constructor (res: Response) {
this.originalRes = res
}
status (code: number): this {
this.statusCode = code
return this
}
set (key: string | Record<string, string>, value?: string): this {
if (typeof key === 'object' && key !== null) {
for (const [k, v] of Object.entries(key)) {
this.headers[k.toLowerCase()] = String(v)
}
} else if (typeof key === 'string' && value !== undefined) {
this.headers[key.toLowerCase()] = String(value)
}
return this
}
send (data: any): this {
this.body = convertValueToArray(data, this.headers)
return this
}
json (data: any): this {
if (!this.headers['content-type']) {
this.headers['content-type'] = 'application/json'
}
this.body = Utils.toArray(JSON.stringify(data), 'utf8')
return this
}
text (data: string): this {
if (!this.headers['content-type']) {
this.headers['content-type'] = 'text/plain'
}
this.body = Utils.toArray(data, 'utf8')
return this
}
end (): this {
// No-op for buffering, actual end happens on flush
return this
}
getStatusCode (): number {
return this.statusCode
}
getHeaders (): Record<string, string> {
return this.headers
}
getBody (): number[] {
return this.body
}
getOriginalRes (): Response {
return this.originalRes
}
// Called after peer signs the response
flush (): void {
if (this.flushed) return
this.flushed = true
this.originalRes.status(this.statusCode)
for (const [key, value] of Object.entries(this.headers)) {
this.originalRes.set(key, value)
}
if (this.body.length > 0) {
this.originalRes.send(Buffer.from(new Uint8Array(this.body)))
} else {
this.originalRes.end()
}
}
}
/**
* Transport implementation for Express.
*/
export class ExpressTransport implements Transport {
peer?: Peer
allowAuthenticated: boolean
openNonGeneralHandles: Record<string, Array<{ res: Response, next: Function }>> = {}
openGeneralHandles: Record<string, { next: Function, res: Response }> = {}
openNextHandlers: Record<string, NextFunction> = {}
openNextHandlerTimeouts: Record<string, ReturnType<typeof setTimeout>> = {}
private messageCallback?: (message: AuthMessage) => Promise<void>
private readonly logger: typeof console | undefined
private readonly logLevel: LogLevel
/**
* Constructs a new ExpressTransport instance.
*
* @param {boolean} [allowUnauthenticated=false] - Whether to allow unauthenticated requests passed the auth middleware.
* If `true`, requests without authentication will be permitted, and `req.auth.identityKey`
* will be set to `"unknown"`. If `false`, unauthenticated requests will result in a `401 Unauthorized` response.
* @param {typeof console} [logger] - Logger to use (e.g., console). If omitted, logging is disabled.
* @param {'debug' | 'info' | 'warn' | 'error'} [logLevel] - Log level. If omitted, no logs are output.
*/
constructor (
allowUnauthenticated: boolean = false,
logger?: typeof console,
logLevel?: LogLevel
) {
this.allowAuthenticated = allowUnauthenticated
this.logger = logger
this.logLevel = logLevel || 'error' // Default to 'error' if not provided
}
/**
* Internal logging method, only logs if logger is defined and log level is appropriate.
*
* @param level - The log level for this message
* @param message - The message to log
* @param data - Optional additional data to log
*/
private log (
level: LogLevel,
message: string,
data?: any
): void {
if (typeof this.logger !== 'object') return // Logging disabled
if (isLogLevelEnabled(this.logLevel, level)) {
const logMethod = getLogMethod(this.logger, level)
if (data !== undefined) {
logMethod(`[ExpressTransport] [${level.toUpperCase()}] ${message}`, data)
} else {
logMethod(`[ExpressTransport] [${level.toUpperCase()}] ${message}`)
}
}
}
setPeer (peer: Peer): void {
this.peer = peer
this.log('debug', 'Peer set in ExpressTransport', { peer })
}
/**
* Sends an AuthMessage to the connected Peer.
* This method uses an Express response object to deliver the message to the specified Peer.
*
* ### Parameters:
* @param {AuthMessage} message - The authenticated message to send.
*
* ### Returns:
* @returns {Promise<void>} A promise that resolves once the message has been sent successfully.
*/
async send (message: AuthMessage): Promise<void> {
this.log('debug', 'Attempting to send AuthMessage', { message })
if (message.messageType === 'general') {
await this.sendGeneralMessage(message)
} else {
await this.sendNonGeneralMessage(message)
}
}
/**
* Handles a general (authenticated application) AuthMessage response.
*/
private async sendGeneralMessage (message: AuthMessage): Promise<void> {
const reader = new Utils.Reader(message.payload)
const requestId = Utils.toBase64(reader.read(32))
if (typeof this.openGeneralHandles[requestId] !== 'object') {
this.log('warn', 'No response handle for this requestId', { requestId })
throw new Error('No response handle for this requestId!')
}
let { res, next } = this.openGeneralHandles[requestId]
delete this.openGeneralHandles[requestId]
const statusCode = reader.readVarIntNum()
;(res as any).__status(statusCode)
const responseHeaders = this.readResponseHeaders(reader)
responseHeaders['x-bsv-auth-version'] = message.version
responseHeaders['x-bsv-auth-identity-key'] = message.identityKey
responseHeaders['x-bsv-auth-nonce'] = message.nonce!
responseHeaders['x-bsv-auth-your-nonce'] = message.yourNonce!
responseHeaders['x-bsv-auth-signature'] = Utils.toHex(message.signature!)
responseHeaders['x-bsv-auth-request-id'] = requestId
if (message.requestedCertificates) {
responseHeaders['x-bsv-auth-requested-certificates'] = JSON.stringify(message.requestedCertificates)
}
for (const [k, v] of Object.entries(responseHeaders)) {
;(res as any).__set(k, v)
}
let responseBody: number[] | undefined
const responseBodyBytes = reader.readVarIntNum()
if (responseBodyBytes > 0) {
responseBody = reader.read(responseBodyBytes)
}
res = this.resetRes(res, next)
this.log('info', 'Sending general AuthMessage response', {
status: statusCode,
responseHeaders,
responseBodyLength: responseBody ? responseBody.length : 0,
requestId
})
if (responseBody) {
res.send(Buffer.from(new Uint8Array(responseBody)))
} else {
res.end()
}
}
/**
* Reads response headers from a binary reader.
*/
private readResponseHeaders (reader: Utils.Reader): Record<string, string> {
const responseHeaders: Record<string, string> = {}
const nHeaders = reader.readVarIntNum()
for (let i = 0; i < nHeaders; i++) {
const nHeaderKeyBytes = reader.readVarIntNum()
const headerKeyBytes = reader.read(nHeaderKeyBytes)
const headerKey = Utils.toUTF8(headerKeyBytes)
const nHeaderValueBytes = reader.readVarIntNum()
const headerValueBytes = reader.read(nHeaderValueBytes)
const headerValue = Utils.toUTF8(headerValueBytes)
responseHeaders[headerKey] = headerValue
}
return responseHeaders
}
/**
* Handles a non-general (handshake) AuthMessage response.
*/
private async sendNonGeneralMessage (message: AuthMessage): Promise<void> {
const handles = this.openNonGeneralHandles[message.yourNonce!]
if (!Array.isArray(handles) || handles.length === 0) {
this.log('warn', 'No open handles to peer for nonce', { yourNonce: message.yourNonce })
throw new Error('No open handles to this peer!')
}
// Since this is an initial response, we can assume there's only one handle per identity
const { res, next } = handles[0]
const responseHeaders: Record<string, string> = {
'x-bsv-auth-version': message.version,
'x-bsv-auth-message-type': message.messageType,
'x-bsv-auth-identity-key': message.identityKey,
'x-bsv-auth-nonce': message.nonce!,
'x-bsv-auth-your-nonce': message.yourNonce!,
'x-bsv-auth-signature': Utils.toHex(message.signature!)
}
if (typeof message.requestedCertificates === 'object') {
responseHeaders['x-bsv-auth-requested-certificates'] = JSON.stringify(message.requestedCertificates)
}
if ((res as any).__set !== undefined) {
this.resetRes(res, next)
}
for (const [k, v] of Object.entries(responseHeaders)) {
res.set(k, v)
}
this.log('info', 'Sending non-general AuthMessage response', {
status: 200,
responseHeaders,
messagePayload: message
})
res.send(message)
handles.shift()
}
/**
* Stores the callback bound by a Peer
* @param callback
*/
async onData (callback: (message: AuthMessage) => Promise<void>): Promise<void> {
this.log('debug', 'onData callback set')
this.messageCallback = callback
}
/**
* Handles an incoming request for the Express server.
*
* This method processes both general and non-general message types,
* manages peer-to-peer certificate handling, and modifies the response object
* to enable custom behaviors like certificate requests and tailored responses.
*
* ### Behavior:
* - For `/.well-known/auth`:
* - Handles non-general messages and listens for certificates.
* - Calls the `onCertificatesReceived` callback (if provided) when certificates are received.
* - For general messages:
* - Sets up a listener for peer-to-peer general messages.
* - Overrides response methods (`send`, `json`, etc.) for custom handling.
* - Returns a 401 error if mutual authentication fails.
*
* ### Parameters:
* @param {AuthRequest} req - The incoming HTTP request.
* @param {Response} res - The HTTP response.
* @param {NextFunction} next - The Express `next` middleware function.
* @param {Function} [onCertificatesReceived] - Optional callback invoked when certificates are received.
*/
public async handleIncomingRequest (
req: AuthRequest,
res: Response,
next: NextFunction,
onCertificatesReceived?: (
senderPublicKey: string,
certs: VerifiableCertificate[],
req: AuthRequest,
res: Response,
next: NextFunction
) => void
): Promise<void> {
this.log('debug', 'Handling incoming request', {
path: req.path,
headers: req.headers,
method: req.method,
body: req.body
})
try {
if (!this.peer) {
this.log('error', 'No Peer set in ExpressTransport! Cannot handle request.')
throw new Error('You must set a Peer before you can handle incoming requests!')
}
if (req.path === '/.well-known/auth') {
await this.handleWellKnownAuth(req, res, next, onCertificatesReceived)
} else if (req.headers['x-bsv-auth-request-id']) {
this.handleGeneralMessage(req, res, next)
} else {
this.handleUnauthenticated(req, res, next)
}
} catch (error) {
this.log('error', 'Caught error in handleIncomingRequest', { error })
next(error)
}
}
/**
* Handles a request to /.well-known/auth (non-general / handshake messages).
*/
private async handleWellKnownAuth (
req: AuthRequest,
res: Response,
next: NextFunction,
onCertificatesReceived?: (
senderPublicKey: string,
certs: VerifiableCertificate[],
req: AuthRequest,
res: Response,
next: NextFunction
) => void
): Promise<void> {
const message = req.body as AuthMessage
this.log('debug', 'Received non-general message at /.well-known/auth', { message })
let requestId = req.headers['x-bsv-auth-request-id'] as string
if (!requestId) {
requestId = message.initialNonce!
}
if (Array.isArray(this.openNonGeneralHandles[requestId])) {
this.openNonGeneralHandles[requestId].push({ res, next })
} else {
this.openNonGeneralHandles[requestId] = [{ res, next }]
}
if (!await this.peer!.sessionManager.hasSession(message.identityKey)) {
this.registerCertificateListener(req, res, next, requestId, message, onCertificatesReceived)
}
if (this.messageCallback) {
this.log('debug', 'Invoking stored messageCallback for non-general message')
this.messageCallback(message).catch((err) => {
this.log('error', 'Error in messageCallback', { error: err.message, err })
return res.status(500).json({
status: 'error',
code: 'ERR_INTERNAL_SERVER_ERROR',
description: err.message || 'An unknown error occurred.'
})
})
}
}
/**
* Registers a certificate-received listener for a non-general message.
*/
private registerCertificateListener (
req: AuthRequest,
res: Response,
next: NextFunction,
requestId: string,
message: AuthMessage,
onCertificatesReceived?: (
senderPublicKey: string,
certs: VerifiableCertificate[],
req: AuthRequest,
res: Response,
next: NextFunction
) => void
): void {
const listenerId = this.peer!.listenForCertificatesReceived(
(senderPublicKey: string, certs: VerifiableCertificate[]) => {
try {
this.log('debug', 'Certificates received event triggered', {
senderPublicKey,
certCount: certs?.length,
requestId
})
if (senderPublicKey === req.body.identityKey) {
this.handleCertificatesForPeer(senderPublicKey, certs, req, res, next, message, onCertificatesReceived)
}
} catch (error) {
this.log('error', 'Error in certificate listener callback', { error })
} finally {
const handles = this.openNonGeneralHandles[requestId]
if (handles && handles.length > 0) {
handles.shift()
if (handles.length === 0) {
delete this.openNonGeneralHandles[requestId]
}
}
this.peer?.stopListeningForCertificatesReceived(listenerId)
}
})
this.log('debug', 'listenForCertificatesReceived registered', { listenerId, requestId })
}
/**
* Processes certificates received from a peer during the handshake.
*/
private handleCertificatesForPeer (
senderPublicKey: string,
certs: VerifiableCertificate[],
req: AuthRequest,
res: Response,
next: NextFunction,
message: AuthMessage,
onCertificatesReceived?: (
senderPublicKey: string,
certs: VerifiableCertificate[],
req: AuthRequest,
res: Response,
next: NextFunction
) => void
): void {
if (!Array.isArray(certs) || certs.length === 0) {
this.log('warn', 'No certificates provided by peer', { senderPublicKey })
const handles = this.openNonGeneralHandles[req.headers['x-bsv-auth-request-id'] as string ?? message.initialNonce]
if (handles && handles.length > 0) {
handles[0].res.status(400).json({ status: 'No certificates provided' })
}
return
}
this.log('info', 'Certificates successfully received from peer', { senderPublicKey, certs })
if (typeof onCertificatesReceived === 'function') {
onCertificatesReceived(senderPublicKey, certs, req, res, next)
}
// Validate that identityKey is an own property of the handler map before
// invoking, preventing CodeQL js/unvalidated-dynamic-method-call from
// flagging prototype-chain dispatch on a user-supplied key.
const identityKey = message.identityKey
if (typeof identityKey === 'string' && Object.hasOwn(this.openNextHandlers, identityKey)) {
const nextFn = this.openNextHandlers[identityKey]
const timeoutHandle = this.openNextHandlerTimeouts[identityKey]
if (timeoutHandle != null) {
clearTimeout(timeoutHandle)
delete this.openNextHandlerTimeouts[identityKey]
}
nextFn()
delete this.openNextHandlers[identityKey]
}
}
/**
* Handles an authenticated general message (has x-bsv-auth-request-id header).
*/
private handleGeneralMessage (
req: AuthRequest,
res: Response,
next: NextFunction
): void {
const message = buildAuthMessageFromRequest(req, this.logger, this.logLevel)
this.log('debug', 'Received general message with x-bsv-auth-request-id', { message })
// Setup general message listener
const listenerId = this.peer!.listenForGeneralMessages((senderPublicKey: string, payload: number[]) => {
try {
if (senderPublicKey !== req.headers['x-bsv-auth-identity-key']) return
const requestId = Utils.toBase64(new Utils.Reader(payload).read(32))
if (requestId === req.headers['x-bsv-auth-request-id']) {
this.peer?.stopListeningForGeneralMessages(listenerId)
this.setupAuthenticatedResponse(req, res, next, senderPublicKey, requestId)
}
} catch (error) {
this.log('error', 'Error in listenForGeneralMessages callback', { error })
next(error)
}
})
this.log('debug', 'listenForGeneralMessages registered', { listenerId })
if (this.messageCallback) {
this.log('debug', 'Invoking stored messageCallback for general message')
this.messageCallback(message).catch((err) => {
const msg = err instanceof Error ? err.message : String(err)
const isAuthError = /nonce|signature|session|auth version/i.test(msg)
this.log('error', 'Error in messageCallback (general message)', { error: msg, isAuthError })
const statusCode = isAuthError ? 401 : 500
const code = isAuthError ? 'ERR_AUTH_FAILED' : 'ERR_INTERNAL_SERVER_ERROR'
const description = isAuthError
? (msg || 'Authentication failed.')
: (msg || 'An unexpected error occurred.')
return res.status(statusCode).json({ status: 'error', code, description })
})
}
}
/**
* Sets up the intercepted response for an authenticated general message.
*/
private setupAuthenticatedResponse (
req: AuthRequest,
res: Response,
next: NextFunction,
senderPublicKey: string,
requestId: string
): void {
this.log('debug', 'General message from the correct identity key', { requestId, senderPublicKey })
req.auth = { identityKey: senderPublicKey }
const wrapper = new ResponseWriterWrapper(res)
let responseSent = false
const buildAndSendResponse = async (): Promise<void> => {
if (responseSent) return
responseSent = true
try {
const responsePayload = buildResponsePayload(
requestId,
wrapper.getStatusCode(),
wrapper.getHeaders(),
wrapper.getBody(),
req,
this.logger,
this.logLevel
)
this.openGeneralHandles[requestId] = { res, next }
this.log('debug', 'Sending general message response', {
requestId,
responseStatus: wrapper.getStatusCode(),
responseHeaders: wrapper.getHeaders(),
responseBodyLength: wrapper.getBody().length
})
await this.peer?.toPeer(responsePayload, req.headers['x-bsv-auth-identity-key'] as string)
} catch (err) {
delete this.openGeneralHandles[requestId]
this.log('error', 'Failed to build and send authenticated response', { error: err })
try {
const restored = this.resetRes(res, next)
restored.status(500).json({
status: 'error',
code: 'ERR_RESPONSE_SIGNING_FAILED',
description: err instanceof Error ? err.message : 'Failed to sign response'
})
} catch (_responseAlreadySent) {
// Response may already be partially sent — nothing to do
}
}
}
this.hijackResponse(res, next, wrapper, buildAndSendResponse)
void this.scheduleNextOrCertificateWait(next, senderPublicKey, wrapper, buildAndSendResponse).catch(next)
}
/**
* Overrides the response methods to intercept and buffer the response for signing.
*/
private hijackResponse (
res: Response,
next: NextFunction,
wrapper: ResponseWriterWrapper,
buildAndSendResponse: () => Promise<void>
): void {
// Override methods to capture response data
this.checkRes(res, 'needs to be clear', next)
;(res as any).__status = res.status
res.status = (n) => {
wrapper.status(n)
return res
}
;(res as any).__set = res.set
;(res as any).set = (keyOrHeaders: string | Record<string, string>, value?: string) => {
wrapper.set(keyOrHeaders, value)
return res
}
;(res as any).__send = res.send
;(res as any).send = (val: any) => {
if (typeof val === 'object' && val !== null && !wrapper.getHeaders()['content-type']) {
wrapper.set('content-type', 'application/json')
}
wrapper.send(val)
buildAndSendResponse()
return res
}
;(res as any).__json = res.json
;(res as any).json = (obj: any) => {
wrapper.json(obj)
buildAndSendResponse()
return res
}
;(res as any).__text = (res as any).text
;(res as any).text = (str: string) => {
wrapper.text(str)
buildAndSendResponse()
return res
}
;(res as any).__end = res.end
;(res as any).end = () => {
buildAndSendResponse()
return res
}
;(res as any).__sendFile = res.sendFile
;(res as any).sendFile = (path: string, options?: any, callback?: Function) => {
fs.readFile(path, (err, data) => {
if (err) {
this.log('error', 'Error reading file in sendFile', { error: err.message })
if (callback) return callback(err)
wrapper.status(500)
buildAndSendResponse()
return
}
const mimeType = mime.lookup(path) || 'application/octet-stream'
wrapper.set('Content-Type', mimeType)
wrapper.send(Array.from(data))
buildAndSendResponse()
})
}
}
/**
* Either calls next() immediately or stores it pending certificate arrival.
*/
private async scheduleNextOrCertificateWait (
next: NextFunction,
senderPublicKey: string,
wrapper: ResponseWriterWrapper,
buildAndSendResponse: () => Promise<void>
): Promise<void> {
const hasSession = await (this.peer?.sessionManager.hasSession(senderPublicKey) ?? false)
const needsCertificates = this.peer?.certificatesToRequest?.certifiers?.length
this.log('debug', 'Checking if we need to wait for certificates', {
senderPublicKey,
hasSession,
needsCertificates,
openNextHandlersKeys: Object.keys(this.openNextHandlers)
})
if (!needsCertificates || hasSession) {
this.log('debug', 'Calling next() immediately - no certificate wait needed', { senderPublicKey, hasSession })
next()
return
}
this.log('debug', 'Storing next handler to wait for certificates', { senderPublicKey })
const existingTimeout = this.openNextHandlerTimeouts[senderPublicKey]
if (existingTimeout != null) {
clearTimeout(existingTimeout)
delete this.openNextHandlerTimeouts[senderPublicKey]
}
this.openNextHandlers[senderPublicKey] = next
const CERTIFICATE_TIMEOUT_MS = 30000
const timeoutHandle = setTimeout(() => {
if (this.openNextHandlers[senderPublicKey]) {
this.log('warn', 'Certificate request timed out', { senderPublicKey })
delete this.openNextHandlers[senderPublicKey]
delete this.openNextHandlerTimeouts[senderPublicKey]
wrapper.status(408).json({
status: 'error',
code: 'CERTIFICATE_TIMEOUT',
message: 'Certificate request timed out'
})
buildAndSendResponse()
}
}, CERTIFICATE_TIMEOUT_MS)
this.openNextHandlerTimeouts[senderPublicKey] = timeoutHandle
}
/**
* Handles a request with no auth headers.
*/
private handleUnauthenticated (req: AuthRequest, res: Response, next: NextFunction): void {
this.log(
'warn',
'No Auth headers found on request. Checking allowUnauthenticated setting.',
{ allowAuthenticated: this.allowAuthenticated }
)
if (this.allowAuthenticated) {
req.auth = { identityKey: 'unknown' }
next()
} else {
this.log('warn', 'Mutual-authentication failed. Returning 401.')
res.status(401).json({
status: 'error',
code: 'UNAUTHORIZED',
message: 'Mutual-authentication failed!'
})
}
}
private checkRes (res: any, test?: 'needs to be clear' | 'needs to be hijacked', next?: Function): void {
if (test === 'needs to be clear') {
if (
typeof res.__status === 'function' ||
typeof res.__set === 'function' ||
typeof res.__json === 'function' ||
typeof res.__text === 'function' ||
typeof res.__send === 'function' ||
typeof res.__end === 'function' ||
typeof res.__sendFile === 'function'
) {
const e = new Error('Unable to install Auth midddleware on the response object as it is not clear. Are two middleware instances installed?')
if (typeof next === 'function') {
next(e)
}
throw e
}
} else if (
typeof res.__status !== 'function' ||
typeof res.__set !== 'function' ||
typeof res.__json !== 'function' ||
typeof res.__send !== 'function' ||
typeof res.__end !== 'function' ||
typeof res.__sendFile !== 'function'
) {
const e = new Error('Unable to restore response object. Did you tamper with hijacked properties (res.__status, __set, __json, __text, __send, __end, __sendFile) ?')
if (typeof next === 'function') {
next(e)
}
throw e
}
}
private resetRes (res: Response, next?: Function): Response {
this.checkRes(res, 'needs to be hijacked', next)
res.status = (res as any).__status
res.set = (res as any).__set
res.json = (res as any).__json
;(res as any).text = (res as any).__text
res.send = (res as any).__send
res.end = (res as any).__end
res.sendFile = (res as any).__sendFile
return res
}
}
/**
* Helper: Build AuthMessage from Request
*/
function buildAuthMessageFromRequest (
req: Request,
logger?: typeof console,
logLevel?: LogLevel
): AuthMessage {
const debugLog = makeDebugLogger(logger, logLevel)
debugLog('[buildAuthMessageFromRequest] Building message from request...', {
path: req.path,
headers: req.headers,
method: req.method,
body: req.body
})
const writer = new Utils.Writer()
const requestNonce = req.headers['x-bsv-auth-request-id']
const requestNonceBytes = requestNonce ? Utils.toArray(requestNonce, 'base64') : []
writer.write(requestNonceBytes)
writer.writeVarIntNum(req.method.length)
writer.write(Utils.toArray(req.method))
const protocol = req.protocol
const host = req.get('host')
const parsedUrl = new URL(`${protocol}://${host}${req.originalUrl}`)
writeUrlToWriter(parsedUrl, writer)
writeRequestHeadersToWriter(req, writer)
writeBodyToWriter(req, writer, logger, logLevel)
const authMessage = {
messageType: 'general' as const,
version: req.headers['x-bsv-auth-version'] as string,
identityKey: req.headers['x-bsv-auth-identity-key'] as string,
nonce: req.headers['x-bsv-auth-nonce'] as string,
yourNonce: req.headers['x-bsv-auth-your-nonce'] as string,
payload: writer.toArray(),
signature: req.headers['x-bsv-auth-signature']
? Utils.toArray(req.headers['x-bsv-auth-signature'], 'hex')
: []
}
debugLog('[buildAuthMessageFromRequest] AuthMessage built', { authMessage })
return authMessage
}
/**
* Helper: Build response payload for sending back to peer
*/
function buildResponsePayload (
requestId: string,
responseStatus: number,
responseHeaders: Record<string, any>,
responseBody: number[],
req: Request,
logger?: typeof console,
logLevel?: LogLevel
): number[] {
const debugLog = makeDebugLogger(logger, logLevel)
debugLog('[buildResponsePayload] Building response payload', {
requestId,
responseStatus,
responseHeaders,
responseBodyLength: responseBody.length
})
const writer = new Utils.Writer()
writer.write(Utils.toArray(requestId, 'base64'))
writer.writeVarIntNum(responseStatus)
// Filter out headers that should NOT be signed:
// - Include custom headers prefixed with x-bsv (excluding those starting with x-bsv-auth)
// - Include the authorization header
const includedHeaders: Array<[string, string]> = []
Object.entries(responseHeaders).forEach(([key, value]) => {
const lowerKey = key.toLowerCase()
if ((lowerKey.startsWith('x-bsv-') || lowerKey === 'authorization') && !lowerKey.startsWith('x-bsv-auth')) {
includedHeaders.push([lowerKey, value])
}
})
// Sort the headers by key to ensure a consistent order for signing and verification.
includedHeaders.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
writer.writeVarIntNum(includedHeaders.length)
for (const [headerKey, headerValue] of includedHeaders) {
writeHeaderPair(writer, headerKey, headerValue)
}
if (responseBody.length > 0) {
writer.writeVarIntNum(responseBody.length)
writer.write(responseBody)
} else {
writer.writeVarIntNum(-1)
}
return writer.toArray()
}
/**
* Creates an Express middleware that handles authentication via BSV-SDK.
*
* @param {AuthMiddlewareOptions} options
* @returns {(req: Request, res: Response, next: NextFunction) => void} Express middleware
*/
export function createAuthMiddleware (options: AuthMiddlewareOptions): (req: AuthRequest, res: Response, next: NextFunction) => void {
const {
wallet,
sessionManager,
allowUnauthenticated,
certificatesToRequest,
onCertificatesReceived,
logger,
logLevel
} = options
if (!wallet) {
if (logger && logLevel && isLogLevelEnabled(logLevel, 'error')) {
getLogMethod(logger, 'error')(
'[createAuthMiddleware] No wallet provided in AuthMiddlewareOptions.'
)
}
throw new Error('You must configure the auth middleware with a wallet.')
}
const transport = new ExpressTransport(allowUnauthenticated ?? false, logger, logLevel)
const sessionMgr = sessionManager || new SessionManager()
if (logger && logLevel && isLogLevelEnabled(logLevel, 'info')) {
getLogMethod(logger, 'info')(
`[createAuthMiddleware] Creating Peer with provided wallet & transport. Session Manager: ${sessionManager ? 'Custom' : 'Default'
}`
)
}
const peer = new Peer(wallet, transport, certificatesToRequest, sessionMgr)
transport.setPeer(peer)
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) {
getLogMethod(logger, 'debug')('[createAuthMiddleware] Incoming request to auth middleware', {
path: req.path,
headers: req.headers,
method: req.method
})
}
void transport.handleIncomingRequest(req, res, next, onCertificatesReceived).catch(next)
}
}