@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +149 -46
  3. package/dist/cjs/core/api-client.d.ts +16 -5
  4. package/dist/cjs/core/api-client.js +23 -5
  5. package/dist/cjs/core/bastion-http.d.ts +7 -0
  6. package/dist/cjs/core/bastion-http.js +27 -3
  7. package/dist/cjs/core/errors.d.ts +19 -2
  8. package/dist/cjs/core/errors.js +24 -2
  9. package/dist/cjs/core/jwks-verifier.d.ts +1 -1
  10. package/dist/cjs/core/jwks-verifier.js +11 -10
  11. package/dist/cjs/core/jwt.d.ts +13 -0
  12. package/dist/cjs/core/jwt.js +55 -0
  13. package/dist/cjs/core/service-token-provider.js +2 -2
  14. package/dist/cjs/nest/bastion-api-client.provider.d.ts +3 -2
  15. package/dist/cjs/nest/bastion-api-client.provider.js +3 -2
  16. package/dist/cjs/nest/bastion-http-exception.d.ts +16 -6
  17. package/dist/cjs/nest/bastion-http-exception.js +38 -7
  18. package/dist/cjs/nest/bastion.module.d.ts +5 -3
  19. package/dist/cjs/nest/bastion.module.js +8 -1
  20. package/dist/cjs/nest/bastion.service.d.ts +1 -1
  21. package/dist/cjs/nest/bastion.service.js +7 -2
  22. package/dist/cjs/nest/decorators/current-user.decorator.d.ts +2 -0
  23. package/dist/cjs/nest/decorators/current-user.decorator.js +3 -1
  24. package/dist/cjs/nest/guards/app-user.guard.d.ts +16 -0
  25. package/dist/cjs/nest/guards/app-user.guard.js +47 -0
  26. package/dist/cjs/nest/index.d.ts +3 -1
  27. package/dist/cjs/nest/index.js +6 -1
  28. package/dist/cjs/nest/options.d.ts +7 -0
  29. package/dist/cjs/nest/request-context.d.ts +10 -0
  30. package/dist/cjs/nest/request-context.js +19 -0
  31. package/dist/cjs/webhooks/index.d.ts +19 -4
  32. package/dist/cjs/webhooks/index.js +48 -11
  33. package/dist/esm/core/api-client.d.ts +16 -5
  34. package/dist/esm/core/api-client.js +24 -6
  35. package/dist/esm/core/bastion-http.d.ts +7 -0
  36. package/dist/esm/core/bastion-http.js +28 -4
  37. package/dist/esm/core/errors.d.ts +19 -2
  38. package/dist/esm/core/errors.js +23 -2
  39. package/dist/esm/core/jwks-verifier.d.ts +1 -1
  40. package/dist/esm/core/jwks-verifier.js +11 -10
  41. package/dist/esm/core/jwt.d.ts +13 -0
  42. package/dist/esm/core/jwt.js +50 -0
  43. package/dist/esm/core/service-token-provider.js +2 -2
  44. package/dist/esm/nest/bastion-api-client.provider.d.ts +3 -2
  45. package/dist/esm/nest/bastion-api-client.provider.js +3 -2
  46. package/dist/esm/nest/bastion-http-exception.d.ts +16 -6
  47. package/dist/esm/nest/bastion-http-exception.js +40 -9
  48. package/dist/esm/nest/bastion.module.d.ts +5 -3
  49. package/dist/esm/nest/bastion.module.js +9 -2
  50. package/dist/esm/nest/bastion.service.d.ts +1 -1
  51. package/dist/esm/nest/bastion.service.js +7 -2
  52. package/dist/esm/nest/decorators/current-user.decorator.d.ts +2 -0
  53. package/dist/esm/nest/decorators/current-user.decorator.js +2 -0
  54. package/dist/esm/nest/guards/app-user.guard.d.ts +16 -0
  55. package/dist/esm/nest/guards/app-user.guard.js +44 -0
  56. package/dist/esm/nest/index.d.ts +3 -1
  57. package/dist/esm/nest/index.js +3 -1
  58. package/dist/esm/nest/options.d.ts +7 -0
  59. package/dist/esm/nest/request-context.d.ts +10 -0
  60. package/dist/esm/nest/request-context.js +15 -0
  61. package/dist/esm/webhooks/index.d.ts +19 -4
  62. package/dist/esm/webhooks/index.js +47 -10
  63. 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` (an `HttpException` with Bastion's status, a stable `code` and the upstream message). */
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
- /** Outbound HTTP to Bastion. A failure surfaces as `BastionHttpException` (an `HttpException` with Bastion's status, a stable `code` and the upstream message). */
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({ baseUrl: options.baseUrl, timeoutMs: options.timeoutMs });
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 };
@@ -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';
@@ -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. Opt in only while migrating a consumer that cannot see v2 yet.
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 age of `t` on a v2 signature, default 300 s. */
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 `X-Bastion-Signature-V2`
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 v2 is absent **and** `allowLegacySignature` is set. Any of the caller's `secrets` matching any `v1=` accepts:
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
- const direct = headers[name] ?? headers[name.toLowerCase()];
11
- if (direct !== undefined)
12
- return Array.isArray(direct) ? direct[0] : direct;
13
- const key = Object.keys(headers).find((k) => k.toLowerCase() === name);
14
- const v = key ? headers[key] : undefined;
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 `X-Bastion-Signature-V2`
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 v2 is absent **and** `allowLegacySignature` is set. Any of the caller's `secrets` matching any `v1=` accepts:
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(Boolean);
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
- const now = Math.floor((options.now ?? Date.now)() / 1000);
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.7.2",
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
  },