@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,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.JwksVerifier = void 0;
4
- const jose_1 = require("jose");
4
+ const jwt_js_1 = require("./jwt.js");
5
5
  const errors_js_1 = require("./errors.js");
6
6
  const types_js_1 = require("./types.js");
7
7
  /**
@@ -12,7 +12,7 @@ const types_js_1 = require("./types.js");
12
12
  */
13
13
  const UNKNOWN_KID_REFETCH_COOLDOWN_MS = 30_000;
14
14
  /**
15
- * The canonical consumer of Bastion's JWKS (docs/BASTION_INTEGRATION.md):
15
+ * The canonical consumer of Bastion's JWKS (docs/integrations/bastion.md in the platform repo):
16
16
  * keys cached and indexed by `kid`, refetch on an unknown kid with a
17
17
  * cooldown, stale cache served when a refetch fails. Second safety net only:
18
18
  * Bastion publishes a key before signing with it (publish-before-use), so a
@@ -39,12 +39,12 @@ class JwksVerifier {
39
39
  async verify(token) {
40
40
  let kid;
41
41
  try {
42
- ({ kid } = (0, jose_1.decodeProtectedHeader)(token));
42
+ kid = (0, jwt_js_1.decodeJwt)(token).header.kid;
43
43
  }
44
44
  catch {
45
45
  throw new errors_js_1.BastionTokenError('malformed', 'Malformed token');
46
46
  }
47
- if (!kid)
47
+ if (typeof kid !== 'string' || !kid)
48
48
  throw new errors_js_1.BastionTokenError('missing_kid', 'Missing kid in JWT header');
49
49
  await this.ensureFresh();
50
50
  let key = this.keys.get(kid);
@@ -57,11 +57,7 @@ class JwksVerifier {
57
57
  throw new errors_js_1.BastionTokenError('unknown_kid', 'Unknown or expired kid');
58
58
  }
59
59
  try {
60
- const { payload } = await (0, jose_1.jwtVerify)(token, key, {
61
- algorithms: ['RS256'],
62
- ...(this.issuer ? { issuer: this.issuer } : {}),
63
- });
64
- return payload;
60
+ return (0, jwt_js_1.verifyRs256)(token, key, this.issuer);
65
61
  }
66
62
  catch {
67
63
  throw new errors_js_1.BastionTokenError('invalid', 'Invalid token');
@@ -79,7 +75,12 @@ class JwksVerifier {
79
75
  for (const jwk of keys) {
80
76
  if (!jwk.kid)
81
77
  continue;
82
- next.set(jwk.kid, await (0, jose_1.importJWK)(jwk, 'RS256'));
78
+ try {
79
+ next.set(jwk.kid, (0, jwt_js_1.importRs256Jwk)(jwk));
80
+ }
81
+ catch (err) {
82
+ this.logger.warn(`JWKS: skipping kid=${jwk.kid}: ${err.message}`);
83
+ }
83
84
  }
84
85
  this.keys = next;
85
86
  this.fetchedAt = Date.now();
@@ -0,0 +1,13 @@
1
+ import { type KeyObject } from 'node:crypto';
2
+ /** Header and payload of a compact JWS, unverified. Throws on anything that isn't one. */
3
+ export declare function decodeJwt(token: string): {
4
+ header: Record<string, unknown>;
5
+ payload: Record<string, unknown>;
6
+ };
7
+ /** An RS256 verification key from a JWKS entry; RSA ≥ 2048 bits only. */
8
+ export declare function importRs256Jwk(jwk: JsonWebKey): KeyObject;
9
+ /**
10
+ * RS256 signature, `exp`/`nbf`, optional `iss`. What `jose.jwtVerify` checked for us,
11
+ * in node:crypto so the CJS build has no ESM-only dependency. Throws on any failure.
12
+ */
13
+ export declare function verifyRs256(token: string, key: KeyObject, issuer: string | null, now?: number): Record<string, unknown>;
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.decodeJwt = decodeJwt;
4
+ exports.importRs256Jwk = importRs256Jwk;
5
+ exports.verifyRs256 = verifyRs256;
6
+ const node_crypto_1 = require("node:crypto");
7
+ /** Header and payload of a compact JWS, unverified. Throws on anything that isn't one. */
8
+ function decodeJwt(token) {
9
+ const parts = token.split('.');
10
+ // Strict alphabet: Buffer's base64url decoder skips padding and junk, which would let one token have many spellings.
11
+ if (parts.length !== 3 || !parts.every((part) => /^[A-Za-z0-9_-]*$/.test(part)))
12
+ throw new Error('not a compact JWS');
13
+ const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
14
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
15
+ if (!isObject(header) || !isObject(payload))
16
+ throw new Error('header or payload not an object');
17
+ return { header, payload };
18
+ }
19
+ /** An RS256 verification key from a JWKS entry; RSA ≥ 2048 bits only. */
20
+ function importRs256Jwk(jwk) {
21
+ if (jwk.kty !== 'RSA' || (jwk.alg !== undefined && jwk.alg !== 'RS256') || (jwk.use !== undefined && jwk.use !== 'sig')) {
22
+ throw new Error('not an RS256 signing key');
23
+ }
24
+ const key = (0, node_crypto_1.createPublicKey)({ key: jwk, format: 'jwk' });
25
+ if ((key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048)
26
+ throw new Error('RSA key shorter than 2048 bits');
27
+ return key;
28
+ }
29
+ /**
30
+ * RS256 signature, `exp`/`nbf`, optional `iss`. What `jose.jwtVerify` checked for us,
31
+ * in node:crypto so the CJS build has no ESM-only dependency. Throws on any failure.
32
+ */
33
+ function verifyRs256(token, key, issuer, now = Date.now()) {
34
+ const { header, payload } = decodeJwt(token);
35
+ if (header.alg !== 'RS256')
36
+ throw new Error('alg not RS256');
37
+ if (header.crit !== undefined)
38
+ throw new Error('unsupported crit header');
39
+ const [h, p, s] = token.split('.');
40
+ if (!(0, node_crypto_1.verify)('RSA-SHA256', Buffer.from(`${h}.${p}`), key, Buffer.from(s, 'base64url')))
41
+ throw new Error('bad signature');
42
+ const seconds = Math.floor(now / 1000);
43
+ if (typeof payload.exp !== 'number' || payload.exp <= seconds)
44
+ throw new Error('expired');
45
+ if (payload.nbf !== undefined && (typeof payload.nbf !== 'number' || payload.nbf > seconds))
46
+ throw new Error('not yet valid');
47
+ if (payload.iat !== undefined && typeof payload.iat !== 'number')
48
+ throw new Error('bad iat');
49
+ if (issuer && payload.iss !== issuer)
50
+ throw new Error('wrong issuer');
51
+ return payload;
52
+ }
53
+ function isObject(value) {
54
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
55
+ }
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ServiceTokenProvider = void 0;
4
- const jose_1 = require("jose");
4
+ const jwt_js_1 = require("./jwt.js");
5
5
  const types_js_1 = require("./types.js");
6
6
  const REFRESH_MARGIN_MS = 5 * 60 * 1000;
7
7
  const FALLBACK_TTL_MS = 60 * 60 * 1000;
@@ -62,7 +62,7 @@ class ServiceTokenProvider {
62
62
  if (!credential?.apiKey)
63
63
  throw new Error(`Bastion service client for "${serviceSlug}" not configured`);
64
64
  const { accessToken } = await this.http.clientAuth(credential);
65
- const { exp, tenantId } = (0, jose_1.decodeJwt)(accessToken);
65
+ const { exp, tenantId } = (0, jwt_js_1.decodeJwt)(accessToken).payload;
66
66
  this.cache.set(serviceSlug, {
67
67
  token: accessToken,
68
68
  expiresAt: exp ? exp * 1000 : Date.now() + FALLBACK_TTL_MS,
@@ -4,8 +4,9 @@ import { type BastionModuleOptions } from './options.js';
4
4
  /**
5
5
  * `BastionApiClient` bound to this app (`serviceSlug` as `appSlug`,
6
6
  * `tenantSlug`), sharing `BastionService.http`. Inject it in an app backend
7
- * that proxies its users' login/refresh/account flows; a pure service
8
- * (Herald, Beacon) never needs it. Failures are `BastionHttpException`s:
7
+ * that proxies its users' login/refresh/account flows, or one that reads
8
+ * its users' Bastion data with their token (Beacon: `listSocialAccounts`);
9
+ * a pure machine service (Herald) never needs it. Failures are `BastionHttpException`s:
9
10
  * an `HttpException` with Bastion's status plus `code`/`upstreamMessage`.
10
11
  */
11
12
  export declare class BastionApiClientProvider extends BastionApiClient {
@@ -21,8 +21,9 @@ const nest_logger_js_1 = require("./nest-logger.js");
21
21
  /**
22
22
  * `BastionApiClient` bound to this app (`serviceSlug` as `appSlug`,
23
23
  * `tenantSlug`), sharing `BastionService.http`. Inject it in an app backend
24
- * that proxies its users' login/refresh/account flows; a pure service
25
- * (Herald, Beacon) never needs it. Failures are `BastionHttpException`s:
24
+ * that proxies its users' login/refresh/account flows, or one that reads
25
+ * its users' Bastion data with their token (Beacon: `listSocialAccounts`);
26
+ * a pure machine service (Herald) never needs it. Failures are `BastionHttpException`s:
26
27
  * an `HttpException` with Bastion's status plus `code`/`upstreamMessage`.
27
28
  */
28
29
  let BastionApiClientProvider = class BastionApiClientProvider extends api_client_js_1.BastionApiClient {
@@ -1,16 +1,26 @@
1
1
  import { HttpException } from '@nestjs/common';
2
2
  import { BastionHttp, type BastionRequestInit } from '../core/bastion-http.js';
3
- import { BastionHttpError, type BastionErrorCode } from '../core/errors.js';
3
+ import { BastionErrorCode, BastionHttpError } from '../core/errors.js';
4
4
  /**
5
- * The Nest face of a Bastion error: an `HttpException` carrying Bastion's
6
- * status, so the global filter answers with it unchanged, plus the stable
7
- * `code` and the upstream message for the handlers that branch on them.
8
- * `getResponse()` is `{ code, message }`.
5
+ * The Nest face of a Bastion error, so the global filter answers the browser
6
+ * per api.md §3/§14:
7
+ * - 4xx: Bastion's status, `code`, message and `errors` (field errors of a 400). A 4xx the
8
+ * package can't classify keeps Bastion's own code (`PASSWORD_TOO_SHORT`) or the status
9
+ * default (`NOT_FOUND`), never `UPSTREAM`;
10
+ * - Bastion down, timed out or answering 503: 503 `UNAVAILABLE`, generic message;
11
+ * - any other 5xx: 502 `UPSTREAM`, generic message.
12
+ *
13
+ * `getResponse()` is `{ code, message, errors? }`. `upstreamStatus` and
14
+ * `upstreamMessage` keep Bastion's own for logs and branching; `message`
15
+ * carries them too, so the filter's 5xx log line says what Bastion answered.
9
16
  */
10
17
  export declare class BastionHttpException extends HttpException {
11
18
  readonly code: BastionErrorCode;
12
19
  readonly upstreamMessage: string;
13
- constructor(code: BastionErrorCode, status: number, upstreamMessage: string);
20
+ readonly errors?: string[] | undefined;
21
+ readonly upstreamCode?: string | undefined;
22
+ readonly upstreamStatus: number;
23
+ constructor(code: BastionErrorCode, status: number, upstreamMessage: string, errors?: string[] | undefined, upstreamCode?: string | undefined);
14
24
  static from(err: BastionHttpError): BastionHttpException;
15
25
  }
16
26
  /** `BastionHttp` whose failures are `BastionHttpException`s — what `BastionService.http` is. */
@@ -5,24 +5,55 @@ const common_1 = require("@nestjs/common");
5
5
  const bastion_http_js_1 = require("../core/bastion-http.js");
6
6
  const errors_js_1 = require("../core/errors.js");
7
7
  /**
8
- * The Nest face of a Bastion error: an `HttpException` carrying Bastion's
9
- * status, so the global filter answers with it unchanged, plus the stable
10
- * `code` and the upstream message for the handlers that branch on them.
11
- * `getResponse()` is `{ code, message }`.
8
+ * The Nest face of a Bastion error, so the global filter answers the browser
9
+ * per api.md §3/§14:
10
+ * - 4xx: Bastion's status, `code`, message and `errors` (field errors of a 400). A 4xx the
11
+ * package can't classify keeps Bastion's own code (`PASSWORD_TOO_SHORT`) or the status
12
+ * default (`NOT_FOUND`), never `UPSTREAM`;
13
+ * - Bastion down, timed out or answering 503: 503 `UNAVAILABLE`, generic message;
14
+ * - any other 5xx: 502 `UPSTREAM`, generic message.
15
+ *
16
+ * `getResponse()` is `{ code, message, errors? }`. `upstreamStatus` and
17
+ * `upstreamMessage` keep Bastion's own for logs and branching; `message`
18
+ * carries them too, so the filter's 5xx log line says what Bastion answered.
12
19
  */
13
20
  class BastionHttpException extends common_1.HttpException {
14
21
  code;
15
22
  upstreamMessage;
16
- constructor(code, status, upstreamMessage) {
17
- super({ code, message: upstreamMessage }, status);
23
+ errors;
24
+ upstreamCode;
25
+ upstreamStatus;
26
+ constructor(code, status, upstreamMessage, errors, upstreamCode) {
27
+ const publicCode = code === errors_js_1.BastionErrorCode.UPSTREAM ? (upstreamCode ?? statusCode(status)) : code;
28
+ const unavailable = code === errors_js_1.BastionErrorCode.UNAVAILABLE || status === 503;
29
+ const publicStatus = status < 500 ? status : unavailable ? 503 : 502;
30
+ const body = status < 500
31
+ ? { code: publicCode, message: upstreamMessage, ...(errors?.length ? { errors } : {}) }
32
+ : unavailable
33
+ ? { code: errors_js_1.BastionErrorCode.UNAVAILABLE, message: 'Upstream service unavailable' }
34
+ : { code: errors_js_1.BastionErrorCode.UPSTREAM, message: 'Upstream service error' };
35
+ super(body, publicStatus);
18
36
  this.code = code;
19
37
  this.upstreamMessage = upstreamMessage;
38
+ this.errors = errors;
39
+ this.upstreamCode = upstreamCode;
40
+ this.upstreamStatus = status;
41
+ if (status >= 500)
42
+ this.message = `Bastion ${status}: ${upstreamMessage}`;
20
43
  }
21
44
  static from(err) {
22
- return new BastionHttpException(err.code, err.status, err.message);
45
+ return new BastionHttpException(err.code, err.status, err.message, err.errors, err.upstreamCode);
23
46
  }
24
47
  }
25
48
  exports.BastionHttpException = BastionHttpException;
49
+ /** api.md §3 default code of a 4xx: `NOT_FOUND`, `UNPROCESSABLE`, `RATE_LIMITED`, … */
50
+ function statusCode(status) {
51
+ if (status === 422)
52
+ return 'UNPROCESSABLE';
53
+ if (status === 429)
54
+ return 'RATE_LIMITED';
55
+ return common_1.HttpStatus[status] ?? 'BAD_REQUEST';
56
+ }
26
57
  /** `BastionHttp` whose failures are `BastionHttpException`s — what `BastionService.http` is. */
27
58
  class NestBastionHttp extends bastion_http_js_1.BastionHttp {
28
59
  async call(method, path, body, init) {
@@ -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;
@@ -17,12 +17,15 @@ const service_client_jwt_guard_js_1 = require("./guards/service-client-jwt.guard
17
17
  const bastion_user_guard_js_1 = require("./guards/bastion-user.guard.js");
18
18
  const audit_interceptor_js_1 = require("./interceptors/audit.interceptor.js");
19
19
  const bastion_api_client_provider_js_1 = require("./bastion-api-client.provider.js");
20
+ const app_user_guard_js_1 = require("./guards/app-user.guard.js");
21
+ const request_context_js_1 = require("./request-context.js");
20
22
  const PROVIDERS = [
21
23
  bastion_jwks_service_js_1.BastionJwksService,
22
24
  bastion_service_js_1.BastionService,
23
25
  bastion_audit_service_js_1.BastionAuditService,
24
26
  service_client_jwt_guard_js_1.ServiceClientJwtGuard,
25
27
  bastion_user_guard_js_1.BastionUserGuard,
28
+ app_user_guard_js_1.AppUserGuard,
26
29
  audit_interceptor_js_1.AuditInterceptor,
27
30
  bastion_api_client_provider_js_1.BastionApiClientProvider,
28
31
  ];
@@ -37,9 +40,13 @@ const PROVIDERS = [
37
40
  * }),
38
41
  * })
39
42
  * ```
40
- * Global: import once in `AppModule`, inject anywhere.
43
+ * Global: import once in `AppModule`, inject anywhere. Every route gets a
44
+ * middleware that tracks the request, so outbound calls carry its `X-Request-Id`.
41
45
  */
42
46
  let BastionModule = BastionModule_1 = class BastionModule {
47
+ configure(consumer) {
48
+ consumer.apply(request_context_js_1.requestContextMiddleware).forRoutes({ path: '{*splat}', method: common_1.RequestMethod.ALL });
49
+ }
43
50
  static forRoot(options) {
44
51
  return this.build({ provide: options_js_1.BASTION_OPTIONS, useValue: options });
45
52
  }
@@ -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;
@@ -16,13 +16,18 @@ exports.BastionService = void 0;
16
16
  const common_1 = require("@nestjs/common");
17
17
  const bastion_http_exception_js_1 = require("./bastion-http-exception.js");
18
18
  const options_js_1 = require("./options.js");
19
- /** Outbound HTTP to Bastion. A failure surfaces as `BastionHttpException` (an `HttpException` with Bastion's status, a stable `code` and the upstream message). */
19
+ const request_context_js_1 = require("./request-context.js");
20
+ /** 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). */
20
21
  let BastionService = class BastionService {
21
22
  options;
22
23
  http;
23
24
  constructor(options) {
24
25
  this.options = options;
25
- this.http = new bastion_http_exception_js_1.NestBastionHttp({ baseUrl: options.baseUrl, timeoutMs: options.timeoutMs });
26
+ this.http = new bastion_http_exception_js_1.NestBastionHttp({
27
+ baseUrl: options.baseUrl,
28
+ timeoutMs: options.timeoutMs,
29
+ requestId: options.requestId ?? request_context_js_1.currentRequestId,
30
+ });
26
31
  }
27
32
  call(method, path, body, init) {
28
33
  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;
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CurrentAdminUser = exports.CurrentClient = void 0;
3
+ exports.CurrentUser = exports.CurrentAdminUser = exports.CurrentClient = void 0;
4
4
  const common_1 = require("@nestjs/common");
5
5
  /** The machine token payload set by `ServiceClientJwtGuard`. */
6
6
  exports.CurrentClient = (0, common_1.createParamDecorator)((_, ctx) => ctx.switchToHttp().getRequest().user);
7
7
  /** The user payload set by `BastionUserGuard`. */
8
8
  exports.CurrentAdminUser = (0, common_1.createParamDecorator)((_, ctx) => ctx.switchToHttp().getRequest().adminUser);
9
+ /** The app user's payload set by `AppUserGuard`. */
10
+ exports.CurrentUser = (0, common_1.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,47 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ 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;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.AppUserGuard = void 0;
13
+ const common_1 = require("@nestjs/common");
14
+ const core_1 = require("@nestjs/core");
15
+ const bastion_jwks_service_js_1 = require("../bastion-jwks.service.js");
16
+ const public_decorator_js_1 = require("../decorators/public.decorator.js");
17
+ /**
18
+ * Guard for an app backend's own users, meant as the global `APP_GUARD`: every
19
+ * route needs a Bastion *user* token minted for this app (`serviceSlug` is the
20
+ * app's slug) unless it carries `@Public()`. Machine tokens and other apps'
21
+ * tokens are refused. The payload lands on `req.user`, read with `@CurrentUser()`.
22
+ * Suspension, age gate and roles stay the app's business: stack its own guard after this one.
23
+ */
24
+ let AppUserGuard = class AppUserGuard {
25
+ jwks;
26
+ reflector;
27
+ constructor(jwks, reflector) {
28
+ this.jwks = jwks;
29
+ this.reflector = reflector;
30
+ }
31
+ async canActivate(ctx) {
32
+ if (this.reflector.getAllAndOverride(public_decorator_js_1.IS_PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]))
33
+ return true;
34
+ const req = ctx.switchToHttp().getRequest();
35
+ const auth = req.headers['authorization'];
36
+ if (!auth?.startsWith('Bearer '))
37
+ throw new common_1.UnauthorizedException('Missing token');
38
+ req.user = await this.jwks.verifyUserToken(auth.slice(7));
39
+ return true;
40
+ }
41
+ };
42
+ exports.AppUserGuard = AppUserGuard;
43
+ exports.AppUserGuard = AppUserGuard = __decorate([
44
+ (0, common_1.Injectable)(),
45
+ __metadata("design:paramtypes", [bastion_jwks_service_js_1.BastionJwksService,
46
+ core_1.Reflector])
47
+ ], 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';
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.CROSS_TENANT_ROLE = exports.resolveTenantScope = exports.CurrentAdminUser = exports.CurrentClient = exports.AUDIT_EVENT_KEY = exports.Audit = exports.REQUIRED_SCOPES_KEY = exports.RequireScope = exports.IS_PUBLIC_KEY = exports.Public = exports.AuditInterceptor = exports.BastionUserGuard = exports.ServiceClientJwtGuard = exports.BastionAuditService = exports.NestBastionHttp = exports.BastionHttpException = exports.BastionApiClientProvider = exports.BastionService = exports.BastionJwksService = exports.BASTION_OPTIONS = exports.BastionModule = void 0;
17
+ exports.CROSS_TENANT_ROLE = exports.resolveTenantScope = exports.currentRequestId = exports.CurrentUser = exports.CurrentAdminUser = exports.CurrentClient = exports.AUDIT_EVENT_KEY = exports.Audit = exports.REQUIRED_SCOPES_KEY = exports.RequireScope = exports.IS_PUBLIC_KEY = exports.Public = exports.AuditInterceptor = exports.AppUserGuard = exports.BastionUserGuard = exports.ServiceClientJwtGuard = exports.BastionAuditService = exports.NestBastionHttp = exports.BastionHttpException = exports.BastionApiClientProvider = exports.BastionService = exports.BastionJwksService = exports.BASTION_OPTIONS = exports.BastionModule = void 0;
18
18
  __exportStar(require("../core/index.js"), exports);
19
19
  var bastion_module_js_1 = require("./bastion.module.js");
20
20
  Object.defineProperty(exports, "BastionModule", { enumerable: true, get: function () { return bastion_module_js_1.BastionModule; } });
@@ -35,6 +35,8 @@ var service_client_jwt_guard_js_1 = require("./guards/service-client-jwt.guard.j
35
35
  Object.defineProperty(exports, "ServiceClientJwtGuard", { enumerable: true, get: function () { return service_client_jwt_guard_js_1.ServiceClientJwtGuard; } });
36
36
  var bastion_user_guard_js_1 = require("./guards/bastion-user.guard.js");
37
37
  Object.defineProperty(exports, "BastionUserGuard", { enumerable: true, get: function () { return bastion_user_guard_js_1.BastionUserGuard; } });
38
+ var app_user_guard_js_1 = require("./guards/app-user.guard.js");
39
+ Object.defineProperty(exports, "AppUserGuard", { enumerable: true, get: function () { return app_user_guard_js_1.AppUserGuard; } });
38
40
  var audit_interceptor_js_1 = require("./interceptors/audit.interceptor.js");
39
41
  Object.defineProperty(exports, "AuditInterceptor", { enumerable: true, get: function () { return audit_interceptor_js_1.AuditInterceptor; } });
40
42
  var public_decorator_js_1 = require("./decorators/public.decorator.js");
@@ -49,6 +51,9 @@ Object.defineProperty(exports, "AUDIT_EVENT_KEY", { enumerable: true, get: funct
49
51
  var current_user_decorator_js_1 = require("./decorators/current-user.decorator.js");
50
52
  Object.defineProperty(exports, "CurrentClient", { enumerable: true, get: function () { return current_user_decorator_js_1.CurrentClient; } });
51
53
  Object.defineProperty(exports, "CurrentAdminUser", { enumerable: true, get: function () { return current_user_decorator_js_1.CurrentAdminUser; } });
54
+ Object.defineProperty(exports, "CurrentUser", { enumerable: true, get: function () { return current_user_decorator_js_1.CurrentUser; } });
55
+ var request_context_js_1 = require("./request-context.js");
56
+ Object.defineProperty(exports, "currentRequestId", { enumerable: true, get: function () { return request_context_js_1.currentRequestId; } });
52
57
  var tenant_scope_js_1 = require("./tenant-scope.js");
53
58
  Object.defineProperty(exports, "resolveTenantScope", { enumerable: true, get: function () { return tenant_scope_js_1.resolveTenantScope; } });
54
59
  Object.defineProperty(exports, "CROSS_TENANT_ROLE", { enumerable: true, get: function () { return tenant_scope_js_1.CROSS_TENANT_ROLE; } });
@@ -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,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.requestContextMiddleware = requestContextMiddleware;
4
+ exports.currentRequestId = currentRequestId;
5
+ const node_async_hooks_1 = require("node:async_hooks");
6
+ const store = new node_async_hooks_1.AsyncLocalStorage();
7
+ /** Express/Fastify middleware: keeps the request reachable for the rest of its async chain. */
8
+ function requestContextMiddleware(req, _res, next) {
9
+ store.run(req, next);
10
+ }
11
+ /**
12
+ * The served request's own id, `req.id` as set by pino-http (`genReqId`, api.md §7):
13
+ * the default `requestId` of `BastionModule`. Read lazily, so the logger
14
+ * middleware may run before or after this one.
15
+ */
16
+ function currentRequestId() {
17
+ const id = store.getStore()?.id;
18
+ return typeof id === 'string' ? id : undefined;
19
+ }
@@ -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;