@heyatom/bastion-client 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Added
6
+
7
+ - `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'`.
8
+ - Header name constants `WEBHOOK_ID_HEADER`, `WEBHOOK_TIMESTAMP_HEADER`, `WEBHOOK_SIGNATURE_HEADER`.
9
+
10
+ ### Changed
11
+
12
+ - 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.
13
+ - 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).
14
+
15
+ ### Breaking (types)
16
+
17
+ - `VerifyWebhookResult` gains the `version: 'standard'` variant: an exhaustive `switch` on `version` needs the new case.
18
+
19
+ ## 0.8.1
20
+
21
+ ### Added
22
+
23
+ - 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`.
24
+
3
25
  ## 0.8.0
4
26
 
5
27
  ### Breaking
package/README.md CHANGED
@@ -143,7 +143,8 @@ Every failed call throws `BastionHttpError` (core) or `BastionHttpException` (Ne
143
143
 
144
144
  `INVALID_CREDENTIALS`, `EMAIL_NOT_VERIFIED`, `NO_APP_ACCESS`, `ACCOUNT_INACTIVE`,
145
145
  `INVALID_APP_CONTEXT` (your configuration, not the user), `INVALID_REFRESH_TOKEN`,
146
- `INVALID_OAUTH_CODE`, `VALIDATION_FAILED`, `CONFLICT`, `RATE_LIMITED`, the age-gate codes
146
+ `INVALID_OAUTH_CODE`, `VALIDATION_FAILED`, `STEP_UP_FAILED` (wrong current password or 2FA
147
+ code on a step-up route: the session is fine), `CONFLICT`, `RATE_LIMITED`, the age-gate codes
147
148
  (`BIRTH_DATE_REQUIRED`, `BIRTH_DATE_INVALID`, `UNDER_MINIMUM_AGE`), the OAuth flow codes
148
149
  (`OAUTH_*`), `UNAVAILABLE` (Bastion unreachable or timed out) and `UPSTREAM`.
149
150
 
@@ -206,8 +207,12 @@ if (!result.ok) throw new UnauthorizedException(result.reason);
206
207
  // result.deliveryId: store it and ignore repeats
207
208
  ```
208
209
 
209
- Checks `X-Bastion-Signature-V2` (timestamp + delivery id + body, 5 min tolerance). Leave
210
- `allowLegacySignature` off.
210
+ Checks [Standard Webhooks](https://www.standardwebhooks.com/) headers (`webhook-id`,
211
+ `webhook-timestamp`, `webhook-signature`) when present: HMAC-SHA256 over id + timestamp + body,
212
+ 5 min tolerance, secret used as a UTF-8 string, `result.deliveryId` = `webhook-id`. Without them
213
+ it falls back to `X-Bastion-Signature-V2` (same guarantees), so it works against an older Bastion
214
+ too; Bastion will stop sending V2 in a later release. `result.version` says which one matched.
215
+ Leave `allowLegacySignature` off.
211
216
 
212
217
  ## Changelog
213
218
 
@@ -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";
@@ -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',
@@ -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) {
@@ -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";
@@ -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',
@@ -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": "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)",