@heyatom/bastion-client 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +149 -46
- package/dist/cjs/core/api-client.d.ts +16 -5
- package/dist/cjs/core/api-client.js +23 -5
- package/dist/cjs/core/bastion-http.d.ts +7 -0
- package/dist/cjs/core/bastion-http.js +27 -3
- package/dist/cjs/core/errors.d.ts +19 -2
- package/dist/cjs/core/errors.js +24 -2
- package/dist/cjs/core/jwks-verifier.d.ts +1 -1
- package/dist/cjs/core/jwks-verifier.js +11 -10
- package/dist/cjs/core/jwt.d.ts +13 -0
- package/dist/cjs/core/jwt.js +55 -0
- package/dist/cjs/core/service-token-provider.js +2 -2
- package/dist/cjs/nest/bastion-api-client.provider.d.ts +3 -2
- package/dist/cjs/nest/bastion-api-client.provider.js +3 -2
- package/dist/cjs/nest/bastion-http-exception.d.ts +16 -6
- package/dist/cjs/nest/bastion-http-exception.js +38 -7
- package/dist/cjs/nest/bastion.module.d.ts +5 -3
- package/dist/cjs/nest/bastion.module.js +8 -1
- package/dist/cjs/nest/bastion.service.d.ts +1 -1
- package/dist/cjs/nest/bastion.service.js +7 -2
- package/dist/cjs/nest/decorators/current-user.decorator.d.ts +2 -0
- package/dist/cjs/nest/decorators/current-user.decorator.js +3 -1
- package/dist/cjs/nest/guards/app-user.guard.d.ts +16 -0
- package/dist/cjs/nest/guards/app-user.guard.js +47 -0
- package/dist/cjs/nest/index.d.ts +3 -1
- package/dist/cjs/nest/index.js +6 -1
- package/dist/cjs/nest/options.d.ts +7 -0
- package/dist/cjs/nest/request-context.d.ts +10 -0
- package/dist/cjs/nest/request-context.js +19 -0
- package/dist/cjs/webhooks/index.d.ts +19 -4
- package/dist/cjs/webhooks/index.js +48 -11
- package/dist/esm/core/api-client.d.ts +16 -5
- package/dist/esm/core/api-client.js +24 -6
- package/dist/esm/core/bastion-http.d.ts +7 -0
- package/dist/esm/core/bastion-http.js +28 -4
- package/dist/esm/core/errors.d.ts +19 -2
- package/dist/esm/core/errors.js +23 -2
- package/dist/esm/core/jwks-verifier.d.ts +1 -1
- package/dist/esm/core/jwks-verifier.js +11 -10
- package/dist/esm/core/jwt.d.ts +13 -0
- package/dist/esm/core/jwt.js +50 -0
- package/dist/esm/core/service-token-provider.js +2 -2
- package/dist/esm/nest/bastion-api-client.provider.d.ts +3 -2
- package/dist/esm/nest/bastion-api-client.provider.js +3 -2
- package/dist/esm/nest/bastion-http-exception.d.ts +16 -6
- package/dist/esm/nest/bastion-http-exception.js +40 -9
- package/dist/esm/nest/bastion.module.d.ts +5 -3
- package/dist/esm/nest/bastion.module.js +9 -2
- package/dist/esm/nest/bastion.service.d.ts +1 -1
- package/dist/esm/nest/bastion.service.js +7 -2
- package/dist/esm/nest/decorators/current-user.decorator.d.ts +2 -0
- package/dist/esm/nest/decorators/current-user.decorator.js +2 -0
- package/dist/esm/nest/guards/app-user.guard.d.ts +16 -0
- package/dist/esm/nest/guards/app-user.guard.js +44 -0
- package/dist/esm/nest/index.d.ts +3 -1
- package/dist/esm/nest/index.js +3 -1
- package/dist/esm/nest/options.d.ts +7 -0
- package/dist/esm/nest/request-context.d.ts +10 -0
- package/dist/esm/nest/request-context.js +15 -0
- package/dist/esm/webhooks/index.d.ts +19 -4
- package/dist/esm/webhooks/index.js +47 -10
- package/package.json +3 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DynamicModule } from '@nestjs/common';
|
|
1
|
+
import { DynamicModule, type MiddlewareConsumer, type NestModule } from '@nestjs/common';
|
|
2
2
|
import { type BastionModuleAsyncOptions, type BastionModuleOptions } from './options.js';
|
|
3
3
|
/**
|
|
4
4
|
* ```ts
|
|
@@ -11,9 +11,11 @@ import { type BastionModuleAsyncOptions, type BastionModuleOptions } from './opt
|
|
|
11
11
|
* }),
|
|
12
12
|
* })
|
|
13
13
|
* ```
|
|
14
|
-
* Global: import once in `AppModule`, inject anywhere.
|
|
14
|
+
* Global: import once in `AppModule`, inject anywhere. Every route gets a
|
|
15
|
+
* middleware that tracks the request, so outbound calls carry its `X-Request-Id`.
|
|
15
16
|
*/
|
|
16
|
-
export declare class BastionModule {
|
|
17
|
+
export declare class BastionModule implements NestModule {
|
|
18
|
+
configure(consumer: MiddlewareConsumer): void;
|
|
17
19
|
static forRoot(options: BastionModuleOptions): DynamicModule;
|
|
18
20
|
static forRootAsync(options: BastionModuleAsyncOptions): DynamicModule;
|
|
19
21
|
private static build;
|
|
@@ -5,7 +5,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
7
|
var BastionModule_1;
|
|
8
|
-
import { Global, Module } from '@nestjs/common';
|
|
8
|
+
import { Global, Module, RequestMethod, } from '@nestjs/common';
|
|
9
9
|
import { BASTION_OPTIONS } from './options.js';
|
|
10
10
|
import { BastionJwksService } from './bastion-jwks.service.js';
|
|
11
11
|
import { BastionService } from './bastion.service.js';
|
|
@@ -14,12 +14,15 @@ import { ServiceClientJwtGuard } from './guards/service-client-jwt.guard.js';
|
|
|
14
14
|
import { BastionUserGuard } from './guards/bastion-user.guard.js';
|
|
15
15
|
import { AuditInterceptor } from './interceptors/audit.interceptor.js';
|
|
16
16
|
import { BastionApiClientProvider } from './bastion-api-client.provider.js';
|
|
17
|
+
import { AppUserGuard } from './guards/app-user.guard.js';
|
|
18
|
+
import { requestContextMiddleware } from './request-context.js';
|
|
17
19
|
const PROVIDERS = [
|
|
18
20
|
BastionJwksService,
|
|
19
21
|
BastionService,
|
|
20
22
|
BastionAuditService,
|
|
21
23
|
ServiceClientJwtGuard,
|
|
22
24
|
BastionUserGuard,
|
|
25
|
+
AppUserGuard,
|
|
23
26
|
AuditInterceptor,
|
|
24
27
|
BastionApiClientProvider,
|
|
25
28
|
];
|
|
@@ -34,9 +37,13 @@ const PROVIDERS = [
|
|
|
34
37
|
* }),
|
|
35
38
|
* })
|
|
36
39
|
* ```
|
|
37
|
-
* Global: import once in `AppModule`, inject anywhere.
|
|
40
|
+
* Global: import once in `AppModule`, inject anywhere. Every route gets a
|
|
41
|
+
* middleware that tracks the request, so outbound calls carry its `X-Request-Id`.
|
|
38
42
|
*/
|
|
39
43
|
let BastionModule = BastionModule_1 = class BastionModule {
|
|
44
|
+
configure(consumer) {
|
|
45
|
+
consumer.apply(requestContextMiddleware).forRoutes({ path: '{*splat}', method: RequestMethod.ALL });
|
|
46
|
+
}
|
|
40
47
|
static forRoot(options) {
|
|
41
48
|
return this.build({ provide: BASTION_OPTIONS, useValue: options });
|
|
42
49
|
}
|
|
@@ -2,7 +2,7 @@ import { type BastionRequestInit } from '../core/bastion-http.js';
|
|
|
2
2
|
import { NestBastionHttp } from './bastion-http-exception.js';
|
|
3
3
|
import type { AuditEventInput, TokenResponse } from '../core/types.js';
|
|
4
4
|
import { type BastionModuleOptions } from './options.js';
|
|
5
|
-
/** Outbound HTTP to Bastion. A failure surfaces as `BastionHttpException` (
|
|
5
|
+
/** Outbound HTTP to Bastion, `X-Request-Id` included. A failure surfaces as `BastionHttpException` (4xx as Bastion's, 5xx as 502/503 with a generic message). */
|
|
6
6
|
export declare class BastionService {
|
|
7
7
|
private readonly options;
|
|
8
8
|
readonly http: NestBastionHttp;
|
|
@@ -13,13 +13,18 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
13
13
|
import { Inject, Injectable } from '@nestjs/common';
|
|
14
14
|
import { NestBastionHttp } from './bastion-http-exception.js';
|
|
15
15
|
import { BASTION_OPTIONS } from './options.js';
|
|
16
|
-
|
|
16
|
+
import { currentRequestId } from './request-context.js';
|
|
17
|
+
/** Outbound HTTP to Bastion, `X-Request-Id` included. A failure surfaces as `BastionHttpException` (4xx as Bastion's, 5xx as 502/503 with a generic message). */
|
|
17
18
|
let BastionService = class BastionService {
|
|
18
19
|
options;
|
|
19
20
|
http;
|
|
20
21
|
constructor(options) {
|
|
21
22
|
this.options = options;
|
|
22
|
-
this.http = new NestBastionHttp({
|
|
23
|
+
this.http = new NestBastionHttp({
|
|
24
|
+
baseUrl: options.baseUrl,
|
|
25
|
+
timeoutMs: options.timeoutMs,
|
|
26
|
+
requestId: options.requestId ?? currentRequestId,
|
|
27
|
+
});
|
|
23
28
|
}
|
|
24
29
|
call(method, path, body, init) {
|
|
25
30
|
return this.http.call(method, path, body, init);
|
|
@@ -2,3 +2,5 @@
|
|
|
2
2
|
export declare const CurrentClient: (...dataOrPipes: unknown[]) => ParameterDecorator;
|
|
3
3
|
/** The user payload set by `BastionUserGuard`. */
|
|
4
4
|
export declare const CurrentAdminUser: (...dataOrPipes: unknown[]) => ParameterDecorator;
|
|
5
|
+
/** The app user's payload set by `AppUserGuard`. */
|
|
6
|
+
export declare const CurrentUser: (...dataOrPipes: unknown[]) => ParameterDecorator;
|
|
@@ -3,3 +3,5 @@ import { createParamDecorator } from '@nestjs/common';
|
|
|
3
3
|
export const CurrentClient = createParamDecorator((_, ctx) => ctx.switchToHttp().getRequest().user);
|
|
4
4
|
/** The user payload set by `BastionUserGuard`. */
|
|
5
5
|
export const CurrentAdminUser = createParamDecorator((_, ctx) => ctx.switchToHttp().getRequest().adminUser);
|
|
6
|
+
/** The app user's payload set by `AppUserGuard`. */
|
|
7
|
+
export const CurrentUser = createParamDecorator((_, ctx) => ctx.switchToHttp().getRequest().user);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
|
2
|
+
import { Reflector } from '@nestjs/core';
|
|
3
|
+
import { BastionJwksService } from '../bastion-jwks.service.js';
|
|
4
|
+
/**
|
|
5
|
+
* Guard for an app backend's own users, meant as the global `APP_GUARD`: every
|
|
6
|
+
* route needs a Bastion *user* token minted for this app (`serviceSlug` is the
|
|
7
|
+
* app's slug) unless it carries `@Public()`. Machine tokens and other apps'
|
|
8
|
+
* tokens are refused. The payload lands on `req.user`, read with `@CurrentUser()`.
|
|
9
|
+
* Suspension, age gate and roles stay the app's business: stack its own guard after this one.
|
|
10
|
+
*/
|
|
11
|
+
export declare class AppUserGuard implements CanActivate {
|
|
12
|
+
private readonly jwks;
|
|
13
|
+
private readonly reflector;
|
|
14
|
+
constructor(jwks: BastionJwksService, reflector: Reflector);
|
|
15
|
+
canActivate(ctx: ExecutionContext): Promise<boolean>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
11
|
+
import { Reflector } from '@nestjs/core';
|
|
12
|
+
import { BastionJwksService } from '../bastion-jwks.service.js';
|
|
13
|
+
import { IS_PUBLIC_KEY } from '../decorators/public.decorator.js';
|
|
14
|
+
/**
|
|
15
|
+
* Guard for an app backend's own users, meant as the global `APP_GUARD`: every
|
|
16
|
+
* route needs a Bastion *user* token minted for this app (`serviceSlug` is the
|
|
17
|
+
* app's slug) unless it carries `@Public()`. Machine tokens and other apps'
|
|
18
|
+
* tokens are refused. The payload lands on `req.user`, read with `@CurrentUser()`.
|
|
19
|
+
* Suspension, age gate and roles stay the app's business: stack its own guard after this one.
|
|
20
|
+
*/
|
|
21
|
+
let AppUserGuard = class AppUserGuard {
|
|
22
|
+
jwks;
|
|
23
|
+
reflector;
|
|
24
|
+
constructor(jwks, reflector) {
|
|
25
|
+
this.jwks = jwks;
|
|
26
|
+
this.reflector = reflector;
|
|
27
|
+
}
|
|
28
|
+
async canActivate(ctx) {
|
|
29
|
+
if (this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]))
|
|
30
|
+
return true;
|
|
31
|
+
const req = ctx.switchToHttp().getRequest();
|
|
32
|
+
const auth = req.headers['authorization'];
|
|
33
|
+
if (!auth?.startsWith('Bearer '))
|
|
34
|
+
throw new UnauthorizedException('Missing token');
|
|
35
|
+
req.user = await this.jwks.verifyUserToken(auth.slice(7));
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
AppUserGuard = __decorate([
|
|
40
|
+
Injectable(),
|
|
41
|
+
__metadata("design:paramtypes", [BastionJwksService,
|
|
42
|
+
Reflector])
|
|
43
|
+
], AppUserGuard);
|
|
44
|
+
export { AppUserGuard };
|
package/dist/esm/nest/index.d.ts
CHANGED
|
@@ -8,9 +8,11 @@ export { BastionHttpException, NestBastionHttp } from './bastion-http-exception.
|
|
|
8
8
|
export { BastionAuditService, type AuditActor } from './bastion-audit.service.js';
|
|
9
9
|
export { ServiceClientJwtGuard } from './guards/service-client-jwt.guard.js';
|
|
10
10
|
export { BastionUserGuard } from './guards/bastion-user.guard.js';
|
|
11
|
+
export { AppUserGuard } from './guards/app-user.guard.js';
|
|
11
12
|
export { AuditInterceptor } from './interceptors/audit.interceptor.js';
|
|
12
13
|
export { Public, IS_PUBLIC_KEY } from './decorators/public.decorator.js';
|
|
13
14
|
export { RequireScope, REQUIRED_SCOPES_KEY } from './decorators/require-scope.decorator.js';
|
|
14
15
|
export { Audit, AUDIT_EVENT_KEY, type AuditOptions, type AuditRequest, type AuditDescriptor } from './decorators/audit.decorator.js';
|
|
15
|
-
export { CurrentClient, CurrentAdminUser } from './decorators/current-user.decorator.js';
|
|
16
|
+
export { CurrentClient, CurrentAdminUser, CurrentUser } from './decorators/current-user.decorator.js';
|
|
17
|
+
export { currentRequestId } from './request-context.js';
|
|
16
18
|
export { resolveTenantScope, CROSS_TENANT_ROLE } from './tenant-scope.js';
|
package/dist/esm/nest/index.js
CHANGED
|
@@ -8,9 +8,11 @@ export { BastionHttpException, NestBastionHttp } from './bastion-http-exception.
|
|
|
8
8
|
export { BastionAuditService } from './bastion-audit.service.js';
|
|
9
9
|
export { ServiceClientJwtGuard } from './guards/service-client-jwt.guard.js';
|
|
10
10
|
export { BastionUserGuard } from './guards/bastion-user.guard.js';
|
|
11
|
+
export { AppUserGuard } from './guards/app-user.guard.js';
|
|
11
12
|
export { AuditInterceptor } from './interceptors/audit.interceptor.js';
|
|
12
13
|
export { Public, IS_PUBLIC_KEY } from './decorators/public.decorator.js';
|
|
13
14
|
export { RequireScope, REQUIRED_SCOPES_KEY } from './decorators/require-scope.decorator.js';
|
|
14
15
|
export { Audit, AUDIT_EVENT_KEY } from './decorators/audit.decorator.js';
|
|
15
|
-
export { CurrentClient, CurrentAdminUser } from './decorators/current-user.decorator.js';
|
|
16
|
+
export { CurrentClient, CurrentAdminUser, CurrentUser } from './decorators/current-user.decorator.js';
|
|
17
|
+
export { currentRequestId } from './request-context.js';
|
|
16
18
|
export { resolveTenantScope, CROSS_TENANT_ROLE } from './tenant-scope.js';
|
|
@@ -15,6 +15,13 @@ export interface BastionModuleOptions {
|
|
|
15
15
|
jwksTtlMs?: number;
|
|
16
16
|
/** Expected `iss`. Default `'bastion'`; `null` disables the check. */
|
|
17
17
|
issuer?: string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Id sent as `X-Request-Id` on calls to Bastion. Default: `req.id` of the request
|
|
20
|
+
* being served (pino-http / nest-common `genReqId`), tracked by the module's middleware.
|
|
21
|
+
* Express only: under Fastify the middleware sees the raw request, which has no `id`,
|
|
22
|
+
* so pass a getter here (e.g. from nestjs-cls).
|
|
23
|
+
*/
|
|
24
|
+
requestId?: () => string | undefined;
|
|
18
25
|
/** `BastionUserGuard`: which apps' user tokens are accepted. Default `['meridian']`. */
|
|
19
26
|
acceptedAppSlugs?: string[];
|
|
20
27
|
/** `BastionUserGuard`: which roles are accepted. Default `['ADMIN', 'OWNER', 'SUPER_ADMIN']`. */
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Express/Fastify middleware: keeps the request reachable for the rest of its async chain. */
|
|
2
|
+
export declare function requestContextMiddleware(req: {
|
|
3
|
+
id?: unknown;
|
|
4
|
+
}, _res: unknown, next: () => void): void;
|
|
5
|
+
/**
|
|
6
|
+
* The served request's own id, `req.id` as set by pino-http (`genReqId`, api.md §7):
|
|
7
|
+
* the default `requestId` of `BastionModule`. Read lazily, so the logger
|
|
8
|
+
* middleware may run before or after this one.
|
|
9
|
+
*/
|
|
10
|
+
export declare function currentRequestId(): string | undefined;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
const store = new AsyncLocalStorage();
|
|
3
|
+
/** Express/Fastify middleware: keeps the request reachable for the rest of its async chain. */
|
|
4
|
+
export function requestContextMiddleware(req, _res, next) {
|
|
5
|
+
store.run(req, next);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The served request's own id, `req.id` as set by pino-http (`genReqId`, api.md §7):
|
|
9
|
+
* the default `requestId` of `BastionModule`. Read lazily, so the logger
|
|
10
|
+
* middleware may run before or after this one.
|
|
11
|
+
*/
|
|
12
|
+
export function currentRequestId() {
|
|
13
|
+
const id = store.getStore()?.id;
|
|
14
|
+
return typeof id === 'string' ? id : undefined;
|
|
15
|
+
}
|
|
@@ -3,6 +3,10 @@ export declare const BASTION_SIGNATURE_HEADER = "x-bastion-signature";
|
|
|
3
3
|
export declare const BASTION_SIGNATURE_V2_HEADER = "x-bastion-signature-v2";
|
|
4
4
|
export declare const BASTION_EVENT_HEADER = "x-bastion-event";
|
|
5
5
|
export declare const BASTION_DELIVERY_ID_HEADER = "x-bastion-delivery-id";
|
|
6
|
+
/** Standard Webhooks (standardwebhooks.com) headers, sent alongside v2 during the migration. */
|
|
7
|
+
export declare const WEBHOOK_ID_HEADER = "webhook-id";
|
|
8
|
+
export declare const WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
|
|
9
|
+
export declare const WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
|
|
6
10
|
/** Sent to every app with a webhook URL, whether or not the user has a role in it. */
|
|
7
11
|
export declare const BASTION_LIFECYCLE_EVENTS: readonly ["user.deleted", "user.expired_unverified", "user.deactivated"];
|
|
8
12
|
export interface BastionWebhookPayload {
|
|
@@ -29,15 +33,21 @@ export interface VerifyWebhookOptions {
|
|
|
29
33
|
* Accept the legacy body-only `X-Bastion-Signature` when v2 is absent.
|
|
30
34
|
* Default `false`: legacy carries no timestamp or delivery id, so accepting
|
|
31
35
|
* it lets anyone holding an old delivery replay it by stripping the v2
|
|
32
|
-
* header.
|
|
36
|
+
* header. Bastion no longer sends the legacy header, so there is nothing
|
|
37
|
+
* left to migrate: leave it off.
|
|
33
38
|
*/
|
|
34
39
|
allowLegacySignature?: boolean;
|
|
35
|
-
/** Max
|
|
40
|
+
/** Max distance of the signed timestamp from now (Standard Webhooks and v2), default 300 s. */
|
|
36
41
|
toleranceSeconds?: number;
|
|
37
42
|
/** For tests. */
|
|
38
43
|
now?: () => number;
|
|
39
44
|
}
|
|
40
45
|
export type VerifyWebhookResult = {
|
|
46
|
+
ok: true;
|
|
47
|
+
version: 'standard';
|
|
48
|
+
timestamp: number;
|
|
49
|
+
deliveryId: string;
|
|
50
|
+
} | {
|
|
41
51
|
ok: true;
|
|
42
52
|
version: 'v2';
|
|
43
53
|
timestamp: number;
|
|
@@ -50,12 +60,17 @@ export type VerifyWebhookResult = {
|
|
|
50
60
|
reason: 'missing' | 'malformed' | 'stale' | 'mismatch';
|
|
51
61
|
};
|
|
52
62
|
/**
|
|
53
|
-
* Verifies a Bastion delivery. Prefers `
|
|
63
|
+
* Verifies a Bastion delivery. Prefers Standard Webhooks when any of `webhook-id`,
|
|
64
|
+
* `webhook-timestamp`, `webhook-signature` is present: `webhook-signature` is a
|
|
65
|
+
* space-separated list of `v1,<base64 HMAC-SHA256(secret, "<id>.<timestamp>.<body>")>`,
|
|
66
|
+
* keyed with the secret as UTF-8 (not a `whsec_` base64 key); `deliveryId` is `webhook-id`.
|
|
67
|
+
* Otherwise falls back to `X-Bastion-Signature-V2`
|
|
54
68
|
* (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
|
|
55
69
|
* which binds the timestamp and the delivery id so a stale or replayed
|
|
56
70
|
* delivery is rejected without trusting any unsigned header. The legacy
|
|
57
71
|
* `X-Bastion-Signature: sha256=<hex>` over the body alone is accepted only
|
|
58
|
-
* when
|
|
72
|
+
* when both of the above are absent **and** `allowLegacySignature` is set. Any of the caller's
|
|
73
|
+
* `secrets` matching any `v1` entry accepts:
|
|
59
74
|
* that is how a secret rotation stays zero-downtime on both sides.
|
|
60
75
|
*/
|
|
61
76
|
export declare function verifyWebhookSignature(options: VerifyWebhookOptions): VerifyWebhookResult;
|
|
@@ -4,14 +4,18 @@ export const BASTION_SIGNATURE_HEADER = 'x-bastion-signature';
|
|
|
4
4
|
export const BASTION_SIGNATURE_V2_HEADER = 'x-bastion-signature-v2';
|
|
5
5
|
export const BASTION_EVENT_HEADER = 'x-bastion-event';
|
|
6
6
|
export const BASTION_DELIVERY_ID_HEADER = 'x-bastion-delivery-id';
|
|
7
|
+
/** Standard Webhooks (standardwebhooks.com) headers, sent alongside v2 during the migration. */
|
|
8
|
+
export const WEBHOOK_ID_HEADER = 'webhook-id';
|
|
9
|
+
export const WEBHOOK_TIMESTAMP_HEADER = 'webhook-timestamp';
|
|
10
|
+
export const WEBHOOK_SIGNATURE_HEADER = 'webhook-signature';
|
|
7
11
|
/** Sent to every app with a webhook URL, whether or not the user has a role in it. */
|
|
8
12
|
export const BASTION_LIFECYCLE_EVENTS = ['user.deleted', 'user.expired_unverified', 'user.deactivated'];
|
|
9
13
|
function header(headers, name) {
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
let v = headers[name];
|
|
15
|
+
if (v === undefined) {
|
|
16
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === name);
|
|
17
|
+
v = key === undefined ? undefined : headers[key];
|
|
18
|
+
}
|
|
15
19
|
return Array.isArray(v) ? v[0] : v;
|
|
16
20
|
}
|
|
17
21
|
function macEquals(hexA, hexB) {
|
|
@@ -19,20 +23,54 @@ function macEquals(hexA, hexB) {
|
|
|
19
23
|
const b = Buffer.from(hexB, 'hex');
|
|
20
24
|
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
|
|
21
25
|
}
|
|
26
|
+
function isFresh(timestamp, options) {
|
|
27
|
+
const now = Math.floor((options.now ?? Date.now)() / 1000);
|
|
28
|
+
return Math.abs(now - timestamp) <= (options.toleranceSeconds ?? 300);
|
|
29
|
+
}
|
|
22
30
|
/**
|
|
23
|
-
* Verifies a Bastion delivery. Prefers `
|
|
31
|
+
* Verifies a Bastion delivery. Prefers Standard Webhooks when any of `webhook-id`,
|
|
32
|
+
* `webhook-timestamp`, `webhook-signature` is present: `webhook-signature` is a
|
|
33
|
+
* space-separated list of `v1,<base64 HMAC-SHA256(secret, "<id>.<timestamp>.<body>")>`,
|
|
34
|
+
* keyed with the secret as UTF-8 (not a `whsec_` base64 key); `deliveryId` is `webhook-id`.
|
|
35
|
+
* Otherwise falls back to `X-Bastion-Signature-V2`
|
|
24
36
|
* (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
|
|
25
37
|
* which binds the timestamp and the delivery id so a stale or replayed
|
|
26
38
|
* delivery is rejected without trusting any unsigned header. The legacy
|
|
27
39
|
* `X-Bastion-Signature: sha256=<hex>` over the body alone is accepted only
|
|
28
|
-
* when
|
|
40
|
+
* when both of the above are absent **and** `allowLegacySignature` is set. Any of the caller's
|
|
41
|
+
* `secrets` matching any `v1` entry accepts:
|
|
29
42
|
* that is how a secret rotation stays zero-downtime on both sides.
|
|
30
43
|
*/
|
|
31
44
|
export function verifyWebhookSignature(options) {
|
|
32
45
|
const body = typeof options.rawBody === 'string' ? Buffer.from(options.rawBody, 'utf8') : options.rawBody;
|
|
33
|
-
const secrets = (Array.isArray(options.secrets) ? options.secrets : [options.secrets]).filter(
|
|
46
|
+
const secrets = (Array.isArray(options.secrets) ? options.secrets : [options.secrets]).filter(
|
|
47
|
+
// A whitespace-only secret is a misconfiguration, never a key; the rest are used byte-exact.
|
|
48
|
+
(s) => typeof s === 'string' && s.trim() !== '');
|
|
34
49
|
if (secrets.length === 0)
|
|
35
50
|
return { ok: false, reason: 'missing' };
|
|
51
|
+
const stdId = header(options.headers, WEBHOOK_ID_HEADER);
|
|
52
|
+
const stdTs = header(options.headers, WEBHOOK_TIMESTAMP_HEADER);
|
|
53
|
+
const stdSig = header(options.headers, WEBHOOK_SIGNATURE_HEADER);
|
|
54
|
+
if (stdId || stdTs || stdSig) {
|
|
55
|
+
const macs = (stdSig ?? '')
|
|
56
|
+
.split(' ')
|
|
57
|
+
.map((entry) => entry.split(','))
|
|
58
|
+
.filter(([version, value]) => version === 'v1' && value)
|
|
59
|
+
.map(([, value]) => Buffer.from(value, 'base64'));
|
|
60
|
+
if (!stdId || !stdTs || !/^\d+$/.test(stdTs) || macs.length === 0)
|
|
61
|
+
return { ok: false, reason: 'malformed' };
|
|
62
|
+
const timestamp = Number(stdTs);
|
|
63
|
+
if (!isFresh(timestamp, options))
|
|
64
|
+
return { ok: false, reason: 'stale' };
|
|
65
|
+
const signed = Buffer.concat([Buffer.from(`${stdId}.${stdTs}.`, 'utf8'), body]);
|
|
66
|
+
for (const secret of secrets) {
|
|
67
|
+
const expected = createHmac('sha256', secret).update(signed).digest();
|
|
68
|
+
if (macs.some((mac) => mac.length === expected.length && timingSafeEqual(mac, expected))) {
|
|
69
|
+
return { ok: true, version: 'standard', timestamp, deliveryId: stdId };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { ok: false, reason: 'mismatch' };
|
|
73
|
+
}
|
|
36
74
|
const v2 = header(options.headers, BASTION_SIGNATURE_V2_HEADER);
|
|
37
75
|
if (v2) {
|
|
38
76
|
const parts = v2.split(',').map((p) => p.trim());
|
|
@@ -42,8 +80,7 @@ export function verifyWebhookSignature(options) {
|
|
|
42
80
|
const timestamp = Number(t);
|
|
43
81
|
if (!t || !id || macs.length === 0 || !Number.isFinite(timestamp))
|
|
44
82
|
return { ok: false, reason: 'malformed' };
|
|
45
|
-
|
|
46
|
-
if (Math.abs(now - timestamp) > (options.toleranceSeconds ?? 300))
|
|
83
|
+
if (!isFresh(timestamp, options))
|
|
47
84
|
return { ok: false, reason: 'stale' };
|
|
48
85
|
const signed = Buffer.concat([Buffer.from(`${timestamp}.${id}.`, 'utf8'), body]);
|
|
49
86
|
for (const secret of secrets) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyatom/bastion-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Consumer SDK for the Bastion identity provider: JWKS verification, service-client auth, audit writes, NestJS guards",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Andrea Tombolato <hey@heyatom.dev> (https://heyatom.dev)",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
13
|
-
"README.md"
|
|
13
|
+
"README.md",
|
|
14
|
+
"CHANGELOG.md"
|
|
14
15
|
],
|
|
15
16
|
"main": "./dist/cjs/core/index.js",
|
|
16
17
|
"module": "./dist/esm/core/index.js",
|
|
@@ -60,7 +61,6 @@
|
|
|
60
61
|
"peerDependencies": {
|
|
61
62
|
"@nestjs/common": "^11.0.0",
|
|
62
63
|
"@nestjs/core": "^11.0.0",
|
|
63
|
-
"jose": "^6.0.0",
|
|
64
64
|
"reflect-metadata": "^0.2.0",
|
|
65
65
|
"rxjs": "^7.0.0"
|
|
66
66
|
},
|