Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/auth-permission/api-key/check-api-key-permission.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { BrokerContext } from '../broker-context';
import type { PermissionValidationResult, PermissionValidator } from './permission-validator';
import { AmbiguousBrokerTargetError } from '../errors/ambiguous-broker-target-error';
import { PermissionDeniedError } from '../errors/permission-denied-error';
import { PermissionValidatorUnauthorizedError } from '../errors/permission-validator-unauthorized-error';
import type { RouteProperties } from '../route-properties';
import { readBrokerId, resolveBrokerScope } from '../shared/broker-scope-resolution';
import type { WaveRequest } from '../shared/wave-request';
Expand Down Expand Up @@ -48,3 +50,41 @@ export async function checkApiKeyPermission(
`The API key does not have the ${route.permissionName} permission`,
);
}

/** Resolves with the first key that passes; rejects only when every key is rejected. */
export async function checkAnyApiKeyPermission(
request: WaveRequest,
route: RouteProperties,
apiKeys: readonly string[],
validator: PermissionValidator,
brokerIdHeader: string,
brokerIdMaxLength: number,
): Promise<BrokerContext | undefined> {
let rejection: Error | undefined;
for (const apiKey of apiKeys) {
try {
return await checkApiKeyPermission(
request,
route,
apiKey,
validator,
brokerIdHeader,
brokerIdMaxLength,
);
} catch (error) {
const isKeyRejection =
error instanceof PermissionValidatorUnauthorizedError ||
error instanceof PermissionDeniedError ||
error instanceof AmbiguousBrokerTargetError;
if (!isKeyRejection) {
throw error;
}
// A key that is genuine but lacks the permission explains the failure
// better than one that is simply unknown.
if (rejection === undefined || rejection instanceof PermissionValidatorUnauthorizedError) {
rejection = error;
}
}
}
throw rejection ?? new PermissionValidatorUnauthorizedError();
}
125 changes: 125 additions & 0 deletions src/auth-permission/express/express-register-permission-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PermissionValidatorUnauthorizedError } from '../errors/permission-valid
import { PermissionDeniedError } from '../errors/permission-denied-error';
import { AmbiguousBrokerTargetError } from '../errors/ambiguous-broker-target-error';
import { SessionTokenInvalidError } from '../errors/session-token-invalid-error';
import { PermissionValidatorUnavailableError } from '../errors/permission-validator-unavailable-error';
import type {
PermissionValidationRequest,
PermissionValidationResult,
Expand Down Expand Up @@ -77,6 +78,10 @@ function testErrorHandler(error: Error, req: Request, res: Response, next: NextF
res.status(401).json({ type: 'session-token-invalid', message: error.message });
return;
}
if (error instanceof PermissionValidatorUnavailableError) {
res.status(503).json({ type: 'unavailable', message: error.message });
return;
}
res.status(500).json({ type: 'unexpected', message: error.message });
}

Expand All @@ -85,6 +90,7 @@ interface BuildAppOptions {
sessionTokenVerifier?: SessionTokenVerifier;
routes: Readonly<Record<string, RouteProperties>>;
publicPaths?: string[];
apiKeyHeader?: string | readonly string[];
}

function buildTestApp(options: BuildAppOptions): Express {
Expand All @@ -95,6 +101,7 @@ function buildTestApp(options: BuildAppOptions): Express {
sessionTokenVerifier: options.sessionTokenVerifier,
routes: options.routes,
publicPaths: options.publicPaths,
apiKeyHeader: options.apiKeyHeader,
});

app.use(middleware);
Expand Down Expand Up @@ -173,6 +180,124 @@ describe('expressRegisterPermissionAuth', () => {
});
});

