@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
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @heyatom/bastion-client
|
|
2
|
+
|
|
3
|
+
Consumer SDK for [Bastion](https://github.com/heyatomdev/bastion), the HeyAtom identity provider.
|
|
4
|
+
Replaces the `src/modules/bastion/` boilerplate every service used to copy by hand.
|
|
5
|
+
|
|
6
|
+
- `@heyatom/bastion-client` — framework-free core: `JwksVerifier` (kid-indexed cache, unknown-kid refetch with cooldown, stale-on-error), `BastionHttp` (`POST /auth/client`, `POST /events`, any other route), `ServiceTokenProvider` (single-flight refresh ahead of `exp`), payload types.
|
|
7
|
+
- `@heyatom/bastion-client/nest` — NestJS: `BastionModule`, `ServiceClientJwtGuard`, `BastionUserGuard`, `BastionAuditService`, `AuditInterceptor`, `@Public()`, `@RequireScope()`, `@Audit()`, `@CurrentClient()`, `@CurrentAdminUser()`.
|
|
8
|
+
|
|
9
|
+
Ships CJS and ESM. Peer deps: `jose` (always), `@nestjs/common`, `@nestjs/core`, `rxjs`, `reflect-metadata` (for `/nest`).
|
|
10
|
+
|
|
11
|
+
## NestJS
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// app.module.ts
|
|
15
|
+
import { APP_GUARD } from '@nestjs/core';
|
|
16
|
+
import { BastionModule, ServiceClientJwtGuard } from '@heyatom/bastion-client/nest';
|
|
17
|
+
|
|
18
|
+
@Module({
|
|
19
|
+
imports: [
|
|
20
|
+
BastionModule.forRootAsync({
|
|
21
|
+
inject: [ConfigService],
|
|
22
|
+
useFactory: (config: ConfigService) => ({
|
|
23
|
+
baseUrl: config.getOrThrow('BASTION_URL'),
|
|
24
|
+
serviceSlug: config.getOrThrow('BASTION_APP_SLUG'),
|
|
25
|
+
apiKey: config.get('BASTION_CLIENT_API_KEY'),
|
|
26
|
+
// tenantSlug, jwksTtlMs, issuer, acceptedAppSlugs, acceptedRoles — optional
|
|
27
|
+
}),
|
|
28
|
+
}),
|
|
29
|
+
],
|
|
30
|
+
providers: [{ provide: APP_GUARD, useClass: ServiceClientJwtGuard }],
|
|
31
|
+
})
|
|
32
|
+
export class AppModule {}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
@Controller('mail')
|
|
37
|
+
export class MailController {
|
|
38
|
+
constructor(private readonly audit: BastionAuditService) {}
|
|
39
|
+
|
|
40
|
+
@Post('send')
|
|
41
|
+
@RequireScope('mail.send')
|
|
42
|
+
send(@CurrentClient() client: ServiceClientJwtPayload, @Body() dto: SendDto) {
|
|
43
|
+
// ...
|
|
44
|
+
void this.audit.write('mail.sent', { metadata: { toDomain: 'example.com' } });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Get('/health')
|
|
48
|
+
@Public()
|
|
49
|
+
health() { return { status: 'ok' }; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Admin surface: user tokens from the console
|
|
53
|
+
@Controller('admin/config')
|
|
54
|
+
@UseGuards(BastionUserGuard)
|
|
55
|
+
@UseInterceptors(AuditInterceptor)
|
|
56
|
+
export class AdminController {
|
|
57
|
+
@Patch()
|
|
58
|
+
@Audit('config.updated', { metadata: (_result, req) => ({ keys: Object.keys(req.body as object) }) })
|
|
59
|
+
update(@CurrentAdminUser() admin: UserJwtPayload, @Body() dto: UpdateDto) { /* ... */ }
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Bastion stores app events as `<serviceSlug>.<event>` — send the bare `resource.action`.
|
|
64
|
+
|
|
65
|
+
## Without NestJS
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { JwksVerifier, BastionHttp, ServiceTokenProvider } from '@heyatom/bastion-client';
|
|
69
|
+
|
|
70
|
+
const verifier = new JwksVerifier({ baseUrl: process.env.BASTION_URL! });
|
|
71
|
+
const payload = await verifier.verify(bearerToken); // throws BastionTokenError
|
|
72
|
+
|
|
73
|
+
const http = new BastionHttp({ baseUrl: process.env.BASTION_URL! });
|
|
74
|
+
const tokens = new ServiceTokenProvider(http, { apiKey, serviceSlug: 'my-service' });
|
|
75
|
+
await http.writeAuditEvent(await tokens.getToken(), { event: 'thing.done' });
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Release
|
|
79
|
+
|
|
80
|
+
Bump `version`, tag `vX.Y.Z`, push the tag. The workflow runs lint/test/build/DI smoke and publishes with provenance (`NPM_TOKEN` secret).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AuditEventInput, TokenResponse } from './types.js';
|
|
2
|
+
export interface BastionHttpOptions {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
fetch?: typeof fetch;
|
|
5
|
+
}
|
|
6
|
+
export interface ClientAuthInput {
|
|
7
|
+
apiKey: string;
|
|
8
|
+
serviceSlug: string;
|
|
9
|
+
tenantSlug?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
|
|
13
|
+
* `JwksVerifier`. `call` is public so a service can reach any other route
|
|
14
|
+
* (e.g. `POST /auth/login` from a BFF) with the same error handling; pass the
|
|
15
|
+
* browser's IP as `X-Real-IP` on user-facing calls so Bastion's rate-limit
|
|
16
|
+
* and audit rows see the real client, not your container.
|
|
17
|
+
*/
|
|
18
|
+
export declare class BastionHttp {
|
|
19
|
+
private readonly baseUrl;
|
|
20
|
+
private readonly fetchImpl;
|
|
21
|
+
constructor(options: BastionHttpOptions);
|
|
22
|
+
call<T>(method: string, path: string, body?: unknown, init?: {
|
|
23
|
+
token?: string;
|
|
24
|
+
headers?: Record<string, string>;
|
|
25
|
+
}): Promise<T>;
|
|
26
|
+
clientAuth(input: ClientAuthInput): Promise<TokenResponse>;
|
|
27
|
+
/** `POST /events`. Bastion stores the event as `<serviceSlug>.<event>`; send the bare `resource.action`. */
|
|
28
|
+
writeAuditEvent(token: string, data: AuditEventInput): Promise<{
|
|
29
|
+
id: string;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BastionHttp = void 0;
|
|
4
|
+
const errors_js_1 = require("./errors.js");
|
|
5
|
+
/**
|
|
6
|
+
* Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
|
|
7
|
+
* `JwksVerifier`. `call` is public so a service can reach any other route
|
|
8
|
+
* (e.g. `POST /auth/login` from a BFF) with the same error handling; pass the
|
|
9
|
+
* browser's IP as `X-Real-IP` on user-facing calls so Bastion's rate-limit
|
|
10
|
+
* and audit rows see the real client, not your container.
|
|
11
|
+
*/
|
|
12
|
+
class BastionHttp {
|
|
13
|
+
baseUrl;
|
|
14
|
+
fetchImpl;
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
17
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
18
|
+
}
|
|
19
|
+
async call(method, path, body, init = {}) {
|
|
20
|
+
const headers = {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
...init.headers,
|
|
23
|
+
};
|
|
24
|
+
if (init.token)
|
|
25
|
+
headers['Authorization'] = `Bearer ${init.token}`;
|
|
26
|
+
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
27
|
+
method,
|
|
28
|
+
headers,
|
|
29
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const err = (await res.json().catch(() => ({})));
|
|
33
|
+
throw new errors_js_1.BastionHttpError(res.status, err.message ?? 'Bastion error');
|
|
34
|
+
}
|
|
35
|
+
if (res.status === 204)
|
|
36
|
+
return undefined;
|
|
37
|
+
return (await res.json());
|
|
38
|
+
}
|
|
39
|
+
clientAuth(input) {
|
|
40
|
+
return this.call('POST', '/auth/client', input);
|
|
41
|
+
}
|
|
42
|
+
/** `POST /events`. Bastion stores the event as `<serviceSlug>.<event>`; send the bare `resource.action`. */
|
|
43
|
+
writeAuditEvent(token, data) {
|
|
44
|
+
return this.call('POST', '/events', data, { token });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.BastionHttp = BastionHttp;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type TokenErrorCode = 'malformed' | 'missing_kid' | 'unknown_kid' | 'invalid';
|
|
2
|
+
/** Verification failure. `code` is safe to log; the message is safe to return to the caller. */
|
|
3
|
+
export declare class BastionTokenError extends Error {
|
|
4
|
+
readonly code: TokenErrorCode;
|
|
5
|
+
constructor(code: TokenErrorCode, message: string);
|
|
6
|
+
}
|
|
7
|
+
/** A non-2xx answer from Bastion's HTTP API. */
|
|
8
|
+
export declare class BastionHttpError extends Error {
|
|
9
|
+
readonly status: number;
|
|
10
|
+
constructor(status: number, message: string);
|
|
11
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BastionHttpError = exports.BastionTokenError = void 0;
|
|
4
|
+
/** Verification failure. `code` is safe to log; the message is safe to return to the caller. */
|
|
5
|
+
class BastionTokenError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = 'BastionTokenError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
exports.BastionTokenError = BastionTokenError;
|
|
14
|
+
/** A non-2xx answer from Bastion's HTTP API. */
|
|
15
|
+
class BastionHttpError extends Error {
|
|
16
|
+
status;
|
|
17
|
+
constructor(status, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.name = 'BastionHttpError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
exports.BastionHttpError = BastionHttpError;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './errors.js';
|
|
3
|
+
export { JwksVerifier, type JwksVerifierOptions } from './jwks-verifier.js';
|
|
4
|
+
export { BastionHttp, type BastionHttpOptions, type ClientAuthInput } from './bastion-http.js';
|
|
5
|
+
export { ServiceTokenProvider } from './service-token-provider.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.ServiceTokenProvider = exports.BastionHttp = exports.JwksVerifier = void 0;
|
|
18
|
+
__exportStar(require("./types.js"), exports);
|
|
19
|
+
__exportStar(require("./errors.js"), exports);
|
|
20
|
+
var jwks_verifier_js_1 = require("./jwks-verifier.js");
|
|
21
|
+
Object.defineProperty(exports, "JwksVerifier", { enumerable: true, get: function () { return jwks_verifier_js_1.JwksVerifier; } });
|
|
22
|
+
var bastion_http_js_1 = require("./bastion-http.js");
|
|
23
|
+
Object.defineProperty(exports, "BastionHttp", { enumerable: true, get: function () { return bastion_http_js_1.BastionHttp; } });
|
|
24
|
+
var service_token_provider_js_1 = require("./service-token-provider.js");
|
|
25
|
+
Object.defineProperty(exports, "ServiceTokenProvider", { enumerable: true, get: function () { return service_token_provider_js_1.ServiceTokenProvider; } });
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type BastionJwtPayload, type BastionLogger } from './types.js';
|
|
2
|
+
export interface JwksVerifierOptions {
|
|
3
|
+
/** Bastion base URL, e.g. `http://bastion:3001`. The JWKS lives at `/.well-known/jwks.json`. */
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
/** How long a fetched key set is trusted before a refetch. Default 300 000 (5 min), matching Bastion's `Cache-Control`. */
|
|
6
|
+
ttlMs?: number;
|
|
7
|
+
/** Expected `iss` claim. Default `'bastion'`. Pass `null` to skip the check. */
|
|
8
|
+
issuer?: string | null;
|
|
9
|
+
logger?: BastionLogger;
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The canonical consumer of Bastion's JWKS (docs/BASTION_INTEGRATION.md):
|
|
14
|
+
* keys cached and indexed by `kid`, refetch on an unknown kid with a
|
|
15
|
+
* cooldown, stale cache served when a refetch fails. Second safety net only:
|
|
16
|
+
* Bastion publishes a key before signing with it (publish-before-use), so a
|
|
17
|
+
* correctly configured consumer should never meet an unknown kid.
|
|
18
|
+
*/
|
|
19
|
+
export declare class JwksVerifier {
|
|
20
|
+
private keys;
|
|
21
|
+
private fetchedAt;
|
|
22
|
+
private lastUnknownKidRefetchAt;
|
|
23
|
+
private inFlight;
|
|
24
|
+
private readonly jwksUrl;
|
|
25
|
+
private readonly ttlMs;
|
|
26
|
+
private readonly issuer;
|
|
27
|
+
private readonly logger;
|
|
28
|
+
private readonly fetchImpl;
|
|
29
|
+
constructor(options: JwksVerifierOptions);
|
|
30
|
+
verify(token: string): Promise<BastionJwtPayload>;
|
|
31
|
+
/** Single-flight: concurrent verifies during a refetch share one HTTP call. */
|
|
32
|
+
private fetchKeys;
|
|
33
|
+
private ensureFresh;
|
|
34
|
+
private refetchForUnknownKid;
|
|
35
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.JwksVerifier = void 0;
|
|
4
|
+
const jose_1 = require("jose");
|
|
5
|
+
const errors_js_1 = require("./errors.js");
|
|
6
|
+
const types_js_1 = require("./types.js");
|
|
7
|
+
/**
|
|
8
|
+
* Refetch cooldown when a token names a `kid` we do not have. The kid comes
|
|
9
|
+
* from an unverified header, so it is attacker-controlled: without a
|
|
10
|
+
* cooldown a flood of random kids becomes a request amplifier against
|
|
11
|
+
* Bastion's JWKS route.
|
|
12
|
+
*/
|
|
13
|
+
const UNKNOWN_KID_REFETCH_COOLDOWN_MS = 30_000;
|
|
14
|
+
/**
|
|
15
|
+
* The canonical consumer of Bastion's JWKS (docs/BASTION_INTEGRATION.md):
|
|
16
|
+
* keys cached and indexed by `kid`, refetch on an unknown kid with a
|
|
17
|
+
* cooldown, stale cache served when a refetch fails. Second safety net only:
|
|
18
|
+
* Bastion publishes a key before signing with it (publish-before-use), so a
|
|
19
|
+
* correctly configured consumer should never meet an unknown kid.
|
|
20
|
+
*/
|
|
21
|
+
class JwksVerifier {
|
|
22
|
+
keys = new Map();
|
|
23
|
+
fetchedAt = 0;
|
|
24
|
+
lastUnknownKidRefetchAt = 0;
|
|
25
|
+
inFlight = null;
|
|
26
|
+
jwksUrl;
|
|
27
|
+
ttlMs;
|
|
28
|
+
issuer;
|
|
29
|
+
logger;
|
|
30
|
+
fetchImpl;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.jwksUrl = `${options.baseUrl.replace(/\/$/, '')}/.well-known/jwks.json`;
|
|
33
|
+
this.ttlMs = options.ttlMs ?? 300_000;
|
|
34
|
+
this.issuer = options.issuer === undefined ? 'bastion' : options.issuer;
|
|
35
|
+
this.logger = options.logger ?? types_js_1.silentLogger;
|
|
36
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
37
|
+
}
|
|
38
|
+
async verify(token) {
|
|
39
|
+
let kid;
|
|
40
|
+
try {
|
|
41
|
+
({ kid } = (0, jose_1.decodeProtectedHeader)(token));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new errors_js_1.BastionTokenError('malformed', 'Malformed token');
|
|
45
|
+
}
|
|
46
|
+
if (!kid)
|
|
47
|
+
throw new errors_js_1.BastionTokenError('missing_kid', 'Missing kid in JWT header');
|
|
48
|
+
await this.ensureFresh();
|
|
49
|
+
let key = this.keys.get(kid);
|
|
50
|
+
if (!key) {
|
|
51
|
+
await this.refetchForUnknownKid();
|
|
52
|
+
key = this.keys.get(kid);
|
|
53
|
+
}
|
|
54
|
+
if (!key) {
|
|
55
|
+
this.logger.warn(`JWT verification failed: unknown kid=${kid}`);
|
|
56
|
+
throw new errors_js_1.BastionTokenError('unknown_kid', 'Unknown or expired kid');
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const { payload } = await (0, jose_1.jwtVerify)(token, key, {
|
|
60
|
+
algorithms: ['RS256'],
|
|
61
|
+
...(this.issuer ? { issuer: this.issuer } : {}),
|
|
62
|
+
});
|
|
63
|
+
return payload;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new errors_js_1.BastionTokenError('invalid', 'Invalid token');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Single-flight: concurrent verifies during a refetch share one HTTP call. */
|
|
70
|
+
fetchKeys() {
|
|
71
|
+
this.inFlight ??= (async () => {
|
|
72
|
+
try {
|
|
73
|
+
const res = await this.fetchImpl(this.jwksUrl);
|
|
74
|
+
if (!res.ok)
|
|
75
|
+
throw new Error(`JWKS fetch failed: ${res.status}`);
|
|
76
|
+
const { keys } = (await res.json());
|
|
77
|
+
const next = new Map();
|
|
78
|
+
for (const jwk of keys) {
|
|
79
|
+
if (!jwk.kid)
|
|
80
|
+
continue;
|
|
81
|
+
next.set(jwk.kid, await (0, jose_1.importJWK)(jwk, 'RS256'));
|
|
82
|
+
}
|
|
83
|
+
this.keys = next;
|
|
84
|
+
this.fetchedAt = Date.now();
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
this.inFlight = null;
|
|
88
|
+
}
|
|
89
|
+
})();
|
|
90
|
+
return this.inFlight;
|
|
91
|
+
}
|
|
92
|
+
async ensureFresh() {
|
|
93
|
+
const stale = Date.now() - this.fetchedAt >= this.ttlMs;
|
|
94
|
+
if (!stale && this.keys.size)
|
|
95
|
+
return;
|
|
96
|
+
try {
|
|
97
|
+
await this.fetchKeys();
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (!this.keys.size)
|
|
101
|
+
throw err;
|
|
102
|
+
this.logger.warn(`JWKS fetch failed, serving stale cache: ${err.message}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async refetchForUnknownKid() {
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
if (now - this.lastUnknownKidRefetchAt < UNKNOWN_KID_REFETCH_COOLDOWN_MS)
|
|
108
|
+
return;
|
|
109
|
+
this.lastUnknownKidRefetchAt = now;
|
|
110
|
+
try {
|
|
111
|
+
await this.fetchKeys();
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
this.logger.warn(`JWKS refetch for unknown kid failed: ${err.message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
exports.JwksVerifier = JwksVerifier;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BastionHttp, ClientAuthInput } from './bastion-http.js';
|
|
2
|
+
import { type BastionLogger } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Holds the service-client token for outbound calls and renews it ahead of
|
|
5
|
+
* `exp`. Single-flight: a burst of first calls shares one `POST /auth/client`
|
|
6
|
+
* instead of issuing one per caller — the previous per-service copies had no
|
|
7
|
+
* such guard.
|
|
8
|
+
*/
|
|
9
|
+
export declare class ServiceTokenProvider {
|
|
10
|
+
private readonly http;
|
|
11
|
+
private readonly credentials;
|
|
12
|
+
private readonly logger;
|
|
13
|
+
private token;
|
|
14
|
+
private expiresAt;
|
|
15
|
+
private inFlight;
|
|
16
|
+
/** Tenant the token was issued for — the tenant `POST /events` rows land in. */
|
|
17
|
+
tenantId: string | null;
|
|
18
|
+
constructor(http: BastionHttp, credentials: ClientAuthInput, logger?: BastionLogger);
|
|
19
|
+
getToken(): Promise<string>;
|
|
20
|
+
private refresh;
|
|
21
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ServiceTokenProvider = void 0;
|
|
4
|
+
const jose_1 = require("jose");
|
|
5
|
+
const types_js_1 = require("./types.js");
|
|
6
|
+
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
7
|
+
const FALLBACK_TTL_MS = 60 * 60 * 1000;
|
|
8
|
+
/**
|
|
9
|
+
* Holds the service-client token for outbound calls and renews it ahead of
|
|
10
|
+
* `exp`. Single-flight: a burst of first calls shares one `POST /auth/client`
|
|
11
|
+
* instead of issuing one per caller — the previous per-service copies had no
|
|
12
|
+
* such guard.
|
|
13
|
+
*/
|
|
14
|
+
class ServiceTokenProvider {
|
|
15
|
+
http;
|
|
16
|
+
credentials;
|
|
17
|
+
logger;
|
|
18
|
+
token = null;
|
|
19
|
+
expiresAt = 0;
|
|
20
|
+
inFlight = null;
|
|
21
|
+
/** Tenant the token was issued for — the tenant `POST /events` rows land in. */
|
|
22
|
+
tenantId = null;
|
|
23
|
+
constructor(http, credentials, logger = types_js_1.silentLogger) {
|
|
24
|
+
this.http = http;
|
|
25
|
+
this.credentials = credentials;
|
|
26
|
+
this.logger = logger;
|
|
27
|
+
}
|
|
28
|
+
getToken() {
|
|
29
|
+
if (this.token && Date.now() < this.expiresAt - REFRESH_MARGIN_MS) {
|
|
30
|
+
return Promise.resolve(this.token);
|
|
31
|
+
}
|
|
32
|
+
this.inFlight ??= this.refresh().finally(() => {
|
|
33
|
+
this.inFlight = null;
|
|
34
|
+
});
|
|
35
|
+
return this.inFlight;
|
|
36
|
+
}
|
|
37
|
+
async refresh() {
|
|
38
|
+
const { accessToken } = await this.http.clientAuth(this.credentials);
|
|
39
|
+
const { exp, tenantId } = (0, jose_1.decodeJwt)(accessToken);
|
|
40
|
+
this.token = accessToken;
|
|
41
|
+
this.expiresAt = exp ? exp * 1000 : Date.now() + FALLBACK_TTL_MS;
|
|
42
|
+
this.tenantId = tenantId ?? null;
|
|
43
|
+
this.logger.log('service client token refreshed');
|
|
44
|
+
return accessToken;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.ServiceTokenProvider = ServiceTokenProvider;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Claims Bastion puts on every RS256 token it signs (see bastion/CLAUDE.md, "JWT payload"). */
|
|
2
|
+
interface CommonClaims {
|
|
3
|
+
sub: string;
|
|
4
|
+
tenantId: string;
|
|
5
|
+
tenantSlug: string;
|
|
6
|
+
iss?: string;
|
|
7
|
+
aud?: string;
|
|
8
|
+
jti?: string;
|
|
9
|
+
iat: number;
|
|
10
|
+
exp: number;
|
|
11
|
+
}
|
|
12
|
+
/** A user session token: `POST /auth/login`, `/auth/refresh`, `/auth/exchange`. Never carries `type`. */
|
|
13
|
+
export interface UserJwtPayload extends CommonClaims {
|
|
14
|
+
type?: undefined;
|
|
15
|
+
email: string | null;
|
|
16
|
+
username: string | null;
|
|
17
|
+
image: string | null;
|
|
18
|
+
preferredLocale: string | null;
|
|
19
|
+
appSlug: string;
|
|
20
|
+
role: string;
|
|
21
|
+
permissions: string[];
|
|
22
|
+
sessionId?: string;
|
|
23
|
+
}
|
|
24
|
+
/** A machine token from `POST /auth/client`. Discriminated by `type`. */
|
|
25
|
+
export interface ServiceClientJwtPayload extends CommonClaims {
|
|
26
|
+
type: 'service_client';
|
|
27
|
+
clientName: string;
|
|
28
|
+
serviceSlug: string;
|
|
29
|
+
scopes: string[];
|
|
30
|
+
}
|
|
31
|
+
export type BastionJwtPayload = UserJwtPayload | ServiceClientJwtPayload;
|
|
32
|
+
export declare function isServiceClientToken(payload: BastionJwtPayload): payload is ServiceClientJwtPayload;
|
|
33
|
+
export interface TokenResponse {
|
|
34
|
+
accessToken: string;
|
|
35
|
+
}
|
|
36
|
+
export interface AuditEventInput {
|
|
37
|
+
event: string;
|
|
38
|
+
userId?: string;
|
|
39
|
+
metadata?: Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
/** Minimal logger so the core never depends on a framework. Defaults to silent. */
|
|
42
|
+
export interface BastionLogger {
|
|
43
|
+
warn(message: string): void;
|
|
44
|
+
error(message: string): void;
|
|
45
|
+
log(message: string): void;
|
|
46
|
+
}
|
|
47
|
+
export declare const silentLogger: BastionLogger;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.silentLogger = void 0;
|
|
4
|
+
exports.isServiceClientToken = isServiceClientToken;
|
|
5
|
+
function isServiceClientToken(payload) {
|
|
6
|
+
return payload.type === 'service_client';
|
|
7
|
+
}
|
|
8
|
+
exports.silentLogger = {
|
|
9
|
+
warn: () => undefined,
|
|
10
|
+
error: () => undefined,
|
|
11
|
+
log: () => undefined,
|
|
12
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import type { UserJwtPayload } from '../core/types.js';
|
|
3
|
+
import { BastionService } from './bastion.service.js';
|
|
4
|
+
import { type BastionModuleOptions } from './options.js';
|
|
5
|
+
/**
|
|
6
|
+
* Fire-and-forget audit writes to `POST /events` under this service's own
|
|
7
|
+
* machine token. Never throws: a failed write is logged, the request that
|
|
8
|
+
* produced it is not affected.
|
|
9
|
+
*/
|
|
10
|
+
export declare class BastionAuditService implements OnModuleInit {
|
|
11
|
+
private readonly bastion;
|
|
12
|
+
private readonly logger;
|
|
13
|
+
private readonly tokens;
|
|
14
|
+
private tenantMismatchWarned;
|
|
15
|
+
constructor(bastion: BastionService, options: BastionModuleOptions);
|
|
16
|
+
onModuleInit(): Promise<void>;
|
|
17
|
+
/** Tenant of this service's machine token, once known. */
|
|
18
|
+
get tenantId(): string | null;
|
|
19
|
+
write(event: string, opts?: {
|
|
20
|
+
userId?: string;
|
|
21
|
+
metadata?: Record<string, unknown>;
|
|
22
|
+
}): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Attribute an event to an admin user. `userId` is only set when the actor
|
|
25
|
+
* belongs to the same tenant as this service's token — Bastion rejects a
|
|
26
|
+
* foreign userId silently — otherwise the actor goes into `metadata`.
|
|
27
|
+
*/
|
|
28
|
+
writeAsAdmin(event: string, actor: UserJwtPayload | undefined, metadata?: Record<string, unknown>): Promise<void>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
var BastionAuditService_1;
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.BastionAuditService = void 0;
|
|
17
|
+
const common_1 = require("@nestjs/common");
|
|
18
|
+
const service_token_provider_js_1 = require("../core/service-token-provider.js");
|
|
19
|
+
const bastion_service_js_1 = require("./bastion.service.js");
|
|
20
|
+
const options_js_1 = require("./options.js");
|
|
21
|
+
const nest_logger_js_1 = require("./nest-logger.js");
|
|
22
|
+
/**
|
|
23
|
+
* Fire-and-forget audit writes to `POST /events` under this service's own
|
|
24
|
+
* machine token. Never throws: a failed write is logged, the request that
|
|
25
|
+
* produced it is not affected.
|
|
26
|
+
*/
|
|
27
|
+
let BastionAuditService = BastionAuditService_1 = class BastionAuditService {
|
|
28
|
+
bastion;
|
|
29
|
+
logger = new common_1.Logger(BastionAuditService_1.name);
|
|
30
|
+
tokens;
|
|
31
|
+
tenantMismatchWarned = false;
|
|
32
|
+
constructor(bastion, options) {
|
|
33
|
+
this.bastion = bastion;
|
|
34
|
+
this.tokens = options.apiKey
|
|
35
|
+
? new service_token_provider_js_1.ServiceTokenProvider(bastion.http, {
|
|
36
|
+
apiKey: options.apiKey,
|
|
37
|
+
serviceSlug: options.serviceSlug,
|
|
38
|
+
...(options.tenantSlug ? { tenantSlug: options.tenantSlug } : {}),
|
|
39
|
+
}, (0, nest_logger_js_1.nestLogger)(BastionAuditService_1.name))
|
|
40
|
+
: null;
|
|
41
|
+
}
|
|
42
|
+
async onModuleInit() {
|
|
43
|
+
if (!this.tokens)
|
|
44
|
+
return;
|
|
45
|
+
await this.tokens.getToken().catch((err) => this.logger.error(`Bastion unreachable at startup, will retry on first write: ${err.message}`));
|
|
46
|
+
}
|
|
47
|
+
/** Tenant of this service's machine token, once known. */
|
|
48
|
+
get tenantId() {
|
|
49
|
+
return this.tokens?.tenantId ?? null;
|
|
50
|
+
}
|
|
51
|
+
async write(event, opts = {}) {
|
|
52
|
+
if (!this.tokens) {
|
|
53
|
+
this.logger.warn(`audit write skipped, no apiKey configured event=${event}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let token;
|
|
57
|
+
try {
|
|
58
|
+
token = await this.tokens.getToken();
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
this.logger.error(`audit write skipped, no service client token event=${event}: ${err.message}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
await this.bastion
|
|
65
|
+
.writeAuditEvent(token, { event, ...opts })
|
|
66
|
+
.catch((err) => this.logger.error(`audit write failed event=${event}: ${err.message}`));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Attribute an event to an admin user. `userId` is only set when the actor
|
|
70
|
+
* belongs to the same tenant as this service's token — Bastion rejects a
|
|
71
|
+
* foreign userId silently — otherwise the actor goes into `metadata`.
|
|
72
|
+
*/
|
|
73
|
+
async writeAsAdmin(event, actor, metadata = {}) {
|
|
74
|
+
await this.tokens?.getToken().catch(() => undefined);
|
|
75
|
+
const sameTenant = Boolean(actor?.sub) && Boolean(this.tenantId) && actor?.tenantId === this.tenantId;
|
|
76
|
+
if (sameTenant && actor)
|
|
77
|
+
return this.write(event, { userId: actor.sub, metadata });
|
|
78
|
+
if (actor && !this.tenantMismatchWarned) {
|
|
79
|
+
this.tenantMismatchWarned = true;
|
|
80
|
+
this.logger.warn(`admin tenant (${actor.tenantSlug ?? actor.tenantId}) differs from this service's tenant (${this.tenantId ?? 'unknown'}) — audit events fall back to actor metadata instead of userId`);
|
|
81
|
+
}
|
|
82
|
+
return this.write(event, {
|
|
83
|
+
metadata: {
|
|
84
|
+
...metadata,
|
|
85
|
+
actorId: actor?.sub ?? 'unknown',
|
|
86
|
+
actorRole: actor?.role ?? 'unknown',
|
|
87
|
+
actorAppSlug: actor?.appSlug ?? 'unknown',
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
exports.BastionAuditService = BastionAuditService;
|
|
93
|
+
exports.BastionAuditService = BastionAuditService = BastionAuditService_1 = __decorate([
|
|
94
|
+
(0, common_1.Injectable)(),
|
|
95
|
+
__param(1, (0, common_1.Inject)(options_js_1.BASTION_OPTIONS)),
|
|
96
|
+
__metadata("design:paramtypes", [bastion_service_js_1.BastionService, Object])
|
|
97
|
+
], BastionAuditService);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BastionJwtPayload } from '../core/types.js';
|
|
2
|
+
import { type BastionModuleOptions } from './options.js';
|
|
3
|
+
/** Verifies inbound Bastion tokens against the cached JWKS. Throws `UnauthorizedException` on any failure. */
|
|
4
|
+
export declare class BastionJwksService {
|
|
5
|
+
private readonly verifier;
|
|
6
|
+
constructor(options: BastionModuleOptions);
|
|
7
|
+
verify(token: string): Promise<BastionJwtPayload>;
|
|
8
|
+
}
|