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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions src/auth-permission/api-key/check-api-key-permission.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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`,
);
}
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 2 additions & 0 deletions src/auth-permission/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
16 changes: 16 additions & 0 deletions src/auth-permission/errors/session-token-invalid-error.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
14 changes: 14 additions & 0 deletions src/auth-permission/errors/too-many-requests-error.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
11 changes: 8 additions & 3 deletions src/auth-permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -25,4 +28,6 @@ export {
MalformedBrokerHeaderError,
AmbiguousBrokerTargetError,
BrokerContextNotResolvedError,
SessionTokenInvalidError,
TooManyRequestsError,
} from './errors';
41 changes: 41 additions & 0 deletions src/auth-permission/rate-limit/rate-limit-check.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

/**
* 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<FastifyInstance['createRateLimit']> | 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');
}
};
}
21 changes: 21 additions & 0 deletions src/auth-permission/rate-limit/rate-limit-key.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading