@heyatom/bastion-client 0.1.2 → 0.3.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/README.md +40 -0
- package/dist/cjs/core/api-client.d.ts +98 -0
- package/dist/cjs/core/api-client.js +226 -0
- package/dist/cjs/core/api-types.d.ts +138 -0
- package/dist/cjs/core/api-types.js +10 -0
- package/dist/cjs/core/bastion-http.d.ts +13 -8
- package/dist/cjs/core/bastion-http.js +29 -17
- package/dist/cjs/core/errors.d.ts +24 -3
- package/dist/cjs/core/errors.js +45 -3
- package/dist/cjs/core/index.d.ts +4 -2
- package/dist/cjs/core/index.js +4 -1
- package/dist/cjs/core/jwks-verifier.js +2 -1
- package/dist/cjs/core/service-token-provider.d.ts +20 -12
- package/dist/cjs/core/service-token-provider.js +55 -27
- package/dist/cjs/core/types.d.ts +8 -0
- package/dist/cjs/core/types.js +20 -0
- package/dist/cjs/nest/bastion-api-client.provider.d.ts +13 -0
- package/dist/cjs/nest/bastion-api-client.provider.js +43 -0
- package/dist/cjs/nest/bastion-http-exception.d.ts +19 -0
- package/dist/cjs/nest/bastion-http-exception.js +39 -0
- package/dist/cjs/nest/bastion-jwks.service.d.ts +8 -1
- package/dist/cjs/nest/bastion-jwks.service.js +19 -0
- package/dist/cjs/nest/bastion.module.js +2 -0
- package/dist/cjs/nest/bastion.service.d.ts +5 -7
- package/dist/cjs/nest/bastion.service.js +5 -13
- package/dist/cjs/nest/index.d.ts +2 -0
- package/dist/cjs/nest/index.js +6 -1
- package/dist/cjs/nest/options.d.ts +2 -0
- package/dist/cjs/webhooks/index.d.ts +54 -0
- package/dist/cjs/webhooks/index.js +72 -0
- package/dist/esm/core/api-client.d.ts +98 -0
- package/dist/esm/core/api-client.js +222 -0
- package/dist/esm/core/api-types.d.ts +138 -0
- package/dist/esm/core/api-types.js +7 -0
- package/dist/esm/core/bastion-http.d.ts +13 -8
- package/dist/esm/core/bastion-http.js +30 -18
- package/dist/esm/core/errors.d.ts +24 -3
- package/dist/esm/core/errors.js +43 -2
- package/dist/esm/core/index.d.ts +4 -2
- package/dist/esm/core/index.js +2 -0
- package/dist/esm/core/jwks-verifier.js +2 -1
- package/dist/esm/core/service-token-provider.d.ts +20 -12
- package/dist/esm/core/service-token-provider.js +55 -27
- package/dist/esm/core/types.d.ts +8 -0
- package/dist/esm/core/types.js +19 -0
- package/dist/esm/nest/bastion-api-client.provider.d.ts +13 -0
- package/dist/esm/nest/bastion-api-client.provider.js +40 -0
- package/dist/esm/nest/bastion-http-exception.d.ts +19 -0
- package/dist/esm/nest/bastion-http-exception.js +34 -0
- package/dist/esm/nest/bastion-jwks.service.d.ts +8 -1
- package/dist/esm/nest/bastion-jwks.service.js +19 -0
- package/dist/esm/nest/bastion.module.js +2 -0
- package/dist/esm/nest/bastion.service.d.ts +5 -7
- package/dist/esm/nest/bastion.service.js +6 -14
- package/dist/esm/nest/index.d.ts +2 -0
- package/dist/esm/nest/index.js +2 -0
- package/dist/esm/nest/options.d.ts +2 -0
- package/dist/esm/webhooks/index.d.ts +54 -0
- package/dist/esm/webhooks/index.js +68 -0
- package/package.json +14 -1
|
@@ -9,6 +9,8 @@ export interface BastionModuleOptions {
|
|
|
9
9
|
apiKey?: string;
|
|
10
10
|
/** Required by Bastion when the app is `isGlobal` or registered in more than one tenant. */
|
|
11
11
|
tenantSlug?: string;
|
|
12
|
+
/** Per-request timeout for outbound calls, default 8 000 ms. */
|
|
13
|
+
timeoutMs?: number;
|
|
12
14
|
/** `BASTION_JWKS_TTL_MS`, default 300 000. */
|
|
13
15
|
jwksTtlMs?: number;
|
|
14
16
|
/** Expected `iss`. Default `'bastion'`; `null` disables the check. */
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Headers Bastion sends with every delivery. Lower-case, as Node exposes them. */
|
|
2
|
+
export declare const BASTION_SIGNATURE_HEADER = "x-bastion-signature";
|
|
3
|
+
export declare const BASTION_SIGNATURE_V2_HEADER = "x-bastion-signature-v2";
|
|
4
|
+
export declare const BASTION_EVENT_HEADER = "x-bastion-event";
|
|
5
|
+
export declare const BASTION_DELIVERY_ID_HEADER = "x-bastion-delivery-id";
|
|
6
|
+
/** Sent to every app with a webhook URL, whether or not the user has a role in it. */
|
|
7
|
+
export declare const BASTION_LIFECYCLE_EVENTS: readonly ["user.deleted", "user.expired_unverified", "user.deactivated"];
|
|
8
|
+
export interface BastionWebhookPayload {
|
|
9
|
+
event: string;
|
|
10
|
+
tenantSlug?: string;
|
|
11
|
+
userId?: string;
|
|
12
|
+
timestamp?: string;
|
|
13
|
+
/** `auth.email_changed` */
|
|
14
|
+
newEmail?: string;
|
|
15
|
+
/** `auth.email_verified` */
|
|
16
|
+
email?: string;
|
|
17
|
+
/** `user.deleted` from the age gate carries `under_minimum_age`; absent otherwise. */
|
|
18
|
+
reason?: string;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
export interface VerifyWebhookOptions {
|
|
22
|
+
/** The request body exactly as received — never a re-serialized object. */
|
|
23
|
+
rawBody: Buffer | string;
|
|
24
|
+
/** Request headers (any case). */
|
|
25
|
+
headers: Record<string, string | string[] | undefined>;
|
|
26
|
+
/** Your webhook secret, plus the previous one during a rotation. */
|
|
27
|
+
secrets: string | string[];
|
|
28
|
+
/** Max age of `t` on a v2 signature, default 300 s. */
|
|
29
|
+
toleranceSeconds?: number;
|
|
30
|
+
/** For tests. */
|
|
31
|
+
now?: () => number;
|
|
32
|
+
}
|
|
33
|
+
export type VerifyWebhookResult = {
|
|
34
|
+
ok: true;
|
|
35
|
+
version: 'v2';
|
|
36
|
+
timestamp: number;
|
|
37
|
+
deliveryId: string;
|
|
38
|
+
} | {
|
|
39
|
+
ok: true;
|
|
40
|
+
version: 'legacy';
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
reason: 'missing' | 'malformed' | 'stale' | 'mismatch';
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Verifies a Bastion delivery. Prefers `X-Bastion-Signature-V2`
|
|
47
|
+
* (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
|
|
48
|
+
* which binds the timestamp and the delivery id so a stale or replayed
|
|
49
|
+
* delivery is rejected without trusting any unsigned header; falls back to
|
|
50
|
+
* the legacy `X-Bastion-Signature: sha256=<hex>` over the body alone only when
|
|
51
|
+
* v2 is absent. Any of the caller's `secrets` matching any `v1=` accepts:
|
|
52
|
+
* that is how a secret rotation stays zero-downtime on both sides.
|
|
53
|
+
*/
|
|
54
|
+
export declare function verifyWebhookSignature(options: VerifyWebhookOptions): VerifyWebhookResult;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
/** Headers Bastion sends with every delivery. Lower-case, as Node exposes them. */
|
|
3
|
+
export const BASTION_SIGNATURE_HEADER = 'x-bastion-signature';
|
|
4
|
+
export const BASTION_SIGNATURE_V2_HEADER = 'x-bastion-signature-v2';
|
|
5
|
+
export const BASTION_EVENT_HEADER = 'x-bastion-event';
|
|
6
|
+
export const BASTION_DELIVERY_ID_HEADER = 'x-bastion-delivery-id';
|
|
7
|
+
/** Sent to every app with a webhook URL, whether or not the user has a role in it. */
|
|
8
|
+
export const BASTION_LIFECYCLE_EVENTS = ['user.deleted', 'user.expired_unverified', 'user.deactivated'];
|
|
9
|
+
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;
|
|
15
|
+
return Array.isArray(v) ? v[0] : v;
|
|
16
|
+
}
|
|
17
|
+
function macEquals(hexA, hexB) {
|
|
18
|
+
const a = Buffer.from(hexA, 'hex');
|
|
19
|
+
const b = Buffer.from(hexB, 'hex');
|
|
20
|
+
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Verifies a Bastion delivery. Prefers `X-Bastion-Signature-V2`
|
|
24
|
+
* (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
|
|
25
|
+
* which binds the timestamp and the delivery id so a stale or replayed
|
|
26
|
+
* delivery is rejected without trusting any unsigned header; falls back to
|
|
27
|
+
* the legacy `X-Bastion-Signature: sha256=<hex>` over the body alone only when
|
|
28
|
+
* v2 is absent. Any of the caller's `secrets` matching any `v1=` accepts:
|
|
29
|
+
* that is how a secret rotation stays zero-downtime on both sides.
|
|
30
|
+
*/
|
|
31
|
+
export function verifyWebhookSignature(options) {
|
|
32
|
+
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);
|
|
34
|
+
if (secrets.length === 0)
|
|
35
|
+
return { ok: false, reason: 'missing' };
|
|
36
|
+
const v2 = header(options.headers, BASTION_SIGNATURE_V2_HEADER);
|
|
37
|
+
if (v2) {
|
|
38
|
+
const parts = v2.split(',').map((p) => p.trim());
|
|
39
|
+
const t = parts.find((p) => p.startsWith('t='))?.slice(2);
|
|
40
|
+
const id = parts.find((p) => p.startsWith('id='))?.slice(3);
|
|
41
|
+
const macs = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
|
|
42
|
+
const timestamp = Number(t);
|
|
43
|
+
if (!t || !id || macs.length === 0 || !Number.isFinite(timestamp))
|
|
44
|
+
return { ok: false, reason: 'malformed' };
|
|
45
|
+
const now = Math.floor((options.now ?? Date.now)() / 1000);
|
|
46
|
+
if (Math.abs(now - timestamp) > (options.toleranceSeconds ?? 300))
|
|
47
|
+
return { ok: false, reason: 'stale' };
|
|
48
|
+
const signed = Buffer.concat([Buffer.from(`${timestamp}.${id}.`, 'utf8'), body]);
|
|
49
|
+
for (const secret of secrets) {
|
|
50
|
+
const expected = createHmac('sha256', secret).update(signed).digest('hex');
|
|
51
|
+
if (macs.some((mac) => macEquals(mac, expected)))
|
|
52
|
+
return { ok: true, version: 'v2', timestamp, deliveryId: id };
|
|
53
|
+
}
|
|
54
|
+
return { ok: false, reason: 'mismatch' };
|
|
55
|
+
}
|
|
56
|
+
const legacy = header(options.headers, BASTION_SIGNATURE_HEADER);
|
|
57
|
+
if (!legacy)
|
|
58
|
+
return { ok: false, reason: 'missing' };
|
|
59
|
+
if (!legacy.startsWith('sha256='))
|
|
60
|
+
return { ok: false, reason: 'malformed' };
|
|
61
|
+
const provided = legacy.slice('sha256='.length).trim();
|
|
62
|
+
for (const secret of secrets) {
|
|
63
|
+
const expected = createHmac('sha256', secret).update(body).digest('hex');
|
|
64
|
+
if (macEquals(provided, expected))
|
|
65
|
+
return { ok: true, version: 'legacy' };
|
|
66
|
+
}
|
|
67
|
+
return { ok: false, reason: 'mismatch' };
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyatom/bastion-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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)",
|
|
@@ -35,6 +35,16 @@
|
|
|
35
35
|
"types": "./dist/cjs/nest/index.d.ts",
|
|
36
36
|
"default": "./dist/cjs/nest/index.js"
|
|
37
37
|
}
|
|
38
|
+
},
|
|
39
|
+
"./webhooks": {
|
|
40
|
+
"import": {
|
|
41
|
+
"types": "./dist/esm/webhooks/index.d.ts",
|
|
42
|
+
"default": "./dist/esm/webhooks/index.js"
|
|
43
|
+
},
|
|
44
|
+
"require": {
|
|
45
|
+
"types": "./dist/cjs/webhooks/index.d.ts",
|
|
46
|
+
"default": "./dist/cjs/webhooks/index.js"
|
|
47
|
+
}
|
|
38
48
|
}
|
|
39
49
|
},
|
|
40
50
|
"scripts": {
|
|
@@ -85,6 +95,9 @@
|
|
|
85
95
|
],
|
|
86
96
|
"*": [
|
|
87
97
|
"./dist/cjs/core/index.d.ts"
|
|
98
|
+
],
|
|
99
|
+
"webhooks": [
|
|
100
|
+
"./dist/cjs/webhooks/index.d.ts"
|
|
88
101
|
]
|
|
89
102
|
}
|
|
90
103
|
}
|