diff --git a/package.json b/package.json index 6a20211..b50fc27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wave-tech/framework", - "version": "0.15.0", + "version": "0.16.0", "description": "wave tech team shared utils framework", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -25,12 +25,20 @@ "peerDependencies": { "@dbos-inc/dbos-sdk": "^4.27.6", "@prisma/client": ">=6.3.1", + "@types/express": "^4.17.21", + "express": "^4.21.2", "fastify": "^5.0.0" }, "peerDependenciesMeta": { "@dbos-inc/dbos-sdk": { "optional": true }, + "@types/express": { + "optional": true + }, + "express": { + "optional": true + }, "fastify": { "optional": true } @@ -38,11 +46,11 @@ "dependencies": { "@fastify/rate-limit": "^11.2.0", "date-fns": "^4.1.0", - "express": "^4.21.2", "google-auth-library": "^9.15.1", "hono": "^4.8.5", "jose": "^6.2.12", "mathjs": "^14.2.1", + "path-to-regexp": "8.4.2", "uuid": "^11.0.5", "winston": "^3.17.0", "zod": "^4.4.3" @@ -51,7 +59,7 @@ "@dbos-inc/dbos-sdk": "^4.27.6", "@eslint/js": "^9.20.0", "@prisma/client": "^6.3.1", - "@types/express": "^5.0.0", + "@types/express": "^4.17.21", "@types/node": "^24.0.0", "@typescript-eslint/parser": "^8.24.0", "cross-env": "^7.0.3", @@ -60,6 +68,7 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^7.2.1", + "express": "^4.21.2", "fastify": "^5.11.2", "globals": "^15.14.0", "husky": "^9.1.7", @@ -140,6 +149,11 @@ "types": "./dist/auth-permission/index.d.ts", "require": "./dist/auth-permission/index.js", "default": "./dist/auth-permission/index.js" + }, + "./auth-permission/express": { + "types": "./dist/auth-permission/express/index.d.ts", + "require": "./dist/auth-permission/express/index.js", + "default": "./dist/auth-permission/express/index.js" } }, "typesVersions": { @@ -176,6 +190,9 @@ ], "auth-permission": [ "./dist/auth-permission/index.d.ts" + ], + "auth-permission/express": [ + "./dist/auth-permission/express/index.d.ts" ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8485f4d..c0f43ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 - express: - specifier: ^4.21.2 - version: 4.21.2 google-auth-library: specifier: ^9.15.1 version: 9.15.1 @@ -29,6 +26,9 @@ importers: mathjs: specifier: ^14.2.1 version: 14.2.1 + path-to-regexp: + specifier: 8.4.2 + version: 8.4.2 uuid: specifier: ^11.0.5 version: 11.0.5 @@ -49,8 +49,8 @@ importers: specifier: ^6.3.1 version: 6.3.1(typescript@5.7.3) '@types/express': - specifier: ^5.0.0 - version: 5.0.0 + specifier: ^4.17.21 + version: 4.17.25 '@types/node': specifier: ^24.0.0 version: 24.12.2 @@ -75,6 +75,9 @@ importers: eslint-plugin-promise: specifier: ^7.2.1 version: 7.2.1(eslint@9.20.1) + express: + specifier: ^4.21.2 + version: 4.21.2 fastify: specifier: ^5.11.2 version: 5.12.4 @@ -533,11 +536,11 @@ packages: '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} - '@types/express-serve-static-core@5.0.6': - resolution: {integrity: sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==} + '@types/express-serve-static-core@4.19.9': + resolution: {integrity: sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==} - '@types/express@5.0.0': - resolution: {integrity: sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==} + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} '@types/http-errors@2.0.4': resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} @@ -1756,6 +1759,9 @@ packages: path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2720,17 +2726,17 @@ snapshots: '@types/estree@1.0.7': {} - '@types/express-serve-static-core@5.0.6': + '@types/express-serve-static-core@4.19.9': dependencies: '@types/node': 24.12.2 '@types/qs': 6.9.18 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 - '@types/express@5.0.0': + '@types/express@4.17.25': dependencies: '@types/body-parser': 1.19.5 - '@types/express-serve-static-core': 5.0.6 + '@types/express-serve-static-core': 4.19.9 '@types/qs': 6.9.18 '@types/serve-static': 1.15.7 @@ -4181,6 +4187,8 @@ snapshots: path-to-regexp@0.1.12: {} + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} pathval@2.0.0: {} diff --git a/src/auth-permission/api-key/check-api-key-permission.ts b/src/auth-permission/api-key/check-api-key-permission.ts index 836b1ea..9d9fd0d 100644 --- a/src/auth-permission/api-key/check-api-key-permission.ts +++ b/src/auth-permission/api-key/check-api-key-permission.ts @@ -1,24 +1,30 @@ -import type { FastifyRequest } from 'fastify'; +import type { BrokerContext } from '../broker-context'; import type { PermissionValidationResult, PermissionValidator } from './permission-validator'; import { PermissionDeniedError } from '../errors/permission-denied-error'; import type { RouteProperties } from '../route-properties'; -import { readBrokerId, resolveBrokerContext } from '../shared/broker-scope-resolution'; +import { readBrokerId, resolveBrokerScope } from '../shared/broker-scope-resolution'; +import type { WaveRequest } from '../shared/wave-request'; /** * Authenticates and authorizes an `x-api-key` credential against * wave-auth-api: reads the broker id header (if any), calls `validate`, and - * resolves `request.brokerContext`. Shared by the route-map hook and by the + * resolves the broker scope. Shared by the route-map hook and by the * standalone `assertHasPermission` it returns, so the two never drift on * this part of the contract. + * + * Pure — returns the resolved `BrokerContext` (or `undefined` for a + * `brokerScoped: false` route) rather than assigning it onto `request`; + * each framework-specific orchestrator does that assignment onto its own + * native request object. */ export async function checkApiKeyPermission( - request: FastifyRequest, + request: WaveRequest, route: RouteProperties, apiKey: string, validator: PermissionValidator, brokerIdHeader: string, brokerIdMaxLength: number, -): Promise { +): Promise { const brokerId = route.brokerScoped === false ? undefined : readBrokerId(request, brokerIdHeader, brokerIdMaxLength); @@ -35,7 +41,7 @@ export async function checkApiKeyPermission( throw new PermissionDeniedError(`The API key does not have the ${route.permissionName} permission`); } - resolveBrokerContext( + return resolveBrokerScope( request, route, result.brokers, diff --git a/src/auth-permission/broker-context.ts b/src/auth-permission/broker-context.ts index 9f1c640..4c6bb67 100644 --- a/src/auth-permission/broker-context.ts +++ b/src/auth-permission/broker-context.ts @@ -1,25 +1,27 @@ -import type { FastifyRequest } from 'fastify'; import { BrokerContextNotResolvedError } from './errors/broker-context-not-resolved-error'; -/** The broker scope `registerPermissionAuth` resolved for the in-flight request. Never empty. */ +/** + * The broker scope `registerPermissionAuth`/`expressRegisterPermissionAuth` + * resolved for the in-flight request. Never empty. + */ export interface BrokerContext { readonly scope: readonly string[]; } -declare module 'fastify' { - interface FastifyRequest { - /** Set by `registerPermissionAuth`'s hook. Absent on public paths. */ - brokerContext?: BrokerContext; - } -} - /** * The broker scope resolved for this request. Every route this hook actually * protects gets one — a missing context here is a route-wiring defect * (e.g. this route bypasses the hook somehow), not something a caller * triggered, hence the `500` instead of a `4xx`. + * + * Framework-neutral by design: the actual `brokerContext?: BrokerContext` + * augmentation of the real request type lives next to each framework's own + * orchestrator (`fastify/to-wave-request.ts` for Fastify, `express/express-request.ts` + * for Express), never here — this file is on the shared export path, and an + * Express-only consumer resolving a `declare module 'fastify'` augmentation + * here would drag in an unresolvable `fastify` type import for no reason. */ -export function requireBrokerContext(request: FastifyRequest): BrokerContext { +export function requireBrokerContext(request: { brokerContext?: BrokerContext }): BrokerContext { if (request.brokerContext === undefined) { throw new BrokerContextNotResolvedError(); } diff --git a/src/auth-permission/express/express-register-permission-auth.spec.ts b/src/auth-permission/express/express-register-permission-auth.spec.ts new file mode 100644 index 0000000..95778cf --- /dev/null +++ b/src/auth-permission/express/express-register-permission-auth.spec.ts @@ -0,0 +1,293 @@ +import express, { type Express, type Request, type Response, type NextFunction } from 'express'; +import type { Server } from 'node:http'; +import { describe, it, expect, afterEach } from 'vitest'; +import { + expressRegisterPermissionAuth, + API_KEY_HEADER, + AUTHORIZATION_HEADER, +} from './express-register-permission-auth'; +import { requireBrokerContext } from '../broker-context'; +import { PermissionValidatorUnauthorizedError } from '../errors/permission-validator-unauthorized-error'; +import { PermissionDeniedError } from '../errors/permission-denied-error'; +import { AmbiguousBrokerTargetError } from '../errors/ambiguous-broker-target-error'; +import { SessionTokenInvalidError } from '../errors/session-token-invalid-error'; +import type { + PermissionValidationRequest, + PermissionValidationResult, + PermissionValidator, +} from '../api-key/permission-validator'; +import type { SessionTokenClaims, SessionTokenVerifier } from '../session-token/session-token-verifier'; +import type { RouteProperties } from '../route-properties'; + +const API_KEY = 'test-secret-key'; +const SESSION_TOKEN = 'test-session-token'; +const PERMISSION = 'usage.balance.get'; + +class FakePermissionValidator implements PermissionValidator { + public readonly calls: PermissionValidationRequest[] = []; + + constructor( + private readonly resolve: ( + request: PermissionValidationRequest, + ) => PermissionValidationResult | Error, + ) {} + + validate(request: PermissionValidationRequest): Promise { + this.calls.push(request); + const outcome = this.resolve(request); + return outcome instanceof Error ? Promise.reject(outcome) : Promise.resolve(outcome); + } +} + +const authorizes = (brokers: string[] = ['tim']): FakePermissionValidator => + new FakePermissionValidator(() => ({ authorized: true, brokers })); + +const denies = (): FakePermissionValidator => + new FakePermissionValidator(() => ({ authorized: false, brokers: [] })); + +class FakeSessionTokenVerifier implements SessionTokenVerifier { + public readonly calls: string[] = []; + + constructor(private readonly resolve: (token: string) => SessionTokenClaims | Error) {} + + verify(token: string): Promise { + this.calls.push(token); + const outcome = this.resolve(token); + return outcome instanceof Error ? Promise.reject(outcome) : Promise.resolve(outcome); + } +} + +const verifiesAs = (claims: SessionTokenClaims): FakeSessionTokenVerifier => + new FakeSessionTokenVerifier(() => claims); + +function testErrorHandler(error: Error, req: Request, res: Response, next: NextFunction): void { + if (error instanceof PermissionValidatorUnauthorizedError) { + res.status(401).json({ type: 'unauthorized', message: error.message }); + return; + } + if (error instanceof PermissionDeniedError) { + res.status(403).json({ type: 'forbidden', message: error.message }); + return; + } + if (error instanceof AmbiguousBrokerTargetError) { + res.status(400).json({ type: 'ambiguous-broker-target', message: error.message }); + return; + } + if (error instanceof SessionTokenInvalidError) { + res.status(401).json({ type: 'session-token-invalid', message: error.message }); + return; + } + res.status(500).json({ type: 'unexpected', message: error.message }); +} + +interface BuildAppOptions { + validator: PermissionValidator; + sessionTokenVerifier?: SessionTokenVerifier; + routes: Readonly>; + publicPaths?: string[]; +} + +function buildTestApp(options: BuildAppOptions): Express { + const app = express(); + + const { middleware } = expressRegisterPermissionAuth({ + validator: options.validator, + sessionTokenVerifier: options.sessionTokenVerifier, + routes: options.routes, + publicPaths: options.publicPaths, + }); + + app.use(middleware); + + app.get('/public', (req, res) => res.json({ status: 'ok' })); + app.get('/balances', (req, res) => res.json({ scope: requireBrokerContext(req).scope })); + app.get('/tenant-wide', (req, res) => + res.json({ hasBrokerContext: req.brokerContext !== undefined }), + ); + app.get('/session-scoped', (req, res) => res.json({ scope: requireBrokerContext(req).scope })); + + app.use(testErrorHandler); + + return app; +} + +async function listen(app: Express): Promise<{ server: Server; baseUrl: string }> { + const server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Expected the Express server to bind to a TCP port.'); + } + return { server, baseUrl: `http://127.0.0.1:${address.port}` }; +} + +async function inject( + baseUrl: string, + path: string, + headers: Record = {}, +): Promise<{ statusCode: number; json: () => Promise }> { + const res = await fetch(`${baseUrl}${path}`, { headers }); + return { statusCode: res.status, json: () => res.json() }; +} + +describe('expressRegisterPermissionAuth', () => { + let server: Server | undefined; + + afterEach(async () => { + if (server !== undefined) { + await new Promise((resolve) => { + server?.close(() => { + resolve(); + }); + }); + server = undefined; + } + }); + + describe('public paths', () => { + it('allows a public path without a key, without calling the validator', async () => { + const validator = authorizes(); + const app = buildTestApp({ + validator, + routes: { 'GET /balances': { permissionName: PERMISSION } }, + publicPaths: ['/public'], + }); + const listening = await listen(app); + server = listening.server; + + const res = await inject(listening.baseUrl, '/public'); + + expect(res.statusCode).toBe(200); + expect(validator.calls).toHaveLength(0); + }); + + it('defaults publicPaths to empty — router-relative "/public" is never accidentally public', async () => { + const validator = authorizes(); + const app = buildTestApp({ validator, routes: { 'GET /balances': { permissionName: PERMISSION } } }); + const listening = await listen(app); + server = listening.server; + + const res = await inject(listening.baseUrl, '/public'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('authentication', () => { + it('rejects a mapped route without a key (401), without calling the validator', async () => { + const validator = authorizes(); + const app = buildTestApp({ validator, routes: { 'GET /balances': { permissionName: PERMISSION } } }); + const listening = await listen(app); + server = listening.server; + + const res = await inject(listening.baseUrl, '/balances'); + + expect(res.statusCode).toBe(401); + expect(validator.calls).toHaveLength(0); + }); + + it('answers a mapped route once authorized', async () => { + const validator = authorizes(['tim']); + const app = buildTestApp({ validator, routes: { 'GET /balances': { permissionName: PERMISSION } } }); + const listening = await listen(app); + server = listening.server; + + const res = await inject(listening.baseUrl, '/balances', { [API_KEY_HEADER]: API_KEY }); + + expect(res.statusCode).toBe(200); + expect(await res.json()).toEqual({ scope: ['tim'] }); + expect(validator.calls).toEqual([{ apiKey: API_KEY, permission: PERMISSION }]); + }); + + it('rejects a denied verdict (403)', async () => { + const validator = denies(); + const app = buildTestApp({ validator, routes: { 'GET /balances': { permissionName: PERMISSION } } }); + const listening = await listen(app); + server = listening.server; + + const res = await inject(listening.baseUrl, '/balances', { [API_KEY_HEADER]: API_KEY }); + + expect(res.statusCode).toBe(403); + }); + }); + + describe('deny by default', () => { + it('answers 403 (not 404) for a path with a credential but no entry in routes', async () => { + const validator = authorizes(); + const app = buildTestApp({ validator, routes: { 'GET /balances': { permissionName: PERMISSION } } }); + const listening = await listen(app); + server = listening.server; + + const res = await inject(listening.baseUrl, '/unmapped', { [API_KEY_HEADER]: API_KEY }); + + expect(res.statusCode).toBe(403); + expect(validator.calls).toHaveLength(0); + }); + }); + + describe('brokerScoped: false', () => { + it('allows an empty broker scope and sets no broker context', async () => { + const validator = authorizes([]); + const app = buildTestApp({ + validator, + routes: { 'GET /tenant-wide': { permissionName: PERMISSION, brokerScoped: false } }, + }); + const listening = await listen(app); + server = listening.server; + + const res = await inject(listening.baseUrl, '/tenant-wide', { [API_KEY_HEADER]: API_KEY }); + + expect(res.statusCode).toBe(200); + expect(await res.json()).toEqual({ hasBrokerContext: false }); + }); + }); + + describe('acceptsSessionToken', () => { + it('authenticates via a bearer session token, never calling the permission validator', async () => { + const validator = authorizes(); + const sessionTokenVerifier = verifiesAs({ sub: 'key-1', brokers: ['tim'] }); + const app = buildTestApp({ + validator, + sessionTokenVerifier, + routes: { + 'GET /session-scoped': { permissionName: PERMISSION, acceptsSessionToken: true }, + }, + }); + const listening = await listen(app); + server = listening.server; + + const res = await inject(listening.baseUrl, '/session-scoped', { + [AUTHORIZATION_HEADER]: `Bearer ${SESSION_TOKEN}`, + }); + + expect(res.statusCode).toBe(200); + expect(await res.json()).toEqual({ scope: ['tim'] }); + expect(sessionTokenVerifier.calls).toEqual([SESSION_TOKEN]); + expect(validator.calls).toHaveLength(0); + }); + + it('rejects a bearer-only request on a route that does not accept session tokens', async () => { + const validator = authorizes(); + const app = buildTestApp({ validator, routes: { 'GET /balances': { permissionName: PERMISSION } } }); + const listening = await listen(app); + server = listening.server; + + const res = await inject(listening.baseUrl, '/balances', { + [AUTHORIZATION_HEADER]: `Bearer ${SESSION_TOKEN}`, + }); + + expect(res.statusCode).toBe(401); + }); + + it('throws synchronously at registration if a route opts in without a sessionTokenVerifier', () => { + expect(() => + expressRegisterPermissionAuth({ + validator: authorizes(), + routes: { + 'GET /session-scoped': { permissionName: PERMISSION, acceptsSessionToken: true }, + }, + }), + ).toThrow(/sessionTokenVerifier/); + }); + }); +}); diff --git a/src/auth-permission/express/express-register-permission-auth.ts b/src/auth-permission/express/express-register-permission-auth.ts new file mode 100644 index 0000000..a5540d9 --- /dev/null +++ b/src/auth-permission/express/express-register-permission-auth.ts @@ -0,0 +1,185 @@ +import type { Request, RequestHandler } from 'express'; +import type { PermissionValidator } from '../api-key/permission-validator'; +import type { SessionTokenVerifier } from '../session-token/session-token-verifier'; +import { PermissionValidatorUnauthorizedError } from '../errors/permission-validator-unauthorized-error'; +import { PermissionDeniedError } from '../errors/permission-denied-error'; +import type { RouteProperties } from '../route-properties'; +import { checkApiKeyPermission } from '../api-key/check-api-key-permission'; +import { authenticateWithSessionToken } from '../session-token/authenticate-with-session-token'; +import { readApiKey, readBearerToken } from '../shared/read-credentials'; +import { isPublicPath } from '../shared/is-public-path'; +import { compileRoutes, matchRoute } from './route-matcher'; +import './express-request'; + +/** Header clients must send carrying their API key. */ +export const API_KEY_HEADER = 'x-api-key'; +/** Header clients may send a session token in, as `Bearer `. */ +export const AUTHORIZATION_HEADER = 'authorization'; +/** Header a caller sends to restrict a multi-broker scope to a single target. */ +export const BROKER_ID_HEADER = 'x-broker-id'; +/** Shape limit on `BROKER_ID_HEADER`'s value, matching wave-auth-api's own `brokerId` contract. */ +export const BROKER_ID_MAX_LENGTH = 100; + +export interface ExpressPermissionAuthOptions { + validator: PermissionValidator; + /** + * `"METHOD path"` (a `path-to-regexp` pattern, plain `:param` segments + * only — no `:id?`, `*`, or `:id(\d+)`, which `path-to-regexp@8` rejects + * outright) -> what it requires. **Must list entries in the same relative + * order the corresponding routes are registered on the actual Express + * router** — see `matchRoute`'s doc comment for why this matters whenever + * two patterns can overlap the same request. + */ + routes: Readonly>; + /** + * Paths that bypass authentication, matched by exact value or as a + * prefix. Defaults to `[]` — **not** the Fastify hook's defaults: this + * middleware is meant to be mounted on an Express sub-router, where + * `req.path` is router-relative (`GET /api` arrives as `req.path === '/'` + * from inside a router mounted at `/api`), so reusing a default that + * includes `'/'` would silently make that router's own root path public. + */ + publicPaths?: string[]; + apiKeyHeader?: string; + /** Required when any route in `routes` sets `acceptsSessionToken: true`. */ + sessionTokenVerifier?: SessionTokenVerifier; + authorizationHeader?: string; + brokerIdHeader?: string; + brokerIdMaxLength?: number; +} + +/** Checks a single `RouteProperties` requirement for an already-authenticated request. */ +export type ExpressAssertHasPermission = ( + request: Request, + route: RouteProperties, +) => Promise; + +export interface ExpressPermissionAuth { + middleware: RequestHandler; + assertHasPermission: ExpressAssertHasPermission; +} + +/** + * The Express counterpart to `registerPermissionAuth`, for services that + * can't be Fastify (see `@wave-tech/framework/auth-permission`'s own + * `registerPermissionAuth`, which this deliberately mirrors wherever the two + * frameworks allow it). Returns a middleware to `.use()` on the router it + * protects — **before** any `.get/.post/.put/...` calls on that router — + * plus the same standalone `assertHasPermission` for checking a permission + * outside the route map. + * + * Two behaviors are deliberately different from the Fastify hook, both a + * consequence of Express giving a `.use()`-mounted middleware no equivalent + * to Fastify's `request.routeOptions.url` (the already-resolved matched + * route) before the real route handlers run: + * + * 1. Route resolution is done by this module's own `route-matcher`, not + * Express's real router — see its doc comment on the ordering + * requirement this places on `routes`. + * 2. A path not found in `routes` answers `403` (`PermissionDeniedError`), + * never a synthetic `404` — letting a miss fall through unauthenticated + * (`next()`) would defeat deny-by-default for any real, registered route + * the caller forgot to map. Fail-closed, but a real, intentional + * difference from the Fastify hook's own contract. + * + * No rate-limiting option: `@fastify/rate-limit` is Fastify-only. + */ +export function expressRegisterPermissionAuth( + options: ExpressPermissionAuthOptions, +): ExpressPermissionAuth { + const publicPaths = options.publicPaths ?? []; + const apiKeyHeader = options.apiKeyHeader ?? API_KEY_HEADER; + const authorizationHeader = options.authorizationHeader ?? AUTHORIZATION_HEADER; + const brokerIdHeader = options.brokerIdHeader ?? BROKER_ID_HEADER; + const brokerIdMaxLength = options.brokerIdMaxLength ?? BROKER_ID_MAX_LENGTH; + + for (const [routeKey, route] of Object.entries(options.routes)) { + if (route.acceptsSessionToken === true && options.sessionTokenVerifier === undefined) { + throw new Error( + `${routeKey} sets acceptsSessionToken but expressRegisterPermissionAuth was not given a sessionTokenVerifier`, + ); + } + } + + const compiledRoutes = compileRoutes(options.routes); + + const assertHasPermission: ExpressAssertHasPermission = async (request, route) => { + const apiKey = readApiKey(request, apiKeyHeader); + if (apiKey !== undefined) { + const ctx = await checkApiKeyPermission( + request, + route, + apiKey, + options.validator, + brokerIdHeader, + brokerIdMaxLength, + ); + if (ctx !== undefined) { + request.brokerContext = ctx; + } + return; + } + + const token = readBearerToken(request, authorizationHeader); + if (token === undefined || route.acceptsSessionToken !== true) { + throw new PermissionValidatorUnauthorizedError(); + } + + if (options.sessionTokenVerifier === undefined) { + throw new Error('acceptsSessionToken route reached with no sessionTokenVerifier configured'); + } + + const ctx = await authenticateWithSessionToken( + request, + route, + token, + options.sessionTokenVerifier, + brokerIdHeader, + brokerIdMaxLength, + ); + if (ctx !== undefined) { + request.brokerContext = ctx; + } + }; + + const middleware: RequestHandler = (request, response, next) => { + if (isPublicPath(request.path, publicPaths)) { + next(); + return; + } + + const hasCredential = + readApiKey(request, apiKeyHeader) !== undefined || + readBearerToken(request, authorizationHeader) !== undefined; + if (!hasCredential) { + next(new PermissionValidatorUnauthorizedError()); + return; + } + + const route = matchRoute(compiledRoutes, request.method, request.path); + if (route === undefined) { + next(new PermissionDeniedError(`No permission is mapped for ${request.method} ${request.path}`)); + return; + } + + // Express 4 does not catch a rejected promise thrown inside a + // middleware on its own — failures must be funneled through `next` + // explicitly. + void runAssertion(request, route, next); + }; + + async function runAssertion( + request: Request, + route: RouteProperties, + next: Parameters[2], + ): Promise { + try { + await assertHasPermission(request, route); + next(); + } catch (error) { + next(error); + } + } + + return { middleware, assertHasPermission }; +} diff --git a/src/auth-permission/express/express-request.ts b/src/auth-permission/express/express-request.ts new file mode 100644 index 0000000..2041c60 --- /dev/null +++ b/src/auth-permission/express/express-request.ts @@ -0,0 +1,29 @@ +import type { BrokerContext } from '../broker-context'; + +/** + * Augments the non-generic `Express.Request` global namespace — not + * `express-serve-static-core`'s generic `Request` interface directly, which would fail to merge + * (`TS2428: All declarations of 'Request' must have identical type + * parameters`). This is the extension point `@types/express` itself + * publishes for exactly this purpose, and it compiles even in a consumer + * that doesn't have `@types/express` installed at all. The `namespace` + * keyword is unavoidable here — it's how `@types/express` itself declares + * this exact extension point, not a stylistic choice. + * + * A real Express `Request` already satisfies `WaveRequest` natively (it + * prototype-chains onto Node's `http.IncomingMessage`, so `headers` and + * `rawHeaders` are already top-level) — unlike the Fastify integration, + * nothing here needs a conversion function. + */ +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace -- Express's own augmentation shape + namespace Express { + interface Request { + /** Set by `expressRegisterPermissionAuth`'s middleware. Absent on public paths. */ + brokerContext?: BrokerContext; + } + } +} + +export {}; diff --git a/src/auth-permission/express/index.ts b/src/auth-permission/express/index.ts new file mode 100644 index 0000000..92f9e87 --- /dev/null +++ b/src/auth-permission/express/index.ts @@ -0,0 +1,36 @@ +export type { + PermissionValidationRequest, + PermissionValidationResult, + PermissionValidator, +} from '../api-key/permission-validator'; +export { WaveAuthPermissionValidator } from '../api-key/wave-auth-permission-validator'; +export type { SessionTokenClaims, SessionTokenVerifier } from '../session-token/session-token-verifier'; +export { WaveAuthSessionTokenVerifier } from '../session-token/wave-auth-session-token-verifier'; +export { correlationHeaders } from '../shared/correlation-headers'; + +export type { RouteProperties } from '../route-properties'; +export type { BrokerContext } from '../broker-context'; +export { requireBrokerContext } from '../broker-context'; + +export type { + ExpressPermissionAuthOptions, + ExpressAssertHasPermission, + ExpressPermissionAuth, +} from './express-register-permission-auth'; +export { + expressRegisterPermissionAuth, + API_KEY_HEADER, + AUTHORIZATION_HEADER, + BROKER_ID_HEADER, + BROKER_ID_MAX_LENGTH, +} from './express-register-permission-auth'; + +export { + PermissionValidatorUnauthorizedError, + PermissionValidatorUnavailableError, + PermissionDeniedError, + MalformedBrokerHeaderError, + AmbiguousBrokerTargetError, + BrokerContextNotResolvedError, + SessionTokenInvalidError, +} from '../errors'; diff --git a/src/auth-permission/express/route-matcher.spec.ts b/src/auth-permission/express/route-matcher.spec.ts new file mode 100644 index 0000000..e08ac10 --- /dev/null +++ b/src/auth-permission/express/route-matcher.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { compileRoutes, matchRoute } from './route-matcher'; +import type { RouteProperties } from '../route-properties'; + +const route = (permissionName: string): RouteProperties => ({ permissionName }); + +describe('route-matcher', () => { + it('matches an unambiguous request to its one obviously-right pattern', () => { + const compiled = compileRoutes({ + 'GET /accounts/:accountId': route('usage.account.get'), + 'GET /accounts/:accountId/balances': route('usage.balance.get'), + }); + + expect(matchRoute(compiled, 'GET', '/accounts/123')?.permissionName).toBe('usage.account.get'); + expect(matchRoute(compiled, 'GET', '/accounts/123/balances')?.permissionName).toBe( + 'usage.balance.get', + ); + }); + + it('never matches across methods or unmapped paths', () => { + const compiled = compileRoutes({ 'GET /accounts/:accountId': route('usage.account.get') }); + + expect(matchRoute(compiled, 'POST', '/accounts/123')).toBeUndefined(); + expect(matchRoute(compiled, 'GET', '/nowhere')).toBeUndefined(); + }); + + it('never matches two different segment counts against each other', () => { + const compiled = compileRoutes({ + 'GET /accounts/:accountId': route('usage.account.get'), + 'GET /accounts/:accountId/balances': route('usage.balance.get'), + }); + + // `/accounts/123` has 2 segments and must only ever match the 2-segment + // pattern, never the 3-segment one, and vice versa. + expect(matchRoute(compiled, 'GET', '/accounts/123')?.permissionName).toBe('usage.account.get'); + }); + + it( + 'resolves a genuine overlap by map order — the exact shape found live in ' + + 'wave-usage-api’s bff module (/accounts/by-external-code/:externalCode vs ' + + '/accounts/:accountId/balances, both matching /accounts/by-external-code/balances)', + () => { + const byExternalCodeFirst = compileRoutes({ + 'GET /accounts/by-external-code/:externalCode': route('usage.account.get-by-external-code'), + 'GET /accounts/:accountId/balances': route('usage.balance.get'), + }); + expect( + matchRoute(byExternalCodeFirst, 'GET', '/accounts/by-external-code/balances')?.permissionName, + ).toBe('usage.account.get-by-external-code'); + + const balancesFirst = compileRoutes({ + 'GET /accounts/:accountId/balances': route('usage.balance.get'), + 'GET /accounts/by-external-code/:externalCode': route('usage.account.get-by-external-code'), + }); + expect( + matchRoute(balancesFirst, 'GET', '/accounts/by-external-code/balances')?.permissionName, + ).toBe('usage.balance.get'); + }, + ); + + it('rejects optional segments, wildcards and regex-constrained params at compile time', () => { + expect(() => compileRoutes({ 'GET /accounts/:id?': route('x') })).toThrow(); + expect(() => compileRoutes({ 'GET /accounts/*': route('x') })).toThrow(); + expect(() => compileRoutes({ 'GET /accounts/:id(\\d+)': route('x') })).toThrow(); + }); + + it('rejects a route key with no space between method and path', () => { + expect(() => compileRoutes({ 'GET/accounts': route('x') })).toThrow(); + }); +}); diff --git a/src/auth-permission/express/route-matcher.ts b/src/auth-permission/express/route-matcher.ts new file mode 100644 index 0000000..cfabb23 --- /dev/null +++ b/src/auth-permission/express/route-matcher.ts @@ -0,0 +1,62 @@ +import { match, type MatchFunction } from 'path-to-regexp'; +import type { RouteProperties } from '../route-properties'; + +interface CompiledRoute { + method: string; + matchPath: MatchFunction>; + route: RouteProperties; +} + +export type CompiledRoutes = readonly CompiledRoute[]; + +/** + * Compiles the `"METHOD path"` route map into matchers, using + * `path-to-regexp`'s own `:param` syntax — the same syntax Express itself + * accepts, but re-implemented here since nothing short of Express's own + * internal, private router can answer "does this path match a route I + * haven't registered yet" before the real route handlers run. Optional + * (`:id?`), wildcard (`*`) and regex-constrained (`:id(\d+)`) segments throw + * here — `path-to-regexp@8` rejects that syntax outright — so every + * supported pattern has a fixed, `/`-delimited segment count. + */ +export function compileRoutes(routes: Readonly>): CompiledRoutes { + return Object.entries(routes).map(([key, route]) => { + const { method, pattern } = splitRouteKey(key); + return { method, matchPath: match(pattern), route }; + }); +} + +/** + * Returns the first compiled route (in the map's own insertion order) whose + * method and pattern match. This deliberately does **not** try to detect or + * reject overlapping patterns: Express itself resolves an overlap by + * registration order on the router (first-registered handler wins), and two + * of this route map's own patterns can legitimately overlap the same way a + * real Express router's can (e.g. `/accounts/by-external-code/:externalCode` + * and `/accounts/:accountId/balances` both match + * `/accounts/by-external-code/balances`). Replicating that exact tie-break + * means the caller's `routes` map must list entries in the same relative + * order the corresponding routes are registered on the actual Express + * router — get that right and an overlap needs no special handling; get it + * wrong and the wrong route's permission silently applies. + */ +export function matchRoute( + compiled: CompiledRoutes, + method: string, + path: string, +): RouteProperties | undefined { + for (const entry of compiled) { + if (entry.method === method && entry.matchPath(path) !== false) { + return entry.route; + } + } + return undefined; +} + +function splitRouteKey(key: string): { method: string; pattern: string } { + const spaceIndex = key.indexOf(' '); + if (spaceIndex === -1) { + throw new Error(`Invalid route key "${key}" — expected "METHOD /path"`); + } + return { method: key.slice(0, spaceIndex), pattern: key.slice(spaceIndex + 1) }; +} diff --git a/src/auth-permission/rate-limit/rate-limit-check.ts b/src/auth-permission/fastify/rate-limit/rate-limit-check.ts similarity index 95% rename from src/auth-permission/rate-limit/rate-limit-check.ts rename to src/auth-permission/fastify/rate-limit/rate-limit-check.ts index 438ef6d..362390e 100644 --- a/src/auth-permission/rate-limit/rate-limit-check.ts +++ b/src/auth-permission/fastify/rate-limit/rate-limit-check.ts @@ -1,6 +1,6 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; import type { CreateRateLimitOptions } from '@fastify/rate-limit'; -import { TooManyRequestsError } from '../errors/too-many-requests-error'; +import { TooManyRequestsError } from '../../errors/too-many-requests-error'; export type RateLimitCheck = (request: FastifyRequest) => Promise; diff --git a/src/auth-permission/rate-limit/rate-limit-key.ts b/src/auth-permission/fastify/rate-limit/rate-limit-key.ts similarity index 66% rename from src/auth-permission/rate-limit/rate-limit-key.ts rename to src/auth-permission/fastify/rate-limit/rate-limit-key.ts index b335449..823a99d 100644 --- a/src/auth-permission/rate-limit/rate-limit-key.ts +++ b/src/auth-permission/fastify/rate-limit/rate-limit-key.ts @@ -1,5 +1,6 @@ import type { FastifyRequest } from 'fastify'; -import { readApiKey, readBearerToken } from '../shared/read-credentials'; +import { readApiKey, readBearerToken } from '../../shared/read-credentials'; +import { toWaveRequest } from '../to-wave-request'; /** * The default `keyGenerator` for `PermissionAuthOptions.rateLimit`: buckets @@ -14,8 +15,12 @@ export function credentialRateLimitKey( apiKeyHeader: string, authorizationHeader: string, ): (request: FastifyRequest) => string { - return (request) => - readApiKey(request, apiKeyHeader) ?? - readBearerToken(request, authorizationHeader) ?? - request.ip; + return (request) => { + const waveRequest = toWaveRequest(request); + return ( + readApiKey(waveRequest, apiKeyHeader) ?? + readBearerToken(waveRequest, authorizationHeader) ?? + request.ip + ); + }; } diff --git a/src/auth-permission/register-permission-auth.spec.ts b/src/auth-permission/fastify/register-permission-auth.spec.ts similarity index 97% rename from src/auth-permission/register-permission-auth.spec.ts rename to src/auth-permission/fastify/register-permission-auth.spec.ts index 08a9ccc..550fe8c 100644 --- a/src/auth-permission/register-permission-auth.spec.ts +++ b/src/auth-permission/fastify/register-permission-auth.spec.ts @@ -8,22 +8,22 @@ import { AUTHORIZATION_HEADER, BROKER_ID_HEADER, } from './register-permission-auth'; -import { requireBrokerContext } from './broker-context'; -import { PermissionValidatorUnauthorizedError } from './errors/permission-validator-unauthorized-error'; -import { PermissionValidatorUnavailableError } from './errors/permission-validator-unavailable-error'; -import { PermissionDeniedError } from './errors/permission-denied-error'; -import { MalformedBrokerHeaderError } from './errors/malformed-broker-header-error'; -import { AmbiguousBrokerTargetError } from './errors/ambiguous-broker-target-error'; -import { BrokerContextNotResolvedError } from './errors/broker-context-not-resolved-error'; -import { SessionTokenInvalidError } from './errors/session-token-invalid-error'; -import { TooManyRequestsError } from './errors/too-many-requests-error'; +import { requireBrokerContext } from '../broker-context'; +import { PermissionValidatorUnauthorizedError } from '../errors/permission-validator-unauthorized-error'; +import { PermissionValidatorUnavailableError } from '../errors/permission-validator-unavailable-error'; +import { PermissionDeniedError } from '../errors/permission-denied-error'; +import { MalformedBrokerHeaderError } from '../errors/malformed-broker-header-error'; +import { AmbiguousBrokerTargetError } from '../errors/ambiguous-broker-target-error'; +import { BrokerContextNotResolvedError } from '../errors/broker-context-not-resolved-error'; +import { SessionTokenInvalidError } from '../errors/session-token-invalid-error'; +import { TooManyRequestsError } from '../errors/too-many-requests-error'; import type { PermissionValidationRequest, PermissionValidationResult, PermissionValidator, -} from './api-key/permission-validator'; -import type { SessionTokenClaims, SessionTokenVerifier } from './session-token/session-token-verifier'; -import type { RouteProperties } from './route-properties'; +} from '../api-key/permission-validator'; +import type { SessionTokenClaims, SessionTokenVerifier } from '../session-token/session-token-verifier'; +import type { RouteProperties } from '../route-properties'; const API_KEY = 'test-secret-key'; const SESSION_TOKEN = 'test-session-token'; diff --git a/src/auth-permission/register-permission-auth.ts b/src/auth-permission/fastify/register-permission-auth.ts similarity index 85% rename from src/auth-permission/register-permission-auth.ts rename to src/auth-permission/fastify/register-permission-auth.ts index 0f6f3bb..440f053 100644 --- a/src/auth-permission/register-permission-auth.ts +++ b/src/auth-permission/fastify/register-permission-auth.ts @@ -1,15 +1,17 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; import fastifyRateLimit, { type CreateRateLimitOptions } from '@fastify/rate-limit'; -import type { PermissionValidator } from './api-key/permission-validator'; -import type { SessionTokenVerifier } from './session-token/session-token-verifier'; -import { PermissionValidatorUnauthorizedError } from './errors/permission-validator-unauthorized-error'; -import { PermissionDeniedError } from './errors/permission-denied-error'; -import type { RouteProperties } from './route-properties'; -import { checkApiKeyPermission } from './api-key/check-api-key-permission'; -import { authenticateWithSessionToken } from './session-token/authenticate-with-session-token'; -import { readApiKey, readBearerToken } from './shared/read-credentials'; +import type { PermissionValidator } from '../api-key/permission-validator'; +import type { SessionTokenVerifier } from '../session-token/session-token-verifier'; +import { PermissionValidatorUnauthorizedError } from '../errors/permission-validator-unauthorized-error'; +import { PermissionDeniedError } from '../errors/permission-denied-error'; +import type { RouteProperties } from '../route-properties'; +import { checkApiKeyPermission } from '../api-key/check-api-key-permission'; +import { authenticateWithSessionToken } from '../session-token/authenticate-with-session-token'; +import { readApiKey, readBearerToken } from '../shared/read-credentials'; import { credentialRateLimitKey } from './rate-limit/rate-limit-key'; import { createRateLimitCheck } from './rate-limit/rate-limit-check'; +import { toWaveRequest } from './to-wave-request'; +import { isPublicPath } from '../shared/is-public-path'; /** Header clients must send carrying their API key. */ export const API_KEY_HEADER = 'x-api-key'; @@ -58,10 +60,6 @@ export type AssertHasPermission = ( route: RouteProperties, ) => Promise; -function isPublicPath(path: string, publicPaths: readonly string[]): boolean { - return publicPaths.some((p) => path === p || path.startsWith(`${p}/`)); -} - /** * A plain index access (`routes[key]`) types as `RouteProperties`, not * `RouteProperties | undefined` — `Record` doesn't model a missing key. This @@ -147,20 +145,24 @@ export function registerPermissionAuth( } const assertHasPermission: AssertHasPermission = async (request, route) => { - const apiKey = readApiKey(request, apiKeyHeader); + const waveRequest = toWaveRequest(request); + const apiKey = readApiKey(waveRequest, apiKeyHeader); if (apiKey !== undefined) { - await checkApiKeyPermission( - request, + const ctx = await checkApiKeyPermission( + waveRequest, route, apiKey, options.validator, brokerIdHeader, brokerIdMaxLength, ); + if (ctx !== undefined) { + request.brokerContext = ctx; + } return; } - const token = readBearerToken(request, authorizationHeader); + const token = readBearerToken(waveRequest, authorizationHeader); if (token === undefined || route.acceptsSessionToken !== true) { throw new PermissionValidatorUnauthorizedError(); } @@ -174,14 +176,17 @@ export function registerPermissionAuth( throw new Error('acceptsSessionToken route reached with no sessionTokenVerifier configured'); } - await authenticateWithSessionToken( - request, + const ctx = await authenticateWithSessionToken( + waveRequest, route, token, options.sessionTokenVerifier, brokerIdHeader, brokerIdMaxLength, ); + if (ctx !== undefined) { + request.brokerContext = ctx; + } }; app.addHook('onRequest', async (request: FastifyRequest) => { @@ -194,9 +199,10 @@ export function registerPermissionAuth( await checkRateLimit(request); } + const waveRequest = toWaveRequest(request); const hasCredential = - readApiKey(request, apiKeyHeader) !== undefined || - readBearerToken(request, authorizationHeader) !== undefined; + readApiKey(waveRequest, apiKeyHeader) !== undefined || + readBearerToken(waveRequest, authorizationHeader) !== undefined; if (!hasCredential) { throw new PermissionValidatorUnauthorizedError(); } diff --git a/src/auth-permission/fastify/to-wave-request.ts b/src/auth-permission/fastify/to-wave-request.ts new file mode 100644 index 0000000..6190053 --- /dev/null +++ b/src/auth-permission/fastify/to-wave-request.ts @@ -0,0 +1,24 @@ +import type { FastifyRequest } from 'fastify'; +import type { BrokerContext } from '../broker-context'; +import type { WaveRequest } from '../shared/wave-request'; + +declare module 'fastify' { + interface FastifyRequest { + /** Set by `registerPermissionAuth`'s hook. Absent on public paths. */ + brokerContext?: BrokerContext; + } +} + +/** + * Fastify nests the raw Node request under `.raw`, so `rawHeaders` isn't + * top-level the way `WaveRequest` (and a real Express `Request`) expects — + * this is the one small per-request conversion the Fastify side needs that + * Express doesn't. + */ +export function toWaveRequest(request: FastifyRequest): WaveRequest { + return { + headers: request.headers, + rawHeaders: request.raw.rawHeaders, + query: request.query, + }; +} diff --git a/src/auth-permission/index.ts b/src/auth-permission/index.ts index ee9d7f3..264156d 100644 --- a/src/auth-permission/index.ts +++ b/src/auth-permission/index.ts @@ -11,7 +11,7 @@ export { correlationHeaders } from './shared/correlation-headers'; export type { RouteProperties } from './route-properties'; export type { BrokerContext } from './broker-context'; export { requireBrokerContext } from './broker-context'; -export type { PermissionAuthOptions, AssertHasPermission } from './register-permission-auth'; +export type { PermissionAuthOptions, AssertHasPermission } from './fastify/register-permission-auth'; export { registerPermissionAuth, API_KEY_HEADER, @@ -19,7 +19,7 @@ export { BROKER_ID_HEADER, BROKER_ID_MAX_LENGTH, PUBLIC_PATHS, -} from './register-permission-auth'; +} from './fastify/register-permission-auth'; export { PermissionValidatorUnauthorizedError, diff --git a/src/auth-permission/route-properties.ts b/src/auth-permission/route-properties.ts index cd6af3a..6aab647 100644 --- a/src/auth-permission/route-properties.ts +++ b/src/auth-permission/route-properties.ts @@ -1,4 +1,4 @@ -import type { FastifyRequest } from 'fastify'; +import type { WaveRequest } from './shared/wave-request'; /** * Declares what a single route (or a standalone `assertHasPermission` check) @@ -20,8 +20,15 @@ export interface RouteProperties { * Same requirement as `singleBroker`, decided per request instead of * statically (e.g. only when a query parameter selects a broker-scoped * ordering). Takes precedence over `singleBroker` when present. + * + * Shared between the Fastify and Express integrations, so `request` is + * the minimal `WaveRequest` shape both satisfy — only `headers` and + * `query` are available here, never a framework-specific field like + * Fastify's `routeOptions`/`server` or Express's `params` (unpopulated on + * Express at the point this runs, so it would silently mean different + * things per framework). */ - singleBrokerWhen?: (request: FastifyRequest) => boolean; + singleBrokerWhen?: (request: WaveRequest) => boolean; /** * Lets this route accept `Authorization: Bearer ` as an * alternative to `x-api-key`. wave-auth-api's session token deliberately diff --git a/src/auth-permission/session-token/authenticate-with-session-token.ts b/src/auth-permission/session-token/authenticate-with-session-token.ts index 1125f5d..6f76e98 100644 --- a/src/auth-permission/session-token/authenticate-with-session-token.ts +++ b/src/auth-permission/session-token/authenticate-with-session-token.ts @@ -1,7 +1,8 @@ -import type { FastifyRequest } from 'fastify'; +import type { BrokerContext } from '../broker-context'; import type { SessionTokenClaims, SessionTokenVerifier } from './session-token-verifier'; import type { RouteProperties } from '../route-properties'; -import { readBrokerId, resolveBrokerContext } from '../shared/broker-scope-resolution'; +import { readBrokerId, resolveBrokerScope } from '../shared/broker-scope-resolution'; +import type { WaveRequest } from '../shared/wave-request'; /** * The session-token counterpart to `checkApiKeyPermission`. There is no @@ -9,16 +10,19 @@ import { readBrokerId, resolveBrokerContext } from '../shared/broker-scope-resol * deliberately carry no `permissions` claim — so a route only reaches this * path by opting in via `acceptsSessionToken`, and the token's `brokers` * claim (optionally narrowed by `x-broker-id`, the same as an API key's - * scope) becomes the resolved broker context directly. + * scope) becomes the resolved broker scope directly. + * + * Pure — see `checkApiKeyPermission`'s doc comment on why this returns + * rather than mutates. */ export async function authenticateWithSessionToken( - request: FastifyRequest, + request: WaveRequest, route: RouteProperties, token: string, verifier: SessionTokenVerifier, brokerIdHeader: string, brokerIdMaxLength: number, -): Promise { +): Promise { const claims: SessionTokenClaims = await verifier.verify(token); const brokerId = route.brokerScoped === false ? undefined @@ -26,7 +30,7 @@ export async function authenticateWithSessionToken( const brokers = brokerId === undefined ? claims.brokers : restrictToBroker(claims.brokers, brokerId); - resolveBrokerContext( + return resolveBrokerScope( request, route, brokers, diff --git a/src/auth-permission/shared/broker-scope-resolution.ts b/src/auth-permission/shared/broker-scope-resolution.ts index c63061a..f37bb8d 100644 --- a/src/auth-permission/shared/broker-scope-resolution.ts +++ b/src/auth-permission/shared/broker-scope-resolution.ts @@ -1,21 +1,22 @@ -import type { FastifyRequest } from 'fastify'; +import type { BrokerContext } from '../broker-context'; import { PermissionDeniedError } from '../errors/permission-denied-error'; import { MalformedBrokerHeaderError } from '../errors/malformed-broker-header-error'; import { AmbiguousBrokerTargetError } from '../errors/ambiguous-broker-target-error'; import type { RouteProperties } from '../route-properties'; import { countRawHeader } from './raw-headers'; +import type { WaveRequest } from './wave-request'; /** * Reads the broker id header, read from `rawHeaders` rather than `headers` — * see `countRawHeader`. */ export function readBrokerId( - request: FastifyRequest, + request: WaveRequest, brokerIdHeader: string, brokerIdMaxLength: number, ): string | undefined { const header = request.headers[brokerIdHeader]; - if (Array.isArray(header) || countRawHeader(request.raw.rawHeaders, brokerIdHeader) > 1) { + if (Array.isArray(header) || countRawHeader(request.rawHeaders, brokerIdHeader) > 1) { throw new MalformedBrokerHeaderError(`The ${brokerIdHeader} header must be sent at most once`); } @@ -32,7 +33,7 @@ export function readBrokerId( return header; } -function requiresSingleBroker(request: FastifyRequest, route: RouteProperties): boolean { +function requiresSingleBroker(request: WaveRequest, route: RouteProperties): boolean { if (route.singleBrokerWhen) { return route.singleBrokerWhen(request); } @@ -42,23 +43,33 @@ function requiresSingleBroker(request: FastifyRequest, route: RouteProperties): /** * The shared tail of both credential paths — API key and session token: * empty scope -> deny, more than one broker on a route that requires exactly - * one -> ambiguous, otherwise resolve `request.brokerContext`. + * one -> ambiguous, otherwise resolves to the frozen `BrokerContext` the + * caller should attach to its own native request object. * `emptyScopeMessage` differs per caller because an empty API-key scope and * an empty session-token scope are different failures worth describing * differently. * - * A no-op for a route with `brokerScoped: false`: nothing here applies to an - * operation that isn't scoped to any particular broker in the first place, - * and `request.brokerContext` is deliberately left unset for it. + * Pure — never touches the request object itself. Returns `undefined` for a + * route with `brokerScoped: false`: nothing here applies to an operation + * that isn't scoped to any particular broker in the first place. Each + * framework-specific orchestrator is responsible for assigning the result + * onto its own native request (conditionally — never writing `undefined`, + * which would create the property where today it's simply absent). + * + * Takes `WaveRequest` rather than a pre-resolved `requiresSingleBroker` + * boolean so `route.singleBrokerWhen` stays lazily invoked exactly as + * before — only when `brokers.length > 1` and only when `brokerScoped !== + * false` — instead of running on every request regardless of whether its + * result would ever matter. */ -export function resolveBrokerContext( - request: FastifyRequest, +export function resolveBrokerScope( + request: WaveRequest, route: RouteProperties, brokers: readonly string[], emptyScopeMessage: string, -): void { +): BrokerContext | undefined { if (route.brokerScoped === false) { - return; + return undefined; } if (brokers.length === 0) { @@ -71,5 +82,5 @@ export function resolveBrokerContext( ); } - request.brokerContext = Object.freeze({ scope: Object.freeze([...brokers]) }); + return Object.freeze({ scope: Object.freeze([...brokers]) }); } diff --git a/src/auth-permission/shared/is-public-path.ts b/src/auth-permission/shared/is-public-path.ts new file mode 100644 index 0000000..3e6b194 --- /dev/null +++ b/src/auth-permission/shared/is-public-path.ts @@ -0,0 +1,3 @@ +export function isPublicPath(path: string, publicPaths: readonly string[]): boolean { + return publicPaths.some((p) => path === p || path.startsWith(`${p}/`)); +} diff --git a/src/auth-permission/shared/read-credentials.ts b/src/auth-permission/shared/read-credentials.ts index 3561978..a781f8d 100644 --- a/src/auth-permission/shared/read-credentials.ts +++ b/src/auth-permission/shared/read-credentials.ts @@ -1,7 +1,7 @@ -import type { FastifyRequest } from 'fastify'; import { countRawHeader } from './raw-headers'; +import type { WaveRequest } from './wave-request'; -export function readApiKey(request: FastifyRequest, apiKeyHeader: string): string | undefined { +export function readApiKey(request: WaveRequest, apiKeyHeader: string): string | undefined { const header = request.headers[apiKeyHeader]; return typeof header === 'string' && header.length > 0 ? header : undefined; } @@ -15,11 +15,11 @@ export function readApiKey(request: FastifyRequest, apiKeyHeader: string): strin * at all. */ export function readBearerToken( - request: FastifyRequest, + request: WaveRequest, authorizationHeader: string, ): string | undefined { const header = request.headers[authorizationHeader]; - if (Array.isArray(header) || countRawHeader(request.raw.rawHeaders, authorizationHeader) > 1) { + if (Array.isArray(header) || countRawHeader(request.rawHeaders, authorizationHeader) > 1) { return undefined; } diff --git a/src/auth-permission/shared/wave-request.ts b/src/auth-permission/shared/wave-request.ts new file mode 100644 index 0000000..e9841d4 --- /dev/null +++ b/src/auth-permission/shared/wave-request.ts @@ -0,0 +1,18 @@ +import type { IncomingHttpHeaders } from 'node:http'; + +/** + * The minimal request shape this module's transport-agnostic logic needs — + * satisfied natively by Express's `Request` (which prototype-chains onto + * Node's `http.IncomingMessage`, so `rawHeaders` is already top-level) and by + * a small per-request conversion on the Fastify side, where `rawHeaders` + * lives nested under `request.raw` instead. Deliberately excludes `ip` + * (needed only by the Fastify-only rate-limit key generator, read directly + * off the native request there) and `params` (not populated yet on Express + * at the point this module's middleware runs, so including it would + * silently mean different things per framework). + */ +export interface WaveRequest { + readonly headers: IncomingHttpHeaders; + readonly rawHeaders: readonly string[]; + readonly query?: unknown; +}