@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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.decodeJwt = decodeJwt;
|
|
4
|
+
exports.importRs256Jwk = importRs256Jwk;
|
|
5
|
+
exports.verifyRs256 = verifyRs256;
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
/** Header and payload of a compact JWS, unverified. Throws on anything that isn't one. */
|
|
8
|
+
function decodeJwt(token) {
|
|
9
|
+
const parts = token.split('.');
|
|
10
|
+
// Strict alphabet: Buffer's base64url decoder skips padding and junk, which would let one token have many spellings.
|
|
11
|
+
if (parts.length !== 3 || !parts.every((part) => /^[A-Za-z0-9_-]*$/.test(part)))
|
|
12
|
+
throw new Error('not a compact JWS');
|
|
13
|
+
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
|
|
14
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
15
|
+
if (!isObject(header) || !isObject(payload))
|
|
16
|
+
throw new Error('header or payload not an object');
|
|
17
|
+
return { header, payload };
|
|
18
|
+
}
|
|
19
|
+
/** An RS256 verification key from a JWKS entry; RSA ≥ 2048 bits only. */
|
|
20
|
+
function importRs256Jwk(jwk) {
|
|
21
|
+
if (jwk.kty !== 'RSA' || (jwk.alg !== undefined && jwk.alg !== 'RS256') || (jwk.use !== undefined && jwk.use !== 'sig')) {
|
|
22
|
+
throw new Error('not an RS256 signing key');
|
|
23
|
+
}
|
|
24
|
+
const key = (0, node_crypto_1.createPublicKey)({ key: jwk, format: 'jwk' });
|
|
25
|
+
if ((key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048)
|
|
26
|
+
throw new Error('RSA key shorter than 2048 bits');
|
|
27
|
+
return key;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* RS256 signature, `exp`/`nbf`, optional `iss`. What `jose.jwtVerify` checked for us,
|
|
31
|
+
* in node:crypto so the CJS build has no ESM-only dependency. Throws on any failure.
|
|
32
|
+
*/
|
|
33
|
+
function verifyRs256(token, key, issuer, now = Date.now()) {
|
|
34
|
+
const { header, payload } = decodeJwt(token);
|
|
35
|
+
if (header.alg !== 'RS256')
|
|
36
|
+
throw new Error('alg not RS256');
|
|
37
|
+
if (header.crit !== undefined)
|
|
38
|
+
throw new Error('unsupported crit header');
|
|
39
|
+
const [h, p, s] = token.split('.');
|
|
40
|
+
if (!(0, node_crypto_1.verify)('RSA-SHA256', Buffer.from(`${h}.${p}`), key, Buffer.from(s, 'base64url')))
|
|
41
|
+
throw new Error('bad signature');
|
|
42
|
+
const seconds = Math.floor(now / 1000);
|
|
43
|
+
if (typeof payload.exp !== 'number' || payload.exp <= seconds)
|
|
44
|
+
throw new Error('expired');
|
|
45
|
+
if (payload.nbf !== undefined && (typeof payload.nbf !== 'number' || payload.nbf > seconds))
|
|
46
|
+
throw new Error('not yet valid');
|
|
47
|
+
if (payload.iat !== undefined && typeof payload.iat !== 'number')
|
|
48
|
+
throw new Error('bad iat');
|
|
49
|
+
if (issuer && payload.iss !== issuer)
|
|
50
|
+
throw new Error('wrong issuer');
|
|
51
|
+
return payload;
|
|
52
|
+
}
|
|
53
|
+
function isObject(value) {
|
|
54
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
55
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ServiceTokenProvider = void 0;
|
|
4
|
-
const
|
|
4
|
+
const jwt_js_1 = require("./jwt.js");
|
|
5
5
|
const types_js_1 = require("./types.js");
|
|
6
6
|
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
7
7
|
const FALLBACK_TTL_MS = 60 * 60 * 1000;
|
|
@@ -62,7 +62,7 @@ class ServiceTokenProvider {
|
|
|
62
62
|
if (!credential?.apiKey)
|
|
63
63
|
throw new Error(`Bastion service client for "${serviceSlug}" not configured`);
|
|
64
64
|
const { accessToken } = await this.http.clientAuth(credential);
|
|
65
|
-
const { exp, tenantId } = (0,
|
|
65
|
+
const { exp, tenantId } = (0, jwt_js_1.decodeJwt)(accessToken).payload;
|
|
66
66
|
this.cache.set(serviceSlug, {
|
|
67
67
|
token: accessToken,
|
|
68
68
|
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 {
|
|
@@ -21,8 +21,9 @@ const nest_logger_js_1 = require("./nest-logger.js");
|
|
|
21
21
|
/**
|
|
22
22
|
* `BastionApiClient` bound to this app (`serviceSlug` as `appSlug`,
|
|
23
23
|
* `tenantSlug`), sharing `BastionService.http`. Inject it in an app backend
|
|
24
|
-
* that proxies its users' login/refresh/account flows
|
|
25
|
-
*
|
|
24
|
+
* that proxies its users' login/refresh/account flows, or one that reads
|
|
25
|
+
* its users' Bastion data with their token (Beacon: `listSocialAccounts`);
|
|
26
|
+
* a pure machine service (Herald) never needs it. Failures are `BastionHttpException`s:
|
|
26
27
|
* an `HttpException` with Bastion's status plus `code`/`upstreamMessage`.
|
|
27
28
|
*/
|
|
28
29
|
let BastionApiClientProvider = class BastionApiClientProvider extends api_client_js_1.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. */
|
|
@@ -5,24 +5,55 @@ const common_1 = require("@nestjs/common");
|
|
|
5
5
|
const bastion_http_js_1 = require("../core/bastion-http.js");
|
|
6
6
|
const errors_js_1 = require("../core/errors.js");
|
|
7
7
|
/**
|
|
8
|
-
* The Nest face of a Bastion error
|
|
9
|
-
*
|
|
10
|
-
* `code
|
|
11
|
-
*
|
|
8
|
+
* The Nest face of a Bastion error, so the global filter answers the browser
|
|
9
|
+
* per api.md §3/§14:
|
|
10
|
+
* - 4xx: Bastion's status, `code`, message and `errors` (field errors of a 400). A 4xx the
|
|
11
|
+
* package can't classify keeps Bastion's own code (`PASSWORD_TOO_SHORT`) or the status
|
|
12
|
+
* default (`NOT_FOUND`), never `UPSTREAM`;
|
|
13
|
+
* - Bastion down, timed out or answering 503: 503 `UNAVAILABLE`, generic message;
|
|
14
|
+
* - any other 5xx: 502 `UPSTREAM`, generic message.
|
|
15
|
+
*
|
|
16
|
+
* `getResponse()` is `{ code, message, errors? }`. `upstreamStatus` and
|
|
17
|
+
* `upstreamMessage` keep Bastion's own for logs and branching; `message`
|
|
18
|
+
* carries them too, so the filter's 5xx log line says what Bastion answered.
|
|
12
19
|
*/
|
|
13
20
|
class BastionHttpException extends common_1.HttpException {
|
|
14
21
|
code;
|
|
15
22
|
upstreamMessage;
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
errors;
|
|
24
|
+
upstreamCode;
|
|
25
|
+
upstreamStatus;
|
|
26
|
+
constructor(code, status, upstreamMessage, errors, upstreamCode) {
|
|
27
|
+
const publicCode = code === errors_js_1.BastionErrorCode.UPSTREAM ? (upstreamCode ?? statusCode(status)) : code;
|
|
28
|
+
const unavailable = code === errors_js_1.BastionErrorCode.UNAVAILABLE || status === 503;
|
|
29
|
+
const publicStatus = status < 500 ? status : unavailable ? 503 : 502;
|
|
30
|
+
const body = status < 500
|
|
31
|
+
? { code: publicCode, message: upstreamMessage, ...(errors?.length ? { errors } : {}) }
|
|
32
|
+
: unavailable
|
|
33
|
+
? { code: errors_js_1.BastionErrorCode.UNAVAILABLE, message: 'Upstream service unavailable' }
|
|
34
|
+
: { code: errors_js_1.BastionErrorCode.UPSTREAM, message: 'Upstream service error' };
|
|
35
|
+
super(body, publicStatus);
|
|
18
36
|
this.code = code;
|
|
19
37
|
this.upstreamMessage = upstreamMessage;
|
|
38
|
+
this.errors = errors;
|
|
39
|
+
this.upstreamCode = upstreamCode;
|
|
40
|
+
this.upstreamStatus = status;
|
|
41
|
+
if (status >= 500)
|
|
42
|
+
this.message = `Bastion ${status}: ${upstreamMessage}`;
|
|
20
43
|
}
|
|
21
44
|
static from(err) {
|
|
22
|
-
return new BastionHttpException(err.code, err.status, err.message);
|
|
45
|
+
return new BastionHttpException(err.code, err.status, err.message, err.errors, err.upstreamCode);
|
|
23
46
|
}
|
|
24
47
|
}
|
|
25
48
|
exports.BastionHttpException = BastionHttpException;
|
|
49
|
+
/** api.md §3 default code of a 4xx: `NOT_FOUND`, `UNPROCESSABLE`, `RATE_LIMITED`, … */
|
|
50
|
+
function statusCode(status) {
|
|
51
|
+
if (status === 422)
|
|
52
|
+
return 'UNPROCESSABLE';
|
|
53
|
+
if (status === 429)
|
|
54
|
+
return 'RATE_LIMITED';
|
|
55
|
+
return common_1.HttpStatus[status] ?? 'BAD_REQUEST';
|
|
56
|
+
}
|
|
26
57
|
/** `BastionHttp` whose failures are `BastionHttpException`s — what `BastionService.http` is. */
|
|
27
58
|
class NestBastionHttp extends bastion_http_js_1.BastionHttp {
|
|
28
59
|
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;
|
|
@@ -17,12 +17,15 @@ const service_client_jwt_guard_js_1 = require("./guards/service-client-jwt.guard
|
|
|
17
17
|
const bastion_user_guard_js_1 = require("./guards/bastion-user.guard.js");
|
|
18
18
|
const audit_interceptor_js_1 = require("./interceptors/audit.interceptor.js");
|
|
19
19
|
const bastion_api_client_provider_js_1 = require("./bastion-api-client.provider.js");
|
|
20
|
+
const app_user_guard_js_1 = require("./guards/app-user.guard.js");
|
|
21
|
+
const request_context_js_1 = require("./request-context.js");
|
|
20
22
|
const PROVIDERS = [
|
|
21
23
|
bastion_jwks_service_js_1.BastionJwksService,
|
|
22
24
|
bastion_service_js_1.BastionService,
|
|
23
25
|
bastion_audit_service_js_1.BastionAuditService,
|
|
24
26
|
service_client_jwt_guard_js_1.ServiceClientJwtGuard,
|
|
25
27
|
bastion_user_guard_js_1.BastionUserGuard,
|
|
28
|
+
app_user_guard_js_1.AppUserGuard,
|
|
26
29
|
audit_interceptor_js_1.AuditInterceptor,
|
|
27
30
|
bastion_api_client_provider_js_1.BastionApiClientProvider,
|
|
28
31
|
];
|
|
@@ -37,9 +40,13 @@ const PROVIDERS = [
|
|
|
37
40
|
* }),
|
|
38
41
|
* })
|
|
39
42
|
* ```
|
|
40
|
-
* Global: import once in `AppModule`, inject anywhere.
|
|
43
|
+
* Global: import once in `AppModule`, inject anywhere. Every route gets a
|
|
44
|
+
* middleware that tracks the request, so outbound calls carry its `X-Request-Id`.
|
|
41
45
|
*/
|
|
42
46
|
let BastionModule = BastionModule_1 = class BastionModule {
|
|
47
|
+
configure(consumer) {
|
|
48
|
+
consumer.apply(request_context_js_1.requestContextMiddleware).forRoutes({ path: '{*splat}', method: common_1.RequestMethod.ALL });
|
|
49
|
+
}
|
|
43
50
|
static forRoot(options) {
|
|
44
51
|
return this.build({ provide: options_js_1.BASTION_OPTIONS, useValue: options });
|
|
45
52
|
}
|
|
@@ -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;
|
|
@@ -16,13 +16,18 @@ exports.BastionService = void 0;
|
|
|
16
16
|
const common_1 = require("@nestjs/common");
|
|
17
17
|
const bastion_http_exception_js_1 = require("./bastion-http-exception.js");
|
|
18
18
|
const options_js_1 = require("./options.js");
|
|
19
|
-
|
|
19
|
+
const request_context_js_1 = require("./request-context.js");
|
|
20
|
+
/** 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). */
|
|
20
21
|
let BastionService = class BastionService {
|
|
21
22
|
options;
|
|
22
23
|
http;
|
|
23
24
|
constructor(options) {
|
|
24
25
|
this.options = options;
|
|
25
|
-
this.http = new bastion_http_exception_js_1.NestBastionHttp({
|
|
26
|
+
this.http = new bastion_http_exception_js_1.NestBastionHttp({
|
|
27
|
+
baseUrl: options.baseUrl,
|
|
28
|
+
timeoutMs: options.timeoutMs,
|
|
29
|
+
requestId: options.requestId ?? request_context_js_1.currentRequestId,
|
|
30
|
+
});
|
|
26
31
|
}
|
|
27
32
|
call(method, path, body, init) {
|
|
28
33
|
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;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.CurrentAdminUser = exports.CurrentClient = void 0;
|
|
3
|
+
exports.CurrentUser = exports.CurrentAdminUser = exports.CurrentClient = void 0;
|
|
4
4
|
const common_1 = require("@nestjs/common");
|
|
5
5
|
/** The machine token payload set by `ServiceClientJwtGuard`. */
|
|
6
6
|
exports.CurrentClient = (0, common_1.createParamDecorator)((_, ctx) => ctx.switchToHttp().getRequest().user);
|
|
7
7
|
/** The user payload set by `BastionUserGuard`. */
|
|
8
8
|
exports.CurrentAdminUser = (0, common_1.createParamDecorator)((_, ctx) => ctx.switchToHttp().getRequest().adminUser);
|
|
9
|
+
/** The app user's payload set by `AppUserGuard`. */
|
|
10
|
+
exports.CurrentUser = (0, common_1.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,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
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;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.AppUserGuard = void 0;
|
|
13
|
+
const common_1 = require("@nestjs/common");
|
|
14
|
+
const core_1 = require("@nestjs/core");
|
|
15
|
+
const bastion_jwks_service_js_1 = require("../bastion-jwks.service.js");
|
|
16
|
+
const public_decorator_js_1 = require("../decorators/public.decorator.js");
|
|
17
|
+
/**
|
|
18
|
+
* Guard for an app backend's own users, meant as the global `APP_GUARD`: every
|
|
19
|
+
* route needs a Bastion *user* token minted for this app (`serviceSlug` is the
|
|
20
|
+
* app's slug) unless it carries `@Public()`. Machine tokens and other apps'
|
|
21
|
+
* tokens are refused. The payload lands on `req.user`, read with `@CurrentUser()`.
|
|
22
|
+
* Suspension, age gate and roles stay the app's business: stack its own guard after this one.
|
|
23
|
+
*/
|
|
24
|
+
let AppUserGuard = class AppUserGuard {
|
|
25
|
+
jwks;
|
|
26
|
+
reflector;
|
|
27
|
+
constructor(jwks, reflector) {
|
|
28
|
+
this.jwks = jwks;
|
|
29
|
+
this.reflector = reflector;
|
|
30
|
+
}
|
|
31
|
+
async canActivate(ctx) {
|
|
32
|
+
if (this.reflector.getAllAndOverride(public_decorator_js_1.IS_PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]))
|
|
33
|
+
return true;
|
|
34
|
+
const req = ctx.switchToHttp().getRequest();
|
|
35
|
+
const auth = req.headers['authorization'];
|
|
36
|
+
if (!auth?.startsWith('Bearer '))
|
|
37
|
+
throw new common_1.UnauthorizedException('Missing token');
|
|
38
|
+
req.user = await this.jwks.verifyUserToken(auth.slice(7));
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
exports.AppUserGuard = AppUserGuard;
|
|
43
|
+
exports.AppUserGuard = AppUserGuard = __decorate([
|
|
44
|
+
(0, common_1.Injectable)(),
|
|
45
|
+
__metadata("design:paramtypes", [bastion_jwks_service_js_1.BastionJwksService,
|
|
46
|
+
core_1.Reflector])
|
|
47
|
+
], AppUserGuard);
|
package/dist/cjs/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/cjs/nest/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.CROSS_TENANT_ROLE = exports.resolveTenantScope = exports.CurrentAdminUser = exports.CurrentClient = exports.AUDIT_EVENT_KEY = exports.Audit = exports.REQUIRED_SCOPES_KEY = exports.RequireScope = exports.IS_PUBLIC_KEY = exports.Public = exports.AuditInterceptor = exports.BastionUserGuard = exports.ServiceClientJwtGuard = exports.BastionAuditService = exports.NestBastionHttp = exports.BastionHttpException = exports.BastionApiClientProvider = exports.BastionService = exports.BastionJwksService = exports.BASTION_OPTIONS = exports.BastionModule = void 0;
|
|
17
|
+
exports.CROSS_TENANT_ROLE = exports.resolveTenantScope = exports.currentRequestId = exports.CurrentUser = exports.CurrentAdminUser = exports.CurrentClient = exports.AUDIT_EVENT_KEY = exports.Audit = exports.REQUIRED_SCOPES_KEY = exports.RequireScope = exports.IS_PUBLIC_KEY = exports.Public = exports.AuditInterceptor = exports.AppUserGuard = exports.BastionUserGuard = exports.ServiceClientJwtGuard = exports.BastionAuditService = exports.NestBastionHttp = exports.BastionHttpException = exports.BastionApiClientProvider = exports.BastionService = exports.BastionJwksService = exports.BASTION_OPTIONS = exports.BastionModule = void 0;
|
|
18
18
|
__exportStar(require("../core/index.js"), exports);
|
|
19
19
|
var bastion_module_js_1 = require("./bastion.module.js");
|
|
20
20
|
Object.defineProperty(exports, "BastionModule", { enumerable: true, get: function () { return bastion_module_js_1.BastionModule; } });
|
|
@@ -35,6 +35,8 @@ var service_client_jwt_guard_js_1 = require("./guards/service-client-jwt.guard.j
|
|
|
35
35
|
Object.defineProperty(exports, "ServiceClientJwtGuard", { enumerable: true, get: function () { return service_client_jwt_guard_js_1.ServiceClientJwtGuard; } });
|
|
36
36
|
var bastion_user_guard_js_1 = require("./guards/bastion-user.guard.js");
|
|
37
37
|
Object.defineProperty(exports, "BastionUserGuard", { enumerable: true, get: function () { return bastion_user_guard_js_1.BastionUserGuard; } });
|
|
38
|
+
var app_user_guard_js_1 = require("./guards/app-user.guard.js");
|
|
39
|
+
Object.defineProperty(exports, "AppUserGuard", { enumerable: true, get: function () { return app_user_guard_js_1.AppUserGuard; } });
|
|
38
40
|
var audit_interceptor_js_1 = require("./interceptors/audit.interceptor.js");
|
|
39
41
|
Object.defineProperty(exports, "AuditInterceptor", { enumerable: true, get: function () { return audit_interceptor_js_1.AuditInterceptor; } });
|
|
40
42
|
var public_decorator_js_1 = require("./decorators/public.decorator.js");
|
|
@@ -49,6 +51,9 @@ Object.defineProperty(exports, "AUDIT_EVENT_KEY", { enumerable: true, get: funct
|
|
|
49
51
|
var current_user_decorator_js_1 = require("./decorators/current-user.decorator.js");
|
|
50
52
|
Object.defineProperty(exports, "CurrentClient", { enumerable: true, get: function () { return current_user_decorator_js_1.CurrentClient; } });
|
|
51
53
|
Object.defineProperty(exports, "CurrentAdminUser", { enumerable: true, get: function () { return current_user_decorator_js_1.CurrentAdminUser; } });
|
|
54
|
+
Object.defineProperty(exports, "CurrentUser", { enumerable: true, get: function () { return current_user_decorator_js_1.CurrentUser; } });
|
|
55
|
+
var request_context_js_1 = require("./request-context.js");
|
|
56
|
+
Object.defineProperty(exports, "currentRequestId", { enumerable: true, get: function () { return request_context_js_1.currentRequestId; } });
|
|
52
57
|
var tenant_scope_js_1 = require("./tenant-scope.js");
|
|
53
58
|
Object.defineProperty(exports, "resolveTenantScope", { enumerable: true, get: function () { return tenant_scope_js_1.resolveTenantScope; } });
|
|
54
59
|
Object.defineProperty(exports, "CROSS_TENANT_ROLE", { enumerable: true, get: function () { return tenant_scope_js_1.CROSS_TENANT_ROLE; } });
|
|
@@ -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']`. */
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Express/Fastify middleware: keeps the request reachable for the rest of its async chain. */
|
|
2
|
+
export declare function requestContextMiddleware(req: {
|
|
3
|
+
id?: unknown;
|
|
4
|
+
}, _res: unknown, next: () => void): void;
|
|
5
|
+
/**
|
|
6
|
+
* The served request's own id, `req.id` as set by pino-http (`genReqId`, api.md §7):
|
|
7
|
+
* the default `requestId` of `BastionModule`. Read lazily, so the logger
|
|
8
|
+
* middleware may run before or after this one.
|
|
9
|
+
*/
|
|
10
|
+
export declare function currentRequestId(): string | undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.requestContextMiddleware = requestContextMiddleware;
|
|
4
|
+
exports.currentRequestId = currentRequestId;
|
|
5
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
6
|
+
const store = new node_async_hooks_1.AsyncLocalStorage();
|
|
7
|
+
/** Express/Fastify middleware: keeps the request reachable for the rest of its async chain. */
|
|
8
|
+
function requestContextMiddleware(req, _res, next) {
|
|
9
|
+
store.run(req, next);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The served request's own id, `req.id` as set by pino-http (`genReqId`, api.md §7):
|
|
13
|
+
* the default `requestId` of `BastionModule`. Read lazily, so the logger
|
|
14
|
+
* middleware may run before or after this one.
|
|
15
|
+
*/
|
|
16
|
+
function currentRequestId() {
|
|
17
|
+
const id = store.getStore()?.id;
|
|
18
|
+
return typeof id === 'string' ? id : undefined;
|
|
19
|
+
}
|
|
@@ -29,7 +29,8 @@ export interface VerifyWebhookOptions {
|
|
|
29
29
|
* Accept the legacy body-only `X-Bastion-Signature` when v2 is absent.
|
|
30
30
|
* Default `false`: legacy carries no timestamp or delivery id, so accepting
|
|
31
31
|
* it lets anyone holding an old delivery replay it by stripping the v2
|
|
32
|
-
* header.
|
|
32
|
+
* header. Bastion no longer sends the legacy header, so there is nothing
|
|
33
|
+
* left to migrate: leave it off.
|
|
33
34
|
*/
|
|
34
35
|
allowLegacySignature?: boolean;
|
|
35
36
|
/** Max age of `t` on a v2 signature, default 300 s. */
|
|
@@ -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>;
|