describe('apiKeyHeader', () => {
it('reads the key from any header in a list, case-insensitively', async () => {
const validator = authorizes(['tim']);
const app = buildTestApp({
validator,
routes: { 'GET /balances': { permissionName: PERMISSION } },
apiKeyHeader: ['x-api-key', 'Authorization'],
});
const listening = await listen(app);
server = listening.server;

const viaAuthorization = await inject(listening.baseUrl, '/balances', { authorization: API_KEY });
const viaApiKey = await inject(listening.baseUrl, '/balances', { [API_KEY_HEADER]: API_KEY });

expect(viaAuthorization.statusCode).toBe(200);
expect(viaApiKey.statusCode).toBe(200);
expect(validator.calls.map((call) => call.apiKey)).toEqual([API_KEY, API_KEY]);
});

it('accepts the request when any presented key is valid', async () => {
const validator = new FakePermissionValidator((request) =>
request.apiKey === API_KEY
? { authorized: true, brokers: ['tim'] }
: new PermissionValidatorUnauthorizedError(),
);
const app = buildTestApp({
validator,
routes: { 'GET /balances': { permissionName: PERMISSION } },
apiKeyHeader: ['x-api-key', 'authorization'],
});
const listening = await listen(app);
server = listening.server;

const res = await inject(listening.baseUrl, '/balances', {
[API_KEY_HEADER]: 'invalid-key',
authorization: API_KEY,
});

expect(res.statusCode).toBe(200);
expect(validator.calls.map((call) => call.apiKey)).toEqual(['invalid-key', API_KEY]);
});

it('rejects (401) only when no presented key is valid', async () => {
const validator = new FakePermissionValidator(
() => new PermissionValidatorUnauthorizedError(),
);
const app = buildTestApp({
validator,
routes: { 'GET /balances': { permissionName: PERMISSION } },
apiKeyHeader: ['x-api-key', 'authorization'],
});
const listening = await listen(app);
server = listening.server;

const res = await inject(listening.baseUrl, '/balances', {
[API_KEY_HEADER]: 'invalid-key',
authorization: 'another-invalid-key',
});

expect(res.statusCode).toBe(401);
expect(validator.calls).toHaveLength(2);
});

it('fails closed (503) when the validator is unavailable, without trying the next key', async () => {
const validator = new FakePermissionValidator(
() => new PermissionValidatorUnavailableError('timeout'),
);
const app = buildTestApp({
validator,
routes: { 'GET /balances': { permissionName: PERMISSION } },
apiKeyHeader: ['x-api-key', 'authorization'],
});
const listening = await listen(app);
server = listening.server;

const res = await inject(listening.baseUrl, '/balances', {
[API_KEY_HEADER]: API_KEY,
authorization: 'another-key',
});

expect(res.statusCode).toBe(503);
expect(validator.calls).toHaveLength(1);
});

it('rejects a header outside the configured list (401)', async () => {
const validator = authorizes();
const app = buildTestApp({
validator,
routes: { 'GET /balances': { permissionName: PERMISSION } },
apiKeyHeader: 'authorization',
});
const listening = await listen(app);
server = listening.server;

const res = await inject(listening.baseUrl, '/balances', { [API_KEY_HEADER]: API_KEY });

expect(res.statusCode).toBe(401);
expect(validator.calls).toHaveLength(0);
});

it('throws synchronously when the authorization header is an API key header and a route accepts session tokens', () => {
expect(() =>
expressRegisterPermissionAuth({
validator: authorizes(),
sessionTokenVerifier: verifiesAs({ sub: 'key-1', brokers: ['tim'] }),
routes: { 'GET /session-scoped': { permissionName: PERMISSION, acceptsSessionToken: true } },
apiKeyHeader: [API_KEY_HEADER, 'Authorization'],
}),
).toThrow(/acceptsSessionToken/);
});

it('throws synchronously on an empty list', () => {
expect(() =>
expressRegisterPermissionAuth({ validator: authorizes(), routes: {}, apiKeyHeader: [] }),
).toThrow(/empty apiKeyHeader/);
});
});

