@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.
- package/CHANGELOG.md +63 -0
- package/README.md +149 -46
- package/dist/cjs/core/api-client.d.ts +16 -5
- package/dist/cjs/core/api-client.js +23 -5
- package/dist/cjs/core/bastion-http.d.ts +7 -0
- package/dist/cjs/core/bastion-http.js +27 -3
- package/dist/cjs/core/errors.d.ts +19 -2
- package/dist/cjs/core/errors.js +24 -2
- package/dist/cjs/core/jwks-verifier.d.ts +1 -1
- package/dist/cjs/core/jwks-verifier.js +11 -10
- package/dist/cjs/core/jwt.d.ts +13 -0
- package/dist/cjs/core/jwt.js +55 -0
- package/dist/cjs/core/service-token-provider.js +2 -2
- package/dist/cjs/nest/bastion-api-client.provider.d.ts +3 -2
- package/dist/cjs/nest/bastion-api-client.provider.js +3 -2
- package/dist/cjs/nest/bastion-http-exception.d.ts +16 -6
- package/dist/cjs/nest/bastion-http-exception.js +38 -7
- package/dist/cjs/nest/bastion.module.d.ts +5 -3
- package/dist/cjs/nest/bastion.module.js +8 -1
- package/dist/cjs/nest/bastion.service.d.ts +1 -1
- package/dist/cjs/nest/bastion.service.js +7 -2
- package/dist/cjs/nest/decorators/current-user.decorator.d.ts +2 -0
- package/dist/cjs/nest/decorators/current-user.decorator.js +3 -1
- package/dist/cjs/nest/guards/app-user.guard.d.ts +16 -0
- package/dist/cjs/nest/guards/app-user.guard.js +47 -0
- package/dist/cjs/nest/index.d.ts +3 -1
- package/dist/cjs/nest/index.js +6 -1
- package/dist/cjs/nest/options.d.ts +7 -0
- package/dist/cjs/nest/request-context.d.ts +10 -0
- package/dist/cjs/nest/request-context.js +19 -0
- package/dist/cjs/webhooks/index.d.ts +19 -4
- package/dist/cjs/webhooks/index.js +48 -11
- package/dist/esm/core/api-client.d.ts +16 -5
- package/dist/esm/core/api-client.js +24 -6
- package/dist/esm/core/bastion-http.d.ts +7 -0
- package/dist/esm/core/bastion-http.js +28 -4
- package/dist/esm/core/errors.d.ts +19 -2
- package/dist/esm/core/errors.js +23 -2
- package/dist/esm/core/jwks-verifier.d.ts +1 -1
- package/dist/esm/core/jwks-verifier.js +11 -10
- package/dist/esm/core/jwt.d.ts +13 -0
- package/dist/esm/core/jwt.js +50 -0
- package/dist/esm/core/service-token-provider.js +2 -2
- package/dist/esm/nest/bastion-api-client.provider.d.ts +3 -2
- package/dist/esm/nest/bastion-api-client.provider.js +3 -2
- package/dist/esm/nest/bastion-http-exception.d.ts +16 -6
- package/dist/esm/nest/bastion-http-exception.js +40 -9
- package/dist/esm/nest/bastion.module.d.ts +5 -3
- package/dist/esm/nest/bastion.module.js +9 -2
- package/dist/esm/nest/bastion.service.d.ts +1 -1
- package/dist/esm/nest/bastion.service.js +7 -2
- package/dist/esm/nest/decorators/current-user.decorator.d.ts +2 -0
- package/dist/esm/nest/decorators/current-user.decorator.js +2 -0
- package/dist/esm/nest/guards/app-user.guard.d.ts +16 -0
- package/dist/esm/nest/guards/app-user.guard.js +44 -0
- package/dist/esm/nest/index.d.ts +3 -1
- package/dist/esm/nest/index.js +3 -1
- package/dist/esm/nest/options.d.ts +7 -0
- package/dist/esm/nest/request-context.d.ts +10 -0
- package/dist/esm/nest/request-context.js +15 -0
- package/dist/esm/webhooks/index.d.ts +19 -4
- package/dist/esm/webhooks/index.js +47 -10
- package/package.json +3 -3
|
@@ -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
|
-
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 `
|
|
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
|
|
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(
|
|
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
|
-
|
|
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) {
|
|
@@ -51,12 +51,23 @@ export declare class BastionApiClient {
|
|
|
51
51
|
message: string;
|
|
52
52
|
}>;
|
|
53
53
|
/**
|
|
54
|
-
*
|
|
55
|
-
* on
|
|
56
|
-
*
|
|
57
|
-
*
|
|
54
|
+
* Starts an OAuth login: the provider's Bastion initiate URL with a fresh
|
|
55
|
+
* `state` on it, or `null` for a provider the app doesn't offer. Keep `state`
|
|
56
|
+
* in an httpOnly `SameSite=Lax` cookie scoped to the callback path, redirect
|
|
57
|
+
* the browser to `url`, then pass the cookie's value to `exchange`.
|
|
58
58
|
*/
|
|
59
|
-
|
|
59
|
+
oauthStart(provider: string): Promise<{
|
|
60
|
+
url: string;
|
|
61
|
+
state: string;
|
|
62
|
+
} | null>;
|
|
63
|
+
/**
|
|
64
|
+
* Trades the one-time OAuth code for tokens. `state` is the value from
|
|
65
|
+
* `oauthStart`, read back from the app's own cookie (never from the callback
|
|
66
|
+
* query): Bastion refuses a code minted under a different one, which is what
|
|
67
|
+
* stops an attacker's code from being redeemed in a victim's browser (login CSRF).
|
|
68
|
+
* Required: an empty `state` throws before calling Bastion.
|
|
69
|
+
*/
|
|
70
|
+
exchange(code: string, ctx: BastionClientContext | undefined, state: string): Promise<BastionLoginResult>;
|
|
60
71
|
completeTwoFactor(twoFactorToken: string, code: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
61
72
|
refresh(refreshToken: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
|
|
62
73
|
/** Revokes the token and any successor issued through `refresh` within the memory window. Never throws. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { silentLogger } from './types.js';
|
|
3
3
|
const DEFAULT_REFRESH_MEMORY_MS = 60_000;
|
|
4
4
|
const AUTH_METHODS_CACHE_MS = 5 * 60_000;
|
|
@@ -59,13 +59,31 @@ export class BastionApiClient {
|
|
|
59
59
|
return this.call('POST', '/auth/register', { ...input, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
63
|
-
* on
|
|
64
|
-
*
|
|
65
|
-
*
|
|
62
|
+
* Starts an OAuth login: the provider's Bastion initiate URL with a fresh
|
|
63
|
+
* `state` on it, or `null` for a provider the app doesn't offer. Keep `state`
|
|
64
|
+
* in an httpOnly `SameSite=Lax` cookie scoped to the callback path, redirect
|
|
65
|
+
* the browser to `url`, then pass the cookie's value to `exchange`.
|
|
66
|
+
*/
|
|
67
|
+
async oauthStart(provider) {
|
|
68
|
+
const method = (await this.getAuthMethods()).oauth.find((m) => m.provider === provider);
|
|
69
|
+
if (!method)
|
|
70
|
+
return null;
|
|
71
|
+
const state = randomBytes(32).toString('base64url');
|
|
72
|
+
const url = new URL(method.authorizeUrl);
|
|
73
|
+
url.searchParams.set('state', state);
|
|
74
|
+
return { url: url.toString(), state };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Trades the one-time OAuth code for tokens. `state` is the value from
|
|
78
|
+
* `oauthStart`, read back from the app's own cookie (never from the callback
|
|
79
|
+
* query): Bastion refuses a code minted under a different one, which is what
|
|
80
|
+
* stops an attacker's code from being redeemed in a victim's browser (login CSRF).
|
|
81
|
+
* Required: an empty `state` throws before calling Bastion.
|
|
66
82
|
*/
|
|
67
83
|
exchange(code, ctx, state) {
|
|
68
|
-
|
|
84
|
+
if (!state)
|
|
85
|
+
return Promise.reject(new Error('BastionApiClient.exchange: state is required (see oauthStart)'));
|
|
86
|
+
return this.call('POST', '/auth/exchange', { code, appSlug: this.appSlug, state }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
69
87
|
}
|
|
70
88
|
completeTwoFactor(twoFactorToken, code, ctx) {
|
|
71
89
|
return this.call('POST', '/auth/2fa/authenticate', { twoFactorToken, code }, { headers: BastionApiClient.clientHeaders(ctx) });
|
|
@@ -4,6 +4,12 @@ export interface BastionHttpOptions {
|
|
|
4
4
|
/** Per-request timeout, default 8 000 ms. */
|
|
5
5
|
timeoutMs?: number;
|
|
6
6
|
fetch?: typeof fetch;
|
|
7
|
+
/**
|
|
8
|
+
* The id of the request being served, sent as `X-Request-Id` on every call
|
|
9
|
+
* (api.md §7) so Bastion logs it as `clientRequestId`. Read per call: back it
|
|
10
|
+
* with the framework's request context. A value outside `[A-Za-z0-9._-]{1,64}` is not sent.
|
|
11
|
+
*/
|
|
12
|
+
requestId?: () => string | undefined;
|
|
7
13
|
}
|
|
8
14
|
export interface BastionRequestInit {
|
|
9
15
|
token?: string;
|
|
@@ -26,6 +32,7 @@ export declare class BastionHttp {
|
|
|
26
32
|
private readonly baseUrl;
|
|
27
33
|
private readonly timeoutMs;
|
|
28
34
|
private readonly fetchImpl;
|
|
35
|
+
private readonly requestId?;
|
|
29
36
|
constructor(options: BastionHttpOptions);
|
|
30
37
|
call<T>(method: string, path: string, body?: unknown, init?: BastionRequestInit): Promise<T>;
|
|
31
38
|
clientAuth(input: ClientAuthInput): Promise<TokenResponse>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { BastionErrorCode, BastionHttpError } from './errors.js';
|
|
1
|
+
import { BastionErrorCode, BastionHttpError, isBastionErrorCode } from './errors.js';
|
|
2
|
+
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
2
3
|
/**
|
|
3
4
|
* Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
|
|
4
5
|
* `JwksVerifier`. `call` is public so a service can reach any route with the
|
|
@@ -10,11 +11,13 @@ export class BastionHttp {
|
|
|
10
11
|
baseUrl;
|
|
11
12
|
timeoutMs;
|
|
12
13
|
fetchImpl;
|
|
14
|
+
requestId;
|
|
13
15
|
constructor(options) {
|
|
14
16
|
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
15
17
|
this.timeoutMs = options.timeoutMs ?? 8_000;
|
|
16
18
|
// Late-bound on purpose: a consumer (or its tests) may replace global fetch after construction.
|
|
17
19
|
this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
|
|
20
|
+
this.requestId = options.requestId;
|
|
18
21
|
}
|
|
19
22
|
async call(method, path, body, init = {}) {
|
|
20
23
|
const headers = { ...init.headers };
|
|
@@ -22,6 +25,10 @@ export class BastionHttp {
|
|
|
22
25
|
headers['Content-Type'] = 'application/json';
|
|
23
26
|
if (init.token)
|
|
24
27
|
headers['Authorization'] = `Bearer ${init.token}`;
|
|
28
|
+
const requestId = this.requestId?.();
|
|
29
|
+
const callerSetId = Object.keys(headers).some((k) => k.toLowerCase() === 'x-request-id');
|
|
30
|
+
if (requestId && REQUEST_ID_PATTERN.test(requestId) && !callerSetId)
|
|
31
|
+
headers['X-Request-Id'] = requestId;
|
|
25
32
|
let res;
|
|
26
33
|
try {
|
|
27
34
|
res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
@@ -35,11 +42,28 @@ export class BastionHttp {
|
|
|
35
42
|
throw new BastionHttpError(503, `Bastion unreachable: ${err.message}`, BastionErrorCode.UNAVAILABLE);
|
|
36
43
|
}
|
|
37
44
|
if (!res.ok) {
|
|
38
|
-
|
|
45
|
+
// Bastion's error body (api.md §3): `{ code, message, errors? }`; older builds put the field errors in `message`.
|
|
46
|
+
const parsed = ((await res.json().catch(() => null)) ?? {});
|
|
47
|
+
const errors = Array.isArray(parsed.errors)
|
|
48
|
+
? parsed.errors.map(String)
|
|
49
|
+
: Array.isArray(parsed.message)
|
|
50
|
+
? parsed.message.map(String)
|
|
51
|
+
: undefined;
|
|
39
52
|
const message = Array.isArray(parsed.message)
|
|
40
53
|
? parsed.message.join(', ')
|
|
41
|
-
:
|
|
42
|
-
|
|
54
|
+
: typeof parsed.message === 'string'
|
|
55
|
+
? parsed.message
|
|
56
|
+
: `Bastion error ${res.status}`;
|
|
57
|
+
// A code Bastion still puts in `message` (UNDER_MINIMUM_AGE under `code: FORBIDDEN`) wins: classify reads it.
|
|
58
|
+
const code = isBastionErrorCode(message)
|
|
59
|
+
? undefined
|
|
60
|
+
: isBastionErrorCode(parsed.code)
|
|
61
|
+
? parsed.code
|
|
62
|
+
: res.status === 400 && errors?.length
|
|
63
|
+
? BastionErrorCode.VALIDATION_FAILED
|
|
64
|
+
: undefined;
|
|
65
|
+
const upstreamCode = typeof parsed.code === 'string' && !isBastionErrorCode(parsed.code) ? parsed.code : undefined;
|
|
66
|
+
throw new BastionHttpError(res.status, message, code, errors, upstreamCode);
|
|
43
67
|
}
|
|
44
68
|
if (res.status === 204)
|
|
45
69
|
return undefined;
|
|
@@ -21,7 +21,12 @@ export declare const BastionErrorCode: {
|
|
|
21
21
|
readonly INVALID_REFRESH_TOKEN: "INVALID_REFRESH_TOKEN";
|
|
22
22
|
/** `POST /auth/exchange` refused the one-time OAuth code: expired, already used, or minted for another app. */
|
|
23
23
|
readonly INVALID_OAUTH_CODE: "INVALID_OAUTH_CODE";
|
|
24
|
+
/** A 400 carrying field errors (`BastionHttpError.errors`): Bastion's `ValidationPipe`. */
|
|
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";
|
|
24
28
|
readonly CONFLICT: "CONFLICT";
|
|
29
|
+
readonly RATE_LIMITED: "RATE_LIMITED";
|
|
25
30
|
readonly BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED";
|
|
26
31
|
readonly BIRTH_DATE_INVALID: "BIRTH_DATE_INVALID";
|
|
27
32
|
readonly UNDER_MINIMUM_AGE: "UNDER_MINIMUM_AGE";
|
|
@@ -36,9 +41,21 @@ export declare const BastionErrorCode: {
|
|
|
36
41
|
};
|
|
37
42
|
export type BastionErrorCode = (typeof BastionErrorCode)[keyof typeof BastionErrorCode];
|
|
38
43
|
export declare function classifyBastionError(status: number, message: string): BastionErrorCode;
|
|
39
|
-
|
|
44
|
+
export declare function isBastionErrorCode(value: unknown): value is BastionErrorCode;
|
|
45
|
+
/**
|
|
46
|
+
* A non-2xx answer from Bastion's HTTP API, or Bastion unreachable (`status` 503, code `UNAVAILABLE`).
|
|
47
|
+
* `message` and `errors` are Bastion's own: fine to show for a 4xx, never for a 5xx (api.md §14).
|
|
48
|
+
*/
|
|
40
49
|
export declare class BastionHttpError extends Error {
|
|
41
50
|
readonly status: number;
|
|
51
|
+
/** Field errors of a 400 (`VALIDATION_FAILED`), one line each. */
|
|
52
|
+
readonly errors?: string[] | undefined;
|
|
53
|
+
/** Bastion's own `code` when it is not a `BastionErrorCode` (`NOT_FOUND`, `PASSWORD_TOO_SHORT`, …). */
|
|
54
|
+
readonly upstreamCode?: string | undefined;
|
|
42
55
|
readonly code: BastionErrorCode;
|
|
43
|
-
constructor(status: number, message: string, code?: BastionErrorCode
|
|
56
|
+
constructor(status: number, message: string, code?: BastionErrorCode,
|
|
57
|
+
/** Field errors of a 400 (`VALIDATION_FAILED`), one line each. */
|
|
58
|
+
errors?: string[] | undefined,
|
|
59
|
+
/** Bastion's own `code` when it is not a `BastionErrorCode` (`NOT_FOUND`, `PASSWORD_TOO_SHORT`, …). */
|
|
60
|
+
upstreamCode?: string | undefined);
|
|
44
61
|
}
|
package/dist/esm/core/errors.js
CHANGED
|
@@ -24,7 +24,12 @@ export const BastionErrorCode = {
|
|
|
24
24
|
INVALID_REFRESH_TOKEN: 'INVALID_REFRESH_TOKEN',
|
|
25
25
|
/** `POST /auth/exchange` refused the one-time OAuth code: expired, already used, or minted for another app. */
|
|
26
26
|
INVALID_OAUTH_CODE: 'INVALID_OAUTH_CODE',
|
|
27
|
+
/** A 400 carrying field errors (`BastionHttpError.errors`): Bastion's `ValidationPipe`. */
|
|
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',
|
|
27
31
|
CONFLICT: 'CONFLICT',
|
|
32
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
28
33
|
BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
|
|
29
34
|
BIRTH_DATE_INVALID: 'BIRTH_DATE_INVALID',
|
|
30
35
|
UNDER_MINIMUM_AGE: 'UNDER_MINIMUM_AGE',
|
|
@@ -72,17 +77,33 @@ export function classifyBastionError(status, message) {
|
|
|
72
77
|
return BastionErrorCode.NO_APP_ACCESS;
|
|
73
78
|
if (status === 409)
|
|
74
79
|
return BastionErrorCode.CONFLICT;
|
|
80
|
+
if (status === 429)
|
|
81
|
+
return BastionErrorCode.RATE_LIMITED;
|
|
75
82
|
if (status === 503)
|
|
76
83
|
return BastionErrorCode.UNAVAILABLE;
|
|
77
84
|
return BastionErrorCode.UPSTREAM;
|
|
78
85
|
}
|
|
79
|
-
|
|
86
|
+
export function isBastionErrorCode(value) {
|
|
87
|
+
return typeof value === 'string' && Object.hasOwn(BastionErrorCode, value);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A non-2xx answer from Bastion's HTTP API, or Bastion unreachable (`status` 503, code `UNAVAILABLE`).
|
|
91
|
+
* `message` and `errors` are Bastion's own: fine to show for a 4xx, never for a 5xx (api.md §14).
|
|
92
|
+
*/
|
|
80
93
|
export class BastionHttpError extends Error {
|
|
81
94
|
status;
|
|
95
|
+
errors;
|
|
96
|
+
upstreamCode;
|
|
82
97
|
code;
|
|
83
|
-
constructor(status, message, code
|
|
98
|
+
constructor(status, message, code,
|
|
99
|
+
/** Field errors of a 400 (`VALIDATION_FAILED`), one line each. */
|
|
100
|
+
errors,
|
|
101
|
+
/** Bastion's own `code` when it is not a `BastionErrorCode` (`NOT_FOUND`, `PASSWORD_TOO_SHORT`, …). */
|
|
102
|
+
upstreamCode) {
|
|
84
103
|
super(message);
|
|
85
104
|
this.status = status;
|
|
105
|
+
this.errors = errors;
|
|
106
|
+
this.upstreamCode = upstreamCode;
|
|
86
107
|
this.name = 'BastionHttpError';
|
|
87
108
|
this.code = code ?? classifyBastionError(status, message);
|
|
88
109
|
}
|
|
@@ -10,7 +10,7 @@ export interface JwksVerifierOptions {
|
|
|
10
10
|
fetch?: typeof fetch;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* The canonical consumer of Bastion's JWKS (docs/
|
|
13
|
+
* The canonical consumer of Bastion's JWKS (docs/integrations/bastion.md in the platform repo):
|
|
14
14
|
* keys cached and indexed by `kid`, refetch on an unknown kid with a
|
|
15
15
|
* cooldown, stale cache served when a refetch fails. Second safety net only:
|
|
16
16
|
* Bastion publishes a key before signing with it (publish-before-use), so a
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { decodeJwt, importRs256Jwk, verifyRs256 } from './jwt.js';
|
|
2
2
|
import { BastionTokenError } from './errors.js';
|
|
3
3
|
import { silentLogger } from './types.js';
|
|
4
4
|
/**
|
|
@@ -9,7 +9,7 @@ import { silentLogger } from './types.js';
|
|
|
9
9
|
*/
|
|
10
10
|
const UNKNOWN_KID_REFETCH_COOLDOWN_MS = 30_000;
|
|
11
11
|
/**
|
|
12
|
-
* The canonical consumer of Bastion's JWKS (docs/
|
|
12
|
+
* The canonical consumer of Bastion's JWKS (docs/integrations/bastion.md in the platform repo):
|
|
13
13
|
* keys cached and indexed by `kid`, refetch on an unknown kid with a
|
|
14
14
|
* cooldown, stale cache served when a refetch fails. Second safety net only:
|
|
15
15
|
* Bastion publishes a key before signing with it (publish-before-use), so a
|
|
@@ -36,12 +36,12 @@ export class JwksVerifier {
|
|
|
36
36
|
async verify(token) {
|
|
37
37
|
let kid;
|
|
38
38
|
try {
|
|
39
|
-
|
|
39
|
+
kid = decodeJwt(token).header.kid;
|
|
40
40
|
}
|
|
41
41
|
catch {
|
|
42
42
|
throw new BastionTokenError('malformed', 'Malformed token');
|
|
43
43
|
}
|
|
44
|
-
if (!kid)
|
|
44
|
+
if (typeof kid !== 'string' || !kid)
|
|
45
45
|
throw new BastionTokenError('missing_kid', 'Missing kid in JWT header');
|
|
46
46
|
await this.ensureFresh();
|
|
47
47
|
let key = this.keys.get(kid);
|
|
@@ -54,11 +54,7 @@ export class JwksVerifier {
|
|
|
54
54
|
throw new BastionTokenError('unknown_kid', 'Unknown or expired kid');
|
|
55
55
|
}
|
|
56
56
|
try {
|
|
57
|
-
|
|
58
|
-
algorithms: ['RS256'],
|
|
59
|
-
...(this.issuer ? { issuer: this.issuer } : {}),
|
|
60
|
-
});
|
|
61
|
-
return payload;
|
|
57
|
+
return verifyRs256(token, key, this.issuer);
|
|
62
58
|
}
|
|
63
59
|
catch {
|
|
64
60
|
throw new BastionTokenError('invalid', 'Invalid token');
|
|
@@ -76,7 +72,12 @@ export class JwksVerifier {
|
|
|
76
72
|
for (const jwk of keys) {
|
|
77
73
|
if (!jwk.kid)
|
|
78
74
|
continue;
|
|
79
|
-
|
|
75
|
+
try {
|
|
76
|
+
next.set(jwk.kid, importRs256Jwk(jwk));
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
this.logger.warn(`JWKS: skipping kid=${jwk.kid}: ${err.message}`);
|
|
80
|
+
}
|
|
80
81
|
}
|
|
81
82
|
this.keys = next;
|
|
82
83
|
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,50 @@
|
|
|
1
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
2
|
+
/** Header and payload of a compact JWS, unverified. Throws on anything that isn't one. */
|
|
3
|
+
export function decodeJwt(token) {
|
|
4
|
+
const parts = token.split('.');
|
|
5
|
+
// Strict alphabet: Buffer's base64url decoder skips padding and junk, which would let one token have many spellings.
|
|
6
|
+
if (parts.length !== 3 || !parts.every((part) => /^[A-Za-z0-9_-]*$/.test(part)))
|
|
7
|
+
throw new Error('not a compact JWS');
|
|
8
|
+
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
|
|
9
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
10
|
+
if (!isObject(header) || !isObject(payload))
|
|
11
|
+
throw new Error('header or payload not an object');
|
|
12
|
+
return { header, payload };
|
|
13
|
+
}
|
|
14
|
+
/** An RS256 verification key from a JWKS entry; RSA ≥ 2048 bits only. */
|
|
15
|
+
export function importRs256Jwk(jwk) {
|
|
16
|
+
if (jwk.kty !== 'RSA' || (jwk.alg !== undefined && jwk.alg !== 'RS256') || (jwk.use !== undefined && jwk.use !== 'sig')) {
|
|
17
|
+
throw new Error('not an RS256 signing key');
|
|
18
|
+
}
|
|
19
|
+
const key = createPublicKey({ key: jwk, format: 'jwk' });
|
|
20
|
+
if ((key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048)
|
|
21
|
+
throw new Error('RSA key shorter than 2048 bits');
|
|
22
|
+
return key;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* RS256 signature, `exp`/`nbf`, optional `iss`. What `jose.jwtVerify` checked for us,
|
|
26
|
+
* in node:crypto so the CJS build has no ESM-only dependency. Throws on any failure.
|
|
27
|
+
*/
|
|
28
|
+
export function verifyRs256(token, key, issuer, now = Date.now()) {
|
|
29
|
+
const { header, payload } = decodeJwt(token);
|
|
30
|
+
if (header.alg !== 'RS256')
|
|
31
|
+
throw new Error('alg not RS256');
|
|
32
|
+
if (header.crit !== undefined)
|
|
33
|
+
throw new Error('unsupported crit header');
|
|
34
|
+
const [h, p, s] = token.split('.');
|
|
35
|
+
if (!verify('RSA-SHA256', Buffer.from(`${h}.${p}`), key, Buffer.from(s, 'base64url')))
|
|
36
|
+
throw new Error('bad signature');
|
|
37
|
+
const seconds = Math.floor(now / 1000);
|
|
38
|
+
if (typeof payload.exp !== 'number' || payload.exp <= seconds)
|
|
39
|
+
throw new Error('expired');
|
|
40
|
+
if (payload.nbf !== undefined && (typeof payload.nbf !== 'number' || payload.nbf > seconds))
|
|
41
|
+
throw new Error('not yet valid');
|
|
42
|
+
if (payload.iat !== undefined && typeof payload.iat !== 'number')
|
|
43
|
+
throw new Error('bad iat');
|
|
44
|
+
if (issuer && payload.iss !== issuer)
|
|
45
|
+
throw new Error('wrong issuer');
|
|
46
|
+
return payload;
|
|
47
|
+
}
|
|
48
|
+
function isObject(value) {
|
|
49
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { decodeJwt } from '
|
|
1
|
+
import { decodeJwt } from './jwt.js';
|
|
2
2
|
import { silentLogger } from './types.js';
|
|
3
3
|
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
4
4
|
const FALLBACK_TTL_MS = 60 * 60 * 1000;
|
|
@@ -59,7 +59,7 @@ export class ServiceTokenProvider {
|
|
|
59
59
|
if (!credential?.apiKey)
|
|
60
60
|
throw new Error(`Bastion service client for "${serviceSlug}" not configured`);
|
|
61
61
|
const { accessToken } = await this.http.clientAuth(credential);
|
|
62
|
-
const { exp, tenantId } = decodeJwt(accessToken);
|
|
62
|
+
const { exp, tenantId } = decodeJwt(accessToken).payload;
|
|
63
63
|
this.cache.set(serviceSlug, {
|
|
64
64
|
token: accessToken,
|
|
65
65
|
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
|
|
8
|
-
*
|
|
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 {
|
|
@@ -18,8 +18,9 @@ import { nestLogger } from './nest-logger.js';
|
|
|
18
18
|
/**
|
|
19
19
|
* `BastionApiClient` bound to this app (`serviceSlug` as `appSlug`,
|
|
20
20
|
* `tenantSlug`), sharing `BastionService.http`. Inject it in an app backend
|
|
21
|
-
* that proxies its users' login/refresh/account flows
|
|
22
|
-
*
|
|
21
|
+
* that proxies its users' login/refresh/account flows, or one that reads
|
|
22
|
+
* its users' Bastion data with their token (Beacon: `listSocialAccounts`);
|
|
23
|
+
* a pure machine service (Herald) never needs it. Failures are `BastionHttpException`s:
|
|
23
24
|
* an `HttpException` with Bastion's status plus `code`/`upstreamMessage`.
|
|
24
25
|
*/
|
|
25
26
|
let BastionApiClientProvider = class BastionApiClientProvider extends 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 {
|
|
3
|
+
import { BastionErrorCode, BastionHttpError } from '../core/errors.js';
|
|
4
4
|
/**
|
|
5
|
-
* The Nest face of a Bastion error
|
|
6
|
-
*
|
|
7
|
-
* `code
|
|
8
|
-
*
|
|
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
|
-
|
|
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. */
|
|
@@ -1,24 +1,55 @@
|
|
|
1
|
-
import { HttpException } from '@nestjs/common';
|
|
1
|
+
import { HttpException, HttpStatus } from '@nestjs/common';
|
|
2
2
|
import { BastionHttp } from '../core/bastion-http.js';
|
|
3
|
-
import { BastionHttpError } from '../core/errors.js';
|
|
3
|
+
import { BastionErrorCode, BastionHttpError } from '../core/errors.js';
|
|
4
4
|
/**
|
|
5
|
-
* The Nest face of a Bastion error
|
|
6
|
-
*
|
|
7
|
-
* `code
|
|
8
|
-
*
|
|
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 class BastionHttpException extends HttpException {
|
|
11
18
|
code;
|
|
12
19
|
upstreamMessage;
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
errors;
|
|
21
|
+
upstreamCode;
|
|
22
|
+
upstreamStatus;
|
|
23
|
+
constructor(code, status, upstreamMessage, errors, upstreamCode) {
|
|
24
|
+
const publicCode = code === BastionErrorCode.UPSTREAM ? (upstreamCode ?? statusCode(status)) : code;
|
|
25
|
+
const unavailable = code === BastionErrorCode.UNAVAILABLE || status === 503;
|
|
26
|
+
const publicStatus = status < 500 ? status : unavailable ? 503 : 502;
|
|
27
|
+
const body = status < 500
|
|
28
|
+
? { code: publicCode, message: upstreamMessage, ...(errors?.length ? { errors } : {}) }
|
|
29
|
+
: unavailable
|
|
30
|
+
? { code: BastionErrorCode.UNAVAILABLE, message: 'Upstream service unavailable' }
|
|
31
|
+
: { code: BastionErrorCode.UPSTREAM, message: 'Upstream service error' };
|
|
32
|
+
super(body, publicStatus);
|
|
15
33
|
this.code = code;
|
|
16
34
|
this.upstreamMessage = upstreamMessage;
|
|
35
|
+
this.errors = errors;
|
|
36
|
+
this.upstreamCode = upstreamCode;
|
|
37
|
+
this.upstreamStatus = status;
|
|
38
|
+
if (status >= 500)
|
|
39
|
+
this.message = `Bastion ${status}: ${upstreamMessage}`;
|
|
17
40
|
}
|
|
18
41
|
static from(err) {
|
|
19
|
-
return new BastionHttpException(err.code, err.status, err.message);
|
|
42
|
+
return new BastionHttpException(err.code, err.status, err.message, err.errors, err.upstreamCode);
|
|
20
43
|
}
|
|
21
44
|
}
|
|
45
|
+
/** api.md §3 default code of a 4xx: `NOT_FOUND`, `UNPROCESSABLE`, `RATE_LIMITED`, … */
|
|
46
|
+
function statusCode(status) {
|
|
47
|
+
if (status === 422)
|
|
48
|
+
return 'UNPROCESSABLE';
|
|
49
|
+
if (status === 429)
|
|
50
|
+
return 'RATE_LIMITED';
|
|
51
|
+
return HttpStatus[status] ?? 'BAD_REQUEST';
|
|
52
|
+
}
|
|
22
53
|
/** `BastionHttp` whose failures are `BastionHttpException`s — what `BastionService.http` is. */
|
|
23
54
|
export class NestBastionHttp extends BastionHttp {
|
|
24
55
|
async call(method, path, body, init) {
|