@heyatom/bastion-client 0.8.0 → 1.0.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 CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0
4
+
5
+ Reads both the current Bastion answers and the `{ data }` envelope Bastion 2.0 will send, so you can upgrade before Bastion does.
6
+
7
+ ### Breaking
8
+
9
+ - `BastionHttp.call` (and so every `BastionApiClient`, `BastionService.call`, `ServiceTokenProvider` and `BastionHttpException` call) unwraps a success body whose only key is `data` and returns `data`. A page (`{ data, meta }`), an array, any other object and an empty body are returned unchanged. Three Bastion routes answer `{ data }` alone today (`GET /auth/me/tenants`, `GET /admin/apps/:slug/permissions`, `PUT /admin/apps/:slug/roles/:roleId/permissions`): through `call` they now give you the inner array. Read them as `Array.isArray(res) ? res : res.data`, which also works with any later Bastion.
10
+ - `listMyEvents` returns `{ data, meta: { total, page, limit, totalPages } }` (new type `BastionPageMeta`). An older Bastion's `{ data, total, page, limit }` is converted to this shape.
11
+ - `err.code` is the domain code for `PROVIDER_TAKEN`, `ALREADY_LINKED`, `OAUTH_EMAIL_TAKEN`, `AGE_ALREADY_VERIFIED` (previously `CONFLICT`), `AGE_GATE_NOT_CONFIGURED`, `CURRENT_PASSWORD_REQUIRED`, `TWO_FACTOR_CODE_REQUIRED`, `OAUTH_USER_INACTIVE`, `OAUTH_UNDER_MINIMUM_AGE` (previously `UPSTREAM`). `BastionHttpException` forwards that code to your client instead of `CONFLICT` / `BAD_REQUEST` / `FORBIDDEN`. When Bastion sends these codes in `code`, `upstreamCode` is no longer set for them.
12
+
13
+ ### Added
14
+
15
+ - The error codes above in `BastionErrorCode`.
16
+ - Bastion's `code` is used whenever it is a `BastionErrorCode` and `message` is not itself one, so the code survives a reworded `message`. Message-based classification stays as the fallback for a Bastion that sends no domain `code`.
17
+
18
+ ## 0.9.0
19
+
20
+ ### Added
21
+
22
+ - `verifyWebhookSignature` verifies [Standard Webhooks](https://www.standardwebhooks.com/) headers (`webhook-id`, `webhook-timestamp`, `webhook-signature`: space-separated `v1,<base64 HMAC-SHA256("<id>.<timestamp>.<body>")>`, secret used as UTF-8). When any of them is present they are the ones checked: same tolerance, any secret against any `v1` entry, `deliveryId` = `webhook-id`, result `version: 'standard'`.
23
+ - Header name constants `WEBHOOK_ID_HEADER`, `WEBHOOK_TIMESTAMP_HEADER`, `WEBHOOK_SIGNATURE_HEADER`.
24
+
25
+ ### Changed
26
+
27
+ - Without Standard Webhooks headers, `X-Bastion-Signature-V2` is verified exactly as before, so receivers keep working against an older Bastion. Bastion sends both for one release and will then drop V2. `allowLegacySignature` is unchanged.
28
+ - Whitespace-only entries in `secrets` (e.g. `' '` from an unset env var) are ignored instead of keying the HMAC; if none is left the result is `{ ok: false, reason: 'missing' }`. Other secrets are still used byte-exact (not trimmed).
29
+
30
+ ### Breaking (types)
31
+
32
+ - `VerifyWebhookResult` gains the `version: 'standard'` variant: an exhaustive `switch` on `version` needs the new case.
33
+
34
+ ## 0.8.1
35
+
36
+ ### Added
37
+
38
+ - Error code `STEP_UP_FAILED`: the 401 Bastion answers when the current password or 2FA code of a step-up protected route is wrong (or the step-up counter is locked). Tells a wrong factor apart from a dead session, also a 401 (`INVALID_CREDENTIALS`). Needs a Bastion that sends `code: 'STEP_UP_FAILED'`; an older one still classifies as `INVALID_CREDENTIALS`.
39
+
3
40
  ## 0.8.0
4
41
 
5
42
  ### Breaking
package/README.md CHANGED
@@ -1,3 +1,14 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/heyatomdev/bastion-client/main/docs/brand/banner.png" width="500" alt="HeyAtom @heyatom/bastion-client" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/@heyatom/bastion-client"><img src="https://img.shields.io/npm/v/@heyatom/bastion-client" alt="npm version" /></a>
7
+ <a href="https://github.com/heyatomdev/bastion-client/actions/workflows/test.yml"><img src="https://github.com/heyatomdev/bastion-client/actions/workflows/test.yml/badge.svg?branch=main" alt="Test" /></a>
8
+ <img src="https://img.shields.io/node/v/@heyatom/bastion-client" alt="Node version" />
9
+ <img src="https://img.shields.io/npm/l/@heyatom/bastion-client" alt="License" />
10
+ </p>
11
+
1
12
  # @heyatom/bastion-client
2
13
 
3
14
  Client SDK for [Bastion](https://github.com/heyatomdev/bastion), the HeyAtom identity provider.
@@ -136,6 +147,11 @@ export class AuthController {
136
147
  from one browser must collapse into one call. Use one client instance per process.
137
148
  - `logout` revokes the latest token in the rotation chain and never throws.
138
149
 
150
+ ## Responses
151
+
152
+ Calls return Bastion's body. A body whose only key is `data` is unwrapped, so the same code reads
153
+ Bastion with and without the `{ data }` envelope; pages keep their `{ data, meta }` shape.
154
+
139
155
  ## Errors
140
156
 
141
157
  Every failed call throws `BastionHttpError` (core) or `BastionHttpException` (Nest) with a stable
@@ -143,9 +159,12 @@ Every failed call throws `BastionHttpError` (core) or `BastionHttpException` (Ne
143
159
 
144
160
  `INVALID_CREDENTIALS`, `EMAIL_NOT_VERIFIED`, `NO_APP_ACCESS`, `ACCOUNT_INACTIVE`,
145
161
  `INVALID_APP_CONTEXT` (your configuration, not the user), `INVALID_REFRESH_TOKEN`,
146
- `INVALID_OAUTH_CODE`, `VALIDATION_FAILED`, `CONFLICT`, `RATE_LIMITED`, the age-gate codes
147
- (`BIRTH_DATE_REQUIRED`, `BIRTH_DATE_INVALID`, `UNDER_MINIMUM_AGE`), the OAuth flow codes
148
- (`OAUTH_*`), `UNAVAILABLE` (Bastion unreachable or timed out) and `UPSTREAM`.
162
+ `INVALID_OAUTH_CODE`, `VALIDATION_FAILED`, `STEP_UP_FAILED` (wrong current password or 2FA
163
+ code on a step-up route: the session is fine), `CURRENT_PASSWORD_REQUIRED` and
164
+ `TWO_FACTOR_CODE_REQUIRED` (a step-up field is missing: ask for it), `CONFLICT`,
165
+ `PROVIDER_TAKEN`, `ALREADY_LINKED`, `RATE_LIMITED`, the age-gate codes (`BIRTH_DATE_REQUIRED`,
166
+ `BIRTH_DATE_INVALID`, `UNDER_MINIMUM_AGE`, `AGE_ALREADY_VERIFIED`, `AGE_GATE_NOT_CONFIGURED`),
167
+ the OAuth codes (`OAUTH_*`), `UNAVAILABLE` (Bastion unreachable or timed out) and `UPSTREAM`.
149
168
 
150
169
  - A 400 carries the field errors in `errors: string[]`. Show them: `message` is only
151
170
  "Validation failed".
@@ -206,8 +225,12 @@ if (!result.ok) throw new UnauthorizedException(result.reason);
206
225
  // result.deliveryId: store it and ignore repeats
207
226
  ```
208
227
 
209
- Checks `X-Bastion-Signature-V2` (timestamp + delivery id + body, 5 min tolerance). Leave
210
- `allowLegacySignature` off.
228
+ Checks [Standard Webhooks](https://www.standardwebhooks.com/) headers (`webhook-id`,
229
+ `webhook-timestamp`, `webhook-signature`) when present: HMAC-SHA256 over id + timestamp + body,
230
+ 5 min tolerance, secret used as a UTF-8 string, `result.deliveryId` = `webhook-id`. Without them
231
+ it falls back to `X-Bastion-Signature-V2` (same guarantees), so it works against an older Bastion
232
+ too; Bastion will stop sending V2 in a later release. `result.version` says which one matched.
233
+ Leave `allowLegacySignature` off.
211
234
 
212
235
  ## Changelog
213
236
 
@@ -225,14 +225,20 @@ class BastionApiClient {
225
225
  }
226
226
  // ── GDPR ─────────────────────────────────────────────────────────────────
227
227
  /** The user's own audit trail (`GET /auth/me/events`), paginated. */
228
- listMyEvents(accessToken, page = {}) {
228
+ async listMyEvents(accessToken, page = {}) {
229
229
  const params = new URLSearchParams();
230
230
  if (page.page !== undefined)
231
231
  params.set('page', String(page.page));
232
232
  if (page.limit !== undefined)
233
233
  params.set('limit', String(page.limit));
234
234
  const qs = params.toString();
235
- return this.call('GET', `/auth/me/events${qs ? `?${qs}` : ''}`, undefined, { token: accessToken });
235
+ const res = await this.call('GET', `/auth/me/events${qs ? `?${qs}` : ''}`, undefined, { token: accessToken });
236
+ // No body (a proxy in between): nothing to normalise, same as every other method.
237
+ if (!res || typeof res !== 'object' || 'meta' in res)
238
+ return res;
239
+ // Bastion before 2.0: the page fields sit next to `data`.
240
+ const { data, total, page: current, limit } = res;
241
+ return { data, meta: { total, page: current, limit, totalPages: limit > 0 ? Math.ceil(total / limit) : 0 } };
236
242
  }
237
243
  exportData(accessToken) {
238
244
  return this.call('GET', '/auth/me/export', undefined, { token: accessToken });
@@ -136,9 +136,13 @@ export interface BastionUserAuditEvent {
136
136
  metadata: unknown;
137
137
  createdAt: string;
138
138
  }
139
- export interface BastionUserAuditEventsPage {
140
- data: BastionUserAuditEvent[];
139
+ export interface BastionPageMeta {
141
140
  total: number;
142
141
  page: number;
143
142
  limit: number;
143
+ totalPages: number;
144
+ }
145
+ export interface BastionUserAuditEventsPage {
146
+ data: BastionUserAuditEvent[];
147
+ meta: BastionPageMeta;
144
148
  }
@@ -11,6 +11,12 @@ export interface BastionHttpOptions {
11
11
  */
12
12
  requestId?: () => string | undefined;
13
13
  }
14
+ /**
15
+ * Bastion answers `{ data }` (or `{ data, meta }` for a page) from 2.0; earlier builds answer the
16
+ * bare body. An object whose only key is `data` gives back `data`; a page, an array, a bare body
17
+ * or no body passes through unchanged, so every caller reads the same shape from both.
18
+ */
19
+ export declare function unwrapData(body: unknown): unknown;
14
20
  export interface BastionRequestInit {
15
21
  token?: string;
16
22
  headers?: Record<string, string>;
@@ -24,9 +30,10 @@ export interface ClientAuthInput {
24
30
  /**
25
31
  * Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
26
32
  * `JwksVerifier`. `call` is public so a service can reach any route with the
27
- * same error handling: a non-2xx answer becomes `BastionHttpError` with the
28
- * status and a stable `code`; Bastion unreachable or timed out is a 503
29
- * `UNAVAILABLE`. Pass the browser's IP as `X-Real-IP` on user-facing calls.
33
+ * same error handling and the same success shape (`unwrapData`): a non-2xx
34
+ * answer becomes `BastionHttpError` with the status and a stable `code`;
35
+ * Bastion unreachable or timed out is a 503 `UNAVAILABLE`. Pass the browser's
36
+ * IP as `X-Real-IP` on user-facing calls.
30
37
  */
31
38
  export declare class BastionHttp {
32
39
  private readonly baseUrl;
@@ -1,14 +1,29 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BastionHttp = void 0;
4
+ exports.unwrapData = unwrapData;
4
5
  const errors_js_1 = require("./errors.js");
5
6
  const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
7
+ /**
8
+ * Bastion answers `{ data }` (or `{ data, meta }` for a page) from 2.0; earlier builds answer the
9
+ * bare body. An object whose only key is `data` gives back `data`; a page, an array, a bare body
10
+ * or no body passes through unchanged, so every caller reads the same shape from both.
11
+ */
12
+ function unwrapData(body) {
13
+ if (body && typeof body === 'object' && !Array.isArray(body)) {
14
+ const keys = Object.keys(body);
15
+ if (keys.length === 1 && keys[0] === 'data')
16
+ return body.data;
17
+ }
18
+ return body;
19
+ }
6
20
  /**
7
21
  * Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
8
22
  * `JwksVerifier`. `call` is public so a service can reach any route with the
9
- * same error handling: a non-2xx answer becomes `BastionHttpError` with the
10
- * status and a stable `code`; Bastion unreachable or timed out is a 503
11
- * `UNAVAILABLE`. Pass the browser's IP as `X-Real-IP` on user-facing calls.
23
+ * same error handling and the same success shape (`unwrapData`): a non-2xx
24
+ * answer becomes `BastionHttpError` with the status and a stable `code`;
25
+ * Bastion unreachable or timed out is a 503 `UNAVAILABLE`. Pass the browser's
26
+ * IP as `X-Real-IP` on user-facing calls.
12
27
  */
13
28
  class BastionHttp {
14
29
  baseUrl;
@@ -57,7 +72,9 @@ class BastionHttp {
57
72
  : typeof parsed.message === 'string'
58
73
  ? parsed.message
59
74
  : `Bastion error ${res.status}`;
60
- // A code Bastion still puts in `message` (UNDER_MINIMUM_AGE under `code: FORBIDDEN`) wins: classify reads it.
75
+ // A code in `message` wins: an older Bastion sends it under a generic `code` that is also one
76
+ // of ours (ALREADY_LINKED under `code: CONFLICT`); classify reads it. Otherwise Bastion's
77
+ // `code` wins when it is one of ours, so a cleaned-up `message` still carries its code.
61
78
  const code = (0, errors_js_1.isBastionErrorCode)(message)
62
79
  ? undefined
63
80
  : (0, errors_js_1.isBastionErrorCode)(parsed.code)
@@ -70,7 +87,7 @@ class BastionHttp {
70
87
  }
71
88
  if (res.status === 204)
72
89
  return undefined;
73
- return (await res.json().catch(() => undefined));
90
+ return unwrapData(await res.json().catch(() => undefined));
74
91
  }
75
92
  clientAuth(input) {
76
93
  return this.call('POST', '/auth/client', input);
@@ -6,9 +6,9 @@ export declare class BastionTokenError extends Error {
6
6
  }
7
7
  /**
8
8
  * Stable classification of a Bastion error answer, so an app can branch on a
9
- * code instead of on Bastion's message text. The age-gate codes are Bastion's
10
- * own (it returns them verbatim); the rest are derived from status + message
11
- * until Bastion carries a `code` field on every error.
9
+ * code instead of on Bastion's message text. Bastion's own `code` is used when
10
+ * it is one of these; for an older Bastion that sends none, the code is derived
11
+ * from status + message (`classifyBastionError`).
12
12
  */
13
13
  export declare const BastionErrorCode: {
14
14
  readonly EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED";
@@ -23,6 +23,8 @@ export declare const BastionErrorCode: {
23
23
  readonly INVALID_OAUTH_CODE: "INVALID_OAUTH_CODE";
24
24
  /** A 400 carrying field errors (`BastionHttpError.errors`): Bastion's `ValidationPipe`. */
25
25
  readonly VALIDATION_FAILED: "VALIDATION_FAILED";
26
+ /** A 401 from a step-up protected route (password change, email change, 2FA setup/confirm/disable, account delete): the current password or 2FA code is wrong, or the step-up counter is locked. The session is fine — do not log the user out. Bastion sends it as `code`; an older Bastion sends a plain 401, classified `INVALID_CREDENTIALS`. */
27
+ readonly STEP_UP_FAILED: "STEP_UP_FAILED";
26
28
  readonly CONFLICT: "CONFLICT";
27
29
  readonly RATE_LIMITED: "RATE_LIMITED";
28
30
  readonly BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED";
@@ -34,6 +36,20 @@ export declare const BastionErrorCode: {
34
36
  readonly OAUTH_NO_ACCOUNT: "OAUTH_NO_ACCOUNT";
35
37
  /** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
36
38
  readonly OAUTH_LINK_USER_MISMATCH: "OAUTH_LINK_USER_MISMATCH";
39
+ /** OAuth signup with an email that a verified account of the tenant already owns. */
40
+ readonly OAUTH_EMAIL_TAKEN: "OAUTH_EMAIL_TAKEN";
41
+ readonly OAUTH_USER_INACTIVE: "OAUTH_USER_INACTIVE";
42
+ readonly OAUTH_UNDER_MINIMUM_AGE: "OAUTH_UNDER_MINIMUM_AGE";
43
+ /** Linking a provider account that belongs to another user. */
44
+ readonly PROVIDER_TAKEN: "PROVIDER_TAKEN";
45
+ /** The user already has an account of this provider linked. */
46
+ readonly ALREADY_LINKED: "ALREADY_LINKED";
47
+ readonly AGE_ALREADY_VERIFIED: "AGE_ALREADY_VERIFIED";
48
+ readonly AGE_GATE_NOT_CONFIGURED: "AGE_GATE_NOT_CONFIGURED";
49
+ /** A step-up route was called without `currentPassword`. Ask for it; the session is fine. */
50
+ readonly CURRENT_PASSWORD_REQUIRED: "CURRENT_PASSWORD_REQUIRED";
51
+ /** A step-up route was called without `twoFactorCode`. Ask for it; the session is fine. */
52
+ readonly TWO_FACTOR_CODE_REQUIRED: "TWO_FACTOR_CODE_REQUIRED";
37
53
  readonly UNAVAILABLE: "UNAVAILABLE";
38
54
  readonly UPSTREAM: "UPSTREAM";
39
55
  };
@@ -15,9 +15,9 @@ class BastionTokenError extends Error {
15
15
  exports.BastionTokenError = BastionTokenError;
16
16
  /**
17
17
  * Stable classification of a Bastion error answer, so an app can branch on a
18
- * code instead of on Bastion's message text. The age-gate codes are Bastion's
19
- * own (it returns them verbatim); the rest are derived from status + message
20
- * until Bastion carries a `code` field on every error.
18
+ * code instead of on Bastion's message text. Bastion's own `code` is used when
19
+ * it is one of these; for an older Bastion that sends none, the code is derived
20
+ * from status + message (`classifyBastionError`).
21
21
  */
22
22
  exports.BastionErrorCode = {
23
23
  EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED',
@@ -32,6 +32,8 @@ exports.BastionErrorCode = {
32
32
  INVALID_OAUTH_CODE: 'INVALID_OAUTH_CODE',
33
33
  /** A 400 carrying field errors (`BastionHttpError.errors`): Bastion's `ValidationPipe`. */
34
34
  VALIDATION_FAILED: 'VALIDATION_FAILED',
35
+ /** A 401 from a step-up protected route (password change, email change, 2FA setup/confirm/disable, account delete): the current password or 2FA code is wrong, or the step-up counter is locked. The session is fine — do not log the user out. Bastion sends it as `code`; an older Bastion sends a plain 401, classified `INVALID_CREDENTIALS`. */
36
+ STEP_UP_FAILED: 'STEP_UP_FAILED',
35
37
  CONFLICT: 'CONFLICT',
36
38
  RATE_LIMITED: 'RATE_LIMITED',
37
39
  BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
@@ -43,6 +45,20 @@ exports.BastionErrorCode = {
43
45
  OAUTH_NO_ACCOUNT: 'OAUTH_NO_ACCOUNT',
44
46
  /** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
45
47
  OAUTH_LINK_USER_MISMATCH: 'OAUTH_LINK_USER_MISMATCH',
48
+ /** OAuth signup with an email that a verified account of the tenant already owns. */
49
+ OAUTH_EMAIL_TAKEN: 'OAUTH_EMAIL_TAKEN',
50
+ OAUTH_USER_INACTIVE: 'OAUTH_USER_INACTIVE',
51
+ OAUTH_UNDER_MINIMUM_AGE: 'OAUTH_UNDER_MINIMUM_AGE',
52
+ /** Linking a provider account that belongs to another user. */
53
+ PROVIDER_TAKEN: 'PROVIDER_TAKEN',
54
+ /** The user already has an account of this provider linked. */
55
+ ALREADY_LINKED: 'ALREADY_LINKED',
56
+ AGE_ALREADY_VERIFIED: 'AGE_ALREADY_VERIFIED',
57
+ AGE_GATE_NOT_CONFIGURED: 'AGE_GATE_NOT_CONFIGURED',
58
+ /** A step-up route was called without `currentPassword`. Ask for it; the session is fine. */
59
+ CURRENT_PASSWORD_REQUIRED: 'CURRENT_PASSWORD_REQUIRED',
60
+ /** A step-up route was called without `twoFactorCode`. Ask for it; the session is fine. */
61
+ TWO_FACTOR_CODE_REQUIRED: 'TWO_FACTOR_CODE_REQUIRED',
46
62
  UNAVAILABLE: 'UNAVAILABLE',
47
63
  UPSTREAM: 'UPSTREAM',
48
64
  };
@@ -56,6 +72,15 @@ const CODE_MESSAGES = new Set([
56
72
  exports.BastionErrorCode.OAUTH_EMAIL_REQUIRED,
57
73
  exports.BastionErrorCode.OAUTH_NO_ACCOUNT,
58
74
  exports.BastionErrorCode.OAUTH_LINK_USER_MISMATCH,
75
+ exports.BastionErrorCode.OAUTH_EMAIL_TAKEN,
76
+ exports.BastionErrorCode.OAUTH_USER_INACTIVE,
77
+ exports.BastionErrorCode.OAUTH_UNDER_MINIMUM_AGE,
78
+ exports.BastionErrorCode.PROVIDER_TAKEN,
79
+ exports.BastionErrorCode.ALREADY_LINKED,
80
+ exports.BastionErrorCode.AGE_ALREADY_VERIFIED,
81
+ exports.BastionErrorCode.AGE_GATE_NOT_CONFIGURED,
82
+ exports.BastionErrorCode.CURRENT_PASSWORD_REQUIRED,
83
+ exports.BastionErrorCode.TWO_FACTOR_CODE_REQUIRED,
59
84
  ]);
60
85
  const APP_CONTEXT_RE = /app not found|tenant not found|tenant is inactive|no tenants configured|tenantslug is required/i;
61
86
  const OAUTH_CODE_RE = /already used code|code expired|code\/app mismatch/i;
@@ -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 {
@@ -33,12 +37,17 @@ export interface VerifyWebhookOptions {
33
37
  * left to migrate: leave it off.
34
38
  */
35
39
  allowLegacySignature?: boolean;
36
- /** 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. */
37
41
  toleranceSeconds?: number;
38
42
  /** For tests. */
39
43
  now?: () => number;
40
44
  }
41
45
  export type VerifyWebhookResult = {
46
+ ok: true;
47
+ version: 'standard';
48
+ timestamp: number;
49
+ deliveryId: string;
50
+ } | {
42
51
  ok: true;
43
52
  version: 'v2';
44
53
  timestamp: number;
@@ -51,12 +60,17 @@ export type VerifyWebhookResult = {
51
60
  reason: 'missing' | 'malformed' | 'stale' | 'mismatch';
52
61
  };
53
62
  /**
54
- * 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`
55
68
  * (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
56
69
  * which binds the timestamp and the delivery id so a stale or replayed
57
70
  * delivery is rejected without trusting any unsigned header. The legacy
58
71
  * `X-Bastion-Signature: sha256=<hex>` over the body alone is accepted only
59
- * 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:
60
74
  * that is how a secret rotation stays zero-downtime on both sides.
61
75
  */
62
76
  export declare function verifyWebhookSignature(options: VerifyWebhookOptions): VerifyWebhookResult;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BASTION_LIFECYCLE_EVENTS = exports.BASTION_DELIVERY_ID_HEADER = exports.BASTION_EVENT_HEADER = exports.BASTION_SIGNATURE_V2_HEADER = exports.BASTION_SIGNATURE_HEADER = void 0;
3
+ exports.BASTION_LIFECYCLE_EVENTS = exports.WEBHOOK_SIGNATURE_HEADER = exports.WEBHOOK_TIMESTAMP_HEADER = exports.WEBHOOK_ID_HEADER = exports.BASTION_DELIVERY_ID_HEADER = exports.BASTION_EVENT_HEADER = exports.BASTION_SIGNATURE_V2_HEADER = exports.BASTION_SIGNATURE_HEADER = void 0;
4
4
  exports.verifyWebhookSignature = verifyWebhookSignature;
5
5
  const node_crypto_1 = require("node:crypto");
6
6
  /** Headers Bastion sends with every delivery. Lower-case, as Node exposes them. */
@@ -8,14 +8,18 @@ exports.BASTION_SIGNATURE_HEADER = 'x-bastion-signature';
8
8
  exports.BASTION_SIGNATURE_V2_HEADER = 'x-bastion-signature-v2';
9
9
  exports.BASTION_EVENT_HEADER = 'x-bastion-event';
10
10
  exports.BASTION_DELIVERY_ID_HEADER = 'x-bastion-delivery-id';
11
+ /** Standard Webhooks (standardwebhooks.com) headers, sent alongside v2 during the migration. */
12
+ exports.WEBHOOK_ID_HEADER = 'webhook-id';
13
+ exports.WEBHOOK_TIMESTAMP_HEADER = 'webhook-timestamp';
14
+ exports.WEBHOOK_SIGNATURE_HEADER = 'webhook-signature';
11
15
  /** Sent to every app with a webhook URL, whether or not the user has a role in it. */
12
16
  exports.BASTION_LIFECYCLE_EVENTS = ['user.deleted', 'user.expired_unverified', 'user.deactivated'];
13
17
  function header(headers, name) {
14
- const direct = headers[name] ?? headers[name.toLowerCase()];
15
- if (direct !== undefined)
16
- return Array.isArray(direct) ? direct[0] : direct;
17
- const key = Object.keys(headers).find((k) => k.toLowerCase() === name);
18
- const v = key ? headers[key] : undefined;
18
+ let v = headers[name];
19
+ if (v === undefined) {
20
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === name);
21
+ v = key === undefined ? undefined : headers[key];
22
+ }
19
23
  return Array.isArray(v) ? v[0] : v;
20
24
  }
21
25
  function macEquals(hexA, hexB) {
@@ -23,20 +27,54 @@ function macEquals(hexA, hexB) {
23
27
  const b = Buffer.from(hexB, 'hex');
24
28
  return a.length === b.length && a.length > 0 && (0, node_crypto_1.timingSafeEqual)(a, b);
25
29
  }
30
+ function isFresh(timestamp, options) {
31
+ const now = Math.floor((options.now ?? Date.now)() / 1000);
32
+ return Math.abs(now - timestamp) <= (options.toleranceSeconds ?? 300);
33
+ }
26
34
  /**
27
- * Verifies a Bastion delivery. Prefers `X-Bastion-Signature-V2`
35
+ * Verifies a Bastion delivery. Prefers Standard Webhooks when any of `webhook-id`,
36
+ * `webhook-timestamp`, `webhook-signature` is present: `webhook-signature` is a
37
+ * space-separated list of `v1,<base64 HMAC-SHA256(secret, "<id>.<timestamp>.<body>")>`,
38
+ * keyed with the secret as UTF-8 (not a `whsec_` base64 key); `deliveryId` is `webhook-id`.
39
+ * Otherwise falls back to `X-Bastion-Signature-V2`
28
40
  * (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
29
41
  * which binds the timestamp and the delivery id so a stale or replayed
30
42
  * delivery is rejected without trusting any unsigned header. The legacy
31
43
  * `X-Bastion-Signature: sha256=<hex>` over the body alone is accepted only
32
- * when v2 is absent **and** `allowLegacySignature` is set. Any of the caller's `secrets` matching any `v1=` accepts:
44
+ * when both of the above are absent **and** `allowLegacySignature` is set. Any of the caller's
45
+ * `secrets` matching any `v1` entry accepts:
33
46
  * that is how a secret rotation stays zero-downtime on both sides.
34
47
  */
35
48
  function verifyWebhookSignature(options) {
36
49
  const body = typeof options.rawBody === 'string' ? Buffer.from(options.rawBody, 'utf8') : options.rawBody;
37
- const secrets = (Array.isArray(options.secrets) ? options.secrets : [options.secrets]).filter(Boolean);
50
+ const secrets = (Array.isArray(options.secrets) ? options.secrets : [options.secrets]).filter(
51
+ // A whitespace-only secret is a misconfiguration, never a key; the rest are used byte-exact.
52
+ (s) => typeof s === 'string' && s.trim() !== '');
38
53
  if (secrets.length === 0)
39
54
  return { ok: false, reason: 'missing' };
55
+ const stdId = header(options.headers, exports.WEBHOOK_ID_HEADER);
56
+ const stdTs = header(options.headers, exports.WEBHOOK_TIMESTAMP_HEADER);
57
+ const stdSig = header(options.headers, exports.WEBHOOK_SIGNATURE_HEADER);
58
+ if (stdId || stdTs || stdSig) {
59
+ const macs = (stdSig ?? '')
60
+ .split(' ')
61
+ .map((entry) => entry.split(','))
62
+ .filter(([version, value]) => version === 'v1' && value)
63
+ .map(([, value]) => Buffer.from(value, 'base64'));
64
+ if (!stdId || !stdTs || !/^\d+$/.test(stdTs) || macs.length === 0)
65
+ return { ok: false, reason: 'malformed' };
66
+ const timestamp = Number(stdTs);
67
+ if (!isFresh(timestamp, options))
68
+ return { ok: false, reason: 'stale' };
69
+ const signed = Buffer.concat([Buffer.from(`${stdId}.${stdTs}.`, 'utf8'), body]);
70
+ for (const secret of secrets) {
71
+ const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(signed).digest();
72
+ if (macs.some((mac) => mac.length === expected.length && (0, node_crypto_1.timingSafeEqual)(mac, expected))) {
73
+ return { ok: true, version: 'standard', timestamp, deliveryId: stdId };
74
+ }
75
+ }
76
+ return { ok: false, reason: 'mismatch' };
77
+ }
40
78
  const v2 = header(options.headers, exports.BASTION_SIGNATURE_V2_HEADER);
41
79
  if (v2) {
42
80
  const parts = v2.split(',').map((p) => p.trim());
@@ -46,8 +84,7 @@ function verifyWebhookSignature(options) {
46
84
  const timestamp = Number(t);
47
85
  if (!t || !id || macs.length === 0 || !Number.isFinite(timestamp))
48
86
  return { ok: false, reason: 'malformed' };
49
- const now = Math.floor((options.now ?? Date.now)() / 1000);
50
- if (Math.abs(now - timestamp) > (options.toleranceSeconds ?? 300))
87
+ if (!isFresh(timestamp, options))
51
88
  return { ok: false, reason: 'stale' };
52
89
  const signed = Buffer.concat([Buffer.from(`${timestamp}.${id}.`, 'utf8'), body]);
53
90
  for (const secret of secrets) {
@@ -222,14 +222,20 @@ export class BastionApiClient {
222
222
  }
223
223
  // ── GDPR ─────────────────────────────────────────────────────────────────
224
224
  /** The user's own audit trail (`GET /auth/me/events`), paginated. */
225
- listMyEvents(accessToken, page = {}) {
225
+ async listMyEvents(accessToken, page = {}) {
226
226
  const params = new URLSearchParams();
227
227
  if (page.page !== undefined)
228
228
  params.set('page', String(page.page));
229
229
  if (page.limit !== undefined)
230
230
  params.set('limit', String(page.limit));
231
231
  const qs = params.toString();
232
- return this.call('GET', `/auth/me/events${qs ? `?${qs}` : ''}`, undefined, { token: accessToken });
232
+ const res = await this.call('GET', `/auth/me/events${qs ? `?${qs}` : ''}`, undefined, { token: accessToken });
233
+ // No body (a proxy in between): nothing to normalise, same as every other method.
234
+ if (!res || typeof res !== 'object' || 'meta' in res)
235
+ return res;
236
+ // Bastion before 2.0: the page fields sit next to `data`.
237
+ const { data, total, page: current, limit } = res;
238
+ return { data, meta: { total, page: current, limit, totalPages: limit > 0 ? Math.ceil(total / limit) : 0 } };
233
239
  }
234
240
  exportData(accessToken) {
235
241
  return this.call('GET', '/auth/me/export', undefined, { token: accessToken });
@@ -136,9 +136,13 @@ export interface BastionUserAuditEvent {
136
136
  metadata: unknown;
137
137
  createdAt: string;
138
138
  }
139
- export interface BastionUserAuditEventsPage {
140
- data: BastionUserAuditEvent[];
139
+ export interface BastionPageMeta {
141
140
  total: number;
142
141
  page: number;
143
142
  limit: number;
143
+ totalPages: number;
144
+ }
145
+ export interface BastionUserAuditEventsPage {
146
+ data: BastionUserAuditEvent[];
147
+ meta: BastionPageMeta;
144
148
  }
@@ -11,6 +11,12 @@ export interface BastionHttpOptions {
11
11
  */
12
12
  requestId?: () => string | undefined;
13
13
  }
14
+ /**
15
+ * Bastion answers `{ data }` (or `{ data, meta }` for a page) from 2.0; earlier builds answer the
16
+ * bare body. An object whose only key is `data` gives back `data`; a page, an array, a bare body
17
+ * or no body passes through unchanged, so every caller reads the same shape from both.
18
+ */
19
+ export declare function unwrapData(body: unknown): unknown;
14
20
  export interface BastionRequestInit {
15
21
  token?: string;
16
22
  headers?: Record<string, string>;
@@ -24,9 +30,10 @@ export interface ClientAuthInput {
24
30
  /**
25
31
  * Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
26
32
  * `JwksVerifier`. `call` is public so a service can reach any route with the
27
- * same error handling: a non-2xx answer becomes `BastionHttpError` with the
28
- * status and a stable `code`; Bastion unreachable or timed out is a 503
29
- * `UNAVAILABLE`. Pass the browser's IP as `X-Real-IP` on user-facing calls.
33
+ * same error handling and the same success shape (`unwrapData`): a non-2xx
34
+ * answer becomes `BastionHttpError` with the status and a stable `code`;
35
+ * Bastion unreachable or timed out is a 503 `UNAVAILABLE`. Pass the browser's
36
+ * IP as `X-Real-IP` on user-facing calls.
30
37
  */
31
38
  export declare class BastionHttp {
32
39
  private readonly baseUrl;
@@ -1,11 +1,25 @@
1
1
  import { BastionErrorCode, BastionHttpError, isBastionErrorCode } from './errors.js';
2
2
  const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
3
+ /**
4
+ * Bastion answers `{ data }` (or `{ data, meta }` for a page) from 2.0; earlier builds answer the
5
+ * bare body. An object whose only key is `data` gives back `data`; a page, an array, a bare body
6
+ * or no body passes through unchanged, so every caller reads the same shape from both.
7
+ */
8
+ export function unwrapData(body) {
9
+ if (body && typeof body === 'object' && !Array.isArray(body)) {
10
+ const keys = Object.keys(body);
11
+ if (keys.length === 1 && keys[0] === 'data')
12
+ return body.data;
13
+ }
14
+ return body;
15
+ }
3
16
  /**
4
17
  * Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
5
18
  * `JwksVerifier`. `call` is public so a service can reach any route with the
6
- * same error handling: a non-2xx answer becomes `BastionHttpError` with the
7
- * status and a stable `code`; Bastion unreachable or timed out is a 503
8
- * `UNAVAILABLE`. Pass the browser's IP as `X-Real-IP` on user-facing calls.
19
+ * same error handling and the same success shape (`unwrapData`): a non-2xx
20
+ * answer becomes `BastionHttpError` with the status and a stable `code`;
21
+ * Bastion unreachable or timed out is a 503 `UNAVAILABLE`. Pass the browser's
22
+ * IP as `X-Real-IP` on user-facing calls.
9
23
  */
10
24
  export class BastionHttp {
11
25
  baseUrl;
@@ -54,7 +68,9 @@ export class BastionHttp {
54
68
  : typeof parsed.message === 'string'
55
69
  ? parsed.message
56
70
  : `Bastion error ${res.status}`;
57
- // A code Bastion still puts in `message` (UNDER_MINIMUM_AGE under `code: FORBIDDEN`) wins: classify reads it.
71
+ // A code in `message` wins: an older Bastion sends it under a generic `code` that is also one
72
+ // of ours (ALREADY_LINKED under `code: CONFLICT`); classify reads it. Otherwise Bastion's
73
+ // `code` wins when it is one of ours, so a cleaned-up `message` still carries its code.
58
74
  const code = isBastionErrorCode(message)
59
75
  ? undefined
60
76
  : isBastionErrorCode(parsed.code)
@@ -67,7 +83,7 @@ export class BastionHttp {
67
83
  }
68
84
  if (res.status === 204)
69
85
  return undefined;
70
- return (await res.json().catch(() => undefined));
86
+ return unwrapData(await res.json().catch(() => undefined));
71
87
  }
72
88
  clientAuth(input) {
73
89
  return this.call('POST', '/auth/client', input);
@@ -6,9 +6,9 @@ export declare class BastionTokenError extends Error {
6
6
  }
7
7
  /**
8
8
  * Stable classification of a Bastion error answer, so an app can branch on a
9
- * code instead of on Bastion's message text. The age-gate codes are Bastion's
10
- * own (it returns them verbatim); the rest are derived from status + message
11
- * until Bastion carries a `code` field on every error.
9
+ * code instead of on Bastion's message text. Bastion's own `code` is used when
10
+ * it is one of these; for an older Bastion that sends none, the code is derived
11
+ * from status + message (`classifyBastionError`).
12
12
  */
13
13
  export declare const BastionErrorCode: {
14
14
  readonly EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED";
@@ -23,6 +23,8 @@ export declare const BastionErrorCode: {
23
23
  readonly INVALID_OAUTH_CODE: "INVALID_OAUTH_CODE";
24
24
  /** A 400 carrying field errors (`BastionHttpError.errors`): Bastion's `ValidationPipe`. */
25
25
  readonly VALIDATION_FAILED: "VALIDATION_FAILED";
26
+ /** A 401 from a step-up protected route (password change, email change, 2FA setup/confirm/disable, account delete): the current password or 2FA code is wrong, or the step-up counter is locked. The session is fine — do not log the user out. Bastion sends it as `code`; an older Bastion sends a plain 401, classified `INVALID_CREDENTIALS`. */
27
+ readonly STEP_UP_FAILED: "STEP_UP_FAILED";
26
28
  readonly CONFLICT: "CONFLICT";
27
29
  readonly RATE_LIMITED: "RATE_LIMITED";
28
30
  readonly BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED";
@@ -34,6 +36,20 @@ export declare const BastionErrorCode: {
34
36
  readonly OAUTH_NO_ACCOUNT: "OAUTH_NO_ACCOUNT";
35
37
  /** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
36
38
  readonly OAUTH_LINK_USER_MISMATCH: "OAUTH_LINK_USER_MISMATCH";
39
+ /** OAuth signup with an email that a verified account of the tenant already owns. */
40
+ readonly OAUTH_EMAIL_TAKEN: "OAUTH_EMAIL_TAKEN";
41
+ readonly OAUTH_USER_INACTIVE: "OAUTH_USER_INACTIVE";
42
+ readonly OAUTH_UNDER_MINIMUM_AGE: "OAUTH_UNDER_MINIMUM_AGE";
43
+ /** Linking a provider account that belongs to another user. */
44
+ readonly PROVIDER_TAKEN: "PROVIDER_TAKEN";
45
+ /** The user already has an account of this provider linked. */
46
+ readonly ALREADY_LINKED: "ALREADY_LINKED";
47
+ readonly AGE_ALREADY_VERIFIED: "AGE_ALREADY_VERIFIED";
48
+ readonly AGE_GATE_NOT_CONFIGURED: "AGE_GATE_NOT_CONFIGURED";
49
+ /** A step-up route was called without `currentPassword`. Ask for it; the session is fine. */
50
+ readonly CURRENT_PASSWORD_REQUIRED: "CURRENT_PASSWORD_REQUIRED";
51
+ /** A step-up route was called without `twoFactorCode`. Ask for it; the session is fine. */
52
+ readonly TWO_FACTOR_CODE_REQUIRED: "TWO_FACTOR_CODE_REQUIRED";
37
53
  readonly UNAVAILABLE: "UNAVAILABLE";
38
54
  readonly UPSTREAM: "UPSTREAM";
39
55
  };
@@ -9,9 +9,9 @@ export class BastionTokenError extends Error {
9
9
  }
10
10
  /**
11
11
  * Stable classification of a Bastion error answer, so an app can branch on a
12
- * code instead of on Bastion's message text. The age-gate codes are Bastion's
13
- * own (it returns them verbatim); the rest are derived from status + message
14
- * until Bastion carries a `code` field on every error.
12
+ * code instead of on Bastion's message text. Bastion's own `code` is used when
13
+ * it is one of these; for an older Bastion that sends none, the code is derived
14
+ * from status + message (`classifyBastionError`).
15
15
  */
16
16
  export const BastionErrorCode = {
17
17
  EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED',
@@ -26,6 +26,8 @@ export const BastionErrorCode = {
26
26
  INVALID_OAUTH_CODE: 'INVALID_OAUTH_CODE',
27
27
  /** A 400 carrying field errors (`BastionHttpError.errors`): Bastion's `ValidationPipe`. */
28
28
  VALIDATION_FAILED: 'VALIDATION_FAILED',
29
+ /** A 401 from a step-up protected route (password change, email change, 2FA setup/confirm/disable, account delete): the current password or 2FA code is wrong, or the step-up counter is locked. The session is fine — do not log the user out. Bastion sends it as `code`; an older Bastion sends a plain 401, classified `INVALID_CREDENTIALS`. */
30
+ STEP_UP_FAILED: 'STEP_UP_FAILED',
29
31
  CONFLICT: 'CONFLICT',
30
32
  RATE_LIMITED: 'RATE_LIMITED',
31
33
  BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
@@ -37,6 +39,20 @@ export const BastionErrorCode = {
37
39
  OAUTH_NO_ACCOUNT: 'OAUTH_NO_ACCOUNT',
38
40
  /** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
39
41
  OAUTH_LINK_USER_MISMATCH: 'OAUTH_LINK_USER_MISMATCH',
42
+ /** OAuth signup with an email that a verified account of the tenant already owns. */
43
+ OAUTH_EMAIL_TAKEN: 'OAUTH_EMAIL_TAKEN',
44
+ OAUTH_USER_INACTIVE: 'OAUTH_USER_INACTIVE',
45
+ OAUTH_UNDER_MINIMUM_AGE: 'OAUTH_UNDER_MINIMUM_AGE',
46
+ /** Linking a provider account that belongs to another user. */
47
+ PROVIDER_TAKEN: 'PROVIDER_TAKEN',
48
+ /** The user already has an account of this provider linked. */
49
+ ALREADY_LINKED: 'ALREADY_LINKED',
50
+ AGE_ALREADY_VERIFIED: 'AGE_ALREADY_VERIFIED',
51
+ AGE_GATE_NOT_CONFIGURED: 'AGE_GATE_NOT_CONFIGURED',
52
+ /** A step-up route was called without `currentPassword`. Ask for it; the session is fine. */
53
+ CURRENT_PASSWORD_REQUIRED: 'CURRENT_PASSWORD_REQUIRED',
54
+ /** A step-up route was called without `twoFactorCode`. Ask for it; the session is fine. */
55
+ TWO_FACTOR_CODE_REQUIRED: 'TWO_FACTOR_CODE_REQUIRED',
40
56
  UNAVAILABLE: 'UNAVAILABLE',
41
57
  UPSTREAM: 'UPSTREAM',
42
58
  };
@@ -50,6 +66,15 @@ const CODE_MESSAGES = new Set([
50
66
  BastionErrorCode.OAUTH_EMAIL_REQUIRED,
51
67
  BastionErrorCode.OAUTH_NO_ACCOUNT,
52
68
  BastionErrorCode.OAUTH_LINK_USER_MISMATCH,
69
+ BastionErrorCode.OAUTH_EMAIL_TAKEN,
70
+ BastionErrorCode.OAUTH_USER_INACTIVE,
71
+ BastionErrorCode.OAUTH_UNDER_MINIMUM_AGE,
72
+ BastionErrorCode.PROVIDER_TAKEN,
73
+ BastionErrorCode.ALREADY_LINKED,
74
+ BastionErrorCode.AGE_ALREADY_VERIFIED,
75
+ BastionErrorCode.AGE_GATE_NOT_CONFIGURED,
76
+ BastionErrorCode.CURRENT_PASSWORD_REQUIRED,
77
+ BastionErrorCode.TWO_FACTOR_CODE_REQUIRED,
53
78
  ]);
54
79
  const APP_CONTEXT_RE = /app not found|tenant not found|tenant is inactive|no tenants configured|tenantslug is required/i;
55
80
  const OAUTH_CODE_RE = /already used code|code expired|code\/app mismatch/i;
@@ -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 {
@@ -33,12 +37,17 @@ export interface VerifyWebhookOptions {
33
37
  * left to migrate: leave it off.
34
38
  */
35
39
  allowLegacySignature?: boolean;
36
- /** 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. */
37
41
  toleranceSeconds?: number;
38
42
  /** For tests. */
39
43
  now?: () => number;
40
44
  }
41
45
  export type VerifyWebhookResult = {
46
+ ok: true;
47
+ version: 'standard';
48
+ timestamp: number;
49
+ deliveryId: string;
50
+ } | {
42
51
  ok: true;
43
52
  version: 'v2';
44
53
  timestamp: number;
@@ -51,12 +60,17 @@ export type VerifyWebhookResult = {
51
60
  reason: 'missing' | 'malformed' | 'stale' | 'mismatch';
52
61
  };
53
62
  /**
54
- * 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`
55
68
  * (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
56
69
  * which binds the timestamp and the delivery id so a stale or replayed
57
70
  * delivery is rejected without trusting any unsigned header. The legacy
58
71
  * `X-Bastion-Signature: sha256=<hex>` over the body alone is accepted only
59
- * 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:
60
74
  * that is how a secret rotation stays zero-downtime on both sides.
61
75
  */
62
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.8.0",
3
+ "version": "1.0.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)",