describe('authentication', () => {
it('rejects a mapped route without a key (401), without calling the validator', async () => {
const validator = authorizes();
Expand Down
31 changes: 22 additions & 9 deletions src/auth-permission/express/express-register-permission-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import type { SessionTokenVerifier } from '../session-token/session-token-verifi
import { PermissionValidatorUnauthorizedError } from '../errors/permission-validator-unauthorized-error';
import { PermissionDeniedError } from '../errors/permission-denied-error';
import type { RouteProperties } from '../route-properties';
import { checkApiKeyPermission } from '../api-key/check-api-key-permission';
import { checkAnyApiKeyPermission } from '../api-key/check-api-key-permission';
import { authenticateWithSessionToken } from '../session-token/authenticate-with-session-token';
import { readApiKey, readBearerToken } from '../shared/read-credentials';
import { resolveApiKeyHeaders } from '../shared/api-key-headers';
import { readApiKeys, readBearerToken } from '../shared/read-credentials';
import { isPublicPath } from '../shared/is-public-path';
import { compileRoutes, matchRoute } from './route-matcher';
import './express-request';
Expand Down Expand Up @@ -40,7 +41,13 @@ export interface ExpressPermissionAuthOptions {
* includes `'/'` would silently make that router's own root path public.
*/
publicPaths?: string[];
apiKeyHeader?: string;
/**
* Header(s) the API key is read from, case-insensitively. With a list, the
* request passes if any key presented is valid, at the cost of one
* validator call per distinct key tried. May only include
* `authorizationHeader` when no route sets `acceptsSessionToken`.
*/
apiKeyHeader?: string | readonly string[];
/** Required when any route in `routes` sets `acceptsSessionToken: true`. */
sessionTokenVerifier?: SessionTokenVerifier;
authorizationHeader?: string;
Expand Down Expand Up @@ -88,7 +95,6 @@ export function expressRegisterPermissionAuth(
options: ExpressPermissionAuthOptions,
): ExpressPermissionAuth {
const publicPaths = options.publicPaths ?? [];
const apiKeyHeader = options.apiKeyHeader ?? API_KEY_HEADER;
const authorizationHeader = options.authorizationHeader ?? AUTHORIZATION_HEADER;
const brokerIdHeader = options.brokerIdHeader ?? BROKER_ID_HEADER;
const brokerIdMaxLength = options.brokerIdMaxLength ?? BROKER_ID_MAX_LENGTH;
Expand All @@ -101,15 +107,22 @@ export function expressRegisterPermissionAuth(
}
}

const apiKeyHeaders = resolveApiKeyHeaders(
options.apiKeyHeader ?? API_KEY_HEADER,
authorizationHeader,
options.routes,
'expressRegisterPermissionAuth',
);

const compiledRoutes = compileRoutes(options.routes);

const assertHasPermission: ExpressAssertHasPermission = async (request, route) => {
const apiKey = readApiKey(request, apiKeyHeader);
if (apiKey !== undefined) {
const ctx = await checkApiKeyPermission(
const apiKeys = readApiKeys(request, apiKeyHeaders);
if (apiKeys.length > 0) {
const ctx = await checkAnyApiKeyPermission(
request,
route,
apiKey,
apiKeys,
options.validator,
brokerIdHeader,
brokerIdMaxLength,
Expand Down Expand Up @@ -149,7 +162,7 @@ export function expressRegisterPermissionAuth(
}

const hasCredential =
readApiKey(request, apiKeyHeader) !== undefined ||
readApiKeys(request, apiKeyHeaders).length > 0 ||
readBearerToken(request, authorizationHeader) !== undefined;
if (!hasCredential) {
next(new PermissionValidatorUnauthorizedError());
Expand Down
21 changes: 19 additions & 2 deletions src/auth-permission/fastify/rate-limit/rate-limit-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,34 @@ export type RateLimitCheck = (request: FastifyRequest) => Promise<void>;
export function createRateLimitCheck(
app: FastifyInstance,
options: CreateRateLimitOptions,
credentialKeys: (request: FastifyRequest) => readonly string[],
): RateLimitCheck {
const currentKey = new WeakMap<FastifyRequest, string>();
let checkRateLimit: ReturnType<FastifyInstance['createRateLimit']> | undefined;

return async (request) => {
const checkKey = async (request: FastifyRequest): Promise<void> => {
if (checkRateLimit === undefined) {
checkRateLimit = app.createRateLimit(options);
checkRateLimit = app.createRateLimit({
keyGenerator: (keyed) => currentKey.get(keyed) ?? keyed.ip,
...options,
});
}

const verdict = await checkRateLimit(request);
if (!verdict.isAllowed && verdict.isExceeded) {
throw new TooManyRequestsError('Rate limit exceeded for this credential');
}
};

return async (request) => {
if (options.keyGenerator !== undefined) {
await checkKey(request);
return;
}

for (const key of credentialKeys(request)) {
currentKey.set(request, key);
await checkKey(request);
}
};
}
22 changes: 12 additions & 10 deletions src/auth-permission/fastify/rate-limit/rate-limit-key.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { FastifyRequest } from 'fastify';
import { readApiKey, readBearerToken } from '../../shared/read-credentials';
import { readApiKeys, readBearerToken } from '../../shared/read-credentials';
import { toWaveRequest } from '../to-wave-request';

/**
Expand All @@ -9,18 +9,20 @@ import { toWaveRequest } from '../to-wave-request';
* 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).
* (still useful to cap unauthenticated request floods). Every API key
* presented gets its own bucket checked, since any one of them may be the
* one that authenticates.
*/
export function credentialRateLimitKey(
apiKeyHeader: string,
export function credentialRateLimitKeys(
apiKeyHeaders: readonly string[],
authorizationHeader: string,
): (request: FastifyRequest) => string {
): (request: FastifyRequest) => string[] {
return (request) => {
const waveRequest = toWaveRequest(request);
return (
readApiKey(waveRequest, apiKeyHeader) ??
readBearerToken(waveRequest, authorizationHeader) ??
request.ip
);
const apiKeys = readApiKeys(waveRequest, apiKeyHeaders);
if (apiKeys.length > 0) {
return apiKeys;
}
return [readBearerToken(waveRequest, authorizationHeader) ?? request.ip];
};
}
Loading
Loading