@lenne.tech/nest-server 11.41.2 → 11.41.4
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/.claude/rules/architecture.md +1 -0
- package/.claude/rules/configurable-features.md +1 -0
- package/.claude/rules/role-system.md +15 -1
- package/.claude/rules/testing.md +16 -4
- package/CLAUDE.md +2 -1
- package/FRAMEWORK-API.md +4 -1
- package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -1
- package/dist/core/common/helpers/logging.helper.js +2 -0
- package/dist/core/common/helpers/logging.helper.js.map +1 -1
- package/dist/core/common/helpers/process-diagnostics.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +20 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.d.ts +2 -2
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +46 -6
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +7 -1
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
- package/dist/core/modules/api-token/core-api-token.constants.d.ts +6 -0
- package/dist/core/modules/api-token/core-api-token.constants.js +11 -0
- package/dist/core/modules/api-token/core-api-token.constants.js.map +1 -0
- package/dist/core/modules/api-token/core-api-token.decorators.d.ts +1 -0
- package/dist/core/modules/api-token/core-api-token.decorators.js +8 -0
- package/dist/core/modules/api-token/core-api-token.decorators.js.map +1 -0
- package/dist/core/modules/api-token/core-api-token.helpers.d.ts +127 -0
- package/dist/core/modules/api-token/core-api-token.helpers.js +398 -0
- package/dist/core/modules/api-token/core-api-token.helpers.js.map +1 -0
- package/dist/core/modules/api-token/core-api-token.middleware.d.ts +10 -0
- package/dist/core/modules/api-token/core-api-token.middleware.js +58 -0
- package/dist/core/modules/api-token/core-api-token.middleware.js.map +1 -0
- package/dist/core/modules/api-token/core-api-token.model.d.ts +20 -0
- package/dist/core/modules/api-token/core-api-token.model.js +199 -0
- package/dist/core/modules/api-token/core-api-token.model.js.map +1 -0
- package/dist/core/modules/api-token/core-api-token.module.d.ts +11 -0
- package/dist/core/modules/api-token/core-api-token.module.js +39 -0
- package/dist/core/modules/api-token/core-api-token.module.js.map +1 -0
- package/dist/core/modules/api-token/core-api-token.registry.d.ts +9 -0
- package/dist/core/modules/api-token/core-api-token.registry.js +17 -0
- package/dist/core/modules/api-token/core-api-token.registry.js.map +1 -0
- package/dist/core/modules/api-token/core-api-token.service.d.ts +104 -0
- package/dist/core/modules/api-token/core-api-token.service.js +550 -0
- package/dist/core/modules/api-token/core-api-token.service.js.map +1 -0
- package/dist/core/modules/auth/guards/roles.guard.js +17 -1
- package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth-roles.guard.js +12 -2
- package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.middleware.js +4 -0
- package/dist/core/modules/better-auth/core-better-auth.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.d.ts +4 -0
- package/dist/core/modules/better-auth/core-better-auth.module.js +18 -0
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant-guard.registry.d.ts +2 -0
- package/dist/core/modules/tenant/core-tenant-guard.registry.js +19 -0
- package/dist/core/modules/tenant/core-tenant-guard.registry.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +28 -12
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.helpers.d.ts +1 -0
- package/dist/core/modules/tenant/core-tenant.helpers.js +19 -0
- package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.module.d.ts +5 -2
- package/dist/core/modules/tenant/core-tenant.module.js +8 -0
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
- package/dist/core/modules/user/core-user.service.js +7 -0
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core.module.js +6 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +49 -1
- package/docs/security-overrides.md +16 -10
- package/migration-guides/11.41.3-to-11.41.4.md +172 -0
- package/package.json +38 -37
- package/src/core/common/helpers/graceful-shutdown.helper.ts +9 -0
- package/src/core/common/helpers/logging.helper.ts +7 -0
- package/src/core/common/helpers/process-diagnostics.helper.ts +4 -0
- package/src/core/common/interfaces/server-options.interface.ts +110 -0
- package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +17 -10
- package/src/core/modules/ai/README.md +9 -1
- package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +74 -7
- package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +13 -1
- package/src/core/modules/api-token/INTEGRATION-CHECKLIST.md +121 -0
- package/src/core/modules/api-token/README.md +212 -0
- package/src/core/modules/api-token/core-api-token.constants.ts +27 -0
- package/src/core/modules/api-token/core-api-token.decorators.ts +29 -0
- package/src/core/modules/api-token/core-api-token.helpers.ts +711 -0
- package/src/core/modules/api-token/core-api-token.middleware.ts +57 -0
- package/src/core/modules/api-token/core-api-token.model.ts +193 -0
- package/src/core/modules/api-token/core-api-token.module.ts +48 -0
- package/src/core/modules/api-token/core-api-token.registry.ts +53 -0
- package/src/core/modules/api-token/core-api-token.service.ts +822 -0
- package/src/core/modules/auth/guards/roles.guard.ts +23 -2
- package/src/core/modules/better-auth/better-auth-roles.guard.ts +18 -4
- package/src/core/modules/better-auth/core-better-auth.middleware.ts +8 -0
- package/src/core/modules/better-auth/core-better-auth.module.ts +33 -0
- package/src/core/modules/tenant/README.md +17 -0
- package/src/core/modules/tenant/core-tenant-guard.registry.ts +36 -0
- package/src/core/modules/tenant/core-tenant.guard.ts +52 -12
- package/src/core/modules/tenant/core-tenant.helpers.ts +30 -0
- package/src/core/modules/tenant/core-tenant.module.ts +19 -2
- package/src/core/modules/user/core-user.service.ts +14 -0
- package/src/core.module.ts +12 -0
- package/src/index.ts +12 -0
|
@@ -1473,6 +1473,86 @@ export interface IMultiTenancy {
|
|
|
1473
1473
|
cacheTtlMs?: number;
|
|
1474
1474
|
}
|
|
1475
1475
|
|
|
1476
|
+
/**
|
|
1477
|
+
* Configuration of API tokens (`apiTokens`).
|
|
1478
|
+
*
|
|
1479
|
+
* Two kinds share one model and one policy:
|
|
1480
|
+
* - USER tokens act as their user with the user's CURRENT rights (never global roles), optionally
|
|
1481
|
+
* narrowed to scopes, one tenant and a maximum tenant role. Work with and without multi-tenancy.
|
|
1482
|
+
* - TENANT tokens belong to a tenant, are managed by its administrators and act with the lowest tenant
|
|
1483
|
+
* role inside that tenant only. Available while multi-tenancy is active.
|
|
1484
|
+
*
|
|
1485
|
+
* Both are denied on every route that does not declare `@ApiTokenScopes(...)`.
|
|
1486
|
+
*
|
|
1487
|
+
* @since 11.41.4
|
|
1488
|
+
*/
|
|
1489
|
+
export interface IApiTokens {
|
|
1490
|
+
/**
|
|
1491
|
+
* Pre-configure without enabling.
|
|
1492
|
+
* @default true (when the object is present)
|
|
1493
|
+
*/
|
|
1494
|
+
enabled?: boolean;
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* Pass-phrase for the AES-256-GCM encryption of each token's signing key (used for signed
|
|
1498
|
+
* assertions). Falls back to the `SECRETS_ENCRYPTION_KEY` environment variable. REQUIRED in
|
|
1499
|
+
* `production` / `staging` — the boot fails without it; elsewhere an insecure development default
|
|
1500
|
+
* is used with a warning. Rotating it invalidates the signing keys of all existing tokens.
|
|
1501
|
+
*/
|
|
1502
|
+
encryptionKey?: string;
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Tenant role required to create, list, change, revoke and delete TENANT tokens. Hierarchy roles
|
|
1506
|
+
* compare by level, so higher roles qualify too. Must be a declared tenant role that the lowest
|
|
1507
|
+
* hierarchy role (the role a tenant token acts with) does not reach. Platform admins qualify while
|
|
1508
|
+
* `multiTenancy.adminBypass` is on.
|
|
1509
|
+
* @default the highest role of `multiTenancy.roleHierarchy`
|
|
1510
|
+
*/
|
|
1511
|
+
manageRole?: string;
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Maximum lifetime of a signed assertion, measured from the moment it is presented. A longer-lived
|
|
1515
|
+
* assertion is refused (401). An invalid value falls back to the default — never to "unbounded".
|
|
1516
|
+
* @default 900 (15 minutes)
|
|
1517
|
+
*/
|
|
1518
|
+
maxAssertionLifetimeSeconds?: number;
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* Recognisable token prefix: 2-16 characters, lowercase letters and digits, starting with a letter.
|
|
1522
|
+
* Tokens read `<prefix>_<publicId>_<secret>`, assertions `<prefix>s_<payload>.<signature>`.
|
|
1523
|
+
* @default 'ltt'
|
|
1524
|
+
*/
|
|
1525
|
+
prefix?: string;
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* Per-token request limit (fixed window, shared across replicas when `redis` is configured).
|
|
1529
|
+
* An exceeded limit answers 429 with `Retry-After`. `false` switches it off.
|
|
1530
|
+
* @default { max: 600, windowSeconds: 60 }
|
|
1531
|
+
*/
|
|
1532
|
+
rateLimit?: boolean | { enabled?: boolean; max?: number; windowSeconds?: number };
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* Vocabulary of scopes a token may carry (e.g. `['upload', 'read', 'export']`). Creating or updating
|
|
1536
|
+
* a token with any other scope fails with 400; with an empty vocabulary no token can be created.
|
|
1537
|
+
* A user token created without scopes receives the whole vocabulary.
|
|
1538
|
+
* Scopes: 1-64 characters of letters, digits, `:`, `.`, `_`, `-`.
|
|
1539
|
+
* @default []
|
|
1540
|
+
*/
|
|
1541
|
+
scopes?: string[];
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* Allow tenant tokens. Only takes effect while multi-tenancy is active.
|
|
1545
|
+
* @default true
|
|
1546
|
+
*/
|
|
1547
|
+
tenantTokens?: boolean;
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Allow user tokens.
|
|
1551
|
+
* @default true
|
|
1552
|
+
*/
|
|
1553
|
+
userTokens?: boolean;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1476
1556
|
/**
|
|
1477
1557
|
* Cookie configuration for authentication handling.
|
|
1478
1558
|
*
|
|
@@ -2020,6 +2100,20 @@ export interface IServerOptions {
|
|
|
2020
2100
|
*/
|
|
2021
2101
|
appUrl?: string;
|
|
2022
2102
|
|
|
2103
|
+
/**
|
|
2104
|
+
* API tokens: bearer credentials for machine clients and embedded pages that cannot carry a session
|
|
2105
|
+
* cookie. USER tokens act as their user (without global roles); TENANT tokens belong to a tenant
|
|
2106
|
+
* (multi-tenancy only). Both are denied on every route that does not declare `@ApiTokenScopes(...)`,
|
|
2107
|
+
* and both respect tenant boundaries whenever multi-tenancy is active.
|
|
2108
|
+
*
|
|
2109
|
+
* Boolean shorthand: `true` / `{}` enable with defaults, `{ enabled: false }` pre-configures,
|
|
2110
|
+
* absent = off (no behaviour change). See `src/core/modules/api-token/README.md`.
|
|
2111
|
+
*
|
|
2112
|
+
* @default undefined (disabled)
|
|
2113
|
+
* @since 11.41.4
|
|
2114
|
+
*/
|
|
2115
|
+
apiTokens?: boolean | IApiTokens;
|
|
2116
|
+
|
|
2023
2117
|
/**
|
|
2024
2118
|
* Authentication system configuration
|
|
2025
2119
|
*
|
|
@@ -4322,6 +4416,22 @@ interface IBetterAuthWithPasskey extends IBetterAuthBase {
|
|
|
4322
4416
|
* @since 11.22.0
|
|
4323
4417
|
*/
|
|
4324
4418
|
export interface ICoreModuleOverrides {
|
|
4419
|
+
/**
|
|
4420
|
+
* Override API token collaborators with project-specific subclasses (`apiTokens` config).
|
|
4421
|
+
*
|
|
4422
|
+
* - `model` must extend `CoreApiTokenModel` (e.g. to bind a token to project data)
|
|
4423
|
+
* - `service` must extend `CoreApiTokenService`
|
|
4424
|
+
*
|
|
4425
|
+
* @example
|
|
4426
|
+
* ```typescript
|
|
4427
|
+
* { apiToken: { model: ApiToken, service: ApiTokenService } }
|
|
4428
|
+
* ```
|
|
4429
|
+
*/
|
|
4430
|
+
apiToken?: {
|
|
4431
|
+
model?: Type<any>;
|
|
4432
|
+
service?: Type<any>;
|
|
4433
|
+
};
|
|
4434
|
+
|
|
4325
4435
|
/**
|
|
4326
4436
|
* Override AI module collaborators with project-specific subclasses.
|
|
4327
4437
|
*
|
|
@@ -209,9 +209,15 @@ The response body carries the stable `#LTNS_0901` code, never the raw error.
|
|
|
209
209
|
|
|
210
210
|
```typescript
|
|
211
211
|
import { mountAiMcpOAuth } from '@lenne.tech/nest-server';
|
|
212
|
-
await mountAiMcpOAuth(app
|
|
212
|
+
await mountAiMcpOAuth(app);
|
|
213
213
|
```
|
|
214
214
|
|
|
215
|
+
**WHY no `baseUrl` argument:** the issuer is every URL in the discovery metadata, and MCP clients
|
|
216
|
+
follow them. The helper takes it from the server's `baseUrl` (`NSC__BASE_URL` when deployed) and
|
|
217
|
+
fails the boot in a deployed environment that has none, rather than advertising `localhost` —
|
|
218
|
+
which sends clients to the user's own machine. Pass `{ baseUrl }` only for an issuer that must
|
|
219
|
+
differ from the server's `baseUrl`; never add a `localhost` fallback of your own.
|
|
220
|
+
|
|
215
221
|
Override `CoreAiMcpOAuthService.authorizeConsent()` with your login/consent UI (the only
|
|
216
222
|
browser-interactive step). All other OAuth pieces (tokens, PKCE, stores) are built in.
|
|
217
223
|
|
|
@@ -231,12 +237,13 @@ browser-interactive step). All other OAuth pieces (tokens, PKCE, stores) are bui
|
|
|
231
237
|
|
|
232
238
|
## Common Mistakes
|
|
233
239
|
|
|
234
|
-
| Mistake
|
|
235
|
-
|
|
|
236
|
-
| No `ai` config block
|
|
237
|
-
| No encryption secret in prod
|
|
238
|
-
| Encryption secret changed after keys stored
|
|
239
|
-
| Tool returns `.lean()`/aggregate data
|
|
240
|
-
| Tool not registered
|
|
241
|
-
| Overridden resolver method missing decorators
|
|
242
|
-
| Storing a real API key in `config.env.ts`
|
|
240
|
+
| Mistake | Symptom | Fix |
|
|
241
|
+
| ------------------------------------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
|
242
|
+
| No `ai` config block | Module not loaded, `aiPrompt` missing from schema | Add an `ai` block (presence implies enabled) |
|
|
243
|
+
| No encryption secret in prod | **App refuses to boot** (throws); dev/local only warns | Set `NSC__AI__ENCRYPTION_SECRET` (32+ chars) |
|
|
244
|
+
| Encryption secret changed after keys stored | Boot logs "key(s) could not be decrypted"; those prompts fail | Re-enter the API key for the listed connections |
|
|
245
|
+
| Tool returns `.lean()`/aggregate data | `@Restricted` fields leak into the LLM context | Route through `CrudService` with `context.serviceOptions` |
|
|
246
|
+
| Tool not registered | Tool never offered to the LLM | Declare it as a provider in a module (extends `AiTool`) |
|
|
247
|
+
| Overridden resolver method missing decorators | Method absent from GraphQL schema | Re-declare `@Mutation`/`@Query`/`@Roles` in the override |
|
|
248
|
+
| Storing a real API key in `config.env.ts` | Secret committed to the repo | Use `apiKeyEnv` or the runtime connection CRUD |
|
|
249
|
+
| `localhost` fallback for the MCP OAuth `baseUrl` | MCP client fails to register with `ECONNREFUSED`; discovery metadata names `localhost` | Call `mountAiMcpOAuth(app)` without `baseUrl`, set `NSC__BASE_URL` |
|
|
@@ -657,9 +657,17 @@ also accepts OAuth access tokens. Mount the discovery/token endpoints in `main.t
|
|
|
657
657
|
```typescript
|
|
658
658
|
// main.ts, after app.init()
|
|
659
659
|
import { mountAiMcpOAuth } from '@lenne.tech/nest-server';
|
|
660
|
-
await mountAiMcpOAuth(app
|
|
660
|
+
await mountAiMcpOAuth(app);
|
|
661
661
|
```
|
|
662
662
|
|
|
663
|
+
The issuer — and with it every endpoint URL in the discovery metadata, which MCP clients follow —
|
|
664
|
+
is the server's `baseUrl` (`NSC__BASE_URL` when deployed), resolved the same way BetterAuth and
|
|
665
|
+
CORS resolve it. `local` / `ci` / `e2e` fall back to `http://localhost:3000`; any other environment
|
|
666
|
+
without a `baseUrl` fails the call instead of guessing, because a deployed API that advertised
|
|
667
|
+
`http://localhost:3000` sent Claude Code off to register the client on the user's own machine.
|
|
668
|
+
Pass `{ baseUrl }` only when the issuer must differ from the server's `baseUrl`, and do not add a
|
|
669
|
+
`localhost` fallback of your own. The resolved issuer is logged once at boot.
|
|
670
|
+
|
|
663
671
|
Override `CoreAiMcpOAuthService.authorizeConsent()` to wire your login/consent UI.
|
|
664
672
|
Set `ai.mcp.oauthSecret` (or reuse `ai.encryptionSecret`) to a random 32+ char value.
|
|
665
673
|
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { resolveServerUrls } from '../../../common/helpers/cookies.helper';
|
|
4
|
+
import { ConfigService } from '../../../common/services/config.service';
|
|
1
5
|
import { CoreAiMcpOAuthService } from '../services/core-ai-mcp-oauth.service';
|
|
2
6
|
|
|
3
7
|
/**
|
|
@@ -11,25 +15,88 @@ import { CoreAiMcpOAuthService } from '../services/core-ai-mcp-oauth.service';
|
|
|
11
15
|
* The interactive consent step requires `CoreAiMcpOAuthService.authorizeConsent`
|
|
12
16
|
* to be overridden with your login/consent UI (see INTEGRATION-CHECKLIST).
|
|
13
17
|
*
|
|
18
|
+
* The base URL must be the server's public URL: it becomes the OAuth issuer and
|
|
19
|
+
* every endpoint in the discovery metadata, and MCP clients follow those URLs.
|
|
20
|
+
* Resolution order:
|
|
21
|
+
*
|
|
22
|
+
* 1. `options.baseUrl`, when non-blank — for an issuer that differs from `baseUrl`
|
|
23
|
+
* 2. `baseUrl` from the server config (`NSC__BASE_URL` in deployed environments)
|
|
24
|
+
* 3. `http://localhost:3000` in `local` / `ci` / `e2e` only
|
|
25
|
+
* 4. otherwise the call throws — it never guesses
|
|
26
|
+
*
|
|
27
|
+
* Steps 2 and 3 are `resolveServerUrls()`, the resolver BetterAuth and CORS use, so
|
|
28
|
+
* the issuer agrees with the URL the rest of the server believes it has. Step 4 is
|
|
29
|
+
* the point: a deployed API that advertised `http://localhost:3000` made Claude Code
|
|
30
|
+
* try to register the client on the user's own machine (`ECONNREFUSED`).
|
|
31
|
+
*
|
|
14
32
|
* @example
|
|
15
33
|
* ```typescript
|
|
16
34
|
* // main.ts, after app.init()
|
|
17
|
-
* await mountAiMcpOAuth(app
|
|
35
|
+
* await mountAiMcpOAuth(app);
|
|
18
36
|
* ```
|
|
19
37
|
*/
|
|
20
38
|
export async function mountAiMcpOAuth(
|
|
21
39
|
app: { get: (token: any) => any; use: (...args: any[]) => any },
|
|
22
|
-
options: { baseUrl
|
|
40
|
+
options: { baseUrl?: string; mcpPath?: string } = {},
|
|
23
41
|
): Promise<void> {
|
|
42
|
+
const { baseUrl, source } = resolveMcpOAuthBaseUrl(options.baseUrl);
|
|
43
|
+
const issuerUrl = parseAbsoluteUrl(baseUrl, source);
|
|
24
44
|
const { mcpAuthRouter } = await import('@modelcontextprotocol/sdk/server/auth/router.js');
|
|
25
45
|
const oauthService: CoreAiMcpOAuthService = app.get(CoreAiMcpOAuthService);
|
|
26
46
|
const mcpPath = options.mcpPath ?? '/ai/mcp';
|
|
27
47
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
let router: unknown;
|
|
49
|
+
try {
|
|
50
|
+
router = mcpAuthRouter({
|
|
51
|
+
issuerUrl,
|
|
52
|
+
provider: oauthService.buildOAuthProvider() as any,
|
|
53
|
+
resourceServerUrl: new URL(`${baseUrl.replace(/\/$/, '')}${mcpPath}`),
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// The SDK's refusals ("Issuer URL must be HTTPS") do not say which URL they refused. Now
|
|
57
|
+
// that the URL can come from config rather than from the call site, that is the one thing
|
|
58
|
+
// an operator needs to know.
|
|
59
|
+
const reason = (error as Error).message;
|
|
60
|
+
throw new Error(`mountAiMcpOAuth: OAuth issuer "${baseUrl}" (from ${source}) rejected: ${reason}`, {
|
|
61
|
+
cause: error,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
33
64
|
|
|
34
65
|
app.use(router);
|
|
66
|
+
new Logger('mountAiMcpOAuth').log(`MCP OAuth issuer: ${issuerUrl.href} (from ${source})`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolves the issuer base URL. A blank `explicit` value counts as absent, because
|
|
71
|
+
* `BASE_URL=` in an env file yields `''`, not `undefined`.
|
|
72
|
+
*/
|
|
73
|
+
function resolveMcpOAuthBaseUrl(explicit: string | undefined): { baseUrl: string; source: string } {
|
|
74
|
+
const given = explicit?.trim();
|
|
75
|
+
if (given) {
|
|
76
|
+
return { baseUrl: given, source: 'options.baseUrl' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const config = ConfigService.configFastButReadOnly;
|
|
80
|
+
const resolved = resolveServerUrls({ baseUrl: config?.baseUrl, env: config?.env });
|
|
81
|
+
if (resolved.baseUrl) {
|
|
82
|
+
return {
|
|
83
|
+
baseUrl: resolved.baseUrl,
|
|
84
|
+
source:
|
|
85
|
+
resolved.baseUrlSource === 'localhost-default' ? `localhost default (env: ${config?.env})` : 'config.baseUrl',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
throw new Error(
|
|
90
|
+
`mountAiMcpOAuth: no public server URL for the OAuth issuer (env: ${config?.env ?? 'unset'}). ` +
|
|
91
|
+
'Set `baseUrl` in the server config (NSC__BASE_URL) or pass `options.baseUrl`. ' +
|
|
92
|
+
'A guessed localhost URL would send MCP clients to their own machine.',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseAbsoluteUrl(value: string, source: string): URL {
|
|
97
|
+
try {
|
|
98
|
+
return new URL(value);
|
|
99
|
+
} catch {
|
|
100
|
+
throw new Error(`mountAiMcpOAuth: "${value}" (from ${source}) is not an absolute URL`);
|
|
101
|
+
}
|
|
35
102
|
}
|
|
@@ -5,6 +5,7 @@ import { Connection } from 'mongoose';
|
|
|
5
5
|
|
|
6
6
|
import { isProductionLikeEnv } from '../../../common/helpers/cookies.helper';
|
|
7
7
|
import { ConfigService } from '../../../common/services/config.service';
|
|
8
|
+
import { getApiTokenContext } from '../../api-token/core-api-token.helpers';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Stored OAuth client (dynamically registered).
|
|
@@ -309,7 +310,18 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
|
|
|
309
310
|
*/
|
|
310
311
|
buildOAuthProvider(accessTtlSeconds = 3600): Record<string, any> {
|
|
311
312
|
return {
|
|
312
|
-
authorize: (client: any, params: any, res: any) =>
|
|
313
|
+
authorize: async (client: any, params: any, res: any) => {
|
|
314
|
+
// An API token (or its signed assertion) must never approve an OAuth consent. The consent mints
|
|
315
|
+
// an MCP access token with the FULL rights of its user, which would lift a scope-limited user
|
|
316
|
+
// token out of every restriction it carries — and a tenant token has no user to consent for at
|
|
317
|
+
// all. This route is an Express router outside the Nest guards, so the deny-by-default of
|
|
318
|
+
// @ApiTokenScopes() does not reach it; the check sits here rather than in authorizeConsent()
|
|
319
|
+
// so an override of that method cannot drop it.
|
|
320
|
+
if (getApiTokenContext(res?.req?.user)) {
|
|
321
|
+
throw new Error('access_denied: an API token cannot authorize an OAuth client');
|
|
322
|
+
}
|
|
323
|
+
return this.authorizeConsent(client, params, res);
|
|
324
|
+
},
|
|
313
325
|
challengeForAuthorizationCode: async (_client: any, authorizationCode: string) => {
|
|
314
326
|
const stored = await this.getAuthorizationCode(authorizationCode);
|
|
315
327
|
return stored?.codeChallenge ?? '';
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# API Tokens Integration Checklist
|
|
2
|
+
|
|
3
|
+
## Reference Implementation
|
|
4
|
+
|
|
5
|
+
- Module: `node_modules/@lenne.tech/nest-server/src/core/modules/api-token/` (README.md explains the model)
|
|
6
|
+
- Management controller as a project writes it: `tests/api-token.e2e-spec.ts` → `ApiTokenAdminController`
|
|
7
|
+
(in the GitHub repository; not shipped in the npm package)
|
|
8
|
+
|
|
9
|
+
## Required Steps
|
|
10
|
+
|
|
11
|
+
### 1. Enable the feature
|
|
12
|
+
|
|
13
|
+
**Edit:** `src/config.env.ts`
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
apiTokens: {
|
|
17
|
+
scopes: ['upload', 'read', 'export'],
|
|
18
|
+
encryptionKey: process.env.API_TOKEN_ENCRYPTION_KEY, // or SECRETS_ENCRYPTION_KEY
|
|
19
|
+
},
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**WHY the key:** the signing key of every token (for signed assertions) is stored encrypted with it.
|
|
23
|
+
The boot fails in production/staging without one. Rotating it invalidates all signing keys.
|
|
24
|
+
|
|
25
|
+
**WHY the scopes:** a token can only carry scopes from this list, and a route can only be opened for
|
|
26
|
+
them. With an empty list no token can be created (a boot warning says so).
|
|
27
|
+
|
|
28
|
+
### 2. Open the routes a token may call
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
@ApiTokenScopes('upload')
|
|
32
|
+
@Roles(RoleEnum.S_USER)
|
|
33
|
+
@Post('documents')
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**WHY explicitly:** tokens are denied on every route without `@ApiTokenScopes()`, public ones
|
|
37
|
+
included. Open only what a machine needs; never the routes that change credentials, email, roles or
|
|
38
|
+
memberships.
|
|
39
|
+
|
|
40
|
+
### 3. Add management endpoints
|
|
41
|
+
|
|
42
|
+
The core ships the service, the project owns the routes (same split as tenant members):
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
@Controller('api-tokens')
|
|
46
|
+
@Roles(RoleEnum.S_USER)
|
|
47
|
+
export class ApiTokenController {
|
|
48
|
+
constructor(private readonly apiTokens: CoreApiTokenService) {}
|
|
49
|
+
|
|
50
|
+
@Post('tenant') // X-Tenant-Id header selects the tenant
|
|
51
|
+
createTenantToken(@CurrentTenant() tenantId: string, @Body() input: any, @CurrentUser() user: any) {
|
|
52
|
+
return this.apiTokens.createTenantToken(tenantId, input, user);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Post('mine')
|
|
56
|
+
createUserToken(@Body() input: any, @CurrentUser() user: any) {
|
|
57
|
+
return this.apiTokens.createUserToken(input, user);
|
|
58
|
+
}
|
|
59
|
+
// find…/update…/revoke…/delete… analogously
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**WHY no rights logic here:** the service checks who may act (owner, `manageRole`, platform admin)
|
|
64
|
+
and refuses token-authenticated callers. Forward `@CurrentUser()` unchanged.
|
|
65
|
+
|
|
66
|
+
**WHY `@HttpCode(200)` on revoke routes:** Nest answers a `POST` with 201 by default.
|
|
67
|
+
|
|
68
|
+
### 4. Clean up with your own entities
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
await this.apiTokenService.deleteAllForTenant(tenantId); // when deleting a tenant
|
|
72
|
+
await this.apiTokenService.deleteAllForUser(userId); // when deleting a user
|
|
73
|
+
await this.apiTokenService.revokeAllForUser(userId); // after an account compromise
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**WHY:** the core has no tenant model to hook into, so nothing calls these for you.
|
|
77
|
+
|
|
78
|
+
- **For TENANT tokens `deleteAllForTenant()` is what ends access.** A tenant token belongs to the
|
|
79
|
+
tenant, not to a person: removing members does not touch it (that is the point — it survives staff
|
|
80
|
+
changes), and the core cannot see a tenant being deleted. Until you call it (or `revokeTenantToken()`),
|
|
81
|
+
a deleted or off-boarded tenant's tokens keep authenticating with the lowest tenant role.
|
|
82
|
+
- **For USER tokens** access already ends when the user or the membership disappears; the call only
|
|
83
|
+
removes the rows.
|
|
84
|
+
|
|
85
|
+
### 5. Optional: bind tokens to project data
|
|
86
|
+
|
|
87
|
+
Extend the model and register it:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
@Schema({ timestamps: true })
|
|
91
|
+
export class ApiToken extends CoreApiTokenModel {
|
|
92
|
+
@UnifiedField({ isOptional: true, mongoose: { type: String } })
|
|
93
|
+
exportConfigId: string = undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
CoreModule.forRoot(envConfig, { apiToken: { model: ApiToken } });
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Extra fields of a create/update input are stored as given (protected fields excepted). Read them from
|
|
100
|
+
`getApiTokenContext(user).tokenId` + your own lookup.
|
|
101
|
+
|
|
102
|
+
## Verification Checklist
|
|
103
|
+
|
|
104
|
+
- [ ] Build succeeds (`pnpm run build`), tests pass (`pnpm test`)
|
|
105
|
+
- [ ] A created token's plaintext appears in the create response only — never in a list
|
|
106
|
+
- [ ] `GET` on a route WITHOUT `@ApiTokenScopes()` with a token → 403 (also for a public route)
|
|
107
|
+
- [ ] A revoked token → 401 on every route
|
|
108
|
+
- [ ] A tenant token with a foreign `X-Tenant-Id` → 403
|
|
109
|
+
- [ ] A user token of an admin cannot call an ADMIN route
|
|
110
|
+
- [ ] Production config sets `apiTokens.encryptionKey` (or `SECRETS_ENCRYPTION_KEY`)
|
|
111
|
+
|
|
112
|
+
## Common Mistakes
|
|
113
|
+
|
|
114
|
+
| Mistake | Symptom | Fix |
|
|
115
|
+
| --------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------- |
|
|
116
|
+
| Route not opened | Token gets 403 everywhere | `@ApiTokenScopes('<scope>')` on method or class |
|
|
117
|
+
| Scope missing from `apiTokens.scopes` | 400 "Unknown scope(s)" on create | Add it to the vocabulary |
|
|
118
|
+
| `manageRole` not a tenant role / hierarchy with one level | Boot error | Declare a tenant role; keep a role below it, or `tenantTokens: false` |
|
|
119
|
+
| No encryption key in production | Boot error | Set `apiTokens.encryptionKey` |
|
|
120
|
+
| Shipping the token to a browser for an embedded page | Long-lived credential exposed | Mint a signed assertion server-side (README → "Signed assertions") |
|
|
121
|
+
| Expecting a token on a GraphQL subscription | 401 / anonymous | Tokens are HTTP-only |
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# API Tokens
|
|
2
|
+
|
|
3
|
+
Bearer credentials for machine clients, scripts and embedded pages that cannot carry a session cookie
|
|
4
|
+
(an iframe inside a third-party application gets no `SameSite=Lax` cookie). Opt-in via `apiTokens`;
|
|
5
|
+
without it nothing changes.
|
|
6
|
+
|
|
7
|
+
| | USER token | TENANT token |
|
|
8
|
+
| ------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
|
9
|
+
| Belongs to | one user | one tenant — survives staff changes |
|
|
10
|
+
| Managed by | its owner | members holding `apiTokens.manageRole`, platform admins |
|
|
11
|
+
| Acts with | its user's **current** rights, **without global roles** (ADMIN, `globalOnlyRoles`) | the **lowest** tenant role, never a global role |
|
|
12
|
+
| Tenants | the user's active memberships — optionally restricted to ONE | its own tenant only |
|
|
13
|
+
| Optional limits | scopes, one tenant, `maxTenantRole`, expiry | scopes, expiry |
|
|
14
|
+
| Needs multi-tenancy | no | yes |
|
|
15
|
+
|
|
16
|
+
Both kinds are **denied on every route** that does not declare `@ApiTokenScopes(...)`, public
|
|
17
|
+
routes included. Tokens never manage tokens.
|
|
18
|
+
|
|
19
|
+
## Configuration
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// config.env.ts
|
|
23
|
+
apiTokens: {
|
|
24
|
+
scopes: ['upload', 'read', 'export'], // the vocabulary routes and tokens use
|
|
25
|
+
encryptionKey: process.env.API_TOKEN_ENCRYPTION_KEY, // required in production/staging
|
|
26
|
+
// prefix: 'ltt', // tokens read ltt_…, assertions ltts_…
|
|
27
|
+
// manageRole: 'owner', // default: highest role of multiTenancy.roleHierarchy
|
|
28
|
+
// maxAssertionLifetimeSeconds: 900,
|
|
29
|
+
// rateLimit: { max: 600, windowSeconds: 60 }, // per token; false switches it off
|
|
30
|
+
// userTokens: true,
|
|
31
|
+
// tenantTokens: true, // only takes effect with multiTenancy
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`true` / `{}` enable with defaults, `{ enabled: false }` pre-configures. Full reference: `IApiTokens`
|
|
36
|
+
in `server-options.interface.ts`. The boot fails on an invalid prefix or scope, on a `manageRole` that
|
|
37
|
+
is not a declared tenant role, on a hierarchy in which the lowest role (a tenant token's role) reaches
|
|
38
|
+
the manage role, and in production/staging without an encryption key.
|
|
39
|
+
|
|
40
|
+
## Opening a route
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
@ApiTokenScopes('upload') // any ONE of the listed scopes; method replaces class
|
|
44
|
+
@Roles(RoleEnum.S_USER)
|
|
45
|
+
@Post('documents')
|
|
46
|
+
async upload(@CurrentUser() caller: any) {
|
|
47
|
+
const token = getApiTokenContext(caller); // undefined for a session / JWT
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
What an opened route means per kind:
|
|
52
|
+
|
|
53
|
+
- **TENANT token** — `S_EVERYONE` / `S_USER` / `S_VERIFIED` count as satisfied (the decorator is the
|
|
54
|
+
explicit permission for a machine); tenant roles resolve against the LOWEST hierarchy role; global
|
|
55
|
+
roles never match; `S_NO_ONE` refuses, and so does a route guarded ONLY by `S_SELF` / `S_CREATOR` —
|
|
56
|
+
those compare a person with a record, and a tenant token is no person. `X-Tenant-Id` is optional — it may only name the token's own
|
|
57
|
+
tenant. `@SkipTenantCheck()` does not unbind it.
|
|
58
|
+
- **USER token** — the ordinary checks run as its user. A tenant-restricted token is bound to that
|
|
59
|
+
tenant (a foreign header refuses, `@SkipTenantCheck()` does not unbind it); `maxTenantRole` caps the
|
|
60
|
+
membership role. Losing a membership removes the token's access at once — rights are read per
|
|
61
|
+
request, never copied into the token.
|
|
62
|
+
|
|
63
|
+
`request.user`:
|
|
64
|
+
|
|
65
|
+
- TENANT token: `{ id: <token id>, name, scopes, tenantId, roles: [], hasRole: () => false }`
|
|
66
|
+
- USER token: the user document (secrets never loaded, global roles removed) with `id` / `hasRole()`
|
|
67
|
+
|
|
68
|
+
`getApiTokenContext(user)` → `{ kind, tokenId, publicId, name, scopes, tenantId?, userId?, maxTenantRole?, assertion? }`
|
|
69
|
+
works for both. It is recognised by a module-private symbol, so a document carrying the same fields is
|
|
70
|
+
never mistaken for a token. `createdBy` / `updatedBy` written by a TENANT token hold the token id.
|
|
71
|
+
|
|
72
|
+
## Using a token
|
|
73
|
+
|
|
74
|
+
```http
|
|
75
|
+
GET /documents
|
|
76
|
+
Authorization: Bearer ltt_5f3c0a9e2b41d7c86e0f1a2b_9b1e… # or: x-api-key: ltt_…
|
|
77
|
+
X-Tenant-Id: <tenant> # optional for tenant tokens
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Token: `<prefix>_<publicId: 24 hex>_<secret: 64 hex>` (32 random bytes). Shown **once** at creation;
|
|
81
|
+
stored only as SHA-256 of the secret and compared in constant time.
|
|
82
|
+
|
|
83
|
+
| Situation | Status |
|
|
84
|
+
| ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
|
85
|
+
| Unknown, revoked, expired, tampered token or assertion; owner deleted (user tokens); two different credentials in `Authorization` and `x-api-key` | **401 on every route** (never silently anonymous) |
|
|
86
|
+
| Route not opened, scope missing, role too low, foreign tenant | 403 |
|
|
87
|
+
| Rate limit exceeded (per token, Redis-shared when configured) | 429 + `Retry-After` |
|
|
88
|
+
|
|
89
|
+
## Signed assertions (embedding)
|
|
90
|
+
|
|
91
|
+
An embedding application must not ship the long-lived token to a browser. It keeps the token's
|
|
92
|
+
**signing key** (returned once at creation, 64 hex characters) on its server and mints a short-lived
|
|
93
|
+
assertion per page load. The assertion acts with the token's scopes and limits, carries an optional
|
|
94
|
+
`sub` / `claims` for the audit trail, and dies with the token (revocation takes effect immediately).
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
payload = base64url( UTF-8 JSON { "claims"?: {...}, "exp": <unix seconds>, "nonce"?: "...", "sub"?: "...", "tid": "<publicId>" } )
|
|
98
|
+
signature = base64url( HMAC-SHA256( key = UTF-8 bytes of the signing key as issued,
|
|
99
|
+
data = ASCII bytes of the payload string ) )
|
|
100
|
+
assertion = "<prefix>s_" + payload + "." + signature # base64url WITHOUT padding
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`publicId` is the middle part of the token. `exp` may lie at most `maxAssertionLifetimeSeconds`
|
|
104
|
+
(default 900) in the future; 30 s of clock skew are tolerated on both edges. The server verifies the
|
|
105
|
+
signature over the payload string as received — key order and whitespace do not matter. `nonce` is
|
|
106
|
+
recorded, not enforced as single-use: an embedded page makes many requests with one assertion.
|
|
107
|
+
|
|
108
|
+
Node:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
import { signApiTokenAssertion } from '@lenne.tech/nest-server';
|
|
112
|
+
|
|
113
|
+
const assertion = signApiTokenAssertion({ publicId, signingKey, expiresInSeconds: 300, subject: 'b7user' });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
C#:
|
|
117
|
+
|
|
118
|
+
```csharp
|
|
119
|
+
using System.Security.Cryptography;
|
|
120
|
+
using System.Text;
|
|
121
|
+
using System.Text.Json;
|
|
122
|
+
|
|
123
|
+
static string B64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
124
|
+
|
|
125
|
+
var json = JsonSerializer.Serialize(new Dictionary<string, object> {
|
|
126
|
+
["exp"] = DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeSeconds(),
|
|
127
|
+
["sub"] = "b7user",
|
|
128
|
+
["tid"] = publicId,
|
|
129
|
+
});
|
|
130
|
+
var payload = B64Url(Encoding.UTF8.GetBytes(json));
|
|
131
|
+
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingKey));
|
|
132
|
+
var assertion = $"ltts_{payload}.{B64Url(hmac.ComputeHash(Encoding.ASCII.GetBytes(payload)))}";
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
PowerShell (5.1 and 7):
|
|
136
|
+
|
|
137
|
+
```powershell
|
|
138
|
+
function ConvertTo-B64Url([byte[]]$b) { [Convert]::ToBase64String($b).TrimEnd('=').Replace('+','-').Replace('/','_') }
|
|
139
|
+
$json = @{ exp = [DateTimeOffset]::UtcNow.AddMinutes(5).ToUnixTimeSeconds(); sub = 'b7user'; tid = $publicId } | ConvertTo-Json -Compress
|
|
140
|
+
$payload = ConvertTo-B64Url ([Text.Encoding]::UTF8.GetBytes($json))
|
|
141
|
+
$hmac = New-Object System.Security.Cryptography.HMACSHA256 (,[Text.Encoding]::UTF8.GetBytes($signingKey))
|
|
142
|
+
$assertion = "ltts_$payload." + (ConvertTo-B64Url ($hmac.ComputeHash([Text.Encoding]::ASCII.GetBytes($payload))))
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The signing key is stored AES-256-GCM encrypted (`apiTokens.encryptionKey`, fallback
|
|
146
|
+
`SECRETS_ENCRYPTION_KEY`). Rotating that key invalidates the signing keys — not the tokens — of all
|
|
147
|
+
existing tokens.
|
|
148
|
+
|
|
149
|
+
## Management — `CoreApiTokenService`
|
|
150
|
+
|
|
151
|
+
Every method takes the acting user and checks the right itself; a project controller only forwards
|
|
152
|
+
`@CurrentUser()` / `@CurrentTenant()`. See `INTEGRATION-CHECKLIST.md` for a controller.
|
|
153
|
+
|
|
154
|
+
| Method | Who |
|
|
155
|
+
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
156
|
+
| `createUserToken(input, currentUser)` → `{ apiToken, token, signingKey }` | the user (for themselves) |
|
|
157
|
+
| `findUserTokens` / `updateUserToken` / `revokeUserToken` / `deleteUserToken` | the owner |
|
|
158
|
+
| `createTenantToken(tenantId, input, currentUser)` → `{ apiToken, token, signingKey }` | `manageRole` in the tenant, platform admin |
|
|
159
|
+
| `findTenantTokens` / `updateTenantToken` / `revokeTenantToken` / `deleteTenantToken` | same |
|
|
160
|
+
| `deleteAllForTenant(tenantId)` | system — call when deleting a tenant (also removes user tokens restricted to it) |
|
|
161
|
+
| `revokeAllForUser(userId)` / `deleteAllForUser(userId)` | system — account compromise / user deletion |
|
|
162
|
+
|
|
163
|
+
Inputs: `name` (required), `description`, `scopes` (subset of the vocabulary; a user token without
|
|
164
|
+
scopes gets the whole vocabulary), `expiresAt` (future, or `null`), for user tokens `tenantId` (an
|
|
165
|
+
active membership of the owner) and `maxTenantRole` (a hierarchy role). Any other field is stored as
|
|
166
|
+
given — that is how a project binds a token to its own data (after extending the model) — except the
|
|
167
|
+
protected ones (`kind`, `tenant`, `user`, `publicId`, hashes, audit fields, `revokedAt`, `lastUsedAt`).
|
|
168
|
+
Returned objects never contain the hash or the signing key.
|
|
169
|
+
|
|
170
|
+
## Better-Auth
|
|
171
|
+
|
|
172
|
+
Runs alongside Better-Auth without changing it and also works without it (legacy mode):
|
|
173
|
+
`CoreBetterAuthMiddleware` leaves prefixed credentials alone, user tokens resolve the same `users`
|
|
174
|
+
document Better-Auth does, and `x-api-key` follows the header convention of Better-Auth's API-key
|
|
175
|
+
plugin. The official `@better-auth/api-key` plugin is deliberately not used: organisation-owned keys
|
|
176
|
+
there are authorised through Better-Auth's organization plugin rather than `CoreTenant`, its request
|
|
177
|
+
integration turns a key into a full session of its user on every route, it requires Better-Auth, and
|
|
178
|
+
it has no signed assertions.
|
|
179
|
+
|
|
180
|
+
## Security notes for projects
|
|
181
|
+
|
|
182
|
+
- **Routes outside the Nest guards do not see `@ApiTokenScopes()`.** Anything mounted with `app.use()`
|
|
183
|
+
(an Express router, a static handler) runs after `CoreApiTokenMiddleware` but before no guard, so
|
|
184
|
+
`req.user` may be a token there. Treat `getApiTokenContext(req.user)` as "not a person" in such code.
|
|
185
|
+
The framework's own case — the MCP OAuth consent step — refuses token requests, because a consent
|
|
186
|
+
would mint an access token with the user's FULL rights.
|
|
187
|
+
- **A tenant token is not a user.** `@CurrentUser()` then has no `email`, no `verified`; code that
|
|
188
|
+
mails or notifies "the current user" must check `getApiTokenContext()` first. Field-level
|
|
189
|
+
`@Restricted(S_VERIFIED)` stays hidden from tenant tokens (fail-closed).
|
|
190
|
+
- **Open routes deliberately.** Never release routes that change credentials, e-mail, roles,
|
|
191
|
+
memberships or tokens. The management service refuses tokens anyway.
|
|
192
|
+
- **Password resets.** A reset that ends the user's sessions ends their user tokens too: the legacy
|
|
193
|
+
reset (`CoreUserService.resetPassword()`) always, the IAM reset while
|
|
194
|
+
`betterAuth.emailAndPassword.revokeSessionsOnPasswordReset` is on. With it off — and on a password
|
|
195
|
+
CHANGE — user tokens survive, like personal access tokens elsewhere; after a suspected compromise
|
|
196
|
+
call `revokeAllForUser(userId)`. Tenant tokens are never touched by a user's reset.
|
|
197
|
+
- **Deleting a tenant.** Call `deleteAllForTenant(tenantId)` — nothing does it for you, and until you
|
|
198
|
+
do, the tenant's tokens keep authenticating (the core has no tenant model to notice the deletion).
|
|
199
|
+
- **Banned users.** If you add Better-Auth's admin plugin, override `loadTokenUser()` so a banned
|
|
200
|
+
user's tokens are refused as well — the framework does not know that flag.
|
|
201
|
+
- **Brute force.** Failed attempts are not limited per IP (guessing 256 bits is not the risk); add an
|
|
202
|
+
IP limit in front of an exposed API if you want one.
|
|
203
|
+
- **Secret scanning:** the fixed shape `<prefix>_<24 hex>_<64 hex>` can be registered as a custom
|
|
204
|
+
pattern in your repository's secret scanning.
|
|
205
|
+
|
|
206
|
+
## Limitations
|
|
207
|
+
|
|
208
|
+
- HTTP only: GraphQL over WebSocket (subscriptions) does not accept API tokens.
|
|
209
|
+
- Better-Auth's native `/iam/*` handlers do not recognise tokens — a token cannot sign in, change a
|
|
210
|
+
password or read a session.
|
|
211
|
+
- A 401/429 from the middleware on `/graphql` has the REST error shape, not the GraphQL one.
|
|
212
|
+
- `lastUsedAt` is accurate to one minute per replica.
|