diff --git a/package.json b/package.json index a5592e7..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", @@ -36,10 +36,12 @@ } }, "dependencies": { + "@fastify/rate-limit": "^11.2.0", "date-fns": "^4.1.0", "express": "^4.21.2", "google-auth-library": "^9.15.1", "hono": "^4.8.5", + "jose": "^6.2.12", "mathjs": "^14.2.1", "uuid": "^11.0.5", "winston": "^3.17.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7ca0e3..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 @@ -20,6 +23,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 @@ -325,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'} @@ -355,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'} @@ -1191,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==} @@ -1393,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'} @@ -1517,6 +1537,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 @@ -2565,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': @@ -2587,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 @@ -3563,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 @@ -3808,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: {} @@ -3929,6 +3965,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/api-key/check-api-key-permission.ts b/src/auth-permission/api-key/check-api-key-permission.ts new file mode 100644 index 0000000..836b1ea --- /dev/null +++ b/src/auth-permission/api-key/check-api-key-permission.ts @@ -0,0 +1,44 @@ +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 '../shared/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 = 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. + 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/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/errors/index.ts b/src/auth-permission/errors/index.ts index 3d355be..0e0b1b7 100644 --- a/src/auth-permission/errors/index.ts +++ b/src/auth-permission/errors/index.ts @@ -4,3 +4,5 @@ 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'; +export { TooManyRequestsError } from './too-many-requests-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/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 5b4a579..ee9d7f3 100644 --- a/src/auth-permission/index.ts +++ b/src/auth-permission/index.ts @@ -2,9 +2,11 @@ export type { PermissionValidationRequest, PermissionValidationResult, PermissionValidator, -} from './permission-validator'; -export { WaveAuthPermissionValidator } from './wave-auth-permission-validator'; -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'; @@ -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,6 @@ export { MalformedBrokerHeaderError, AmbiguousBrokerTargetError, BrokerContextNotResolvedError, + SessionTokenInvalidError, + TooManyRequestsError, } from './errors'; diff --git a/src/auth-permission/rate-limit/rate-limit-check.ts b/src/auth-permission/rate-limit/rate-limit-check.ts new file mode 100644 index 0000000..438ef6d --- /dev/null +++ b/src/auth-permission/rate-limit/rate-limit-check.ts @@ -0,0 +1,41 @@ +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) => { + 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/rate-limit/rate-limit-key.ts b/src/auth-permission/rate-limit/rate-limit-key.ts new file mode 100644 index 0000000..b335449 --- /dev/null +++ b/src/auth-permission/rate-limit/rate-limit-key.ts @@ -0,0 +1,21 @@ +import type { FastifyRequest } from 'fastify'; +import { readApiKey, readBearerToken } from '../shared/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/register-permission-auth.spec.ts b/src/auth-permission/register-permission-auth.spec.ts index 007c405..08a9ccc 100644 --- a/src/auth-permission/register-permission-auth.spec.ts +++ b/src/auth-permission/register-permission-auth.spec.ts @@ -1,7 +1,13 @@ 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, 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 +15,18 @@ import { PermissionDeniedError } from './errors/permission-denied-error'; import { MalformedBrokerHeaderError } from './errors/malformed-broker-header-error'; import { AmbiguousBrokerTargetError } from './errors/ambiguous-broker-target-error'; import { BrokerContextNotResolvedError } from './errors/broker-context-not-resolved-error'; +import { SessionTokenInvalidError } from './errors/session-token-invalid-error'; +import { TooManyRequestsError } from './errors/too-many-requests-error'; import type { PermissionValidationRequest, PermissionValidationResult, PermissionValidator, -} from './permission-validator'; +} from './api-key/permission-validator'; +import type { SessionTokenClaims, SessionTokenVerifier } from './session-token/session-token-verifier'; import type { RouteProperties } from './route-properties'; const API_KEY = 'test-secret-key'; +const SESSION_TOKEN = 'test-session-token'; const PERMISSION = 'carrier.delivery_order.create'; /** Records every call and resolves each one through a caller-supplied function. */ @@ -45,6 +55,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,14 +97,24 @@ 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; + } + 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 }); } interface BuildAppOptions { validator: PermissionValidator; + sessionTokenVerifier?: SessionTokenVerifier; routes?: Readonly>; publicPaths?: string[]; brokerIdMaxLength?: number; + rateLimit?: CreateRateLimitOptions; } function buildTestApp(options: BuildAppOptions): FastifyInstance { @@ -97,9 +133,11 @@ function buildTestApp(options: BuildAppOptions): FastifyInstance { const assertHasPermission = registerPermissionAuth(app, { validator: options.validator, + sessionTokenVerifier: options.sessionTokenVerifier, routes, publicPaths: options.publicPaths, brokerIdMaxLength: options.brokerIdMaxLength, + rateLimit: options.rateLimit, }); app.get('/', () => ({ status: 'ok' })); @@ -109,6 +147,8 @@ function buildTestApp(options: BuildAppOptions): FastifyInstance { app.get('/multi-broker-conditional', (request) => ({ 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 @@ -535,4 +575,289 @@ 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); + }); + }); + + 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(); + 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 ffbf0d1..0f6f3bb 100644 --- a/src/auth-permission/register-permission-auth.ts +++ b/src/auth-permission/register-permission-auth.ts @@ -1,13 +1,20 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; -import type { PermissionValidationResult, PermissionValidator } from './permission-validator'; +import fastifyRateLimit, { type CreateRateLimitOptions } from '@fastify/rate-limit'; +import type { PermissionValidator } from './api-key/permission-validator'; +import type { SessionTokenVerifier } from './session-token/session-token-verifier'; import { PermissionValidatorUnauthorizedError } from './errors/permission-validator-unauthorized-error'; import { PermissionDeniedError } from './errors/permission-denied-error'; -import { MalformedBrokerHeaderError } from './errors/malformed-broker-header-error'; -import { AmbiguousBrokerTargetError } from './errors/ambiguous-broker-target-error'; import type { RouteProperties } from './route-properties'; +import { checkApiKeyPermission } from './api-key/check-api-key-permission'; +import { authenticateWithSessionToken } from './session-token/authenticate-with-session-token'; +import { readApiKey, readBearerToken } from './shared/read-credentials'; +import { credentialRateLimitKey } from './rate-limit/rate-limit-key'; +import { createRateLimitCheck } from './rate-limit/rate-limit-check'; /** 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,8 +29,27 @@ 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; + /** + * 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. */ @@ -49,114 +75,43 @@ 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 requiresSingleBroker(request: FastifyRequest, route: RouteProperties): boolean { - if (route.singleBrokerWhen) { - return route.singleBrokerWhen(request); - } - return route.singleBroker ?? true; -} - -/** - * 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 || result.brokers.length === 0) { - 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', - ); - } - - request.brokerContext = Object.freeze({ scope: Object.freeze([...result.brokers]) }); -} - /** * 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). * + * 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. `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; - * 3. path didn't match any route Fastify knows about -> returns without + * 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; + * 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. broker id header malformed -> `400`, also without reaching the + * 6. `x-api-key` present -> the existing validate-against-wave-auth-api + * path, unconditionally, regardless of `acceptsSessionToken`; + * 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`; + * 8. 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. + * 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. */ export function registerPermissionAuth( app: FastifyInstance, @@ -164,19 +119,66 @@ 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`, + ); + } + } + + // `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 = request.headers[apiKeyHeader]; - if (typeof apiKey !== 'string' || apiKey.length === 0) { + const apiKey = readApiKey(request, apiKeyHeader); + if (apiKey !== undefined) { + await checkApiKeyPermission( + 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 +190,14 @@ export function registerPermissionAuth( return; } - const apiKey = request.headers[apiKeyHeader]; - if (typeof apiKey !== 'string' || apiKey.length === 0) { + if (checkRateLimit !== undefined) { + await checkRateLimit(request); + } + + const hasCredential = + readApiKey(request, apiKeyHeader) !== undefined || + readBearerToken(request, authorizationHeader) !== undefined; + if (!hasCredential) { throw new PermissionValidatorUnauthorizedError(); } @@ -204,14 +212,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..cd6af3a 100644 --- a/src/auth-permission/route-properties.ts +++ b/src/auth-permission/route-properties.ts @@ -22,4 +22,24 @@ 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; + /** + * 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; } diff --git a/src/auth-permission/session-token/authenticate-with-session-token.ts b/src/auth-permission/session-token/authenticate-with-session-token.ts new file mode 100644 index 0000000..1125f5d --- /dev/null +++ b/src/auth-permission/session-token/authenticate-with-session-token.ts @@ -0,0 +1,39 @@ +import type { FastifyRequest } from 'fastify'; +import type { SessionTokenClaims, SessionTokenVerifier } from './session-token-verifier'; +import type { RouteProperties } from '../route-properties'; +import { readBrokerId, resolveBrokerContext } from '../shared/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 = route.brokerScoped === false + ? undefined + : 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/session-token/session-token-verifier.ts b/src/auth-permission/session-token/session-token-verifier.ts new file mode 100644 index 0000000..842634f --- /dev/null +++ b/src/auth-permission/session-token/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/session-token/wave-auth-session-token-verifier.spec.ts b/src/auth-permission/session-token/wave-auth-session-token-verifier.spec.ts new file mode 100644 index 0000000..9b9fe4b --- /dev/null +++ b/src/auth-permission/session-token/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/session-token/wave-auth-session-token-verifier.ts b/src/auth-permission/session-token/wave-auth-session-token-verifier.ts new file mode 100644 index 0000000..6d1c87a --- /dev/null +++ b/src/auth-permission/session-token/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'; diff --git a/src/auth-permission/shared/broker-scope-resolution.ts b/src/auth-permission/shared/broker-scope-resolution.ts new file mode 100644 index 0000000..c63061a --- /dev/null +++ b/src/auth-permission/shared/broker-scope-resolution.ts @@ -0,0 +1,75 @@ +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. + * + * 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, + route: RouteProperties, + brokers: readonly string[], + emptyScopeMessage: string, +): void { + if (route.brokerScoped === false) { + return; + } + + 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/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/shared/raw-headers.ts b/src/auth-permission/shared/raw-headers.ts new file mode 100644 index 0000000..5d59d15 --- /dev/null +++ b/src/auth-permission/shared/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/shared/read-credentials.ts b/src/auth-permission/shared/read-credentials.ts new file mode 100644 index 0000000..3561978 --- /dev/null +++ b/src/auth-permission/shared/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]; +}