@heyatom/bastion-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -0
- package/dist/cjs/core/bastion-http.d.ts +32 -0
- package/dist/cjs/core/bastion-http.js +47 -0
- package/dist/cjs/core/errors.d.ts +11 -0
- package/dist/cjs/core/errors.js +23 -0
- package/dist/cjs/core/index.d.ts +5 -0
- package/dist/cjs/core/index.js +25 -0
- package/dist/cjs/core/jwks-verifier.d.ts +35 -0
- package/dist/cjs/core/jwks-verifier.js +118 -0
- package/dist/cjs/core/service-token-provider.d.ts +21 -0
- package/dist/cjs/core/service-token-provider.js +47 -0
- package/dist/cjs/core/types.d.ts +48 -0
- package/dist/cjs/core/types.js +12 -0
- package/dist/cjs/nest/bastion-audit.service.d.ts +29 -0
- package/dist/cjs/nest/bastion-audit.service.js +97 -0
- package/dist/cjs/nest/bastion-jwks.service.d.ts +8 -0
- package/dist/cjs/nest/bastion-jwks.service.js +49 -0
- package/dist/cjs/nest/bastion.module.d.ts +20 -0
- package/dist/cjs/nest/bastion.module.js +60 -0
- package/dist/cjs/nest/bastion.service.d.ts +18 -0
- package/dist/cjs/nest/bastion.service.js +57 -0
- package/dist/cjs/nest/decorators/audit.decorator.d.ts +15 -0
- package/dist/cjs/nest/decorators/audit.decorator.js +8 -0
- package/dist/cjs/nest/decorators/current-user.decorator.d.ts +4 -0
- package/dist/cjs/nest/decorators/current-user.decorator.js +8 -0
- package/dist/cjs/nest/decorators/public.decorator.d.ts +3 -0
- package/dist/cjs/nest/decorators/public.decorator.js +8 -0
- package/dist/cjs/nest/decorators/require-scope.decorator.d.ts +3 -0
- package/dist/cjs/nest/decorators/require-scope.decorator.js +8 -0
- package/dist/cjs/nest/guards/bastion-user.guard.d.ts +20 -0
- package/dist/cjs/nest/guards/bastion-user.guard.js +87 -0
- package/dist/cjs/nest/guards/service-client-jwt.guard.d.ts +17 -0
- package/dist/cjs/nest/guards/service-client-jwt.guard.js +66 -0
- package/dist/cjs/nest/index.d.ts +14 -0
- package/dist/cjs/nest/index.js +49 -0
- package/dist/cjs/nest/interceptors/audit.interceptor.d.ts +12 -0
- package/dist/cjs/nest/interceptors/audit.interceptor.js +51 -0
- package/dist/cjs/nest/nest-logger.d.ts +2 -0
- package/dist/cjs/nest/nest-logger.js +12 -0
- package/dist/cjs/nest/options.d.ts +24 -0
- package/dist/cjs/nest/options.js +4 -0
- package/dist/cjs/nest/tenant-scope.d.ts +4 -0
- package/dist/cjs/nest/tenant-scope.js +14 -0
- package/dist/esm/core/bastion-http.d.ts +32 -0
- package/dist/esm/core/bastion-http.js +43 -0
- package/dist/esm/core/errors.d.ts +11 -0
- package/dist/esm/core/errors.js +18 -0
- package/dist/esm/core/index.d.ts +5 -0
- package/dist/esm/core/index.js +5 -0
- package/dist/esm/core/jwks-verifier.d.ts +35 -0
- package/dist/esm/core/jwks-verifier.js +114 -0
- package/dist/esm/core/service-token-provider.d.ts +21 -0
- package/dist/esm/core/service-token-provider.js +43 -0
- package/dist/esm/core/types.d.ts +48 -0
- package/dist/esm/core/types.js +8 -0
- package/dist/esm/nest/bastion-audit.service.d.ts +29 -0
- package/dist/esm/nest/bastion-audit.service.js +94 -0
- package/dist/esm/nest/bastion-jwks.service.d.ts +8 -0
- package/dist/esm/nest/bastion-jwks.service.js +46 -0
- package/dist/esm/nest/bastion.module.d.ts +20 -0
- package/dist/esm/nest/bastion.module.js +57 -0
- package/dist/esm/nest/bastion.service.d.ts +18 -0
- package/dist/esm/nest/bastion.service.js +54 -0
- package/dist/esm/nest/decorators/audit.decorator.d.ts +15 -0
- package/dist/esm/nest/decorators/audit.decorator.js +4 -0
- package/dist/esm/nest/decorators/current-user.decorator.d.ts +4 -0
- package/dist/esm/nest/decorators/current-user.decorator.js +5 -0
- package/dist/esm/nest/decorators/public.decorator.d.ts +3 -0
- package/dist/esm/nest/decorators/public.decorator.js +4 -0
- package/dist/esm/nest/decorators/require-scope.decorator.d.ts +3 -0
- package/dist/esm/nest/decorators/require-scope.decorator.js +4 -0
- package/dist/esm/nest/guards/bastion-user.guard.d.ts +20 -0
- package/dist/esm/nest/guards/bastion-user.guard.js +84 -0
- package/dist/esm/nest/guards/service-client-jwt.guard.d.ts +17 -0
- package/dist/esm/nest/guards/service-client-jwt.guard.js +63 -0
- package/dist/esm/nest/index.d.ts +14 -0
- package/dist/esm/nest/index.js +14 -0
- package/dist/esm/nest/interceptors/audit.interceptor.d.ts +12 -0
- package/dist/esm/nest/interceptors/audit.interceptor.js +48 -0
- package/dist/esm/nest/nest-logger.d.ts +2 -0
- package/dist/esm/nest/nest-logger.js +9 -0
- package/dist/esm/nest/options.d.ts +24 -0
- package/dist/esm/nest/options.js +1 -0
- package/dist/esm/nest/tenant-scope.d.ts +4 -0
- package/dist/esm/nest/tenant-scope.js +10 -0
- package/dist/esm/package.json +1 -0
- package/package.json +80 -0
|
@@ -0,0 +1,57 @@
|
|
|
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 BastionModule_1;
|
|
8
|
+
import { Global, Module } from '@nestjs/common';
|
|
9
|
+
import { BASTION_OPTIONS } from './options.js';
|
|
10
|
+
import { BastionJwksService } from './bastion-jwks.service.js';
|
|
11
|
+
import { BastionService } from './bastion.service.js';
|
|
12
|
+
import { BastionAuditService } from './bastion-audit.service.js';
|
|
13
|
+
import { ServiceClientJwtGuard } from './guards/service-client-jwt.guard.js';
|
|
14
|
+
import { BastionUserGuard } from './guards/bastion-user.guard.js';
|
|
15
|
+
import { AuditInterceptor } from './interceptors/audit.interceptor.js';
|
|
16
|
+
const PROVIDERS = [
|
|
17
|
+
BastionJwksService,
|
|
18
|
+
BastionService,
|
|
19
|
+
BastionAuditService,
|
|
20
|
+
ServiceClientJwtGuard,
|
|
21
|
+
BastionUserGuard,
|
|
22
|
+
AuditInterceptor,
|
|
23
|
+
];
|
|
24
|
+
/**
|
|
25
|
+
* ```ts
|
|
26
|
+
* BastionModule.forRootAsync({
|
|
27
|
+
* inject: [ConfigService],
|
|
28
|
+
* useFactory: (config: ConfigService) => ({
|
|
29
|
+
* baseUrl: config.getOrThrow('BASTION_URL'),
|
|
30
|
+
* serviceSlug: config.getOrThrow('BASTION_APP_SLUG'),
|
|
31
|
+
* apiKey: config.get('BASTION_CLIENT_API_KEY'),
|
|
32
|
+
* }),
|
|
33
|
+
* })
|
|
34
|
+
* ```
|
|
35
|
+
* Global: import once in `AppModule`, inject anywhere.
|
|
36
|
+
*/
|
|
37
|
+
let BastionModule = BastionModule_1 = class BastionModule {
|
|
38
|
+
static forRoot(options) {
|
|
39
|
+
return this.build({ provide: BASTION_OPTIONS, useValue: options });
|
|
40
|
+
}
|
|
41
|
+
static forRootAsync(options) {
|
|
42
|
+
return this.build({ provide: BASTION_OPTIONS, useFactory: options.useFactory, inject: options.inject }, options.imports);
|
|
43
|
+
}
|
|
44
|
+
static build(optionsProvider, imports = []) {
|
|
45
|
+
return {
|
|
46
|
+
module: BastionModule_1,
|
|
47
|
+
imports,
|
|
48
|
+
providers: [optionsProvider, ...PROVIDERS],
|
|
49
|
+
exports: [BASTION_OPTIONS, ...PROVIDERS],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
BastionModule = BastionModule_1 = __decorate([
|
|
54
|
+
Global(),
|
|
55
|
+
Module({})
|
|
56
|
+
], BastionModule);
|
|
57
|
+
export { BastionModule };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { BastionHttp } from '../core/bastion-http.js';
|
|
2
|
+
import type { AuditEventInput, TokenResponse } from '../core/types.js';
|
|
3
|
+
import { type BastionModuleOptions } from './options.js';
|
|
4
|
+
/** Outbound HTTP to Bastion. A non-2xx answer surfaces as `HttpException` with Bastion's status. */
|
|
5
|
+
export declare class BastionService {
|
|
6
|
+
private readonly options;
|
|
7
|
+
readonly http: BastionHttp;
|
|
8
|
+
constructor(options: BastionModuleOptions);
|
|
9
|
+
call<T>(method: string, path: string, body?: unknown, init?: {
|
|
10
|
+
token?: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
}): Promise<T>;
|
|
13
|
+
clientAuth(): Promise<TokenResponse>;
|
|
14
|
+
writeAuditEvent(token: string, data: AuditEventInput): Promise<{
|
|
15
|
+
id: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
import { HttpException, Inject, Injectable } from '@nestjs/common';
|
|
14
|
+
import { BastionHttp } from '../core/bastion-http.js';
|
|
15
|
+
import { BastionHttpError } from '../core/errors.js';
|
|
16
|
+
import { BASTION_OPTIONS } from './options.js';
|
|
17
|
+
/** Outbound HTTP to Bastion. A non-2xx answer surfaces as `HttpException` with Bastion's status. */
|
|
18
|
+
let BastionService = class BastionService {
|
|
19
|
+
options;
|
|
20
|
+
http;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.options = options;
|
|
23
|
+
this.http = new BastionHttp({ baseUrl: options.baseUrl });
|
|
24
|
+
}
|
|
25
|
+
async call(method, path, body, init) {
|
|
26
|
+
try {
|
|
27
|
+
return await this.http.call(method, path, body, init);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (err instanceof BastionHttpError)
|
|
31
|
+
throw new HttpException(err.message, err.status);
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
clientAuth() {
|
|
36
|
+
if (!this.options.apiKey) {
|
|
37
|
+
throw new Error('BastionModule: apiKey is required for clientAuth()');
|
|
38
|
+
}
|
|
39
|
+
return this.call('POST', '/auth/client', {
|
|
40
|
+
apiKey: this.options.apiKey,
|
|
41
|
+
serviceSlug: this.options.serviceSlug,
|
|
42
|
+
...(this.options.tenantSlug ? { tenantSlug: this.options.tenantSlug } : {}),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
writeAuditEvent(token, data) {
|
|
46
|
+
return this.call('POST', '/events', data, { token });
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
BastionService = __decorate([
|
|
50
|
+
Injectable(),
|
|
51
|
+
__param(0, Inject(BASTION_OPTIONS)),
|
|
52
|
+
__metadata("design:paramtypes", [Object])
|
|
53
|
+
], BastionService);
|
|
54
|
+
export { BastionService };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const AUDIT_EVENT_KEY = "bastion:auditEvent";
|
|
2
|
+
export interface AuditRequest {
|
|
3
|
+
params: Record<string, string>;
|
|
4
|
+
body: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface AuditOptions {
|
|
7
|
+
/** Builds the metadata from the handler's result and the request. Errors here are logged, never propagated. */
|
|
8
|
+
metadata?: (result: unknown, req: AuditRequest) => Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export interface AuditDescriptor {
|
|
11
|
+
event: string;
|
|
12
|
+
options: AuditOptions;
|
|
13
|
+
}
|
|
14
|
+
/** Writes `event` (attributed to the admin user on the request) after the handler succeeds. Needs `AuditInterceptor`. */
|
|
15
|
+
export declare const Audit: (event: string, options?: AuditOptions) => import("@nestjs/common").CustomDecorator<string>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common';
|
|
2
|
+
export const AUDIT_EVENT_KEY = 'bastion:auditEvent';
|
|
3
|
+
/** Writes `event` (attributed to the admin user on the request) after the handler succeeds. Needs `AuditInterceptor`. */
|
|
4
|
+
export const Audit = (event, options = {}) => SetMetadata(AUDIT_EVENT_KEY, { event, options });
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The machine token payload set by `ServiceClientJwtGuard`. */
|
|
2
|
+
export declare const CurrentClient: (...dataOrPipes: unknown[]) => ParameterDecorator;
|
|
3
|
+
/** The user payload set by `BastionUserGuard`. */
|
|
4
|
+
export declare const CurrentAdminUser: (...dataOrPipes: unknown[]) => ParameterDecorator;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { createParamDecorator } from '@nestjs/common';
|
|
2
|
+
/** The machine token payload set by `ServiceClientJwtGuard`. */
|
|
3
|
+
export const CurrentClient = createParamDecorator((_, ctx) => ctx.switchToHttp().getRequest().user);
|
|
4
|
+
/** The user payload set by `BastionUserGuard`. */
|
|
5
|
+
export const CurrentAdminUser = createParamDecorator((_, ctx) => ctx.switchToHttp().getRequest().adminUser);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const REQUIRED_SCOPES_KEY = "bastion:requiredScopes";
|
|
2
|
+
/** Scopes the machine token must carry to reach this route (all of them). Checked by `ServiceClientJwtGuard`. */
|
|
3
|
+
export declare const RequireScope: (...scopes: string[]) => import("@nestjs/common").CustomDecorator<string>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common';
|
|
2
|
+
export const REQUIRED_SCOPES_KEY = 'bastion:requiredScopes';
|
|
3
|
+
/** Scopes the machine token must carry to reach this route (all of them). Checked by `ServiceClientJwtGuard`. */
|
|
4
|
+
export const RequireScope = (...scopes) => SetMetadata(REQUIRED_SCOPES_KEY, scopes);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
|
2
|
+
import { BastionJwksService } from '../bastion-jwks.service.js';
|
|
3
|
+
import { BastionAuditService } from '../bastion-audit.service.js';
|
|
4
|
+
import { type BastionModuleOptions } from '../options.js';
|
|
5
|
+
/**
|
|
6
|
+
* Admin-surface guard: accepts a *user* token from one of `acceptedAppSlugs`
|
|
7
|
+
* (the console, typically) with one of `acceptedRoles`. Sets `req.adminUser`.
|
|
8
|
+
* A denial is audited as `admin.access_denied`, rate-limited per (app, reason)
|
|
9
|
+
* so a misconfigured console cannot flood the log.
|
|
10
|
+
*/
|
|
11
|
+
export declare class BastionUserGuard implements CanActivate {
|
|
12
|
+
private readonly jwks;
|
|
13
|
+
private readonly audit;
|
|
14
|
+
private readonly acceptedAppSlugs;
|
|
15
|
+
private readonly acceptedRoles;
|
|
16
|
+
private readonly reportedAt;
|
|
17
|
+
constructor(jwks: BastionJwksService, audit: BastionAuditService, options: BastionModuleOptions);
|
|
18
|
+
canActivate(ctx: ExecutionContext): Promise<boolean>;
|
|
19
|
+
private reportAccessDenied;
|
|
20
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
import { ForbiddenException, Inject, Injectable, UnauthorizedException, } from '@nestjs/common';
|
|
14
|
+
import { isServiceClientToken } from '../../core/types.js';
|
|
15
|
+
import { BastionJwksService } from '../bastion-jwks.service.js';
|
|
16
|
+
import { BastionAuditService } from '../bastion-audit.service.js';
|
|
17
|
+
import { BASTION_OPTIONS } from '../options.js';
|
|
18
|
+
const ACCESS_DENIED_COOLDOWN_MS = 5 * 60 * 1000;
|
|
19
|
+
const ACCESS_DENIED_MAX_KEYS = 50;
|
|
20
|
+
const DEFAULT_ACCEPTED_APP_SLUGS = ['meridian'];
|
|
21
|
+
const DEFAULT_ACCEPTED_ROLES = ['ADMIN', 'OWNER', 'SUPER_ADMIN'];
|
|
22
|
+
/**
|
|
23
|
+
* Admin-surface guard: accepts a *user* token from one of `acceptedAppSlugs`
|
|
24
|
+
* (the console, typically) with one of `acceptedRoles`. Sets `req.adminUser`.
|
|
25
|
+
* A denial is audited as `admin.access_denied`, rate-limited per (app, reason)
|
|
26
|
+
* so a misconfigured console cannot flood the log.
|
|
27
|
+
*/
|
|
28
|
+
let BastionUserGuard = class BastionUserGuard {
|
|
29
|
+
jwks;
|
|
30
|
+
audit;
|
|
31
|
+
acceptedAppSlugs;
|
|
32
|
+
acceptedRoles;
|
|
33
|
+
reportedAt = new Map();
|
|
34
|
+
constructor(jwks, audit, options) {
|
|
35
|
+
this.jwks = jwks;
|
|
36
|
+
this.audit = audit;
|
|
37
|
+
this.acceptedAppSlugs = options.acceptedAppSlugs ?? DEFAULT_ACCEPTED_APP_SLUGS;
|
|
38
|
+
this.acceptedRoles = options.acceptedRoles ?? DEFAULT_ACCEPTED_ROLES;
|
|
39
|
+
}
|
|
40
|
+
async canActivate(ctx) {
|
|
41
|
+
const req = ctx.switchToHttp().getRequest();
|
|
42
|
+
const auth = req.headers['authorization'];
|
|
43
|
+
if (!auth?.startsWith('Bearer '))
|
|
44
|
+
throw new UnauthorizedException('Missing token');
|
|
45
|
+
const payload = await this.jwks.verify(auth.slice(7));
|
|
46
|
+
if (isServiceClientToken(payload))
|
|
47
|
+
throw new UnauthorizedException('Service client token not allowed');
|
|
48
|
+
if (!this.acceptedAppSlugs.includes(payload.appSlug)) {
|
|
49
|
+
this.reportAccessDenied('app_not_allowed', payload, req);
|
|
50
|
+
throw new ForbiddenException('App not allowed');
|
|
51
|
+
}
|
|
52
|
+
if (!this.acceptedRoles.includes(payload.role ?? '')) {
|
|
53
|
+
this.reportAccessDenied('role_insufficient', payload, req);
|
|
54
|
+
throw new ForbiddenException('Insufficient role');
|
|
55
|
+
}
|
|
56
|
+
req.adminUser = payload;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
reportAccessDenied(reason, user, req) {
|
|
60
|
+
const key = `${user.appSlug}:${reason}`;
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const last = this.reportedAt.get(key);
|
|
63
|
+
if (last !== undefined && now - last < ACCESS_DENIED_COOLDOWN_MS)
|
|
64
|
+
return;
|
|
65
|
+
if (this.reportedAt.size >= ACCESS_DENIED_MAX_KEYS)
|
|
66
|
+
this.reportedAt.clear();
|
|
67
|
+
this.reportedAt.set(key, now);
|
|
68
|
+
void this.audit.write('admin.access_denied', {
|
|
69
|
+
metadata: {
|
|
70
|
+
reason,
|
|
71
|
+
appSlug: user.appSlug,
|
|
72
|
+
role: user.role ?? 'none',
|
|
73
|
+
path: req.originalUrl ?? req.url ?? 'unknown',
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
BastionUserGuard = __decorate([
|
|
79
|
+
Injectable(),
|
|
80
|
+
__param(2, Inject(BASTION_OPTIONS)),
|
|
81
|
+
__metadata("design:paramtypes", [BastionJwksService,
|
|
82
|
+
BastionAuditService, Object])
|
|
83
|
+
], BastionUserGuard);
|
|
84
|
+
export { BastionUserGuard };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
|
2
|
+
import { Reflector } from '@nestjs/core';
|
|
3
|
+
import { BastionJwksService } from '../bastion-jwks.service.js';
|
|
4
|
+
import { type BastionModuleOptions } from '../options.js';
|
|
5
|
+
/**
|
|
6
|
+
* Machine-to-machine guard, meant as the global `APP_GUARD`: every route needs
|
|
7
|
+
* a `service_client` token issued for this service (`serviceSlug` from the
|
|
8
|
+
* module options, never hard-coded) unless it carries `@Public()`.
|
|
9
|
+
* `@RequireScope()` adds a per-route scope check. The payload lands on `req.user`.
|
|
10
|
+
*/
|
|
11
|
+
export declare class ServiceClientJwtGuard implements CanActivate {
|
|
12
|
+
private readonly jwks;
|
|
13
|
+
private readonly reflector;
|
|
14
|
+
private readonly options;
|
|
15
|
+
constructor(jwks: BastionJwksService, reflector: Reflector, options: BastionModuleOptions);
|
|
16
|
+
canActivate(ctx: ExecutionContext): Promise<boolean>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
import { ForbiddenException, Inject, Injectable, UnauthorizedException, } from '@nestjs/common';
|
|
14
|
+
import { Reflector } from '@nestjs/core';
|
|
15
|
+
import { isServiceClientToken } from '../../core/types.js';
|
|
16
|
+
import { BastionJwksService } from '../bastion-jwks.service.js';
|
|
17
|
+
import { BASTION_OPTIONS } from '../options.js';
|
|
18
|
+
import { IS_PUBLIC_KEY } from '../decorators/public.decorator.js';
|
|
19
|
+
import { REQUIRED_SCOPES_KEY } from '../decorators/require-scope.decorator.js';
|
|
20
|
+
/**
|
|
21
|
+
* Machine-to-machine guard, meant as the global `APP_GUARD`: every route needs
|
|
22
|
+
* a `service_client` token issued for this service (`serviceSlug` from the
|
|
23
|
+
* module options, never hard-coded) unless it carries `@Public()`.
|
|
24
|
+
* `@RequireScope()` adds a per-route scope check. The payload lands on `req.user`.
|
|
25
|
+
*/
|
|
26
|
+
let ServiceClientJwtGuard = class ServiceClientJwtGuard {
|
|
27
|
+
jwks;
|
|
28
|
+
reflector;
|
|
29
|
+
options;
|
|
30
|
+
constructor(jwks, reflector, options) {
|
|
31
|
+
this.jwks = jwks;
|
|
32
|
+
this.reflector = reflector;
|
|
33
|
+
this.options = options;
|
|
34
|
+
}
|
|
35
|
+
async canActivate(ctx) {
|
|
36
|
+
const targets = [ctx.getHandler(), ctx.getClass()];
|
|
37
|
+
if (this.reflector.getAllAndOverride(IS_PUBLIC_KEY, targets))
|
|
38
|
+
return true;
|
|
39
|
+
const req = ctx.switchToHttp().getRequest();
|
|
40
|
+
const auth = req.headers['authorization'];
|
|
41
|
+
if (!auth?.startsWith('Bearer '))
|
|
42
|
+
throw new UnauthorizedException('Missing token');
|
|
43
|
+
const payload = await this.jwks.verify(auth.slice(7));
|
|
44
|
+
if (!isServiceClientToken(payload))
|
|
45
|
+
throw new UnauthorizedException('Not a service client token');
|
|
46
|
+
if (payload.serviceSlug !== this.options.serviceSlug) {
|
|
47
|
+
throw new ForbiddenException('Token not issued for this service');
|
|
48
|
+
}
|
|
49
|
+
const required = this.reflector.getAllAndOverride(REQUIRED_SCOPES_KEY, targets) ?? [];
|
|
50
|
+
const missing = required.filter((s) => !payload.scopes?.includes(s));
|
|
51
|
+
if (missing.length)
|
|
52
|
+
throw new ForbiddenException(`Missing scope: ${missing.join(', ')}`);
|
|
53
|
+
req.user = payload;
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
ServiceClientJwtGuard = __decorate([
|
|
58
|
+
Injectable(),
|
|
59
|
+
__param(2, Inject(BASTION_OPTIONS)),
|
|
60
|
+
__metadata("design:paramtypes", [BastionJwksService,
|
|
61
|
+
Reflector, Object])
|
|
62
|
+
], ServiceClientJwtGuard);
|
|
63
|
+
export { ServiceClientJwtGuard };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from '../core/index.js';
|
|
2
|
+
export { BastionModule } from './bastion.module.js';
|
|
3
|
+
export { BASTION_OPTIONS, type BastionModuleOptions, type BastionModuleAsyncOptions } from './options.js';
|
|
4
|
+
export { BastionJwksService } from './bastion-jwks.service.js';
|
|
5
|
+
export { BastionService } from './bastion.service.js';
|
|
6
|
+
export { BastionAuditService } from './bastion-audit.service.js';
|
|
7
|
+
export { ServiceClientJwtGuard } from './guards/service-client-jwt.guard.js';
|
|
8
|
+
export { BastionUserGuard } from './guards/bastion-user.guard.js';
|
|
9
|
+
export { AuditInterceptor } from './interceptors/audit.interceptor.js';
|
|
10
|
+
export { Public, IS_PUBLIC_KEY } from './decorators/public.decorator.js';
|
|
11
|
+
export { RequireScope, REQUIRED_SCOPES_KEY } from './decorators/require-scope.decorator.js';
|
|
12
|
+
export { Audit, AUDIT_EVENT_KEY, type AuditOptions, type AuditRequest, type AuditDescriptor } from './decorators/audit.decorator.js';
|
|
13
|
+
export { CurrentClient, CurrentAdminUser } from './decorators/current-user.decorator.js';
|
|
14
|
+
export { resolveTenantScope, CROSS_TENANT_ROLE } from './tenant-scope.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from '../core/index.js';
|
|
2
|
+
export { BastionModule } from './bastion.module.js';
|
|
3
|
+
export { BASTION_OPTIONS } from './options.js';
|
|
4
|
+
export { BastionJwksService } from './bastion-jwks.service.js';
|
|
5
|
+
export { BastionService } from './bastion.service.js';
|
|
6
|
+
export { BastionAuditService } from './bastion-audit.service.js';
|
|
7
|
+
export { ServiceClientJwtGuard } from './guards/service-client-jwt.guard.js';
|
|
8
|
+
export { BastionUserGuard } from './guards/bastion-user.guard.js';
|
|
9
|
+
export { AuditInterceptor } from './interceptors/audit.interceptor.js';
|
|
10
|
+
export { Public, IS_PUBLIC_KEY } from './decorators/public.decorator.js';
|
|
11
|
+
export { RequireScope, REQUIRED_SCOPES_KEY } from './decorators/require-scope.decorator.js';
|
|
12
|
+
export { Audit, AUDIT_EVENT_KEY } from './decorators/audit.decorator.js';
|
|
13
|
+
export { CurrentClient, CurrentAdminUser } from './decorators/current-user.decorator.js';
|
|
14
|
+
export { resolveTenantScope, CROSS_TENANT_ROLE } from './tenant-scope.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
|
|
2
|
+
import { Reflector } from '@nestjs/core';
|
|
3
|
+
import { Observable } from 'rxjs';
|
|
4
|
+
import { BastionAuditService } from '../bastion-audit.service.js';
|
|
5
|
+
/** Pairs with `@Audit()`: after a handler succeeds, writes the event attributed to `req.adminUser`. */
|
|
6
|
+
export declare class AuditInterceptor implements NestInterceptor {
|
|
7
|
+
private readonly reflector;
|
|
8
|
+
private readonly audit;
|
|
9
|
+
private readonly logger;
|
|
10
|
+
constructor(reflector: Reflector, audit: BastionAuditService);
|
|
11
|
+
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
var AuditInterceptor_1;
|
|
11
|
+
import { Injectable, Logger } from '@nestjs/common';
|
|
12
|
+
import { Reflector } from '@nestjs/core';
|
|
13
|
+
import { tap } from 'rxjs';
|
|
14
|
+
import { BastionAuditService } from '../bastion-audit.service.js';
|
|
15
|
+
import { AUDIT_EVENT_KEY } from '../decorators/audit.decorator.js';
|
|
16
|
+
/** Pairs with `@Audit()`: after a handler succeeds, writes the event attributed to `req.adminUser`. */
|
|
17
|
+
let AuditInterceptor = AuditInterceptor_1 = class AuditInterceptor {
|
|
18
|
+
reflector;
|
|
19
|
+
audit;
|
|
20
|
+
logger = new Logger(AuditInterceptor_1.name);
|
|
21
|
+
constructor(reflector, audit) {
|
|
22
|
+
this.reflector = reflector;
|
|
23
|
+
this.audit = audit;
|
|
24
|
+
}
|
|
25
|
+
intercept(ctx, next) {
|
|
26
|
+
const descriptor = this.reflector.get(AUDIT_EVENT_KEY, ctx.getHandler());
|
|
27
|
+
if (!descriptor)
|
|
28
|
+
return next.handle();
|
|
29
|
+
const req = ctx.switchToHttp().getRequest();
|
|
30
|
+
return next.handle().pipe(tap((result) => {
|
|
31
|
+
let metadata;
|
|
32
|
+
try {
|
|
33
|
+
metadata = descriptor.options.metadata?.(result, req) ?? {};
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
this.logger.warn(`audit metadata builder failed event=${descriptor.event}: ${err.message}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
void this.audit.writeAsAdmin(descriptor.event, req.adminUser, metadata);
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
AuditInterceptor = AuditInterceptor_1 = __decorate([
|
|
44
|
+
Injectable(),
|
|
45
|
+
__metadata("design:paramtypes", [Reflector,
|
|
46
|
+
BastionAuditService])
|
|
47
|
+
], AuditInterceptor);
|
|
48
|
+
export { AuditInterceptor };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ModuleMetadata } from '@nestjs/common';
|
|
2
|
+
export declare const BASTION_OPTIONS: unique symbol;
|
|
3
|
+
export interface BastionModuleOptions {
|
|
4
|
+
/** `BASTION_URL` — base URL, e.g. `http://bastion:3001`. */
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
/** `BASTION_APP_SLUG` — this service's App slug. Machine tokens must carry it as `serviceSlug`. */
|
|
7
|
+
serviceSlug: string;
|
|
8
|
+
/** `BASTION_CLIENT_API_KEY` — needed for outbound calls (audit writes). Optional for verify-only consumers. */
|
|
9
|
+
apiKey?: string;
|
|
10
|
+
/** Required by Bastion when the app is `isGlobal` or registered in more than one tenant. */
|
|
11
|
+
tenantSlug?: string;
|
|
12
|
+
/** `BASTION_JWKS_TTL_MS`, default 300 000. */
|
|
13
|
+
jwksTtlMs?: number;
|
|
14
|
+
/** Expected `iss`. Default `'bastion'`; `null` disables the check. */
|
|
15
|
+
issuer?: string | null;
|
|
16
|
+
/** `BastionUserGuard`: which apps' user tokens are accepted. Default `['meridian']`. */
|
|
17
|
+
acceptedAppSlugs?: string[];
|
|
18
|
+
/** `BastionUserGuard`: which roles are accepted. Default `['ADMIN', 'OWNER', 'SUPER_ADMIN']`. */
|
|
19
|
+
acceptedRoles?: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface BastionModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
|
|
22
|
+
useFactory: (...args: unknown[]) => BastionModuleOptions | Promise<BastionModuleOptions>;
|
|
23
|
+
inject?: unknown[];
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const BASTION_OPTIONS = Symbol('BASTION_OPTIONS');
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { UserJwtPayload } from '../core/types.js';
|
|
2
|
+
export declare const CROSS_TENANT_ROLE = "SUPER_ADMIN";
|
|
3
|
+
/** The tenant a console user may read: their own, or the requested one if they are SUPER_ADMIN. */
|
|
4
|
+
export declare function resolveTenantScope(user: UserJwtPayload, requested?: string): string | undefined;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ForbiddenException } from '@nestjs/common';
|
|
2
|
+
export const CROSS_TENANT_ROLE = 'SUPER_ADMIN';
|
|
3
|
+
/** The tenant a console user may read: their own, or the requested one if they are SUPER_ADMIN. */
|
|
4
|
+
export function resolveTenantScope(user, requested) {
|
|
5
|
+
if (user.role === CROSS_TENANT_ROLE)
|
|
6
|
+
return requested;
|
|
7
|
+
if (!user.tenantId)
|
|
8
|
+
throw new ForbiddenException('Token without tenant scope');
|
|
9
|
+
return user.tenantId;
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@heyatom/bastion-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Consumer SDK for the Bastion identity provider: JWKS verification, service-client auth, audit writes, NestJS guards",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Andrea Tombolato <hey@heyatom.dev> (https://heyatom.dev)",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/heyatomdev/bastion-client.git"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"main": "./dist/cjs/core/index.js",
|
|
16
|
+
"module": "./dist/esm/core/index.js",
|
|
17
|
+
"types": "./dist/cjs/core/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": {
|
|
21
|
+
"types": "./dist/esm/core/index.d.ts",
|
|
22
|
+
"default": "./dist/esm/core/index.js"
|
|
23
|
+
},
|
|
24
|
+
"require": {
|
|
25
|
+
"types": "./dist/cjs/core/index.d.ts",
|
|
26
|
+
"default": "./dist/cjs/core/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"./nest": {
|
|
30
|
+
"import": {
|
|
31
|
+
"types": "./dist/esm/nest/index.d.ts",
|
|
32
|
+
"default": "./dist/esm/nest/index.js"
|
|
33
|
+
},
|
|
34
|
+
"require": {
|
|
35
|
+
"types": "./dist/cjs/nest/index.d.ts",
|
|
36
|
+
"default": "./dist/cjs/nest/index.js"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@nestjs/common": "^11.0.0",
|
|
45
|
+
"@nestjs/core": "^11.0.0",
|
|
46
|
+
"jose": "^6.0.0",
|
|
47
|
+
"reflect-metadata": "^0.2.0",
|
|
48
|
+
"rxjs": "^7.0.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"@nestjs/common": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"@nestjs/core": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"reflect-metadata": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"rxjs": {
|
|
61
|
+
"optional": true
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@nestjs/common": "^11.2.5",
|
|
66
|
+
"@nestjs/core": "^11.2.5",
|
|
67
|
+
"@types/node": "^25.9.8",
|
|
68
|
+
"jose": "^6.2.12",
|
|
69
|
+
"reflect-metadata": "^0.2.2",
|
|
70
|
+
"rxjs": "^7.8.2",
|
|
71
|
+
"typescript": "^5.9.3",
|
|
72
|
+
"vitest": "^3.2.4"
|
|
73
|
+
},
|
|
74
|
+
"scripts": {
|
|
75
|
+
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\":\"module\"}' > dist/esm/package.json",
|
|
76
|
+
"test": "vitest run",
|
|
77
|
+
"lint": "tsc -p tsconfig.json --noEmit",
|
|
78
|
+
"test:di": "node scripts/di-smoke.cjs"
|
|
79
|
+
}
|
|
80
|
+
}
|