From 8158533ac55ee40d591124045552ef78a7108295 Mon Sep 17 00:00:00 2001 From: Gabriel Machado Date: Wed, 16 Sep 2026 10:55:37 -0300 Subject: [PATCH 1/6] :sparkles: feat(auth-permission): accept a wave-auth-api session token as an alternative to x-api-key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes opt in via RouteProperties.acceptsSessionToken; the Authorization: Bearer JWT is verified offline against wave-auth-api's JWKS (jose, RS256/ES256 only) and its brokers claim becomes the resolved broker scope. No permission check runs on this path — wave-auth-api's session tokens deliberately carry no permissions claim, so the route's own opt-in is the authorization decision. Every existing x-api-key caller is unaffected. Co-Authored-By: Claude Sonnet 5 --- package.json | 1 + pnpm-lock.yaml | 8 + src/auth-permission/errors/index.ts | 1 + .../errors/session-token-invalid-error.ts | 16 ++ src/auth-permission/index.ts | 4 + .../register-permission-auth.spec.ts | 179 ++++++++++++++++- .../register-permission-auth.ts | 182 +++++++++++++++--- src/auth-permission/route-properties.ts | 10 + src/auth-permission/session-token-verifier.ts | 17 ++ .../wave-auth-session-token-verifier.spec.ts | 108 +++++++++++ .../wave-auth-session-token-verifier.ts | 60 ++++++ 11 files changed, 554 insertions(+), 32 deletions(-) create mode 100644 src/auth-permission/errors/session-token-invalid-error.ts create mode 100644 src/auth-permission/session-token-verifier.ts create mode 100644 src/auth-permission/wave-auth-session-token-verifier.spec.ts create mode 100644 src/auth-permission/wave-auth-session-token-verifier.ts diff --git a/package.json b/package.json index a5592e7..739358b 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "express": "^4.21.2", "google-auth-library": "^9.15.1", "hono": "^4.8.5", + "jose": "^6.2.12", "mathjs": "^14.2.1", "uuid": "^11.0.5", "winston": "^3.17.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7ca0e3..b4dbb15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: hono: specifier: ^4.8.5 version: 4.8.5 + jose: + specifier: ^6.2.12 + version: 6.2.12 mathjs: specifier: ^14.2.1 version: 14.2.1 @@ -1517,6 +1520,9 @@ packages: javascript-natural-sort@0.7.1: resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -3929,6 +3935,8 @@ snapshots: javascript-natural-sort@0.7.1: {} + jose@6.2.12: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 diff --git a/src/auth-permission/errors/index.ts b/src/auth-permission/errors/index.ts index 3d355be..281677a 100644 --- a/src/auth-permission/errors/index.ts +++ b/src/auth-permission/errors/index.ts @@ -4,3 +4,4 @@ export { PermissionDeniedError } from './permission-denied-error'; export { MalformedBrokerHeaderError } from './malformed-broker-header-error'; export { AmbiguousBrokerTargetError } from './ambiguous-broker-target-error'; export { BrokerContextNotResolvedError } from './broker-context-not-resolved-error'; +export { SessionTokenInvalidError } from './session-token-invalid-error'; diff --git a/src/auth-permission/errors/session-token-invalid-error.ts b/src/auth-permission/errors/session-token-invalid-error.ts new file mode 100644 index 0000000..7daca30 --- /dev/null +++ b/src/auth-permission/errors/session-token-invalid-error.ts @@ -0,0 +1,16 @@ +/** + * A session token (an `Authorization: Bearer` JWT) failed offline + * verification — bad signature, unrecognised key, malformed compact JWT, or + * expired. Also thrown when wave-auth-api's JWKS itself couldn't be fetched, + * since that too means no verdict on the signature could be reached. + * + * Doesn't extend a consumer's own HTTP error hierarchy: this is a neutral + * error owned by this module. Each consuming service maps it to its own + * response shape once, in its global error handler (typically `401`). + */ +export class SessionTokenInvalidError extends Error { + constructor(detail: string) { + super(detail); + this.name = 'SessionTokenInvalidError'; + } +} diff --git a/src/auth-permission/index.ts b/src/auth-permission/index.ts index 5b4a579..bb92fc1 100644 --- a/src/auth-permission/index.ts +++ b/src/auth-permission/index.ts @@ -4,6 +4,8 @@ export type { PermissionValidator, } from './permission-validator'; export { WaveAuthPermissionValidator } from './wave-auth-permission-validator'; +export type { SessionTokenClaims, SessionTokenVerifier } from './session-token-verifier'; +export { WaveAuthSessionTokenVerifier } from './wave-auth-session-token-verifier'; export { correlationHeaders } from './correlation-headers'; export type { RouteProperties } from './route-properties'; @@ -13,6 +15,7 @@ export type { PermissionAuthOptions, AssertHasPermission } from './register-perm export { registerPermissionAuth, API_KEY_HEADER, + AUTHORIZATION_HEADER, BROKER_ID_HEADER, BROKER_ID_MAX_LENGTH, PUBLIC_PATHS, @@ -25,4 +28,5 @@ export { MalformedBrokerHeaderError, AmbiguousBrokerTargetError, BrokerContextNotResolvedError, + SessionTokenInvalidError, } from './errors'; diff --git a/src/auth-permission/register-permission-auth.spec.ts b/src/auth-permission/register-permission-auth.spec.ts index 007c405..b4480e4 100644 --- a/src/auth-permission/register-permission-auth.spec.ts +++ b/src/auth-permission/register-permission-auth.spec.ts @@ -1,7 +1,12 @@ import { request as httpRequest } from 'node:http'; import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify'; import { describe, it, expect, afterEach } from 'vitest'; -import { registerPermissionAuth, API_KEY_HEADER, BROKER_ID_HEADER } from './register-permission-auth'; +import { + registerPermissionAuth, + API_KEY_HEADER, + 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'; @@ -9,14 +14,17 @@ 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 type { PermissionValidationRequest, PermissionValidationResult, PermissionValidator, } from './permission-validator'; +import type { SessionTokenClaims, SessionTokenVerifier } from './session-token-verifier'; import type { RouteProperties } from './route-properties'; const API_KEY = 'test-secret-key'; +const SESSION_TOKEN = 'test-session-token'; const PERMISSION = 'carrier.delivery_order.create'; /** Records every call and resolves each one through a caller-supplied function. */ @@ -45,6 +53,22 @@ const denies = (brokers: string[] = []): FakePermissionValidator => const throwing = (error: Error): FakePermissionValidator => new FakePermissionValidator(() => error); +/** Records every call and resolves each one through a caller-supplied function. */ +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); + /** Mirrors what a consuming app's own global error handler does: one instanceof per error class. */ function testErrorHandler(error: Error, request: unknown, reply: FastifyReply): void { if (error instanceof PermissionValidatorUnauthorizedError) { @@ -71,11 +95,16 @@ function testErrorHandler(error: Error, request: unknown, reply: FastifyReply): reply.status(500).send({ type: 'broker-context-not-resolved', message: error.message }); return; } + if (error instanceof SessionTokenInvalidError) { + reply.status(401).send({ type: 'session-token-invalid', message: error.message }); + return; + } reply.status(500).send({ type: 'unexpected', message: error.message }); } interface BuildAppOptions { validator: PermissionValidator; + sessionTokenVerifier?: SessionTokenVerifier; routes?: Readonly>; publicPaths?: string[]; brokerIdMaxLength?: number; @@ -97,6 +126,7 @@ function buildTestApp(options: BuildAppOptions): FastifyInstance { const assertHasPermission = registerPermissionAuth(app, { validator: options.validator, + sessionTokenVerifier: options.sessionTokenVerifier, routes, publicPaths: options.publicPaths, brokerIdMaxLength: options.brokerIdMaxLength, @@ -109,6 +139,7 @@ function buildTestApp(options: BuildAppOptions): FastifyInstance { app.get('/multi-broker-conditional', (request) => ({ scope: requireBrokerContext(request).scope, })); + app.get('/session-scoped', (request) => ({ scope: requireBrokerContext(request).scope })); // Registered with Fastify but deliberately absent from `routes` above. app.get('/registered-but-unmapped', () => ({ ok: true })); // A path the global hook treats as public (via publicPaths), whose own @@ -535,4 +566,150 @@ describe('registerPermissionAuth', () => { expect(res.statusCode).toBe(400); }); }); + + describe('session tokens (Authorization: Bearer)', () => { + it('throws synchronously if a route opts in without a sessionTokenVerifier configured', () => { + app = Fastify({ logger: false }); + + expect(() => + registerPermissionAuth(app, { + validator: authorizes(), + routes: { 'GET /session-scoped': { permissionName: PERMISSION, acceptsSessionToken: true } }, + }), + ).toThrow(/sessionTokenVerifier/); + }); + + it('rejects a bearer-only request on a route that does not accept session tokens (401), without verifying it', async () => { + const validator = authorizes(); + const sessionTokenVerifier = verifiesAs({ sub: 'key-1', brokers: ['tim'] }); + app = buildTestApp({ validator, sessionTokenVerifier }); + + const res = await app.inject({ + method: 'POST', + url: '/protected', + headers: { [AUTHORIZATION_HEADER]: `Bearer ${SESSION_TOKEN}` }, + }); + + expect(res.statusCode).toBe(401); + expect(sessionTokenVerifier.calls).toHaveLength(0); + expect(validator.calls).toHaveLength(0); + }); + + it('authenticates a route that opts in, without ever calling the permission validator', async () => { + const validator = authorizes(); + const sessionTokenVerifier = verifiesAs({ sub: 'key-1', brokers: ['tim'] }); + app = buildTestApp({ + validator, + sessionTokenVerifier, + routes: { 'GET /session-scoped': { permissionName: PERMISSION, acceptsSessionToken: true } }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/session-scoped', + headers: { [AUTHORIZATION_HEADER]: `Bearer ${SESSION_TOKEN}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ scope: ['tim'] }); + expect(sessionTokenVerifier.calls).toEqual([SESSION_TOKEN]); + expect(validator.calls).toHaveLength(0); + }); + + it('prefers x-api-key over a bearer token when both are present', async () => { + const validator = authorizes(['tim']); + const sessionTokenVerifier = verifiesAs({ sub: 'key-1', brokers: ['other-broker'] }); + app = buildTestApp({ + validator, + sessionTokenVerifier, + routes: { 'GET /session-scoped': { permissionName: PERMISSION, acceptsSessionToken: true } }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/session-scoped', + headers: { [API_KEY_HEADER]: API_KEY, [AUTHORIZATION_HEADER]: `Bearer ${SESSION_TOKEN}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ scope: ['tim'] }); + expect(validator.calls).toHaveLength(1); + expect(sessionTokenVerifier.calls).toHaveLength(0); + }); + + it('rejects an invalid session token (401)', async () => { + const validator = authorizes(); + const sessionTokenVerifier = new FakeSessionTokenVerifier( + () => new SessionTokenInvalidError('bad signature'), + ); + app = buildTestApp({ + validator, + sessionTokenVerifier, + routes: { 'GET /session-scoped': { permissionName: PERMISSION, acceptsSessionToken: true } }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/session-scoped', + headers: { [AUTHORIZATION_HEADER]: `Bearer ${SESSION_TOKEN}` }, + }); + + expect(res.statusCode).toBe(401); + }); + + it('rejects a multi-broker token scope on a route that requires exactly one, without x-broker-id (400)', async () => { + const validator = authorizes(); + const sessionTokenVerifier = verifiesAs({ sub: 'key-1', brokers: ['broker-a', 'broker-b'] }); + app = buildTestApp({ + validator, + sessionTokenVerifier, + routes: { 'GET /session-scoped': { permissionName: PERMISSION, acceptsSessionToken: true } }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/session-scoped', + headers: { [AUTHORIZATION_HEADER]: `Bearer ${SESSION_TOKEN}` }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('restricts a multi-broker token scope with x-broker-id, the same as an API key', async () => { + const validator = authorizes(); + const sessionTokenVerifier = verifiesAs({ sub: 'key-1', brokers: ['broker-a', 'broker-b'] }); + app = buildTestApp({ + validator, + sessionTokenVerifier, + routes: { 'GET /session-scoped': { permissionName: PERMISSION, acceptsSessionToken: true } }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/session-scoped', + headers: { [AUTHORIZATION_HEADER]: `Bearer ${SESSION_TOKEN}`, [BROKER_ID_HEADER]: 'broker-a' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ scope: ['broker-a'] }); + }); + + it('rejects when x-broker-id selects a broker the token does not carry (403)', async () => { + const validator = authorizes(); + const sessionTokenVerifier = verifiesAs({ sub: 'key-1', brokers: ['broker-a'] }); + app = buildTestApp({ + validator, + sessionTokenVerifier, + routes: { 'GET /session-scoped': { permissionName: PERMISSION, acceptsSessionToken: true } }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/session-scoped', + headers: { [AUTHORIZATION_HEADER]: `Bearer ${SESSION_TOKEN}`, [BROKER_ID_HEADER]: 'broker-z' }, + }); + + expect(res.statusCode).toBe(403); + }); + }); }); diff --git a/src/auth-permission/register-permission-auth.ts b/src/auth-permission/register-permission-auth.ts index ffbf0d1..174b7bb 100644 --- a/src/auth-permission/register-permission-auth.ts +++ b/src/auth-permission/register-permission-auth.ts @@ -1,5 +1,6 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; import type { PermissionValidationResult, PermissionValidator } from './permission-validator'; +import type { SessionTokenClaims, SessionTokenVerifier } from './session-token-verifier'; import { PermissionValidatorUnauthorizedError } from './errors/permission-validator-unauthorized-error'; import { PermissionDeniedError } from './errors/permission-denied-error'; import { MalformedBrokerHeaderError } from './errors/malformed-broker-header-error'; @@ -8,6 +9,8 @@ import type { RouteProperties } from './route-properties'; /** 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. */ @@ -22,6 +25,9 @@ export interface PermissionAuthOptions { /** Paths that bypass authentication, matched by exact value or as a prefix. */ publicPaths?: string[]; apiKeyHeader?: string; + /** Required when any route in `routes` sets `acceptsSessionToken: true`. */ + sessionTokenVerifier?: SessionTokenVerifier; + authorizationHeader?: string; brokerIdHeader?: string; brokerIdMaxLength?: number; } @@ -90,6 +96,29 @@ function countRawHeader(rawHeaders: readonly string[] | undefined, headerName: s return count; } +function readApiKey(request: FastifyRequest, apiKeyHeader: string): string | undefined { + const header = request.headers[apiKeyHeader]; + return typeof header === 'string' && header.length > 0 ? header : undefined; +} + +/** + * Reads the session token out of `Authorization: Bearer `. Anything + * that doesn't conform — a repeated header, a different scheme, an empty + * token — resolves as `undefined`, the same as the header being absent + * entirely: this is a credential lookup, not validation, and a caller with a + * malformed header must not be told anything more than one with no header + * at all. + */ +function readBearerToken(request: FastifyRequest, authorizationHeader: string): string | undefined { + const header = request.headers[authorizationHeader]; + if (Array.isArray(header) || countRawHeader(request.raw.rawHeaders, authorizationHeader) > 1) { + return undefined; + } + + const match = typeof header === 'string' ? /^Bearer (.+)$/i.exec(header) : null; + return match?.[1]; +} + function requiresSingleBroker(request: FastifyRequest, route: RouteProperties): boolean { if (route.singleBrokerWhen) { return route.singleBrokerWhen(request); @@ -97,6 +126,32 @@ function requiresSingleBroker(request: FastifyRequest, route: RouteProperties): return route.singleBroker ?? true; } +/** + * The shared tail of both credential paths: empty scope -> deny, more than + * one broker on a route that requires exactly one -> ambiguous, otherwise + * resolve `request.brokerContext`. `emptyScopeMessage` differs per caller + * because an empty API-key scope and an empty session-token scope are + * different failures worth describing differently. + */ +function resolveBrokerContext( + request: FastifyRequest, + route: RouteProperties, + brokers: readonly string[], + emptyScopeMessage: string, +): void { + if (brokers.length === 0) { + throw new PermissionDeniedError(emptyScopeMessage); + } + + if (brokers.length > 1 && requiresSingleBroker(request, route)) { + throw new AmbiguousBrokerTargetError( + 'This operation applies to a single broker: send the x-broker-id header to select one', + ); + } + + request.brokerContext = Object.freeze({ scope: Object.freeze([...brokers]) }); +} + /** * Everything after the API key is known to be present: reads the broker id * header (if any), calls `validate`, and resolves `request.brokerContext`. @@ -121,17 +176,49 @@ async function checkRoutePermission( ...(brokerId === undefined ? {} : { brokers: [brokerId] }), }); - if (!result.authorized || result.brokers.length === 0) { + if (!result.authorized) { throw new PermissionDeniedError(`The API key does not have the ${route.permissionName} permission`); } - if (result.brokers.length > 1 && requiresSingleBroker(request, route)) { - throw new AmbiguousBrokerTargetError( - 'This operation applies to a single broker: send the x-broker-id header to select one', - ); - } + resolveBrokerContext( + request, + route, + result.brokers, + `The API key does not have the ${route.permissionName} permission`, + ); +} - request.brokerContext = Object.freeze({ scope: Object.freeze([...result.brokers]) }); +/** + * The session-token counterpart to `checkRoutePermission`. There is no + * permission verdict to ask for — wave-auth-api's session tokens + * 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. + */ +async function authenticateWithSessionToken( + request: FastifyRequest, + route: RouteProperties, + token: string, + verifier: SessionTokenVerifier, + brokerIdHeader: string, + brokerIdMaxLength: number, +): Promise { + const claims: SessionTokenClaims = await verifier.verify(token); + const brokerId = readBrokerId(request, brokerIdHeader, brokerIdMaxLength); + const brokers = + brokerId === undefined ? claims.brokers : restrictToBroker(claims.brokers, brokerId); + + resolveBrokerContext( + request, + route, + brokers, + 'The session token does not carry an authorized broker scope', + ); +} + +function restrictToBroker(brokers: readonly string[], brokerId: string): readonly string[] { + return brokers.includes(brokerId) ? [brokerId] : []; } /** @@ -143,20 +230,26 @@ async function checkRoutePermission( * The order of checks is contractual, not stylistic: * * 1. public path -> passes without touching the network; - * 2. `x-api-key` missing or empty -> `401`, even before routing decides, - * so a caller with no credential can't tell an existing route from a - * nonexistent one by omitting it; + * 2. neither `x-api-key` nor a valid `Authorization: Bearer` present -> + * `401`, even before routing decides, so a caller with no credential + * can't tell an existing route from a nonexistent one by omitting it; * 3. path didn't match any route Fastify knows about -> returns without * throwing, letting Fastify's own 404 handling take over; * 4. route matched but has no entry in `routes` -> `403` (deny by * default), without ever reaching the network; - * 5. broker id header malformed -> `400`, also without reaching the + * 5. `x-api-key` present -> the existing validate-against-wave-auth-api + * path, unconditionally, regardless of `acceptsSessionToken`; + * 6. otherwise only a bearer token was presented -> `401` unless the route + * set `acceptsSessionToken: true`, in which case the token is verified + * offline instead and never reaches `validate()` — see + * `authenticateWithSessionToken`; + * 7. broker id header malformed -> `400`, also without reaching the * network; - * 6. `validate()`; - * 7. denied verdict or empty scope -> `403`; - * 8. scope spans more than one broker on a route that requires exactly - * one -> `400`. This one comes *after* `validate()` because only the - * verdict reveals how many brokers the scope actually has. + * 8. denied verdict or empty scope -> `403`; + * 9. scope spans more than one broker on a route that requires exactly + * one -> `400`. This one comes *after* the verdict because only the + * verdict (or the token's claims) reveals how many brokers the scope + * actually has. */ export function registerPermissionAuth( app: FastifyInstance, @@ -164,19 +257,51 @@ export function registerPermissionAuth( ): AssertHasPermission { const publicPaths = options.publicPaths ?? PUBLIC_PATHS; 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 registerPermissionAuth was not given a sessionTokenVerifier`, + ); + } + } + const assertHasPermission: AssertHasPermission = async (request, route) => { - const apiKey = request.headers[apiKeyHeader]; - if (typeof apiKey !== 'string' || apiKey.length === 0) { + const apiKey = readApiKey(request, apiKeyHeader); + if (apiKey !== undefined) { + await checkRoutePermission( + request, + route, + apiKey, + options.validator, + brokerIdHeader, + brokerIdMaxLength, + ); + return; + } + + const token = readBearerToken(request, authorizationHeader); + if (token === undefined || route.acceptsSessionToken !== true) { throw new PermissionValidatorUnauthorizedError(); } - await checkRoutePermission( + + // Guaranteed non-`undefined` for any route reached through + // `options.routes` (checked above, at registration time). A caller + // invoking this returned function directly with its own ad hoc + // `RouteProperties` — bypassing that map — is responsible for the same + // guarantee itself. + if (options.sessionTokenVerifier === undefined) { + throw new Error('acceptsSessionToken route reached with no sessionTokenVerifier configured'); + } + + await authenticateWithSessionToken( request, route, - apiKey, - options.validator, + token, + options.sessionTokenVerifier, brokerIdHeader, brokerIdMaxLength, ); @@ -188,8 +313,10 @@ export function registerPermissionAuth( return; } - const apiKey = request.headers[apiKeyHeader]; - if (typeof apiKey !== 'string' || apiKey.length === 0) { + const hasCredential = + readApiKey(request, apiKeyHeader) !== undefined || + readBearerToken(request, authorizationHeader) !== undefined; + if (!hasCredential) { throw new PermissionValidatorUnauthorizedError(); } @@ -204,14 +331,7 @@ export function registerPermissionAuth( throw new PermissionDeniedError(`No permission is mapped for ${request.method} ${routeUrl}`); } - await checkRoutePermission( - request, - route, - apiKey, - options.validator, - brokerIdHeader, - brokerIdMaxLength, - ); + await assertHasPermission(request, route); }); return assertHasPermission; diff --git a/src/auth-permission/route-properties.ts b/src/auth-permission/route-properties.ts index 4cd2a97..c880170 100644 --- a/src/auth-permission/route-properties.ts +++ b/src/auth-permission/route-properties.ts @@ -22,4 +22,14 @@ export interface RouteProperties { * ordering). Takes precedence over `singleBroker` when present. */ singleBrokerWhen?: (request: FastifyRequest) => boolean; + /** + * Lets this route accept `Authorization: Bearer ` as an + * alternative to `x-api-key`. wave-auth-api's session token deliberately + * carries no permission grant (only `sub` and `brokers`) — a token must + * not carry an authorization decision that revoking the underlying key + * can't take back — so a route that opts in is *not* permission-checked + * when authenticated this way: the opt-in itself is the authorization + * decision, trusting only the token's broker scope. Defaults to `false`. + */ + acceptsSessionToken?: boolean; } diff --git a/src/auth-permission/session-token-verifier.ts b/src/auth-permission/session-token-verifier.ts new file mode 100644 index 0000000..842634f --- /dev/null +++ b/src/auth-permission/session-token-verifier.ts @@ -0,0 +1,17 @@ +/** What a verified wave-auth-api session token carries — identity and broker scope only. */ +export interface SessionTokenClaims { + /** The issuing API key's id. */ + sub: string; + /** Every broker the issuing API key acts for. */ + brokers: readonly string[]; +} + +export interface SessionTokenVerifier { + /** + * Resolves with the token's claims once its signature and expiry are + * verified. Never a permission verdict — wave-auth-api's session tokens + * carry no `permissions` claim by design. Throws `SessionTokenInvalidError` + * when the token is malformed, unsigned by a recognised key, or expired. + */ + verify: (token: string) => Promise; +} diff --git a/src/auth-permission/wave-auth-session-token-verifier.spec.ts b/src/auth-permission/wave-auth-session-token-verifier.spec.ts new file mode 100644 index 0000000..0342fe2 --- /dev/null +++ b/src/auth-permission/wave-auth-session-token-verifier.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { generateKeyPair, exportJWK, SignJWT, type JWK } from 'jose'; +import { WaveAuthSessionTokenVerifier } from './wave-auth-session-token-verifier'; +import { SessionTokenInvalidError } from './errors/session-token-invalid-error'; + +const BASE_URL = 'https://auth.internal.example'; +const JWKS_URL = `${BASE_URL}/.well-known/jwks.json`; +const KID = 'test-kid'; + +function stubJwks(jwk: JWK): void { + vi.stubGlobal('fetch', (input: unknown) => { + expect(String(input)).toBe(JWKS_URL); + return Promise.resolve( + new Response(JSON.stringify({ keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('WaveAuthSessionTokenVerifier', () => { + it('verifies a validly signed token and returns sub and brokers', async () => { + const { publicKey, privateKey } = await generateKeyPair('RS256'); + stubJwks(await exportJWK(publicKey)); + + const token = await new SignJWT({ brokers: ['tim'] }) + .setProtectedHeader({ alg: 'RS256', kid: KID }) + .setSubject('key-id-1') + .setExpirationTime('5m') + .sign(privateKey); + + const claims = await new WaveAuthSessionTokenVerifier(BASE_URL).verify(token); + + expect(claims).toEqual({ sub: 'key-id-1', brokers: ['tim'] }); + }); + + it('rejects an expired token', async () => { + const { publicKey, privateKey } = await generateKeyPair('RS256'); + stubJwks(await exportJWK(publicKey)); + + const token = await new SignJWT({ brokers: ['tim'] }) + .setProtectedHeader({ alg: 'RS256', kid: KID }) + .setSubject('key-id-1') + .setExpirationTime(Math.floor(Date.now() / 1000) - 60) + .sign(privateKey); + + await expect(new WaveAuthSessionTokenVerifier(BASE_URL).verify(token)).rejects.toBeInstanceOf( + SessionTokenInvalidError, + ); + }); + + it('rejects a token signed by a key other than the one published in the JWKS', async () => { + const { publicKey } = await generateKeyPair('RS256'); + const { privateKey: forgedPrivateKey } = await generateKeyPair('RS256'); + stubJwks(await exportJWK(publicKey)); + + const token = await new SignJWT({ brokers: ['tim'] }) + .setProtectedHeader({ alg: 'RS256', kid: KID }) + .setSubject('key-id-1') + .setExpirationTime('5m') + .sign(forgedPrivateKey); + + await expect(new WaveAuthSessionTokenVerifier(BASE_URL).verify(token)).rejects.toBeInstanceOf( + SessionTokenInvalidError, + ); + }); + + it('rejects a validly signed token missing the brokers claim', async () => { + const { publicKey, privateKey } = await generateKeyPair('RS256'); + stubJwks(await exportJWK(publicKey)); + + const token = await new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: KID }) + .setSubject('key-id-1') + .setExpirationTime('5m') + .sign(privateKey); + + await expect(new WaveAuthSessionTokenVerifier(BASE_URL).verify(token)).rejects.toBeInstanceOf( + SessionTokenInvalidError, + ); + }); + + it('rejects a token signed with HS256 even if the JWKS only publishes an RS256 key — algorithm confusion', async () => { + const { publicKey } = await generateKeyPair('RS256'); + stubJwks(await exportJWK(publicKey)); + + const token = await new SignJWT({ brokers: ['tim'] }) + .setProtectedHeader({ alg: 'HS256', kid: KID }) + .setSubject('attacker') + .setExpirationTime('5m') + .sign(new TextEncoder().encode('a-guessed-or-derived-secret')); + + await expect(new WaveAuthSessionTokenVerifier(BASE_URL).verify(token)).rejects.toBeInstanceOf( + SessionTokenInvalidError, + ); + }); + + it('rejects a string that is not a compact JWT at all', async () => { + await expect( + new WaveAuthSessionTokenVerifier(BASE_URL).verify('not-a-jwt'), + ).rejects.toBeInstanceOf(SessionTokenInvalidError); + }); +}); diff --git a/src/auth-permission/wave-auth-session-token-verifier.ts b/src/auth-permission/wave-auth-session-token-verifier.ts new file mode 100644 index 0000000..3ca71ba --- /dev/null +++ b/src/auth-permission/wave-auth-session-token-verifier.ts @@ -0,0 +1,60 @@ +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { z } from 'zod'; +import { SessionTokenInvalidError } from './errors/session-token-invalid-error'; +import type { SessionTokenClaims, SessionTokenVerifier } from './session-token-verifier'; + +/** Not version-prefixed: this is the path wave-auth-api itself publishes. */ +const JWKS_PATH = '/.well-known/jwks.json'; + +/** + * The only algorithms wave-auth-api ever signs with. Passed explicitly to + * `jwtVerify` rather than trusting the token's own `alg` header — accepting + * whatever the token claims would let a token signed with a weaker or + * attacker-chosen algorithm (e.g. `HS256` keyed on the public key) pass. + */ +const ALGORITHMS = ['RS256', 'ES256']; + +const claimsSchema = z.object({ + sub: z.string(), + brokers: z.array(z.string()), +}); + +/** + * Verifies a wave-auth-api session token entirely offline, against the + * public key wave-auth-api publishes at `{baseUrl}/.well-known/jwks.json`. + * No network call happens per verification beyond `jose`'s own JWKS + * caching/refresh — this is the offline counterpart to + * `WaveAuthPermissionValidator`, which is a live call instead. + */ +export class WaveAuthSessionTokenVerifier implements SessionTokenVerifier { + private readonly jwks: ReturnType; + + constructor(baseUrl: string) { + this.jwks = createRemoteJWKSet(new URL(`${baseUrl.replace(/\/+$/, '')}${JWKS_PATH}`)); + } + + async verify(token: string): Promise { + const payload = await this.verifySignature(token); + + const parsed = claimsSchema.safeParse(payload); + if (!parsed.success) { + throw new SessionTokenInvalidError('the session token does not carry sub and brokers'); + } + + return { sub: parsed.data.sub, brokers: parsed.data.brokers }; + } + + private async verifySignature(token: string): Promise { + try { + const { payload } = await jwtVerify(token, this.jwks, { algorithms: ALGORITHMS }); + return payload; + } catch (cause) { + throw new SessionTokenInvalidError( + `the session token failed verification — ${describeFailure(cause)}`, + ); + } + } +} + +const describeFailure = (cause: unknown): string => + cause instanceof Error ? `${cause.name}: ${cause.message}` : 'unknown verification failure'; From 580370e52732f4ab86a564b673331705ba274612 Mon Sep 17 00:00:00 2001 From: Gabriel Machado Date: Wed, 16 Sep 2026 12:01:42 -0300 Subject: [PATCH 2/6] :recycle: refactor(auth-permission): split register-permission-auth.ts per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the API-key check (renamed checkApiKeyPermission, its context wasn't clear before) and the session-token check into their own files, and pulls their shared broker-scope logic into broker-scope-resolution.ts, so the hook file itself is left as pure orchestration. Also documents why this hook does no rate limiting of its own — that's left to each consuming service's infrastructure/gateway, the same layer wave-auth-api itself relies on. Co-Authored-By: Claude Sonnet 5 --- .../authenticate-with-session-token.ts | 37 ++++ .../broker-scope-resolution.ts | 67 ++++++++ .../check-api-key-permission.ts | 42 +++++ src/auth-permission/raw-headers.ts | 21 +++ .../register-permission-auth.ts | 162 ++---------------- 5 files changed, 181 insertions(+), 148 deletions(-) create mode 100644 src/auth-permission/authenticate-with-session-token.ts create mode 100644 src/auth-permission/broker-scope-resolution.ts create mode 100644 src/auth-permission/check-api-key-permission.ts create mode 100644 src/auth-permission/raw-headers.ts diff --git a/src/auth-permission/authenticate-with-session-token.ts b/src/auth-permission/authenticate-with-session-token.ts new file mode 100644 index 0000000..a2213de --- /dev/null +++ b/src/auth-permission/authenticate-with-session-token.ts @@ -0,0 +1,37 @@ +import type { FastifyRequest } from 'fastify'; +import type { SessionTokenClaims, SessionTokenVerifier } from './session-token-verifier'; +import type { RouteProperties } from './route-properties'; +import { readBrokerId, resolveBrokerContext } from './broker-scope-resolution'; + +/** + * The session-token counterpart to `checkApiKeyPermission`. There is no + * permission verdict to ask for — wave-auth-api's session tokens + * 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. + */ +export async function authenticateWithSessionToken( + request: FastifyRequest, + route: RouteProperties, + token: string, + verifier: SessionTokenVerifier, + brokerIdHeader: string, + brokerIdMaxLength: number, +): Promise { + const claims: SessionTokenClaims = await verifier.verify(token); + const brokerId = readBrokerId(request, brokerIdHeader, brokerIdMaxLength); + const brokers = + brokerId === undefined ? claims.brokers : restrictToBroker(claims.brokers, brokerId); + + resolveBrokerContext( + request, + route, + brokers, + 'The session token does not carry an authorized broker scope', + ); +} + +function restrictToBroker(brokers: readonly string[], brokerId: string): readonly string[] { + return brokers.includes(brokerId) ? [brokerId] : []; +} diff --git a/src/auth-permission/broker-scope-resolution.ts b/src/auth-permission/broker-scope-resolution.ts new file mode 100644 index 0000000..4ec1b60 --- /dev/null +++ b/src/auth-permission/broker-scope-resolution.ts @@ -0,0 +1,67 @@ +import type { FastifyRequest } from 'fastify'; +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'; + +/** + * Reads the broker id header, read from `rawHeaders` rather than `headers` — + * see `countRawHeader`. + */ +export function readBrokerId( + request: FastifyRequest, + brokerIdHeader: string, + brokerIdMaxLength: number, +): string | undefined { + const header = request.headers[brokerIdHeader]; + if (Array.isArray(header) || countRawHeader(request.raw.rawHeaders, brokerIdHeader) > 1) { + throw new MalformedBrokerHeaderError(`The ${brokerIdHeader} header must be sent at most once`); + } + + if (header === undefined) { + return undefined; + } + + if (header.length === 0 || header.length > brokerIdMaxLength) { + throw new MalformedBrokerHeaderError( + `The ${brokerIdHeader} header must be between 1 and ${brokerIdMaxLength} characters`, + ); + } + + return header; +} + +function requiresSingleBroker(request: FastifyRequest, route: RouteProperties): boolean { + if (route.singleBrokerWhen) { + return route.singleBrokerWhen(request); + } + return route.singleBroker ?? true; +} + +/** + * 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`. + * `emptyScopeMessage` differs per caller because an empty API-key scope and + * an empty session-token scope are different failures worth describing + * differently. + */ +export function resolveBrokerContext( + request: FastifyRequest, + route: RouteProperties, + brokers: readonly string[], + emptyScopeMessage: string, +): void { + if (brokers.length === 0) { + throw new PermissionDeniedError(emptyScopeMessage); + } + + if (brokers.length > 1 && requiresSingleBroker(request, route)) { + throw new AmbiguousBrokerTargetError( + 'This operation applies to a single broker: send the x-broker-id header to select one', + ); + } + + request.brokerContext = Object.freeze({ scope: Object.freeze([...brokers]) }); +} diff --git a/src/auth-permission/check-api-key-permission.ts b/src/auth-permission/check-api-key-permission.ts new file mode 100644 index 0000000..64099ca --- /dev/null +++ b/src/auth-permission/check-api-key-permission.ts @@ -0,0 +1,42 @@ +import type { FastifyRequest } from 'fastify'; +import type { PermissionValidationResult, PermissionValidator } from './permission-validator'; +import { PermissionDeniedError } from './errors/permission-denied-error'; +import type { RouteProperties } from './route-properties'; +import { readBrokerId, resolveBrokerContext } from './broker-scope-resolution'; + +/** + * 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 + * standalone `assertHasPermission` it returns, so the two never drift on + * this part of the contract. + */ +export async function checkApiKeyPermission( + request: FastifyRequest, + route: RouteProperties, + apiKey: string, + validator: PermissionValidator, + brokerIdHeader: string, + brokerIdMaxLength: number, +): Promise { + const brokerId = readBrokerId(request, brokerIdHeader, brokerIdMaxLength); + + // The key is forwarded byte for byte: this module never stores, derives or + // compares a key's secret — authorization is wave-auth-api's job alone. + const result: PermissionValidationResult = await validator.validate({ + apiKey, + permission: route.permissionName, + ...(brokerId === undefined ? {} : { brokers: [brokerId] }), + }); + + if (!result.authorized) { + throw new PermissionDeniedError(`The API key does not have the ${route.permissionName} permission`); + } + + resolveBrokerContext( + request, + route, + result.brokers, + `The API key does not have the ${route.permissionName} permission`, + ); +} diff --git a/src/auth-permission/raw-headers.ts b/src/auth-permission/raw-headers.ts new file mode 100644 index 0000000..5d59d15 --- /dev/null +++ b/src/auth-permission/raw-headers.ts @@ -0,0 +1,21 @@ +/** + * Counts occurrences of `headerName` in Node's `rawHeaders` (a flattened + * name/value array), case-insensitively. Node folds a repeated header into a + * single comma-joined value in `request.headers`, indistinguishable from one + * legitimate value that happens to contain a comma — `rawHeaders` keeps each + * occurrence separate so a genuinely repeated header can be rejected instead + * of silently accepted as one value. + */ +export function countRawHeader( + rawHeaders: readonly string[] | undefined, + headerName: string, +): number { + const headers = rawHeaders ?? []; + let count = 0; + for (let index = 0; index + 1 < headers.length; index += 2) { + if (headers[index].toLowerCase() === headerName) { + count += 1; + } + } + return count; +} diff --git a/src/auth-permission/register-permission-auth.ts b/src/auth-permission/register-permission-auth.ts index 174b7bb..8ec772a 100644 --- a/src/auth-permission/register-permission-auth.ts +++ b/src/auth-permission/register-permission-auth.ts @@ -1,11 +1,12 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; -import type { PermissionValidationResult, PermissionValidator } from './permission-validator'; -import type { SessionTokenClaims, SessionTokenVerifier } from './session-token-verifier'; +import type { PermissionValidator } from './permission-validator'; +import type { SessionTokenVerifier } from './session-token-verifier'; import { PermissionValidatorUnauthorizedError } from './errors/permission-validator-unauthorized-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 type { RouteProperties } from './route-properties'; +import { countRawHeader } from './raw-headers'; +import { checkApiKeyPermission } from './check-api-key-permission'; +import { authenticateWithSessionToken } from './authenticate-with-session-token'; /** Header clients must send carrying their API key. */ export const API_KEY_HEADER = 'x-api-key'; @@ -55,47 +56,6 @@ function findRoute( return routes[routeKey]; } -/** - * Reads the broker id header, read from `rawHeaders` rather than `headers`: - * Node folds a repeated header into `'b1, b2'` in `headers`, which would be - * indistinguishable from a single opaque broker id that happens to contain a - * comma — `rawHeaders` keeps each occurrence separate so a repeated header - * is detected instead of silently accepted as one value. - */ -function readBrokerId( - request: FastifyRequest, - brokerIdHeader: string, - brokerIdMaxLength: number, -): string | undefined { - const header = request.headers[brokerIdHeader]; - if (Array.isArray(header) || countRawHeader(request.raw.rawHeaders, brokerIdHeader) > 1) { - throw new MalformedBrokerHeaderError(`The ${brokerIdHeader} header must be sent at most once`); - } - - if (header === undefined) { - return undefined; - } - - if (header.length === 0 || header.length > brokerIdMaxLength) { - throw new MalformedBrokerHeaderError( - `The ${brokerIdHeader} header must be between 1 and ${brokerIdMaxLength} characters`, - ); - } - - return header; -} - -function countRawHeader(rawHeaders: readonly string[] | undefined, headerName: string): number { - const headers = rawHeaders ?? []; - let count = 0; - for (let index = 0; index + 1 < headers.length; index += 2) { - if (headers[index].toLowerCase() === headerName) { - count += 1; - } - } - return count; -} - function readApiKey(request: FastifyRequest, apiKeyHeader: string): string | undefined { const header = request.headers[apiKeyHeader]; return typeof header === 'string' && header.length > 0 ? header : undefined; @@ -119,114 +79,20 @@ function readBearerToken(request: FastifyRequest, authorizationHeader: string): return match?.[1]; } -function requiresSingleBroker(request: FastifyRequest, route: RouteProperties): boolean { - if (route.singleBrokerWhen) { - return route.singleBrokerWhen(request); - } - return route.singleBroker ?? true; -} - -/** - * The shared tail of both credential paths: empty scope -> deny, more than - * one broker on a route that requires exactly one -> ambiguous, otherwise - * resolve `request.brokerContext`. `emptyScopeMessage` differs per caller - * because an empty API-key scope and an empty session-token scope are - * different failures worth describing differently. - */ -function resolveBrokerContext( - request: FastifyRequest, - route: RouteProperties, - brokers: readonly string[], - emptyScopeMessage: string, -): void { - if (brokers.length === 0) { - throw new PermissionDeniedError(emptyScopeMessage); - } - - if (brokers.length > 1 && requiresSingleBroker(request, route)) { - throw new AmbiguousBrokerTargetError( - 'This operation applies to a single broker: send the x-broker-id header to select one', - ); - } - - request.brokerContext = Object.freeze({ scope: Object.freeze([...brokers]) }); -} - -/** - * Everything after the API key is known to be present: reads the broker id - * header (if any), calls `validate`, and resolves `request.brokerContext`. - * Shared by the route-map hook and by the standalone `assertHasPermission` - * it returns, so the two never drift on this part of the contract. - */ -async function checkRoutePermission( - request: FastifyRequest, - route: RouteProperties, - apiKey: string, - validator: PermissionValidator, - brokerIdHeader: string, - brokerIdMaxLength: number, -): Promise { - const brokerId = readBrokerId(request, brokerIdHeader, brokerIdMaxLength); - - // The key is forwarded byte for byte: this module never stores, derives or - // compares a key's secret — authorization is wave-auth-api's job alone. - const result: PermissionValidationResult = await validator.validate({ - apiKey, - permission: route.permissionName, - ...(brokerId === undefined ? {} : { brokers: [brokerId] }), - }); - - if (!result.authorized) { - throw new PermissionDeniedError(`The API key does not have the ${route.permissionName} permission`); - } - - resolveBrokerContext( - request, - route, - result.brokers, - `The API key does not have the ${route.permissionName} permission`, - ); -} - -/** - * The session-token counterpart to `checkRoutePermission`. There is no - * permission verdict to ask for — wave-auth-api's session tokens - * 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. - */ -async function authenticateWithSessionToken( - request: FastifyRequest, - route: RouteProperties, - token: string, - verifier: SessionTokenVerifier, - brokerIdHeader: string, - brokerIdMaxLength: number, -): Promise { - const claims: SessionTokenClaims = await verifier.verify(token); - const brokerId = readBrokerId(request, brokerIdHeader, brokerIdMaxLength); - const brokers = - brokerId === undefined ? claims.brokers : restrictToBroker(claims.brokers, brokerId); - - resolveBrokerContext( - request, - route, - brokers, - 'The session token does not carry an authorized broker scope', - ); -} - -function restrictToBroker(brokers: readonly string[], brokerId: string): readonly string[] { - return brokers.includes(brokerId) ? [brokerId] : []; -} - /** * Registers an `onRequest` hook that authenticates and authorizes every * request against wave-auth-api, and returns the same check bound to * `validator` for use outside the route map (e.g. a vendor webhook whose * permission isn't one of this app's own routes). * + * Deliberately does no rate limiting of its own: this hook runs inside a + * shared framework used by many independently deployed services, each with + * its own traffic profile, so a one-size-fits-all limit here would be either + * too strict for some or meaningless for others. Rate limiting is expected + * at the infrastructure/gateway layer per consuming service — the same + * layer wave-auth-api itself relies on (Cloud Armor, configured in its own + * IaC), not inside application code. + * * The order of checks is contractual, not stylistic: * * 1. public path -> passes without touching the network; @@ -272,7 +138,7 @@ export function registerPermissionAuth( const assertHasPermission: AssertHasPermission = async (request, route) => { const apiKey = readApiKey(request, apiKeyHeader); if (apiKey !== undefined) { - await checkRoutePermission( + await checkApiKeyPermission( request, route, apiKey, From e4a1602e94058d65acec7ee40307d48f92d983b8 Mon Sep 17 00:00:00 2001 From: Gabriel Machado Date: Wed, 16 Sep 2026 12:33:10 -0300 Subject: [PATCH 3/6] :sparkles: feat(auth-permission): opt-in rate limiting via options.rateLimit Adds @fastify/rate-limit as a dependency and an optional rateLimit option on registerPermissionAuth, bucketed by the presented credential (API key or session token) rather than IP by default, so a leaked credential can't roam free across IPs and one shared NAT doesn't throttle unrelated callers together. A request over budget throws the new TooManyRequestsError. Uses @fastify/rate-limit's low-level createRateLimit, not its automatic per-route wiring (global: true): that wiring only reaches routes registered after the plugin finishes booting, and registerPermissionAuth is synchronous, so a caller's very next line registering its own routes would silently never get rate-limited. createRateLimit is read lazily on the first real request instead, once .ready() has necessarily already resolved. Left off by default: this hook runs inside a shared framework with no single traffic profile that fits every consuming service, and several already rate-limit at their own infra/gateway layer instead. Co-Authored-By: Claude Sonnet 5 --- package.json | 1 + pnpm-lock.yaml | 30 ++++++ src/auth-permission/errors/index.ts | 1 + .../errors/too-many-requests-error.ts | 14 +++ src/auth-permission/index.ts | 1 + src/auth-permission/rate-limit-check.ts | 38 ++++++++ src/auth-permission/rate-limit-key.ts | 21 +++++ src/auth-permission/read-credentials.ts | 28 ++++++ .../register-permission-auth.spec.ts | 70 ++++++++++++++ .../register-permission-auth.ts | 93 +++++++++++-------- 10 files changed, 258 insertions(+), 39 deletions(-) create mode 100644 src/auth-permission/errors/too-many-requests-error.ts create mode 100644 src/auth-permission/rate-limit-check.ts create mode 100644 src/auth-permission/rate-limit-key.ts create mode 100644 src/auth-permission/read-credentials.ts diff --git a/package.json b/package.json index 739358b..ec97803 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ } }, "dependencies": { + "@fastify/rate-limit": "^11.2.0", "date-fns": "^4.1.0", "express": "^4.21.2", "google-auth-library": "^9.15.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4dbb15..8485f4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@fastify/rate-limit': + specifier: ^11.2.0 + version: 11.2.0 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -328,6 +331,9 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/rate-limit@11.2.0': + resolution: {integrity: sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -358,6 +364,10 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1194,6 +1204,9 @@ packages: fast-uri@4.1.4: resolution: {integrity: sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==} + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + fastify@5.12.4: resolution: {integrity: sha512-zATD676fP6tBMsn+jQ7Q5/cO/Qo+149TXH0dUqUKel+1/hcZxwIbnflqtPa9EjlrvpY8eCZcL3M1UYrMc31Y7g==} @@ -1396,6 +1409,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ip-address@10.7.2: + resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2571,6 +2588,13 @@ snapshots: '@fastify/forwarded': 3.0.2 ipaddr.js: 2.5.0 + '@fastify/rate-limit@11.2.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 6.0.0 + ip-address: 10.7.2 + toad-cache: 3.7.4 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -2593,6 +2617,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@lukeed/ms@2.0.2': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3569,6 +3595,8 @@ snapshots: fast-uri@4.1.4: {} + fastify-plugin@6.0.0: {} + fastify@5.12.4: dependencies: '@fastify/ajv-compiler': 4.0.6 @@ -3814,6 +3842,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ip-address@10.7.2: {} + ipaddr.js@1.9.1: {} ipaddr.js@2.5.0: {} diff --git a/src/auth-permission/errors/index.ts b/src/auth-permission/errors/index.ts index 281677a..0e0b1b7 100644 --- a/src/auth-permission/errors/index.ts +++ b/src/auth-permission/errors/index.ts @@ -5,3 +5,4 @@ export { MalformedBrokerHeaderError } from './malformed-broker-header-error'; export { AmbiguousBrokerTargetError } from './ambiguous-broker-target-error'; export { BrokerContextNotResolvedError } from './broker-context-not-resolved-error'; export { SessionTokenInvalidError } from './session-token-invalid-error'; +export { TooManyRequestsError } from './too-many-requests-error'; diff --git a/src/auth-permission/errors/too-many-requests-error.ts b/src/auth-permission/errors/too-many-requests-error.ts new file mode 100644 index 0000000..010b82c --- /dev/null +++ b/src/auth-permission/errors/too-many-requests-error.ts @@ -0,0 +1,14 @@ +/** + * The credential (or, absent one, the caller's IP) presented on this request + * has exceeded `PermissionAuthOptions.rateLimit`'s configured budget. + * + * Doesn't extend a consumer's own HTTP error hierarchy: this is a neutral + * error owned by this module. Each consuming service maps it to its own + * response shape once, in its global error handler (`429 Too Many Requests`). + */ +export class TooManyRequestsError extends Error { + constructor(detail: string) { + super(detail); + this.name = 'TooManyRequestsError'; + } +} diff --git a/src/auth-permission/index.ts b/src/auth-permission/index.ts index bb92fc1..41ed486 100644 --- a/src/auth-permission/index.ts +++ b/src/auth-permission/index.ts @@ -29,4 +29,5 @@ export { AmbiguousBrokerTargetError, BrokerContextNotResolvedError, SessionTokenInvalidError, + TooManyRequestsError, } from './errors'; diff --git a/src/auth-permission/rate-limit-check.ts b/src/auth-permission/rate-limit-check.ts new file mode 100644 index 0000000..26e383b --- /dev/null +++ b/src/auth-permission/rate-limit-check.ts @@ -0,0 +1,38 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import type { CreateRateLimitOptions } from '@fastify/rate-limit'; +import { TooManyRequestsError } from './errors/too-many-requests-error'; + +export type RateLimitCheck = (request: FastifyRequest) => Promise; + +/** + * Builds a per-request rate-limit check on top of `@fastify/rate-limit`'s + * low-level `createRateLimit` API — deliberately not its automatic per-route + * wiring (`global: true`, the plugin's default). That wiring attaches itself + * via an `onRoute` hook fired only for routes registered *after* the plugin + * has finished booting, and `registerPermissionAuth` is synchronous: it + * cannot `await` the plugin's own registration before returning, and a + * caller's very next line typically registers its actual routes — which + * would then silently never get rate-limited. `createRateLimit` sidesteps + * this: `app.createRateLimit` is only read lazily, on the first real + * request, by which point `.ready()` has necessarily already resolved and + * the plugin is fully booted regardless of when its `register()` settled. + * + * `isAllowed` on `@fastify/rate-limit`'s own verdict is misleadingly named + * for this use — it's `true` only for an allow-listed key bypassing the + * check entirely. The actual "is this request over budget" verdict is + * `isExceeded`. + */ +export function createRateLimitCheck( + app: FastifyInstance, + options: CreateRateLimitOptions, +): RateLimitCheck { + let checkRateLimit: ReturnType | undefined; + + return async (request) => { + checkRateLimit ??= app.createRateLimit(options); + const verdict = await checkRateLimit(request); + if (!verdict.isAllowed && verdict.isExceeded) { + throw new TooManyRequestsError('Rate limit exceeded for this credential'); + } + }; +} diff --git a/src/auth-permission/rate-limit-key.ts b/src/auth-permission/rate-limit-key.ts new file mode 100644 index 0000000..12fc072 --- /dev/null +++ b/src/auth-permission/rate-limit-key.ts @@ -0,0 +1,21 @@ +import type { FastifyRequest } from 'fastify'; +import { readApiKey, readBearerToken } from './read-credentials'; + +/** + * The default `keyGenerator` for `PermissionAuthOptions.rateLimit`: buckets + * by the credential itself (the API key or session token presented) rather + * than by `request.ip`. Grouping by IP would conflate every caller behind + * the same NAT/gateway into one bucket and let a single leaked credential + * roam free across IPs; grouping by credential does the opposite of both. + * Falls back to `request.ip` only when no credential was presented at all + * (still useful to cap unauthenticated request floods). + */ +export function credentialRateLimitKey( + apiKeyHeader: string, + authorizationHeader: string, +): (request: FastifyRequest) => string { + return (request) => + readApiKey(request, apiKeyHeader) ?? + readBearerToken(request, authorizationHeader) ?? + request.ip; +} diff --git a/src/auth-permission/read-credentials.ts b/src/auth-permission/read-credentials.ts new file mode 100644 index 0000000..3561978 --- /dev/null +++ b/src/auth-permission/read-credentials.ts @@ -0,0 +1,28 @@ +import type { FastifyRequest } from 'fastify'; +import { countRawHeader } from './raw-headers'; + +export function readApiKey(request: FastifyRequest, apiKeyHeader: string): string | undefined { + const header = request.headers[apiKeyHeader]; + return typeof header === 'string' && header.length > 0 ? header : undefined; +} + +/** + * Reads the session token out of `Authorization: Bearer `. Anything + * that doesn't conform — a repeated header, a different scheme, an empty + * token — resolves as `undefined`, the same as the header being absent + * entirely: this is a credential lookup, not validation, and a caller with a + * malformed header must not be told anything more than one with no header + * at all. + */ +export function readBearerToken( + request: FastifyRequest, + authorizationHeader: string, +): string | undefined { + const header = request.headers[authorizationHeader]; + if (Array.isArray(header) || countRawHeader(request.raw.rawHeaders, authorizationHeader) > 1) { + return undefined; + } + + const match = typeof header === 'string' ? /^Bearer (.+)$/i.exec(header) : null; + return match?.[1]; +} diff --git a/src/auth-permission/register-permission-auth.spec.ts b/src/auth-permission/register-permission-auth.spec.ts index b4480e4..83d8e96 100644 --- a/src/auth-permission/register-permission-auth.spec.ts +++ b/src/auth-permission/register-permission-auth.spec.ts @@ -1,5 +1,6 @@ import { request as httpRequest } from 'node:http'; import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify'; +import type { CreateRateLimitOptions } from '@fastify/rate-limit'; import { describe, it, expect, afterEach } from 'vitest'; import { registerPermissionAuth, @@ -15,6 +16,7 @@ import { MalformedBrokerHeaderError } from './errors/malformed-broker-header-err 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, @@ -99,6 +101,10 @@ function testErrorHandler(error: Error, request: unknown, reply: FastifyReply): reply.status(401).send({ type: 'session-token-invalid', message: error.message }); return; } + if (error instanceof TooManyRequestsError) { + reply.status(429).send({ type: 'too-many-requests', message: error.message }); + return; + } reply.status(500).send({ type: 'unexpected', message: error.message }); } @@ -108,6 +114,7 @@ interface BuildAppOptions { routes?: Readonly>; publicPaths?: string[]; brokerIdMaxLength?: number; + rateLimit?: CreateRateLimitOptions; } function buildTestApp(options: BuildAppOptions): FastifyInstance { @@ -130,6 +137,7 @@ function buildTestApp(options: BuildAppOptions): FastifyInstance { routes, publicPaths: options.publicPaths, brokerIdMaxLength: options.brokerIdMaxLength, + rateLimit: options.rateLimit, }); app.get('/', () => ({ status: 'ok' })); @@ -712,4 +720,66 @@ describe('registerPermissionAuth', () => { expect(res.statusCode).toBe(403); }); }); + + describe('rate limiting (opt-in via options.rateLimit)', () => { + it('never answers 429 when rateLimit is not configured', async () => { + const validator = authorizes(); + app = buildTestApp({ validator }); + await app.ready(); + + for (let i = 0; i < 5; i += 1) { + const res = await app.inject({ + method: 'POST', + url: '/protected', + headers: { [API_KEY_HEADER]: API_KEY }, + }); + expect(res.statusCode).toBe(200); + } + }); + + it('answers 429 once the configured limit is exceeded for one credential', async () => { + const validator = authorizes(); + app = buildTestApp({ validator, rateLimit: { max: 2, timeWindow: '1 minute' } }); + await app.ready(); + + const inject = (): Promise<{ statusCode: number }> => + app.inject({ method: 'POST', url: '/protected', headers: { [API_KEY_HEADER]: API_KEY } }); + + expect((await inject()).statusCode).toBe(200); + expect((await inject()).statusCode).toBe(200); + expect((await inject()).statusCode).toBe(429); + expect(validator.calls).toHaveLength(2); + }); + + it('buckets by credential, not by IP: a different API key gets its own budget', async () => { + const validator = authorizes(); + app = buildTestApp({ validator, rateLimit: { max: 1, timeWindow: '1 minute' } }); + await app.ready(); + + const injectWith = (apiKey: string): Promise<{ statusCode: number }> => + app.inject({ method: 'POST', url: '/protected', headers: { [API_KEY_HEADER]: apiKey } }); + + expect((await injectWith(API_KEY)).statusCode).toBe(200); + expect((await injectWith(API_KEY)).statusCode).toBe(429); + expect((await injectWith('a-different-api-key')).statusCode).toBe(200); + }); + + it('honours a caller-supplied keyGenerator over the credential-based default', async () => { + const validator = authorizes(); + app = buildTestApp({ + validator, + rateLimit: { max: 1, timeWindow: '1 minute', keyGenerator: () => 'fixed-bucket' }, + }); + await app.ready(); + + const injectWith = (apiKey: string): Promise<{ statusCode: number }> => + app.inject({ method: 'POST', url: '/protected', headers: { [API_KEY_HEADER]: apiKey } }); + + expect((await injectWith(API_KEY)).statusCode).toBe(200); + // A *different* API key still lands in the same fixed bucket — the + // credential-based default would have given this its own budget (see + // the previous test), so a 429 here proves the override took effect. + expect((await injectWith('a-different-api-key')).statusCode).toBe(429); + }); + }); }); diff --git a/src/auth-permission/register-permission-auth.ts b/src/auth-permission/register-permission-auth.ts index 8ec772a..33797dd 100644 --- a/src/auth-permission/register-permission-auth.ts +++ b/src/auth-permission/register-permission-auth.ts @@ -1,12 +1,15 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; +import fastifyRateLimit, { type CreateRateLimitOptions } from '@fastify/rate-limit'; import type { PermissionValidator } from './permission-validator'; import type { SessionTokenVerifier } from './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 { countRawHeader } from './raw-headers'; import { checkApiKeyPermission } from './check-api-key-permission'; import { authenticateWithSessionToken } from './authenticate-with-session-token'; +import { readApiKey, readBearerToken } from './read-credentials'; +import { credentialRateLimitKey } from './rate-limit-key'; +import { createRateLimitCheck } from './rate-limit-check'; /** Header clients must send carrying their API key. */ export const API_KEY_HEADER = 'x-api-key'; @@ -31,6 +34,22 @@ export interface PermissionAuthOptions { authorizationHeader?: string; brokerIdHeader?: string; brokerIdMaxLength?: number; + /** + * Opts every route this hook protects into rate limiting (backed by + * `@fastify/rate-limit`'s `createRateLimit`, see `rate-limit-check.ts`), + * bucketed by the presented credential rather than `request.ip` by + * default (see `credentialRateLimitKey`) — pass a `keyGenerator` to + * override that. A request over budget throws `TooManyRequestsError` + * (`429`). Omitted entirely by default: this hook runs inside a shared + * framework used by many independently deployed services with very + * different traffic profiles, so there's no default this module could + * pick that would fit all of them. Many Wave services already + * rate-limit at the infrastructure/gateway layer instead (e.g. + * wave-auth-api's own Cloud Armor config) and don't need this at all. + * Only applies to the route-map hook itself, not to `assertHasPermission` + * used standalone outside the route map. + */ + rateLimit?: CreateRateLimitOptions; } /** Checks a single `RouteProperties` requirement for an already-authenticated request. */ @@ -56,63 +75,40 @@ function findRoute( return routes[routeKey]; } -function readApiKey(request: FastifyRequest, apiKeyHeader: string): string | undefined { - const header = request.headers[apiKeyHeader]; - return typeof header === 'string' && header.length > 0 ? header : undefined; -} - -/** - * Reads the session token out of `Authorization: Bearer `. Anything - * that doesn't conform — a repeated header, a different scheme, an empty - * token — resolves as `undefined`, the same as the header being absent - * entirely: this is a credential lookup, not validation, and a caller with a - * malformed header must not be told anything more than one with no header - * at all. - */ -function readBearerToken(request: FastifyRequest, authorizationHeader: string): string | undefined { - const header = request.headers[authorizationHeader]; - if (Array.isArray(header) || countRawHeader(request.raw.rawHeaders, authorizationHeader) > 1) { - return undefined; - } - - const match = typeof header === 'string' ? /^Bearer (.+)$/i.exec(header) : null; - return match?.[1]; -} - /** * Registers an `onRequest` hook that authenticates and authorizes every * request against wave-auth-api, and returns the same check bound to * `validator` for use outside the route map (e.g. a vendor webhook whose * permission isn't one of this app's own routes). * - * Deliberately does no rate limiting of its own: this hook runs inside a - * shared framework used by many independently deployed services, each with - * its own traffic profile, so a one-size-fits-all limit here would be either - * too strict for some or meaningless for others. Rate limiting is expected - * at the infrastructure/gateway layer per consuming service — the same - * layer wave-auth-api itself relies on (Cloud Armor, configured in its own - * IaC), not inside application code. + * Rate limiting is opt-in via `options.rateLimit` (see its doc comment) — + * omitted by default, since this hook runs inside a shared framework with no + * single traffic profile that would fit every consuming service. * * The order of checks is contractual, not stylistic: * * 1. public path -> passes without touching the network; - * 2. neither `x-api-key` nor a valid `Authorization: Bearer` present -> + * 2. rate limit exceeded for the presented credential (or IP, absent one) + * -> `429`, before any credential is even read for real, so a flood of + * guesses gets throttled before it can reach `validate()` or a token + * verification; + * 3. neither `x-api-key` nor a valid `Authorization: Bearer` present -> * `401`, even before routing decides, so a caller with no credential * can't tell an existing route from a nonexistent one by omitting it; - * 3. path didn't match any route Fastify knows about -> returns without + * 4. path didn't match any route Fastify knows about -> returns without * throwing, letting Fastify's own 404 handling take over; - * 4. route matched but has no entry in `routes` -> `403` (deny by + * 5. route matched but has no entry in `routes` -> `403` (deny by * default), without ever reaching the network; - * 5. `x-api-key` present -> the existing validate-against-wave-auth-api + * 6. `x-api-key` present -> the existing validate-against-wave-auth-api * path, unconditionally, regardless of `acceptsSessionToken`; - * 6. otherwise only a bearer token was presented -> `401` unless the route + * 7. otherwise only a bearer token was presented -> `401` unless the route * set `acceptsSessionToken: true`, in which case the token is verified * offline instead and never reaches `validate()` — see * `authenticateWithSessionToken`; - * 7. broker id header malformed -> `400`, also without reaching the + * 8. broker id header malformed -> `400`, also without reaching the * network; - * 8. denied verdict or empty scope -> `403`; - * 9. scope spans more than one broker on a route that requires exactly + * 9. denied verdict or empty scope -> `403`; + * 10. scope spans more than one broker on a route that requires exactly * one -> `400`. This one comes *after* the verdict because only the * verdict (or the token's claims) reveals how many brokers the scope * actually has. @@ -135,6 +131,21 @@ export function registerPermissionAuth( } } + // `global: false`: this hook does its own per-request check via + // `createRateLimit` (see rate-limit-check.ts) instead of the plugin's + // automatic per-route wiring, which doesn't reach routes a caller + // registers right after this synchronous function returns. + const checkRateLimit = + options.rateLimit === undefined + ? undefined + : createRateLimitCheck(app, { + keyGenerator: credentialRateLimitKey(apiKeyHeader, authorizationHeader), + ...options.rateLimit, + }); + if (checkRateLimit !== undefined) { + void app.register(fastifyRateLimit, { global: false }); + } + const assertHasPermission: AssertHasPermission = async (request, route) => { const apiKey = readApiKey(request, apiKeyHeader); if (apiKey !== undefined) { @@ -179,6 +190,10 @@ export function registerPermissionAuth( return; } + if (checkRateLimit !== undefined) { + await checkRateLimit(request); + } + const hasCredential = readApiKey(request, apiKeyHeader) !== undefined || readBearerToken(request, authorizationHeader) !== undefined; From 1ebdb852581cab59d0aa76b8b0e6871a2a965337 Mon Sep 17 00:00:00 2001 From: Gabriel Machado Date: Wed, 16 Sep 2026 12:57:58 -0300 Subject: [PATCH 4/6] :sparkles: feat(auth-permission): add brokerScoped: false for tenant-wide routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some credentials (API keys or session tokens) authorize a whole tenant rather than a subset of its brokers. A route can now opt out of broker scope resolution entirely with brokerScoped: false — x-broker-id is not read, an empty broker scope on the verdict/token is not treated as a denial, and request.brokerContext is left unset. Also replaces the ??= lazy-init in rate-limit-check.ts's createRateLimitCheck with a plain if-block for a more conventional read. Co-Authored-By: Claude Sonnet 5 --- .../authenticate-with-session-token.ts | 4 +- .../broker-scope-resolution.ts | 8 ++ .../check-api-key-permission.ts | 4 +- src/auth-permission/rate-limit-check.ts | 5 +- .../register-permission-auth.spec.ts | 78 +++++++++++++++++++ src/auth-permission/route-properties.ts | 10 +++ 6 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/auth-permission/authenticate-with-session-token.ts b/src/auth-permission/authenticate-with-session-token.ts index a2213de..878022e 100644 --- a/src/auth-permission/authenticate-with-session-token.ts +++ b/src/auth-permission/authenticate-with-session-token.ts @@ -20,7 +20,9 @@ export async function authenticateWithSessionToken( brokerIdMaxLength: number, ): Promise { const claims: SessionTokenClaims = await verifier.verify(token); - const brokerId = readBrokerId(request, brokerIdHeader, brokerIdMaxLength); + const brokerId = route.brokerScoped === false + ? undefined + : readBrokerId(request, brokerIdHeader, brokerIdMaxLength); const brokers = brokerId === undefined ? claims.brokers : restrictToBroker(claims.brokers, brokerId); diff --git a/src/auth-permission/broker-scope-resolution.ts b/src/auth-permission/broker-scope-resolution.ts index 4ec1b60..defd407 100644 --- a/src/auth-permission/broker-scope-resolution.ts +++ b/src/auth-permission/broker-scope-resolution.ts @@ -46,6 +46,10 @@ function requiresSingleBroker(request: FastifyRequest, route: RouteProperties): * `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. */ export function resolveBrokerContext( request: FastifyRequest, @@ -53,6 +57,10 @@ export function resolveBrokerContext( brokers: readonly string[], emptyScopeMessage: string, ): void { + if (route.brokerScoped === false) { + return; + } + if (brokers.length === 0) { throw new PermissionDeniedError(emptyScopeMessage); } diff --git a/src/auth-permission/check-api-key-permission.ts b/src/auth-permission/check-api-key-permission.ts index 64099ca..3664502 100644 --- a/src/auth-permission/check-api-key-permission.ts +++ b/src/auth-permission/check-api-key-permission.ts @@ -19,7 +19,9 @@ export async function checkApiKeyPermission( brokerIdHeader: string, brokerIdMaxLength: number, ): Promise { - const brokerId = readBrokerId(request, brokerIdHeader, brokerIdMaxLength); + const brokerId = route.brokerScoped === false + ? undefined + : readBrokerId(request, brokerIdHeader, brokerIdMaxLength); // The key is forwarded byte for byte: this module never stores, derives or // compares a key's secret — authorization is wave-auth-api's job alone. diff --git a/src/auth-permission/rate-limit-check.ts b/src/auth-permission/rate-limit-check.ts index 26e383b..eee2c4c 100644 --- a/src/auth-permission/rate-limit-check.ts +++ b/src/auth-permission/rate-limit-check.ts @@ -29,7 +29,10 @@ export function createRateLimitCheck( let checkRateLimit: ReturnType | undefined; return async (request) => { - checkRateLimit ??= app.createRateLimit(options); + if (checkRateLimit === undefined) { + checkRateLimit = app.createRateLimit(options); + } + const verdict = await checkRateLimit(request); if (!verdict.isAllowed && verdict.isExceeded) { throw new TooManyRequestsError('Rate limit exceeded for this credential'); diff --git a/src/auth-permission/register-permission-auth.spec.ts b/src/auth-permission/register-permission-auth.spec.ts index 83d8e96..919e226 100644 --- a/src/auth-permission/register-permission-auth.spec.ts +++ b/src/auth-permission/register-permission-auth.spec.ts @@ -148,6 +148,7 @@ function buildTestApp(options: BuildAppOptions): FastifyInstance { scope: requireBrokerContext(request).scope, })); app.get('/session-scoped', (request) => ({ scope: requireBrokerContext(request).scope })); + app.get('/tenant-wide', (request) => ({ hasBrokerContext: request.brokerContext !== undefined })); // Registered with Fastify but deliberately absent from `routes` above. app.get('/registered-but-unmapped', () => ({ ok: true })); // A path the global hook treats as public (via publicPaths), whose own @@ -721,6 +722,83 @@ describe('registerPermissionAuth', () => { }); }); + describe('brokerScoped: false — a route not scoped to any particular broker', () => { + it('allows an API key with an empty broker scope, and sets no broker context', async () => { + const validator = authorizes([]); + app = buildTestApp({ + validator, + routes: { 'GET /tenant-wide': { permissionName: PERMISSION, brokerScoped: false } }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/tenant-wide', + headers: { [API_KEY_HEADER]: API_KEY }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ hasBrokerContext: false }); + }); + + it('ignores x-broker-id entirely for an API key, even if malformed', async () => { + const validator = authorizes(['broker-a', 'broker-b']); + app = buildTestApp({ + validator, + routes: { 'GET /tenant-wide': { permissionName: PERMISSION, brokerScoped: false } }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/tenant-wide', + headers: { [API_KEY_HEADER]: API_KEY, [BROKER_ID_HEADER]: '' }, + }); + + expect(res.statusCode).toBe(200); + expect(Object.keys(validator.calls[0])).not.toContain('brokers'); + }); + + it('allows a session token with an empty broker scope, and sets no broker context', async () => { + const validator = authorizes(); + const sessionTokenVerifier = verifiesAs({ sub: 'key-1', brokers: [] }); + app = buildTestApp({ + validator, + sessionTokenVerifier, + routes: { + 'GET /tenant-wide': { permissionName: PERMISSION, acceptsSessionToken: true, brokerScoped: false }, + }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/tenant-wide', + headers: { [AUTHORIZATION_HEADER]: `Bearer ${SESSION_TOKEN}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ hasBrokerContext: false }); + }); + + it('ignores x-broker-id entirely for a session token, even if malformed', async () => { + const validator = authorizes(); + const sessionTokenVerifier = verifiesAs({ sub: 'key-1', brokers: ['broker-a'] }); + app = buildTestApp({ + validator, + sessionTokenVerifier, + routes: { + 'GET /tenant-wide': { permissionName: PERMISSION, acceptsSessionToken: true, brokerScoped: false }, + }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/tenant-wide', + headers: { [AUTHORIZATION_HEADER]: `Bearer ${SESSION_TOKEN}`, [BROKER_ID_HEADER]: '' }, + }); + + expect(res.statusCode).toBe(200); + }); + }); + describe('rate limiting (opt-in via options.rateLimit)', () => { it('never answers 429 when rateLimit is not configured', async () => { const validator = authorizes(); diff --git a/src/auth-permission/route-properties.ts b/src/auth-permission/route-properties.ts index c880170..cd6af3a 100644 --- a/src/auth-permission/route-properties.ts +++ b/src/auth-permission/route-properties.ts @@ -32,4 +32,14 @@ export interface RouteProperties { * decision, trusting only the token's broker scope. Defaults to `false`. */ acceptsSessionToken?: boolean; + /** + * Set to `false` for a route that isn't scoped to any particular + * broker at all — e.g. a tenant-wide operation, authorized for the + * credential's whole tenant rather than a subset of its brokers. Skips + * broker scope resolution entirely: `x-broker-id` is not read, an empty + * broker scope on the verdict/token is not treated as a denial, and + * `request.brokerContext` is left unset — a handler for a route like + * this must not call `requireBrokerContext`. Defaults to `true`. + */ + brokerScoped?: boolean; } From 30b9d022c742d717f9c1dc8aa33e8459780d74b7 Mon Sep 17 00:00:00 2001 From: Gabriel Machado Date: Wed, 16 Sep 2026 13:02:30 -0300 Subject: [PATCH 5/6] :recycle: refactor(auth-permission): group files by concern into subfolders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directory had grown to ~20 flat files across two credential paths, a shared rate-limit feature and generic header helpers. Groups them into api-key/, session-token/, rate-limit/ and shared/, leaving only the public surface (register-permission-auth.ts, route-properties.ts, broker-context.ts, index.ts) and errors/ at the top level. No behavior change — only import paths moved; the package's own @wave-tech/framework/auth-permission export is unaffected. Co-Authored-By: Claude Sonnet 5 --- .../{ => api-key}/check-api-key-permission.ts | 6 +++--- .../{ => api-key}/permission-validator.ts | 0 .../wave-auth-permission-validator.spec.ts | 6 +++--- .../wave-auth-permission-validator.ts | 6 +++--- src/auth-permission/index.ts | 10 +++++----- .../{ => rate-limit}/rate-limit-check.ts | 2 +- .../{ => rate-limit}/rate-limit-key.ts | 2 +- .../register-permission-auth.spec.ts | 4 ++-- src/auth-permission/register-permission-auth.ts | 14 +++++++------- .../authenticate-with-session-token.ts | 4 ++-- .../{ => session-token}/session-token-verifier.ts | 0 .../wave-auth-session-token-verifier.spec.ts | 2 +- .../wave-auth-session-token-verifier.ts | 2 +- .../{ => shared}/broker-scope-resolution.ts | 8 ++++---- .../{ => shared}/correlation-headers.ts | 2 +- src/auth-permission/{ => shared}/raw-headers.ts | 0 .../{ => shared}/read-credentials.ts | 0 17 files changed, 34 insertions(+), 34 deletions(-) rename src/auth-permission/{ => api-key}/check-api-key-permission.ts (86%) rename src/auth-permission/{ => api-key}/permission-validator.ts (100%) rename src/auth-permission/{ => api-key}/wave-auth-permission-validator.spec.ts (96%) rename src/auth-permission/{ => api-key}/wave-auth-permission-validator.ts (92%) rename src/auth-permission/{ => rate-limit}/rate-limit-check.ts (96%) rename src/auth-permission/{ => rate-limit}/rate-limit-key.ts (91%) rename src/auth-permission/{ => session-token}/authenticate-with-session-token.ts (90%) rename src/auth-permission/{ => session-token}/session-token-verifier.ts (100%) rename src/auth-permission/{ => session-token}/wave-auth-session-token-verifier.spec.ts (97%) rename src/auth-permission/{ => session-token}/wave-auth-session-token-verifier.ts (96%) rename src/auth-permission/{ => shared}/broker-scope-resolution.ts (88%) rename src/auth-permission/{ => shared}/correlation-headers.ts (91%) rename src/auth-permission/{ => shared}/raw-headers.ts (100%) rename src/auth-permission/{ => shared}/read-credentials.ts (100%) diff --git a/src/auth-permission/check-api-key-permission.ts b/src/auth-permission/api-key/check-api-key-permission.ts similarity index 86% rename from src/auth-permission/check-api-key-permission.ts rename to src/auth-permission/api-key/check-api-key-permission.ts index 3664502..836b1ea 100644 --- a/src/auth-permission/check-api-key-permission.ts +++ b/src/auth-permission/api-key/check-api-key-permission.ts @@ -1,8 +1,8 @@ import type { FastifyRequest } from 'fastify'; import type { PermissionValidationResult, PermissionValidator } from './permission-validator'; -import { PermissionDeniedError } from './errors/permission-denied-error'; -import type { RouteProperties } from './route-properties'; -import { readBrokerId, resolveBrokerContext } from './broker-scope-resolution'; +import { PermissionDeniedError } from '../errors/permission-denied-error'; +import type { RouteProperties } from '../route-properties'; +import { readBrokerId, resolveBrokerContext } from '../shared/broker-scope-resolution'; /** * Authenticates and authorizes an `x-api-key` credential against diff --git a/src/auth-permission/permission-validator.ts b/src/auth-permission/api-key/permission-validator.ts similarity index 100% rename from src/auth-permission/permission-validator.ts rename to src/auth-permission/api-key/permission-validator.ts diff --git a/src/auth-permission/wave-auth-permission-validator.spec.ts b/src/auth-permission/api-key/wave-auth-permission-validator.spec.ts similarity index 96% rename from src/auth-permission/wave-auth-permission-validator.spec.ts rename to src/auth-permission/api-key/wave-auth-permission-validator.spec.ts index 94d8cd0..2179ce2 100644 --- a/src/auth-permission/wave-auth-permission-validator.spec.ts +++ b/src/auth-permission/api-key/wave-auth-permission-validator.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; -import { setHookContext, setHookCorrelationId } from '../core'; +import { setHookContext, setHookCorrelationId } from '../../core'; import { WaveAuthPermissionValidator } from './wave-auth-permission-validator'; -import { PermissionValidatorUnauthorizedError } from './errors/permission-validator-unauthorized-error'; -import { PermissionValidatorUnavailableError } from './errors/permission-validator-unavailable-error'; +import { PermissionValidatorUnauthorizedError } from '../errors/permission-validator-unauthorized-error'; +import { PermissionValidatorUnavailableError } from '../errors/permission-validator-unavailable-error'; import type { PermissionValidationRequest } from './permission-validator'; const BASE_URL = 'https://auth.internal.example'; diff --git a/src/auth-permission/wave-auth-permission-validator.ts b/src/auth-permission/api-key/wave-auth-permission-validator.ts similarity index 92% rename from src/auth-permission/wave-auth-permission-validator.ts rename to src/auth-permission/api-key/wave-auth-permission-validator.ts index d2bc186..c4374be 100644 --- a/src/auth-permission/wave-auth-permission-validator.ts +++ b/src/auth-permission/api-key/wave-auth-permission-validator.ts @@ -1,12 +1,12 @@ import { z } from 'zod'; -import { PermissionValidatorUnauthorizedError } from './errors/permission-validator-unauthorized-error'; -import { PermissionValidatorUnavailableError } from './errors/permission-validator-unavailable-error'; +import { PermissionValidatorUnauthorizedError } from '../errors/permission-validator-unauthorized-error'; +import { PermissionValidatorUnavailableError } from '../errors/permission-validator-unavailable-error'; import type { PermissionValidationRequest, PermissionValidationResult, PermissionValidator, } from './permission-validator'; -import { correlationHeaders } from './correlation-headers'; +import { correlationHeaders } from '../shared/correlation-headers'; /** Not version-prefixed: this is the path wave-auth-api itself publishes. */ const VALIDATE_PATH = '/auth/permissions/validate'; diff --git a/src/auth-permission/index.ts b/src/auth-permission/index.ts index 41ed486..ee9d7f3 100644 --- a/src/auth-permission/index.ts +++ b/src/auth-permission/index.ts @@ -2,11 +2,11 @@ export type { PermissionValidationRequest, PermissionValidationResult, PermissionValidator, -} from './permission-validator'; -export { WaveAuthPermissionValidator } from './wave-auth-permission-validator'; -export type { SessionTokenClaims, SessionTokenVerifier } from './session-token-verifier'; -export { WaveAuthSessionTokenVerifier } from './wave-auth-session-token-verifier'; -export { correlationHeaders } from './correlation-headers'; +} 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'; diff --git a/src/auth-permission/rate-limit-check.ts b/src/auth-permission/rate-limit/rate-limit-check.ts similarity index 96% rename from src/auth-permission/rate-limit-check.ts rename to src/auth-permission/rate-limit/rate-limit-check.ts index eee2c4c..438ef6d 100644 --- a/src/auth-permission/rate-limit-check.ts +++ b/src/auth-permission/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-key.ts b/src/auth-permission/rate-limit/rate-limit-key.ts similarity index 91% rename from src/auth-permission/rate-limit-key.ts rename to src/auth-permission/rate-limit/rate-limit-key.ts index 12fc072..b335449 100644 --- a/src/auth-permission/rate-limit-key.ts +++ b/src/auth-permission/rate-limit/rate-limit-key.ts @@ -1,5 +1,5 @@ import type { FastifyRequest } from 'fastify'; -import { readApiKey, readBearerToken } from './read-credentials'; +import { readApiKey, readBearerToken } from '../shared/read-credentials'; /** * The default `keyGenerator` for `PermissionAuthOptions.rateLimit`: buckets diff --git a/src/auth-permission/register-permission-auth.spec.ts b/src/auth-permission/register-permission-auth.spec.ts index 919e226..08a9ccc 100644 --- a/src/auth-permission/register-permission-auth.spec.ts +++ b/src/auth-permission/register-permission-auth.spec.ts @@ -21,8 +21,8 @@ import type { PermissionValidationRequest, PermissionValidationResult, PermissionValidator, -} from './permission-validator'; -import type { SessionTokenClaims, SessionTokenVerifier } from './session-token-verifier'; +} 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'; diff --git a/src/auth-permission/register-permission-auth.ts b/src/auth-permission/register-permission-auth.ts index 33797dd..0f6f3bb 100644 --- a/src/auth-permission/register-permission-auth.ts +++ b/src/auth-permission/register-permission-auth.ts @@ -1,15 +1,15 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; import fastifyRateLimit, { type CreateRateLimitOptions } from '@fastify/rate-limit'; -import type { PermissionValidator } from './permission-validator'; -import type { SessionTokenVerifier } from './session-token-verifier'; +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 './check-api-key-permission'; -import { authenticateWithSessionToken } from './authenticate-with-session-token'; -import { readApiKey, readBearerToken } from './read-credentials'; -import { credentialRateLimitKey } from './rate-limit-key'; -import { createRateLimitCheck } from './rate-limit-check'; +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'; /** Header clients must send carrying their API key. */ export const API_KEY_HEADER = 'x-api-key'; diff --git a/src/auth-permission/authenticate-with-session-token.ts b/src/auth-permission/session-token/authenticate-with-session-token.ts similarity index 90% rename from src/auth-permission/authenticate-with-session-token.ts rename to src/auth-permission/session-token/authenticate-with-session-token.ts index 878022e..1125f5d 100644 --- a/src/auth-permission/authenticate-with-session-token.ts +++ b/src/auth-permission/session-token/authenticate-with-session-token.ts @@ -1,7 +1,7 @@ import type { FastifyRequest } from 'fastify'; import type { SessionTokenClaims, SessionTokenVerifier } from './session-token-verifier'; -import type { RouteProperties } from './route-properties'; -import { readBrokerId, resolveBrokerContext } from './broker-scope-resolution'; +import type { RouteProperties } from '../route-properties'; +import { readBrokerId, resolveBrokerContext } from '../shared/broker-scope-resolution'; /** * The session-token counterpart to `checkApiKeyPermission`. There is no diff --git a/src/auth-permission/session-token-verifier.ts b/src/auth-permission/session-token/session-token-verifier.ts similarity index 100% rename from src/auth-permission/session-token-verifier.ts rename to src/auth-permission/session-token/session-token-verifier.ts diff --git a/src/auth-permission/wave-auth-session-token-verifier.spec.ts b/src/auth-permission/session-token/wave-auth-session-token-verifier.spec.ts similarity index 97% rename from src/auth-permission/wave-auth-session-token-verifier.spec.ts rename to src/auth-permission/session-token/wave-auth-session-token-verifier.spec.ts index 0342fe2..9b9fe4b 100644 --- a/src/auth-permission/wave-auth-session-token-verifier.spec.ts +++ b/src/auth-permission/session-token/wave-auth-session-token-verifier.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { generateKeyPair, exportJWK, SignJWT, type JWK } from 'jose'; import { WaveAuthSessionTokenVerifier } from './wave-auth-session-token-verifier'; -import { SessionTokenInvalidError } from './errors/session-token-invalid-error'; +import { SessionTokenInvalidError } from '../errors/session-token-invalid-error'; const BASE_URL = 'https://auth.internal.example'; const JWKS_URL = `${BASE_URL}/.well-known/jwks.json`; diff --git a/src/auth-permission/wave-auth-session-token-verifier.ts b/src/auth-permission/session-token/wave-auth-session-token-verifier.ts similarity index 96% rename from src/auth-permission/wave-auth-session-token-verifier.ts rename to src/auth-permission/session-token/wave-auth-session-token-verifier.ts index 3ca71ba..6d1c87a 100644 --- a/src/auth-permission/wave-auth-session-token-verifier.ts +++ b/src/auth-permission/session-token/wave-auth-session-token-verifier.ts @@ -1,6 +1,6 @@ import { createRemoteJWKSet, jwtVerify } from 'jose'; import { z } from 'zod'; -import { SessionTokenInvalidError } from './errors/session-token-invalid-error'; +import { SessionTokenInvalidError } from '../errors/session-token-invalid-error'; import type { SessionTokenClaims, SessionTokenVerifier } from './session-token-verifier'; /** Not version-prefixed: this is the path wave-auth-api itself publishes. */ diff --git a/src/auth-permission/broker-scope-resolution.ts b/src/auth-permission/shared/broker-scope-resolution.ts similarity index 88% rename from src/auth-permission/broker-scope-resolution.ts rename to src/auth-permission/shared/broker-scope-resolution.ts index defd407..c63061a 100644 --- a/src/auth-permission/broker-scope-resolution.ts +++ b/src/auth-permission/shared/broker-scope-resolution.ts @@ -1,8 +1,8 @@ import type { FastifyRequest } from 'fastify'; -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 { 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'; /** diff --git a/src/auth-permission/correlation-headers.ts b/src/auth-permission/shared/correlation-headers.ts similarity index 91% rename from src/auth-permission/correlation-headers.ts rename to src/auth-permission/shared/correlation-headers.ts index fcbdbea..19824d6 100644 --- a/src/auth-permission/correlation-headers.ts +++ b/src/auth-permission/shared/correlation-headers.ts @@ -1,4 +1,4 @@ -import { getHookCorrelationId } from '../core'; +import { getHookCorrelationId } from '../../core'; /** * Builds the `x-correlation-id` header for an outbound request, propagating diff --git a/src/auth-permission/raw-headers.ts b/src/auth-permission/shared/raw-headers.ts similarity index 100% rename from src/auth-permission/raw-headers.ts rename to src/auth-permission/shared/raw-headers.ts diff --git a/src/auth-permission/read-credentials.ts b/src/auth-permission/shared/read-credentials.ts similarity index 100% rename from src/auth-permission/read-credentials.ts rename to src/auth-permission/shared/read-credentials.ts From d24fbf133211b75053ae4b6bc7d97736b6cc4b24 Mon Sep 17 00:00:00 2001 From: Gabriel Machado Date: Wed, 16 Sep 2026 13:36:20 -0300 Subject: [PATCH 6/6] chore: upgrade package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ec97803..6a20211 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wave-tech/framework", - "version": "0.14.0", + "version": "0.15.0", "description": "wave tech team shared utils framework", "main": "dist/index.js", "types": "dist/index.d.ts",