diff --git a/packages/middleware/auth-express-middleware/jest.config.js b/packages/middleware/auth-express-middleware/jest.config.js index 0c870c51a..4772233b8 100644 --- a/packages/middleware/auth-express-middleware/jest.config.js +++ b/packages/middleware/auth-express-middleware/jest.config.js @@ -4,6 +4,20 @@ export default { testEnvironment: 'node', testPathIgnorePatterns: ['dist/'], modulePathIgnorePatterns: ['/dist/'], // Add this to ignore dist/ for module mapping + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1' + }, + transform: { + '^.+\\.ts$': ['ts-jest', { + diagnostics: false, + tsconfig: { + moduleResolution: 'node', + strict: false, + strictNullChecks: false, + noImplicitAny: false + } + }] + }, // Integration tests use fixed ports and multi-round-trip auth protocol // exchanges that require sequential execution to avoid worker event-loop // scheduling issues and port conflicts. diff --git a/packages/middleware/auth-express-middleware/src/__tests/MockWallet.ts b/packages/middleware/auth-express-middleware/src/__tests/MockWallet.ts index e58b3ed6e..826915232 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/MockWallet.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/MockWallet.ts @@ -63,9 +63,8 @@ import { */ export class MockWallet extends ProtoWallet implements WalletInterface { - keyDeriver: KeyDeriver - constructor(rootKeyOrKeyDeriver: PrivateKey | 'anyone' | KeyDeriverApi) { + constructor (rootKeyOrKeyDeriver: PrivateKey | 'anyone' | KeyDeriverApi) { super(rootKeyOrKeyDeriver) if (rootKeyOrKeyDeriver instanceof KeyDeriver) { @@ -110,16 +109,15 @@ export class MockWallet extends ProtoWallet /** * Add a master certificate to the wallet for testing purposes. */ - addMasterCertificate(masterCertificate: MasterCertificate): void { + addMasterCertificate (masterCertificate: MasterCertificate): void { this.storedCertificates.push(masterCertificate) } - /** * Given a certificate and fields to reveal, this method creates a keyring * for the verifier by leveraging the masterCertificate’s capabilities. */ - async proveCertificate(args: ProveCertificateArgs, originator?: OriginatorDomainNameStringUnder250Bytes): Promise { + async proveCertificate (args: ProveCertificateArgs, originator?: OriginatorDomainNameStringUnder250Bytes): Promise { const storedCert = this.storedCertificates.find(sc => sc.type === args.certificate.type && sc.subject === args.certificate.subject && @@ -149,7 +147,7 @@ export class MockWallet extends ProtoWallet * Mock implementation of internalizeAction. * Logs the provided action details and returns a successful response. */ - async internalizeAction(args: InternalizeActionArgs, originator?: OriginatorDomainNameStringUnder250Bytes): Promise { + async internalizeAction (args: InternalizeActionArgs, originator?: OriginatorDomainNameStringUnder250Bytes): Promise { console.log('Mock internalizeAction called with:', { args, originator }) return await Promise.resolve({ accepted: true }) } @@ -157,7 +155,7 @@ export class MockWallet extends ProtoWallet /** * Returns any certificates whose certifier and type match the requested sets. */ - async listCertificates(args: ListCertificatesArgs, + async listCertificates (args: ListCertificatesArgs, originator?: OriginatorDomainNameStringUnder250Bytes): Promise { // Filter certificates by requested certifiers and types const filtered = this.storedCertificates.filter(cert => { diff --git a/packages/middleware/auth-express-middleware/src/__tests/integration.test.ts b/packages/middleware/auth-express-middleware/src/__tests/integration.test.ts index 7af24b252..1cf063e99 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/integration.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/integration.test.ts @@ -6,7 +6,7 @@ import { PrivateKey, RequestedCertificateTypeIDAndFieldList, Utils, - AuthFetch, + AuthFetch } from '@bsv/sdk' import { Server } from 'http' import { startServer } from './testExpressServer' @@ -101,9 +101,9 @@ describe('AuthFetch and AuthExpress Integration Tests', () => { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', - 'x-bsv-test': 'this is a test header', + 'x-bsv-test': 'this is a test header' }, - body: new URLSearchParams({ message: 'hello!', type: 'form-data' }).toString(), + body: new URLSearchParams({ message: 'hello!', type: 'form-data' }).toString() } ) expect(result.status).toBe(200) @@ -120,9 +120,9 @@ describe('AuthFetch and AuthExpress Integration Tests', () => { method: 'POST', headers: { 'content-type': 'text/plain', - 'x-bsv-test': 'this is a test header', + 'x-bsv-test': 'this is a test header' }, - body: 'Hello, this is a plain text message!', + body: 'Hello, this is a plain text message!' } ) expect(result.status).toBe(200) @@ -139,9 +139,9 @@ describe('AuthFetch and AuthExpress Integration Tests', () => { method: 'POST', headers: { 'content-type': 'application/octet-stream', - 'x-bsv-test': 'this is a test header', + 'x-bsv-test': 'this is a test header' }, - body: Utils.toArray('Hello from binary!'), + body: Utils.toArray('Hello from binary!') } ) expect(result.status).toBe(200) @@ -182,9 +182,9 @@ describe('AuthFetch and AuthExpress Integration Tests', () => { method: 'PUT', headers: { 'content-type': 'application/json', - 'x-bsv-test': 'this is a test header', + 'x-bsv-test': 'this is a test header' }, - body: JSON.stringify({ key: 'value', action: 'update' }), + body: JSON.stringify({ key: 'value', action: 'update' }) } ) expect(result.status).toBe(200) @@ -200,7 +200,7 @@ describe('AuthFetch and AuthExpress Integration Tests', () => { { method: 'DELETE', headers: { - 'x-bsv-test': 'this is a test header', + 'x-bsv-test': 'this is a test header' } } ) @@ -216,7 +216,7 @@ describe('AuthFetch and AuthExpress Integration Tests', () => { const result = await authFetch.fetch('http://localhost:3000/large-upload', { method: 'POST', headers: { - 'content-type': 'application/octet-stream', + 'content-type': 'application/octet-stream' }, body: largeBuffer }) @@ -242,7 +242,7 @@ describe('AuthFetch and AuthExpress Integration Tests', () => { const result = await authFetch.fetch('http://localhost:3000/custom-headers', { method: 'GET', headers: { - 'x-bsv-custom-header': 'CustomHeaderValue', + 'x-bsv-custom-header': 'CustomHeaderValue' } }) expect(result.status).toBe(200) @@ -250,7 +250,6 @@ describe('AuthFetch and AuthExpress Integration Tests', () => { expect(textResponse).toBeDefined() }) - // -------------------------------------------------------------------------- // Edge-Case Tests // -------------------------------------------------------------------------- @@ -262,7 +261,7 @@ describe('AuthFetch and AuthExpress Integration Tests', () => { authFetch.fetch('http://localhost:3000/no-content-type-endpoint', { method: 'POST', // Intentionally no 'content-type' header - body: 'This should fail if your code requires Content-Type for POST.', + body: 'This should fail if your code requires Content-Type for POST.' }) ).rejects.toThrow() }) diff --git a/packages/middleware/auth-express-middleware/src/__tests/testCertExpressServer.ts b/packages/middleware/auth-express-middleware/src/__tests/testCertExpressServer.ts index 41cd8ba05..6f402799d 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/testCertExpressServer.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/testCertExpressServer.ts @@ -27,7 +27,7 @@ export const startCertServer = (port = 3001): ReturnType & { } const privKey = new PrivateKey(2) - const mockWallet = new MockWallet(privKey); + const mockWallet = new MockWallet(privKey) // Asynchronous setup for certificates — exposed as server.ready const ready = (async () => { @@ -87,7 +87,7 @@ export const startCertServer = (port = 3001): ReturnType & { app.post('/cert-protected-endpoint', async (req: Request, res: Response) => { console.log('Received POST body:', req.body) - //wait a moment for the certificates to be received + // wait a moment for the certificates to be received await new Promise(resolve => { const t = setTimeout(resolve, 5000) if (typeof t.unref === 'function') t.unref() @@ -111,7 +111,7 @@ export const startCertServer = (port = 3001): ReturnType & { const server = app.listen(port, () => { console.log(`Server is running on http://localhost:${port}`) - }) as ReturnType & { ready: Promise } + }) server.ready = ready return server } diff --git a/packages/middleware/auth-express-middleware/src/__tests/testCertificaterequests.test.ts b/packages/middleware/auth-express-middleware/src/__tests/testCertificaterequests.test.ts index d7154ef19..e2ed6e203 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/testCertificaterequests.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/testCertificaterequests.test.ts @@ -3,7 +3,7 @@ import { PrivateKey, RequestedCertificateTypeIDAndFieldList, AuthFetch, - MasterCertificate, + MasterCertificate } from '@bsv/sdk' import { Server } from 'http' import { startCertServer } from './testCertExpressServer' @@ -61,14 +61,13 @@ describe('AuthFetch and AuthExpress Certificates Tests', () => { }) }) - test('Test 12: Certificate request', async () => { const requestedCertificates: RequestedCertificateSet = { certifiers: [ - '03caa1baafa05ecbf1a5b310a7a0b00bc1633f56267d9f67b1fd6bb23b3ef1abfa', + '03caa1baafa05ecbf1a5b310a7a0b00bc1633f56267d9f67b1fd6bb23b3ef1abfa' ], types: { - 'z40BOInXkI8m7f/wBrv4MJ09bZfzZbTj2fJqCtONqCY=': ['firstName'], + 'z40BOInXkI8m7f/wBrv4MJ09bZfzZbTj2fJqCtONqCY=': ['firstName'] } } const walletWithRequests = new MockWallet(privKey) @@ -85,7 +84,6 @@ describe('AuthFetch and AuthExpress Certificates Tests', () => { // Add further assertions based on expected certificates }, 30000) - test('Test 16: Simple POST on /cert-protected-endpoint', async () => { const walletWithCerts = new MockWallet(privKey) @@ -106,18 +104,18 @@ describe('AuthFetch and AuthExpress Certificates Tests', () => { try { res = await authFetch.fetch( 'http://localhost:3001/cert-protected-endpoint', { - method: 'POST', headers: { - 'content-type': 'application/json' - }, - body: JSON.stringify({ message: 'Hello protected Route!' }) - }) + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ message: 'Hello protected Route!' }) + }) } catch (error) { console.error('Error during fetch:', error) } - expect(res!.status).toBe(200) - const body = await res!.text() + expect(res.status).toBe(200) + const body = await res.text() expect(body).toBeDefined() console.log(body) - }, 300000) -}) \ No newline at end of file +}) diff --git a/packages/middleware/auth-express-middleware/src/__tests/testExpressServer.ts b/packages/middleware/auth-express-middleware/src/__tests/testExpressServer.ts index 90228ad5a..be88d7c68 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/testExpressServer.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/testExpressServer.ts @@ -27,7 +27,7 @@ export const startServer = (port = 3000): Server => { } const privKey = new PrivateKey(1) - const mockWallet = new MockWallet(privKey); + const mockWallet = new MockWallet(privKey) const sessionManager = new SessionManager(); // Asynchronous setup for certificates and middleware @@ -92,7 +92,7 @@ export const startServer = (port = 3000): Server => { sessionManager, onCertificatesReceived: (_senderPublicKey: string, certs: VerifiableCertificate[], req: Request, res: Response, next: NextFunction) => { console.log('Certificates received:', certs) - }, + } // certificatesToRequest }) @@ -119,9 +119,9 @@ export const startServer = (port = 3000): Server => { res.status(200).send({ message: 'This is another endpoint. 😅' }) }) - app.post('/cert-protected-endpoint', (req: Request, res: Response) => { - console.log('Received POST body:', req.body) - res.status(200).send({ message: 'You must have certs!' }) + app.post('/cert-protected-endpoint', (req: Request, res: Response) => { + console.log('Received POST body:', req.body) + res.status(200).send({ message: 'You must have certs!' }) // await (res as any).sendCertificateRequest(certsToRequest, identityKey) }) diff --git a/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts b/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts new file mode 100644 index 000000000..486f1ebf0 --- /dev/null +++ b/packages/middleware/auth-express-middleware/src/authMiddlewareHelpers.ts @@ -0,0 +1,215 @@ +import { Request } from 'express' +import { Utils } from '@bsv/sdk' + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' + +const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error'] + +/** + * Helper to determine if a given message-level log should be output + * based on the configured log level. + */ +export function isLogLevelEnabled ( + configuredLevel: LogLevel, + messageLevel: LogLevel +): boolean { + return LOG_LEVELS.indexOf(messageLevel) >= LOG_LEVELS.indexOf(configuredLevel) +} + +/** + * Retrieves the appropriate logging method from the logger, + * falling back to `log` if not found. + * + * Uses an explicit switch to avoid dynamic property access on a user-influenced + * key, which prevents CodeQL js/unvalidated-dynamic-method-call alerts. + */ +export function getLogMethod ( + logger: typeof console, + level: LogLevel +): (...args: any[]) => void { + switch (level) { + case 'debug': return (typeof logger.debug === 'function' ? logger.debug : logger.log).bind(logger) + case 'info': return (typeof logger.info === 'function' ? logger.info : logger.log).bind(logger) + case 'warn': return (typeof logger.warn === 'function' ? logger.warn : logger.log).bind(logger) + case 'error': return (typeof logger.error === 'function' ? logger.error : logger.log).bind(logger) + default: return logger.log.bind(logger) + } +} + +/** + * Write the URL pathname and search components to the binary writer. + */ +export function writeUrlToWriter (parsedUrl: URL, writer: Utils.Writer): void { + if (parsedUrl.pathname.length > 0) { + const pathnameAsArray = Utils.toArray(parsedUrl.pathname) + writer.writeVarIntNum(pathnameAsArray.length) + writer.write(pathnameAsArray) + } else { + writer.writeVarIntNum(-1) + } + + if (parsedUrl.search.length > 0) { + const searchAsArray = Utils.toArray(parsedUrl.search) + writer.writeVarIntNum(searchAsArray.length) + writer.write(searchAsArray) + } else { + writer.writeVarIntNum(-1) + } +} + +/** + * Collect and write signed request headers to the binary writer. + */ +export function writeRequestHeadersToWriter (req: Request, writer: Utils.Writer): void { + const includedHeaders: Array<[string, string]> = [] + for (let [k, v] of Object.entries(req.headers)) { + k = k.toLowerCase() + // Normalise to a single string — Express may return string[] when a header + // is repeated (e.g. `Set-Cookie`). Take the first value to avoid + // type-confusion (CodeQL js/type-confusion-through-parameter-tampering). + const vStr: string = Array.isArray(v) ? v[0] : (typeof v === 'string' ? v : '') + let headerValue = vStr + if (k === 'content-type') { + headerValue = vStr.split(';')[0].trim() + } + if ( + (k.startsWith('x-bsv-') || k === 'content-type' || k === 'authorization') && + !k.startsWith('x-bsv-auth') + ) { + includedHeaders.push([k, headerValue]) + } + } + includedHeaders.sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + + writer.writeVarIntNum(includedHeaders.length) + for (const [headerKey, headerValue] of includedHeaders) { + writeHeaderPair(writer, headerKey, headerValue) + } +} + +/** + * Write a header pair (key + value) to the binary writer. + */ +export function writeHeaderPair (writer: Utils.Writer, key: string, value: string): void { + const keyBytes = Utils.toArray(key, 'utf8') + writer.writeVarIntNum(keyBytes.length) + writer.write(keyBytes) + const valueBytes = Utils.toArray(value, 'utf8') + writer.writeVarIntNum(valueBytes.length) + writer.write(valueBytes) +} + +/** + * Helper: Write body to writer + */ +export function writeBodyToWriter ( + req: Request, + writer: Utils.Writer, + logger?: typeof console, + logLevel?: LogLevel +): void { + const { body, headers } = req + const debugLog = makeDebugLogger(logger, logLevel) + + // Inline-normalised content-type to a single string (Express may return string[]). + // Inline narrowing rather than a helper so CodeQL's dataflow analysis can see + // the explicit type guard (avoids js/type-confusion-through-parameter-tampering). + const rawContentType = headers['content-type'] + let contentType = '' + if (typeof rawContentType === 'string') { + contentType = rawContentType + } else if (Array.isArray(rawContentType) && typeof rawContentType[0] === 'string') { + contentType = rawContentType[0] + } + + if (Array.isArray(body) && body.every((item) => typeof item === 'number')) { + writer.writeVarIntNum(body.length) + writer.write(body) + debugLog('[writeBodyToWriter] Body recognized as number[]', { length: body.length }) + return + } + + if (body instanceof Uint8Array) { + writer.writeVarIntNum(body.length) + writer.write(Array.from(body)) + debugLog('[writeBodyToWriter] Body recognized as Uint8Array', { length: body.length }) + return + } + + if (contentType === 'application/json' && typeof body === 'object') { + const bodyAsArray = Utils.toArray(JSON.stringify(body), 'utf8') + writer.writeVarIntNum(bodyAsArray.length) + writer.write(bodyAsArray) + debugLog('[writeBodyToWriter] Body recognized as JSON', { body }) + return + } + + if ( + contentType === 'application/x-www-form-urlencoded' && + body !== null && + typeof body === 'object' && + !Array.isArray(body) && + Object.keys(body).length > 0 + ) { + const parsedBody = new URLSearchParams(body).toString() + const bodyAsArray = Utils.toArray(parsedBody, 'utf8') + writer.writeVarIntNum(bodyAsArray.length) + writer.write(bodyAsArray) + debugLog('[writeBodyToWriter] Body recognized as x-www-form-urlencoded', { parsedBody }) + return + } + + if (contentType === 'text/plain' && typeof body === 'string' && body.length > 0) { + const bodyAsArray = Utils.toArray(body, 'utf8') + writer.writeVarIntNum(bodyAsArray.length) + writer.write(bodyAsArray) + debugLog('[writeBodyToWriter] Body recognized as text/plain', { body }) + return + } + + // No valid body + writer.writeVarIntNum(-1) + debugLog('[writeBodyToWriter] No valid body to write', undefined) +} + +/** + * Helper: Convert values passed to res.send(...) into byte arrays + */ +export function convertValueToArray (val: any, responseHeaders: Record): number[] { + if (typeof val === 'string') { + return Utils.toArray(val, 'utf8') + } + if (val instanceof Buffer) { + return Array.from(val) + } + if (typeof val === 'object' && val !== null) { + if (!responseHeaders['content-type']) { + responseHeaders['content-type'] = 'application/json' + } + return Utils.toArray(JSON.stringify(val), 'utf8') + } + if (typeof val === 'number') { + return Utils.toArray(val.toString(), 'utf8') + } + return Utils.toArray(String(val), 'utf8') +} + +/** + * Returns a no-op or a bound debug logger depending on config. + */ +export function makeDebugLogger ( + logger?: typeof console, + logLevel?: LogLevel +): (msg: string, data: any) => void { + if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) { + const fn = getLogMethod(logger, 'debug') + return (msg: string, data: any) => { + if (data !== undefined) { + fn(msg, data) + } else { + fn(msg) + } + } + } + return () => {} +} diff --git a/packages/middleware/auth-express-middleware/src/index.ts b/packages/middleware/auth-express-middleware/src/index.ts index ca10241d1..b1d1da55d 100644 --- a/packages/middleware/auth-express-middleware/src/index.ts +++ b/packages/middleware/auth-express-middleware/src/index.ts @@ -12,6 +12,21 @@ import { 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?: { @@ -19,8 +34,6 @@ export interface AuthRequest extends Request { } } -export type LogLevel = 'debug' | 'info' | 'warn' | 'error' - // Developers may optionally provide a handler for incoming certificates. export interface AuthMiddlewareOptions { wallet: WalletInterface @@ -52,43 +65,6 @@ export interface AuthMiddlewareOptions { logLevel?: LogLevel } -/** - * Helper to determine if a given message-level log should be output - * based on the configured log level. - */ -function isLogLevelEnabled( - configuredLevel: LogLevel, - messageLevel: LogLevel -): boolean { - const levels: LogLevel[] = ['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: LogLevel -): (...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) - } -} - /** * ResponseWriterWrapper buffers response data until signing is complete. * This pattern matches the Go implementation for cleaner response handling. @@ -100,16 +76,16 @@ class ResponseWriterWrapper { private readonly originalRes: Response private flushed: boolean = false - constructor(res: Response) { + constructor (res: Response) { this.originalRes = res } - status(code: number): this { + status (code: number): this { this.statusCode = code return this } - set(key: string | Record, value?: string): this { + set (key: string | Record, value?: string): this { if (typeof key === 'object' && key !== null) { for (const [k, v] of Object.entries(key)) { this.headers[k.toLowerCase()] = String(v) @@ -120,12 +96,12 @@ class ResponseWriterWrapper { return this } - send(data: any): this { + send (data: any): this { this.body = convertValueToArray(data, this.headers) return this } - json(data: any): this { + json (data: any): this { if (!this.headers['content-type']) { this.headers['content-type'] = 'application/json' } @@ -133,7 +109,7 @@ class ResponseWriterWrapper { return this } - text(data: string): this { + text (data: string): this { if (!this.headers['content-type']) { this.headers['content-type'] = 'text/plain' } @@ -141,29 +117,29 @@ class ResponseWriterWrapper { return this } - end(): this { + end (): this { // No-op for buffering, actual end happens on flush return this } - getStatusCode(): number { + getStatusCode (): number { return this.statusCode } - getHeaders(): Record { + getHeaders (): Record { return this.headers } - getBody(): number[] { + getBody (): number[] { return this.body } - getOriginalRes(): Response { + getOriginalRes (): Response { return this.originalRes } // Called after peer signs the response - flush(): void { + flush (): void { if (this.flushed) return this.flushed = true @@ -196,14 +172,14 @@ export class ExpressTransport implements Transport { /** * 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` + * + * @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( + constructor ( allowUnauthenticated: boolean = false, logger?: typeof console, logLevel?: LogLevel @@ -215,12 +191,12 @@ export class ExpressTransport implements Transport { /** * 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( + private log ( level: LogLevel, message: string, data?: any @@ -236,7 +212,7 @@ export class ExpressTransport implements Transport { } } - setPeer(peer: Peer): void { + setPeer (peer: Peer): void { this.peer = peer this.log('debug', 'Peer set in ExpressTransport', { peer }) } @@ -251,114 +227,132 @@ export class ExpressTransport implements Transport { * ### Returns: * @returns {Promise} A promise that resolves once the message has been sent successfully. */ - async send(message: AuthMessage): Promise { - this.log('debug', `Attempting to send AuthMessage`, { message }) + async send (message: AuthMessage): Promise { + this.log('debug', 'Attempting to send AuthMessage', { message }) if (message.messageType === 'general') { - // 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 = {} - 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 - } - } + await this.sendGeneralMessage(message) + } else { + await this.sendNonGeneralMessage(message) + } + } - 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 + /** + * Handles a general (authenticated application) AuthMessage response. + */ + private async sendGeneralMessage (message: AuthMessage): Promise { + const reader = new Utils.Reader(message.payload) + const requestId = Utils.toBase64(reader.read(32)) - if (message.requestedCertificates) { - responseHeaders['x-bsv-auth-requested-certificates'] = JSON.stringify(message.requestedCertificates) - } + 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) - } + 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) - } + 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() - } + 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 { - 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 = {} - 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) - } + res.end() + } + } - this.log('info', 'Sending non-general AuthMessage response', { - status: 200, - responseHeaders, - messagePayload: message - }) - res.send(message) - handles.shift() - } + /** + * Reads response headers from a binary reader. + */ + private readResponseHeaders (reader: Utils.Reader): Record { + const responseHeaders: Record = {} + 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 { + 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 = { + '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): Promise { - this.log('debug', `onData callback set`) - // Just store the callback + async onData (callback: (message: AuthMessage) => Promise): Promise { + this.log('debug', 'onData callback set') this.messageCallback = callback } @@ -384,7 +378,7 @@ export class ExpressTransport implements Transport { * @param {NextFunction} next - The Express `next` middleware function. * @param {Function} [onCertificatesReceived] - Optional callback invoked when certificates are received. */ - public handleIncomingRequest( + public handleIncomingRequest ( req: AuthRequest, res: Response, next: NextFunction, @@ -396,7 +390,7 @@ export class ExpressTransport implements Transport { next: NextFunction ) => void ): void { - this.log('debug', `Handling incoming request`, { + this.log('debug', 'Handling incoming request', { path: req.path, headers: req.headers, method: req.method, @@ -404,307 +398,405 @@ export class ExpressTransport implements Transport { }) try { if (!this.peer) { - this.log('error', `No Peer set in ExpressTransport! Cannot handle request.`) + 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! - } + 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) + } + } - if (Array.isArray(this.openNonGeneralHandles[requestId])) { - this.openNonGeneralHandles[requestId].push({ res, next }) - } else { - this.openNonGeneralHandles[requestId] = [{ res, next }] + /** + * Handles a request to /.well-known/auth (non-general / handshake messages). + */ + private handleWellKnownAuth ( + req: AuthRequest, + res: Response, + next: NextFunction, + onCertificatesReceived?: ( + senderPublicKey: string, + certs: VerifiableCertificate[], + req: AuthRequest, + res: Response, + next: NextFunction + ) => void + ): 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 (!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) } - if (!this.peer.sessionManager.hasSession(message.identityKey)) { - // Capture requestId in closure for consistent key usage - const handleKey = requestId - const listenerId = this.peer.listenForCertificatesReceived( - (senderPublicKey: string, certs: VerifiableCertificate[]) => { - try { - this.log('debug', 'Certificates received event triggered', { - senderPublicKey, - certCount: certs?.length, - handleKey - }) - if (senderPublicKey !== req.body.identityKey) { - return - } - if (!Array.isArray(certs) || certs.length === 0) { - this.log('warn', 'No certificates provided by peer', { senderPublicKey }) - const handles = this.openNonGeneralHandles[handleKey] - if (handles && handles.length > 0) { - handles[0].res.status(400).json({ status: 'No certificates provided' }) - } - } else { - this.log('info', 'Certificates successfully received from peer', { - senderPublicKey, - certs - }) - if (typeof onCertificatesReceived === 'function') { - onCertificatesReceived(senderPublicKey, certs, req, res, next) - } - - const nextFn = this.openNextHandlers[message.identityKey] - if (typeof nextFn === 'function') { - const timeoutHandle = this.openNextHandlerTimeouts[message.identityKey] - if (timeoutHandle != null) { - clearTimeout(timeoutHandle) - delete this.openNextHandlerTimeouts[message.identityKey] - } - nextFn() - delete this.openNextHandlers[message.identityKey] - } - } - } catch (error) { - this.log('error', 'Error in certificate listener callback', { error }) - } finally { - // Always clean up - use consistent key - const handles = this.openNonGeneralHandles[handleKey] - if (handles && handles.length > 0) { - handles.shift() - if (handles.length === 0) { - delete this.openNonGeneralHandles[handleKey] - } - } - this.peer?.stopListeningForCertificatesReceived(listenerId) - } - }) - this.log('debug', 'listenForCertificatesReceived registered', { listenerId, handleKey }) + }) + 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) + } + }) - 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.' - }) + 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 => { + 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 (_) { + // Response may already be partially sent } - } else if (req.headers['x-bsv-auth-request-id']) { - // Possibly general message - 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 } - - // Use ResponseWriterWrapper for cleaner state management - const wrapper = new ResponseWriterWrapper(res) - - // Track if response has been built and sent - let responseSent = false - - const buildAndSendResponse = async (): Promise => { - 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 (_) { - // Response may already be partially sent - } - } - } - - // 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, 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() - }) - } - - // Check if we need certificates AND don't already have a session with validated certificates - const hasSession = 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) { - // Store next to be called when certificates arrive - 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 - - // Add timeout to prevent indefinite hanging - 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 - } else { - this.log('debug', 'Calling next() immediately - no certificate wait needed', { senderPublicKey, hasSession }) - 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) => { - 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 }) - }) - } - } 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!' - }) - } + this.hijackResponse(res, next, wrapper, buildAndSendResponse) + this.scheduleNextOrCertificateWait(next, senderPublicKey, wrapper, buildAndSendResponse) + } + + /** + * Overrides the response methods to intercept and buffer the response for signing. + */ + private hijackResponse ( + res: Response, + next: NextFunction, + wrapper: ResponseWriterWrapper, + buildAndSendResponse: () => Promise + ): 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, 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 } - } catch (error) { - this.log('error', `Caught error in handleIncomingRequest`, { error }) - next(error) + + const mimeType = mime.lookup(path) || 'application/octet-stream' + wrapper.set('Content-Type', mimeType) + wrapper.send(Array.from(data)) + buildAndSendResponse() + }) } } - private checkRes(res: any, test?: 'needs to be clear' | 'needs to be hijacked', next?: Function): void { + /** + * Either calls next() immediately or stores it pending certificate arrival. + */ + private scheduleNextOrCertificateWait ( + next: NextFunction, + senderPublicKey: string, + wrapper: ResponseWriterWrapper, + buildAndSendResponse: () => Promise + ): void { + const hasSession = 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' || @@ -737,12 +829,12 @@ export class ExpressTransport implements Transport { } } - private resetRes(res: Response, next?: Function): Response { + 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 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 @@ -753,23 +845,18 @@ export class ExpressTransport implements Transport { /** * Helper: Build AuthMessage from Request */ -function buildAuthMessageFromRequest( +function buildAuthMessageFromRequest ( req: Request, logger?: typeof console, logLevel?: LogLevel ): 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 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'] @@ -778,62 +865,12 @@ function buildAuthMessageFromRequest( 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 + const protocol = req.protocol + const host = req.get('host') + const parsedUrl = new URL(`${protocol}://${host}${req.originalUrl}`) - // 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 (const [headerKey, headerValue] of includedHeaders) { - const headerKeyAsArray = Utils.toArray(headerKey, 'utf8') - writer.writeVarIntNum(headerKeyAsArray.length) - writer.write(headerKeyAsArray) - - const headerValueAsArray = Utils.toArray(headerValue, 'utf8') - writer.writeVarIntNum(headerValueAsArray.length) - writer.write(headerValueAsArray) - } - - // Body + writeUrlToWriter(parsedUrl, writer) + writeRequestHeadersToWriter(req, writer) writeBodyToWriter(req, writer, logger, logLevel) const authMessage = { @@ -848,99 +885,15 @@ function buildAuthMessageFromRequest( : [] } - if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) { - getLogMethod(logger, 'debug')( - `[buildAuthMessageFromRequest] AuthMessage built`, - { authMessage } - ) - } + debugLog('[buildAuthMessageFromRequest] AuthMessage built', { authMessage }) return authMessage } -/** - * Helper: Write body to writer - */ -function writeBodyToWriter( - req: Request, - writer: Utils.Writer, - logger?: typeof console, - logLevel?: LogLevel -) { - 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( +function buildResponsePayload ( requestId: string, responseStatus: number, responseHeaders: Record, @@ -949,14 +902,13 @@ function buildResponsePayload( logger?: typeof console, logLevel?: LogLevel ): number[] { - if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) { - getLogMethod(logger, 'debug')(`[buildResponsePayload] Building response payload`, { - requestId, - responseStatus, - responseHeaders, - responseBodyLength: responseBody.length - }) - } + 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')) @@ -978,13 +930,7 @@ function buildResponsePayload( writer.writeVarIntNum(includedHeaders.length) for (const [headerKey, headerValue] of includedHeaders) { - const headerKeyAsArray = Utils.toArray(headerKey, 'utf8') - writer.writeVarIntNum(headerKeyAsArray.length) - writer.write(headerKeyAsArray) - - const headerValueAsArray = Utils.toArray(headerValue, 'utf8') - writer.writeVarIntNum(headerValueAsArray.length) - writer.write(headerValueAsArray) + writeHeaderPair(writer, headerKey, headerValue) } if (responseBody.length > 0) { @@ -997,36 +943,13 @@ function buildResponsePayload( return writer.toArray() } -/** - * Helper: Convert values passed to res.send(...) into byte arrays - */ -function convertValueToArray(val: any, responseHeaders: Record): 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 { +export function createAuthMiddleware (options: AuthMiddlewareOptions): (req: AuthRequest, res: Response, next: NextFunction) => void { const { wallet, sessionManager, @@ -1040,13 +963,12 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions): (req: Auth if (!wallet) { if (logger && logLevel && isLogLevelEnabled(logLevel, 'error')) { getLogMethod(logger, 'error')( - `[createAuthMiddleware] No wallet provided in AuthMiddlewareOptions.` + '[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() @@ -1061,10 +983,9 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions): (req: Auth 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`, { + getLogMethod(logger, 'debug')('[createAuthMiddleware] Incoming request to auth middleware', { path: req.path, headers: req.headers, method: req.method