@bsv/auth-express-middleware
Version:
BSV Blockchain mutual-authentication express middleware
940 lines (861 loc) • 35 kB
text/typescript
import { Request, Response, NextFunction } from 'express'
import fs from 'fs'
import mime from 'mime-types'
import {
Utils,
VerifiableCertificate,
Peer,
AuthMessage,
RequestedCertificateSet,
Transport,
SessionManager,
WalletInterface,
PubKeyHex
} from '@bsv/sdk'
export interface AuthRequest extends Request {
auth?: {
identityKey: PubKeyHex | 'unknown'
}
}
// Developers may optionally provide a handler for incoming certificates.
export interface AuthMiddlewareOptions {
wallet: WalletInterface
sessionManager?: SessionManager // Optional if dev wants custom SessionManager
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?: 'debug' | 'info' | 'warn' | 'error'
}
/**
* Helper to determine if a given message-level log should be output
* based on the configured log level.
*/
function isLogLevelEnabled(
configuredLevel: 'debug' | 'info' | 'warn' | 'error',
messageLevel: 'debug' | 'info' | 'warn' | 'error'
): boolean {
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['debug', 'info', 'warn', 'error']
const configuredIndex = levels.indexOf(configuredLevel)
const messageIndex = levels.indexOf(messageLevel)
return messageIndex >= configuredIndex
}
/**
* Retrieves the appropriate logging method from the logger,
* falling back to `log` if not found.
*/
function getLogMethod(
logger: typeof console,
level: 'debug' | 'info' | 'warn' | 'error'
): (...args: any[]) => void {
switch (level) {
case 'debug':
return typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.log.bind(logger)
case 'info':
// We'll map 'info' to console.info if available
return typeof logger.info === 'function' ? logger.info.bind(logger) : logger.log.bind(logger)
case 'warn':
return typeof logger.warn === 'function' ? logger.warn.bind(logger) : logger.log.bind(logger)
case 'error':
return typeof logger.error === 'function' ? logger.error.bind(logger) : logger.log.bind(logger)
default:
return logger.log.bind(logger)
}
}
/**
* 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> = {}
private messageCallback?: (message: AuthMessage) => Promise<void>
private readonly logger: typeof console | undefined
private readonly logLevel: 'debug' | 'info' | 'warn' | 'error'
/**
* 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?: 'debug' | 'info' | 'warn' | 'error'
) {
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: 'debug' | 'info' | 'warn' | 'error',
message: string,
data?: any
): void {
if (typeof this.logger !== 'object') return // Logging disabled
if (!isLogLevelEnabled(this.logLevel, level)) return
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') {
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!')
} else {
// 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> = {}
responseHeaders['x-bsv-auth-version'] = message.version
responseHeaders['x-bsv-auth-message-type'] = message.messageType
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!)
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()
}
} else {
// General message
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: Record<string, string> = {}
const nHeaders = reader.readVarIntNum()
if (nHeaders > 0) {
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
}
}
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
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()
}
}
}
/**
* Stores the callback bound by a Peer
* @param callback
*/
async onData(callback: (message: AuthMessage) => Promise<void>): Promise<void> {
this.log('debug', `onData callback set`)
// Just store the callback
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 handleIncomingRequest(
req: AuthRequest,
res: Response,
next: NextFunction,
onCertificatesReceived?: (
senderPublicKey: string,
certs: VerifiableCertificate[],
req: AuthRequest,
res: Response,
next: NextFunction
) => void
): 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') {
// Non-general message
const message = req.body as AuthMessage
this.log('debug', `Received non-general message at /.well-known/auth`, { message })
// Get a the request id
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 (!this.peer.sessionManager.hasSession(message.identityKey)) {
const listenerId = this.peer.listenForCertificatesReceived(
(senderPublicKey: string, certs: VerifiableCertificate[]) => {
this.log('debug', 'Certificates received event triggered', {
senderPublicKey,
certCount: certs?.length
})
if (senderPublicKey !== req.body.identityKey) {
return
}
if (!Array.isArray(certs) || certs.length === 0) {
this.log('warn', 'No certificates provided by peer', { senderPublicKey })
this.openNonGeneralHandles[senderPublicKey][0].res
.status(400)
.json({ status: 'No certificates provided' })
} else {
this.log('info', 'Certificates successfully received from peer', {
senderPublicKey,
certs
})
// this.openNonGeneralHandles[message.initialNonce!][0].res.json({ status: 'certificate received' })
if (typeof onCertificatesReceived === 'function') {
onCertificatesReceived(senderPublicKey, certs, req, res, next)
}
const nextFn = this.openNextHandlers[message.identityKey]
if (typeof nextFn === 'function') {
nextFn()
delete this.openNextHandlers[message.identityKey]
}
}
this.openNonGeneralHandles[message.initialNonce!].shift()
this.peer?.stopListeningForCertificatesReceived(listenerId)
})
this.log('debug', 'listenForCertificatesReceived registered', { listenerId })
}
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.'
})
})
}
} else {
// Possibly general message
if (req.headers['x-bsv-auth-request-id']) {
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.log('debug', `General message from the correct identity key`, {
requestId,
senderPublicKey
})
this.peer?.stopListeningForGeneralMessages(listenerId)
req.auth = { identityKey: senderPublicKey }
let responseStatus = 200
let responseHeaders = {}
let responseBody: number[] = []
// Override methods after checking res is clear
this.checkRes(res, 'needs to be clear', next)
; (res as any).__status = res.status
res.status = (n) => {
responseStatus = n
return res // Return res for chaining
}
; (res as any).__set = res.set
; (res as any).set = (keyOrHeaders, value) => {
if (typeof keyOrHeaders === 'object' && keyOrHeaders !== null) {
// Handle setting multiple headers with an object
for (const [key, val] of Object.entries(keyOrHeaders)) {
if (typeof key !== 'string') {
throw new TypeError(
`Header name must be a string, received: ${typeof key}`
)
}
responseHeaders[key.toLowerCase()] = String(val) // Ensure value is a string
}
} else if (typeof keyOrHeaders === 'string') {
// Handle setting a single header
if (typeof value === 'undefined') {
throw new TypeError(
'Value must be provided when setting a single header'
)
}
responseHeaders[keyOrHeaders.toLowerCase()] = String(value) // Ensure value is a string
} else {
throw new TypeError(
'Invalid arguments: res.set expects a string or an object'
)
}
return res // Return res for chaining
}
const buildResponse = async (): Promise<void> => {
const payload = buildResponsePayload(
requestId,
responseStatus,
responseHeaders,
responseBody,
req,
this.logger,
this.logLevel
)
this.openGeneralHandles[requestId] = { res, next }
this.log('debug', `Sending general message response`, {
requestId,
responseStatus,
responseHeaders,
responseBodyLength: responseBody.length
})
await this.peer?.toPeer(payload, req.headers['x-bsv-auth-identity-key'] as string)
}
; (res as any).__send = res.send
; (res as any).send = (val: any) => {
// If the value is an object and no content-type is set, assume JSON
if (
typeof val === 'object' &&
val !== null &&
!responseHeaders['content-type']
) {
res.set('content-type', 'application/json')
}
responseBody = convertValueToArray(val, responseHeaders)
buildResponse()
}
; (res as any).__json = res.json
; (res as any).json = (obj) => {
if (!responseHeaders['content-type']) {
res.set('content-type', 'application/json')
}
responseBody = Utils.toArray(JSON.stringify(obj), 'utf8')
buildResponse()
}
; (res as any).text = (str) => {
if (!responseHeaders['content-type']) {
res.set('content-type', 'text/plain')
}
responseBody = Utils.toArray(str, 'utf8')
buildResponse()
}
; (res as any).__end = res.end
; (res as any).end = () => {
buildResponse()
}
; (res as any).__sendFile = res.sendFile
; (res as any).sendFile = (path, options, callback) => {
fs.readFile(path, (err, data) => {
if (err) {
this.log('error', `Error reading file in sendFile`, { error: err.message })
if (callback) return callback(err)
res.status(500)
return buildResponse()
}
const mimeType = mime.lookup(path) || 'application/octet-stream'
res.set('Content-Type', mimeType)
responseBody = Array.from(data)
buildResponse()
})
}
if (
this.peer?.certificatesToRequest?.certifiers?.length &&
Object.keys(this.openNextHandlers[senderPublicKey] || {}).length > 0
) {
this.openNextHandlers[senderPublicKey] = next;
} else {
next()
}
}
} catch (error) {
this.log('error', `Error in listenForGeneralMessages callback`, { error })
next(error)
}
})
this.log('debug', `listenForGeneralMessages registered`, { listenerId })
if (this.messageCallback) {
// Note: The requester may want more detailed error handling
this.log('debug', `Invoking stored messageCallback for general message`)
this.messageCallback(message).catch((err) => {
this.log('error', `Error in messageCallback (general message)`, { error: err.message })
return res.status(500).json({
status: 'error',
code: 'ERR_INTERNAL_SERVER_ERROR',
description: err.message || 'An unknown error occurred.'
})
})
}
} else {
// No auth headers
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!'
})
}
}
}
} catch (error) {
this.log('error', `Caught error in handleIncomingRequest`, { error })
next(error)
}
}
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?: 'debug' | 'info' | 'warn' | 'error'
): AuthMessage {
// Possibly log raw request details at debug level
if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) {
getLogMethod(logger, 'debug')(
`[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))
// Dynamically determine the base URL
const protocol = req.protocol // Ex. 'http' or 'https'
const host = req.get('host') // Ex. 'example.com:3000'
const baseUrl = `${protocol}://${host}`
const parsedUrl = new URL(`${baseUrl}${req.originalUrl}`)
// Pathname
if (parsedUrl.pathname.length > 0) {
const pathnameAsArray = Utils.toArray(parsedUrl.pathname)
writer.writeVarIntNum(pathnameAsArray.length)
writer.write(pathnameAsArray)
} else {
writer.writeVarIntNum(-1)
}
// Search
if (parsedUrl.search.length > 0) {
const searchAsArray = Utils.toArray(parsedUrl.search)
writer.writeVarIntNum(searchAsArray.length)
writer.write(searchAsArray)
} else {
writer.writeVarIntNum(-1)
}
// Parse request headers from client and include only the signed headers:
// - Include custom headers prefixed with x-bsv (excluding those starting with x-bsv-auth)
// - Include a normalized version of the content-type header
// - Include the authorization header
// Headers
const includedHeaders: Array<[string, string]> = []
for (let [k, v] of Object.entries(req.headers)) {
k = k.toLowerCase()
// Normalize the Content-Type header by removing any parameters.
if (k === 'content-type') {
v = (v as string).split(';')[0].trim()
}
if ((k.startsWith('x-bsv-') || k === 'content-type' || k === 'authorization') && !k.startsWith('x-bsv-auth')) {
includedHeaders.push([k, v as string])
}
}
includedHeaders.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
writer.writeVarIntNum(includedHeaders.length)
for (let i = 0; i < includedHeaders.length; i++) {
const headerKeyAsArray = Utils.toArray(includedHeaders[i][0], 'utf8')
writer.writeVarIntNum(headerKeyAsArray.length)
writer.write(headerKeyAsArray)
const headerValueAsArray = Utils.toArray(includedHeaders[i][1], 'utf8')
writer.writeVarIntNum(headerValueAsArray.length)
writer.write(headerValueAsArray)
}
// Body
writeBodyToWriter(req, writer, logger, logLevel)
const authMessage = {
messageType: 'general' as 'general',
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')
: []
}
if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) {
getLogMethod(logger, 'debug')(
`[buildAuthMessageFromRequest] AuthMessage built`,
{ authMessage }
)
}
return authMessage
}
/**
* Helper: Write body to writer
*/
function writeBodyToWriter(
req: Request,
writer: Utils.Writer,
logger?: typeof console,
logLevel?: 'debug' | 'info' | 'warn' | 'error'
) {
const { body, headers } = req
if (Array.isArray(body) && body.every((item) => typeof item === 'number')) {
// If the body is already a number[]
writer.writeVarIntNum(body.length)
writer.write(body)
if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) {
getLogMethod(logger, 'debug')(`[writeBodyToWriter] Body recognized as number[]`, { length: body.length })
}
} else if (body instanceof Uint8Array) {
// If the body is a Uint8Array
writer.writeVarIntNum(body.length)
writer.write(Array.from(body)) // Convert Uint8Array to number[]
if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) {
getLogMethod(logger, 'debug')(
`[writeBodyToWriter] Body recognized as Uint8Array`,
{ length: body.length }
)
}
} else if (
headers['content-type'] === 'application/json' &&
typeof body === 'object'
) {
// If the body is JSON
const bodyAsArray = Utils.toArray(JSON.stringify(body), 'utf8')
writer.writeVarIntNum(bodyAsArray.length)
writer.write(bodyAsArray)
if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) {
getLogMethod(logger, 'debug')(`[writeBodyToWriter] Body recognized as JSON`, { body })
}
} else if (
headers['content-type'] === 'application/x-www-form-urlencoded' &&
body &&
Object.keys(body).length > 0
) {
// If the body is URL-encoded
const parsedBody = new URLSearchParams(body).toString()
const bodyAsArray = Utils.toArray(parsedBody, 'utf8')
writer.writeVarIntNum(bodyAsArray.length)
writer.write(bodyAsArray)
if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) {
getLogMethod(logger, 'debug')(
`[writeBodyToWriter] Body recognized as x-www-form-urlencoded`,
{ parsedBody }
)
}
} else if (
headers['content-type'] === 'text/plain' &&
typeof body === 'string' &&
body.length > 0
) {
// If the body is plain text
const bodyAsArray = Utils.toArray(body, 'utf8')
writer.writeVarIntNum(bodyAsArray.length)
writer.write(bodyAsArray)
if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) {
getLogMethod(logger, 'debug')(
`[writeBodyToWriter] Body recognized as text/plain`,
{ body }
)
}
} else {
// No valid body
writer.writeVarIntNum(-1)
if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) {
getLogMethod(logger, 'debug')(`[writeBodyToWriter] No valid body to write`)
}
}
}
/**
* 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?: 'debug' | 'info' | 'warn' | 'error'
): number[] {
if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) {
getLogMethod(logger, 'debug')(`[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 (let i = 0; i < includedHeaders.length; i++) {
const headerKeyAsArray = Utils.toArray(includedHeaders[i][0], 'utf8')
writer.writeVarIntNum(headerKeyAsArray.length)
writer.write(headerKeyAsArray)
const headerValueAsArray = Utils.toArray(includedHeaders[i][1], 'utf8')
writer.writeVarIntNum(headerValueAsArray.length)
writer.write(headerValueAsArray)
}
if (responseBody.length > 0) {
writer.writeVarIntNum(responseBody.length)
writer.write(responseBody)
} else {
writer.writeVarIntNum(-1)
}
return writer.toArray()
}
/**
* Helper: Convert values passed to res.send(...) into byte arrays
*/
function convertValueToArray(val: any, responseHeaders: Record<string, any>): number[] {
if (typeof val === 'string') {
return Utils.toArray(val, 'utf8')
} else if (val instanceof Buffer) {
return Array.from(val)
} else if (typeof val === 'object') {
if (val !== null) {
if (!responseHeaders['content-type']) {
responseHeaders['content-type'] = 'application/json'
}
return Utils.toArray(JSON.stringify(val), 'utf8')
}
} else if (typeof val === 'number') {
return Utils.toArray(val.toString(), 'utf8')
} else {
return Utils.toArray(String(val), 'utf8')
}
return []
}
/**
* 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.')
}
// Construct transport with logging
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 the express middleware
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
})
}
transport.handleIncomingRequest(req, res, next, onCertificatesReceived)
}
}