@heyatom/bastion-client 0.7.2 → 0.8.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 +41 -0
- package/README.md +144 -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 +17 -2
- package/dist/cjs/core/errors.js +22 -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 +2 -1
- 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 +17 -2
- package/dist/esm/core/errors.js +21 -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 +2 -1
- package/package.json +3 -3
|
@@ -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,10 @@ 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";
|
|
24
26
|
readonly CONFLICT: "CONFLICT";
|
|
27
|
+
readonly RATE_LIMITED: "RATE_LIMITED";
|
|
25
28
|
readonly BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED";
|
|
26
29
|
readonly BIRTH_DATE_INVALID: "BIRTH_DATE_INVALID";
|
|
27
30
|
readonly UNDER_MINIMUM_AGE: "UNDER_MINIMUM_AGE";
|
|
@@ -36,9 +39,21 @@ export declare const BastionErrorCode: {
|
|
|
36
39
|
};
|
|
37
40
|
export type BastionErrorCode = (typeof BastionErrorCode)[keyof typeof BastionErrorCode];
|
|
38
41
|
export declare function classifyBastionError(status: number, message: string): BastionErrorCode;
|
|
39
|
-
|
|
42
|
+
export declare function isBastionErrorCode(value: unknown): value is BastionErrorCode;
|
|
43
|
+
/**
|
|
44
|
+
* A non-2xx answer from Bastion's HTTP API, or Bastion unreachable (`status` 503, code `UNAVAILABLE`).
|
|
45
|
+
* `message` and `errors` are Bastion's own: fine to show for a 4xx, never for a 5xx (api.md §14).
|
|
46
|
+
*/
|
|
40
47
|
export declare class BastionHttpError extends Error {
|
|
41
48
|
readonly status: number;
|
|
49
|
+
/** Field errors of a 400 (`VALIDATION_FAILED`), one line each. */
|
|
50
|
+
readonly errors?: string[] | undefined;
|
|
51
|
+
/** Bastion's own `code` when it is not a `BastionErrorCode` (`NOT_FOUND`, `PASSWORD_TOO_SHORT`, …). */
|
|
52
|
+
readonly upstreamCode?: string | undefined;
|
|
42
53
|
readonly code: BastionErrorCode;
|
|
43
|
-
constructor(status: number, message: string, code?: BastionErrorCode
|
|
54
|
+
constructor(status: number, message: string, code?: BastionErrorCode,
|
|
55
|
+
/** Field errors of a 400 (`VALIDATION_FAILED`), one line each. */
|
|
56
|
+
errors?: string[] | undefined,
|
|
57
|
+
/** Bastion's own `code` when it is not a `BastionErrorCode` (`NOT_FOUND`, `PASSWORD_TOO_SHORT`, …). */
|
|
58
|
+
upstreamCode?: string | undefined);
|
|
44
59
|
}
|
package/dist/esm/core/errors.js
CHANGED
|
@@ -24,7 +24,10 @@ 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',
|
|
27
29
|
CONFLICT: 'CONFLICT',
|
|
30
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
28
31
|
BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
|
|
29
32
|
BIRTH_DATE_INVALID: 'BIRTH_DATE_INVALID',
|
|
30
33
|
UNDER_MINIMUM_AGE: 'UNDER_MINIMUM_AGE',
|
|
@@ -72,17 +75,33 @@ export function classifyBastionError(status, message) {
|
|
|
72
75
|
return BastionErrorCode.NO_APP_ACCESS;
|
|
73
76
|
if (status === 409)
|
|
74
77
|
return BastionErrorCode.CONFLICT;
|
|
78
|
+
if (status === 429)
|
|
79
|
+
return BastionErrorCode.RATE_LIMITED;
|
|
75
80
|
if (status === 503)
|
|
76
81
|
return BastionErrorCode.UNAVAILABLE;
|
|
77
82
|
return BastionErrorCode.UPSTREAM;
|
|
78
83
|
}
|
|
79
|
-
|
|
84
|
+
export function isBastionErrorCode(value) {
|
|
85
|
+
return typeof value === 'string' && Object.hasOwn(BastionErrorCode, value);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A non-2xx answer from Bastion's HTTP API, or Bastion unreachable (`status` 503, code `UNAVAILABLE`).
|
|
89
|
+
* `message` and `errors` are Bastion's own: fine to show for a 4xx, never for a 5xx (api.md §14).
|
|
90
|
+
*/
|
|
80
91
|
export class BastionHttpError extends Error {
|
|
81
92
|
status;
|
|
93
|
+
errors;
|
|
94
|
+
upstreamCode;
|
|
82
95
|
code;
|
|
83
|
-
constructor(status, message, code
|
|
96
|
+
constructor(status, message, code,
|
|
97
|
+
/** Field errors of a 400 (`VALIDATION_FAILED`), one line each. */
|
|
98
|
+
errors,
|
|
99
|
+
/** Bastion's own `code` when it is not a `BastionErrorCode` (`NOT_FOUND`, `PASSWORD_TOO_SHORT`, …). */
|
|
100
|
+
upstreamCode) {
|
|
84
101
|
super(message);
|
|
85
102
|
this.status = status;
|
|
103
|
+
this.errors = errors;
|
|
104
|
+
this.upstreamCode = upstreamCode;
|
|
86
105
|
this.name = 'BastionHttpError';
|
|
87
106
|
this.code = code ?? classifyBastionError(status, message);
|
|
88
107
|
}
|
|
@@ -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) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DynamicModule } from '@nestjs/common';
|
|
1
|
+
import { DynamicModule, type MiddlewareConsumer, type NestModule } from '@nestjs/common';
|
|
2
2
|
import { type BastionModuleAsyncOptions, type BastionModuleOptions } from './options.js';
|
|
3
3
|
/**
|
|
4
4
|
* ```ts
|
|
@@ -11,9 +11,11 @@ import { type BastionModuleAsyncOptions, type BastionModuleOptions } from './opt
|
|
|
11
11
|
* }),
|
|
12
12
|
* })
|
|
13
13
|
* ```
|
|
14
|
-
* Global: import once in `AppModule`, inject anywhere.
|
|
14
|
+
* Global: import once in `AppModule`, inject anywhere. Every route gets a
|
|
15
|
+
* middleware that tracks the request, so outbound calls carry its `X-Request-Id`.
|
|
15
16
|
*/
|
|
16
|
-
export declare class BastionModule {
|
|
17
|
+
export declare class BastionModule implements NestModule {
|
|
18
|
+
configure(consumer: MiddlewareConsumer): void;
|
|
17
19
|
static forRoot(options: BastionModuleOptions): DynamicModule;
|
|
18
20
|
static forRootAsync(options: BastionModuleAsyncOptions): DynamicModule;
|
|
19
21
|
private static build;
|
|
@@ -5,7 +5,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
7
|
var BastionModule_1;
|
|
8
|
-
import { Global, Module } from '@nestjs/common';
|
|
8
|
+
import { Global, Module, RequestMethod, } from '@nestjs/common';
|
|
9
9
|
import { BASTION_OPTIONS } from './options.js';
|
|
10
10
|
import { BastionJwksService } from './bastion-jwks.service.js';
|
|
11
11
|
import { BastionService } from './bastion.service.js';
|
|
@@ -14,12 +14,15 @@ import { ServiceClientJwtGuard } from './guards/service-client-jwt.guard.js';
|
|
|
14
14
|
import { BastionUserGuard } from './guards/bastion-user.guard.js';
|
|
15
15
|
import { AuditInterceptor } from './interceptors/audit.interceptor.js';
|
|
16
16
|
import { BastionApiClientProvider } from './bastion-api-client.provider.js';
|
|
17
|
+
import { AppUserGuard } from './guards/app-user.guard.js';
|
|
18
|
+
import { requestContextMiddleware } from './request-context.js';
|
|
17
19
|
const PROVIDERS = [
|
|
18
20
|
BastionJwksService,
|
|
19
21
|
BastionService,
|
|
20
22
|
BastionAuditService,
|
|
21
23
|
ServiceClientJwtGuard,
|
|
22
24
|
BastionUserGuard,
|
|
25
|
+
AppUserGuard,
|
|
23
26
|
AuditInterceptor,
|
|
24
27
|
BastionApiClientProvider,
|
|
25
28
|
];
|
|
@@ -34,9 +37,13 @@ const PROVIDERS = [
|
|
|
34
37
|
* }),
|
|
35
38
|
* })
|
|
36
39
|
* ```
|
|
37
|
-
* Global: import once in `AppModule`, inject anywhere.
|
|
40
|
+
* Global: import once in `AppModule`, inject anywhere. Every route gets a
|
|
41
|
+
* middleware that tracks the request, so outbound calls carry its `X-Request-Id`.
|
|
38
42
|
*/
|
|
39
43
|
let BastionModule = BastionModule_1 = class BastionModule {
|
|
44
|
+
configure(consumer) {
|
|
45
|
+
consumer.apply(requestContextMiddleware).forRoutes({ path: '{*splat}', method: RequestMethod.ALL });
|
|
46
|
+
}
|
|
40
47
|
static forRoot(options) {
|
|
41
48
|
return this.build({ provide: BASTION_OPTIONS, useValue: options });
|
|
42
49
|
}
|
|
@@ -2,7 +2,7 @@ import { type BastionRequestInit } from '../core/bastion-http.js';
|
|
|
2
2
|
import { NestBastionHttp } from './bastion-http-exception.js';
|
|
3
3
|
import type { AuditEventInput, TokenResponse } from '../core/types.js';
|
|
4
4
|
import { type BastionModuleOptions } from './options.js';
|
|
5
|
-
/** Outbound HTTP to Bastion. A failure surfaces as `BastionHttpException` (
|
|
5
|
+
/** Outbound HTTP to Bastion, `X-Request-Id` included. A failure surfaces as `BastionHttpException` (4xx as Bastion's, 5xx as 502/503 with a generic message). */
|
|
6
6
|
export declare class BastionService {
|
|
7
7
|
private readonly options;
|
|
8
8
|
readonly http: NestBastionHttp;
|
|
@@ -13,13 +13,18 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
13
13
|
import { Inject, Injectable } from '@nestjs/common';
|
|
14
14
|
import { NestBastionHttp } from './bastion-http-exception.js';
|
|
15
15
|
import { BASTION_OPTIONS } from './options.js';
|
|
16
|
-
|
|
16
|
+
import { currentRequestId } from './request-context.js';
|
|
17
|
+
/** Outbound HTTP to Bastion, `X-Request-Id` included. A failure surfaces as `BastionHttpException` (4xx as Bastion's, 5xx as 502/503 with a generic message). */
|
|
17
18
|
let BastionService = class BastionService {
|
|
18
19
|
options;
|
|
19
20
|
http;
|
|
20
21
|
constructor(options) {
|
|
21
22
|
this.options = options;
|
|
22
|
-
this.http = new NestBastionHttp({
|
|
23
|
+
this.http = new NestBastionHttp({
|
|
24
|
+
baseUrl: options.baseUrl,
|
|
25
|
+
timeoutMs: options.timeoutMs,
|
|
26
|
+
requestId: options.requestId ?? currentRequestId,
|
|
27
|
+
});
|
|
23
28
|
}
|
|
24
29
|
call(method, path, body, init) {
|
|
25
30
|
return this.http.call(method, path, body, init);
|
|
@@ -2,3 +2,5 @@
|
|
|
2
2
|
export declare const CurrentClient: (...dataOrPipes: unknown[]) => ParameterDecorator;
|
|
3
3
|
/** The user payload set by `BastionUserGuard`. */
|
|
4
4
|
export declare const CurrentAdminUser: (...dataOrPipes: unknown[]) => ParameterDecorator;
|
|
5
|
+
/** The app user's payload set by `AppUserGuard`. */
|
|
6
|
+
export declare const CurrentUser: (...dataOrPipes: unknown[]) => ParameterDecorator;
|
|
@@ -3,3 +3,5 @@ import { createParamDecorator } from '@nestjs/common';
|
|
|
3
3
|
export const CurrentClient = createParamDecorator((_, ctx) => ctx.switchToHttp().getRequest().user);
|
|
4
4
|
/** The user payload set by `BastionUserGuard`. */
|
|
5
5
|
export const CurrentAdminUser = createParamDecorator((_, ctx) => ctx.switchToHttp().getRequest().adminUser);
|
|
6
|
+
/** The app user's payload set by `AppUserGuard`. */
|
|
7
|
+
export const CurrentUser = createParamDecorator((_, ctx) => ctx.switchToHttp().getRequest().user);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
|
2
|
+
import { Reflector } from '@nestjs/core';
|
|
3
|
+
import { BastionJwksService } from '../bastion-jwks.service.js';
|
|
4
|
+
/**
|
|
5
|
+
* Guard for an app backend's own users, meant as the global `APP_GUARD`: every
|
|
6
|
+
* route needs a Bastion *user* token minted for this app (`serviceSlug` is the
|
|
7
|
+
* app's slug) unless it carries `@Public()`. Machine tokens and other apps'
|
|
8
|
+
* tokens are refused. The payload lands on `req.user`, read with `@CurrentUser()`.
|
|
9
|
+
* Suspension, age gate and roles stay the app's business: stack its own guard after this one.
|
|
10
|
+
*/
|
|
11
|
+
export declare class AppUserGuard implements CanActivate {
|
|
12
|
+
private readonly jwks;
|
|
13
|
+
private readonly reflector;
|
|
14
|
+
constructor(jwks: BastionJwksService, reflector: Reflector);
|
|
15
|
+
canActivate(ctx: ExecutionContext): Promise<boolean>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
11
|
+
import { Reflector } from '@nestjs/core';
|
|
12
|
+
import { BastionJwksService } from '../bastion-jwks.service.js';
|
|
13
|
+
import { IS_PUBLIC_KEY } from '../decorators/public.decorator.js';
|
|
14
|
+
/**
|
|
15
|
+
* Guard for an app backend's own users, meant as the global `APP_GUARD`: every
|
|
16
|
+
* route needs a Bastion *user* token minted for this app (`serviceSlug` is the
|
|
17
|
+
* app's slug) unless it carries `@Public()`. Machine tokens and other apps'
|
|
18
|
+
* tokens are refused. The payload lands on `req.user`, read with `@CurrentUser()`.
|
|
19
|
+
* Suspension, age gate and roles stay the app's business: stack its own guard after this one.
|
|
20
|
+
*/
|
|
21
|
+
let AppUserGuard = class AppUserGuard {
|
|
22
|
+
jwks;
|
|
23
|
+
reflector;
|
|
24
|
+
constructor(jwks, reflector) {
|
|
25
|
+
this.jwks = jwks;
|
|
26
|
+
this.reflector = reflector;
|
|
27
|
+
}
|
|
28
|
+
async canActivate(ctx) {
|
|
29
|
+
if (this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]))
|
|
30
|
+
return true;
|
|
31
|
+
const req = ctx.switchToHttp().getRequest();
|
|
32
|
+
const auth = req.headers['authorization'];
|
|
33
|
+
if (!auth?.startsWith('Bearer '))
|
|
34
|
+
throw new UnauthorizedException('Missing token');
|
|
35
|
+
req.user = await this.jwks.verifyUserToken(auth.slice(7));
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
AppUserGuard = __decorate([
|
|
40
|
+
Injectable(),
|
|
41
|
+
__metadata("design:paramtypes", [BastionJwksService,
|
|
42
|
+
Reflector])
|
|
43
|
+
], AppUserGuard);
|
|
44
|
+
export { AppUserGuard };
|
package/dist/esm/nest/index.d.ts
CHANGED
|
@@ -8,9 +8,11 @@ export { BastionHttpException, NestBastionHttp } from './bastion-http-exception.
|
|
|
8
8
|
export { BastionAuditService, type AuditActor } from './bastion-audit.service.js';
|
|
9
9
|
export { ServiceClientJwtGuard } from './guards/service-client-jwt.guard.js';
|
|
10
10
|
export { BastionUserGuard } from './guards/bastion-user.guard.js';
|
|
11
|
+
export { AppUserGuard } from './guards/app-user.guard.js';
|
|
11
12
|
export { AuditInterceptor } from './interceptors/audit.interceptor.js';
|
|
12
13
|
export { Public, IS_PUBLIC_KEY } from './decorators/public.decorator.js';
|
|
13
14
|
export { RequireScope, REQUIRED_SCOPES_KEY } from './decorators/require-scope.decorator.js';
|
|
14
15
|
export { Audit, AUDIT_EVENT_KEY, type AuditOptions, type AuditRequest, type AuditDescriptor } from './decorators/audit.decorator.js';
|
|
15
|
-
export { CurrentClient, CurrentAdminUser } from './decorators/current-user.decorator.js';
|
|
16
|
+
export { CurrentClient, CurrentAdminUser, CurrentUser } from './decorators/current-user.decorator.js';
|
|
17
|
+
export { currentRequestId } from './request-context.js';
|
|
16
18
|
export { resolveTenantScope, CROSS_TENANT_ROLE } from './tenant-scope.js';
|
package/dist/esm/nest/index.js
CHANGED
|
@@ -8,9 +8,11 @@ export { BastionHttpException, NestBastionHttp } from './bastion-http-exception.
|
|
|
8
8
|
export { BastionAuditService } from './bastion-audit.service.js';
|
|
9
9
|
export { ServiceClientJwtGuard } from './guards/service-client-jwt.guard.js';
|
|
10
10
|
export { BastionUserGuard } from './guards/bastion-user.guard.js';
|
|
11
|
+
export { AppUserGuard } from './guards/app-user.guard.js';
|
|
11
12
|
export { AuditInterceptor } from './interceptors/audit.interceptor.js';
|
|
12
13
|
export { Public, IS_PUBLIC_KEY } from './decorators/public.decorator.js';
|
|
13
14
|
export { RequireScope, REQUIRED_SCOPES_KEY } from './decorators/require-scope.decorator.js';
|
|
14
15
|
export { Audit, AUDIT_EVENT_KEY } from './decorators/audit.decorator.js';
|
|
15
|
-
export { CurrentClient, CurrentAdminUser } from './decorators/current-user.decorator.js';
|
|
16
|
+
export { CurrentClient, CurrentAdminUser, CurrentUser } from './decorators/current-user.decorator.js';
|
|
17
|
+
export { currentRequestId } from './request-context.js';
|
|
16
18
|
export { resolveTenantScope, CROSS_TENANT_ROLE } from './tenant-scope.js';
|
|
@@ -15,6 +15,13 @@ export interface BastionModuleOptions {
|
|
|
15
15
|
jwksTtlMs?: number;
|
|
16
16
|
/** Expected `iss`. Default `'bastion'`; `null` disables the check. */
|
|
17
17
|
issuer?: string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Id sent as `X-Request-Id` on calls to Bastion. Default: `req.id` of the request
|
|
20
|
+
* being served (pino-http / nest-common `genReqId`), tracked by the module's middleware.
|
|
21
|
+
* Express only: under Fastify the middleware sees the raw request, which has no `id`,
|
|
22
|
+
* so pass a getter here (e.g. from nestjs-cls).
|
|
23
|
+
*/
|
|
24
|
+
requestId?: () => string | undefined;
|
|
18
25
|
/** `BastionUserGuard`: which apps' user tokens are accepted. Default `['meridian']`. */
|
|
19
26
|
acceptedAppSlugs?: string[];
|
|
20
27
|
/** `BastionUserGuard`: which roles are accepted. Default `['ADMIN', 'OWNER', 'SUPER_ADMIN']`. */
|