@lenne.tech/nest-server 11.41.3 → 11.41.5
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 +6 -3
- 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 +21 -1
- 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/migrate/migration-runner.d.ts +1 -0
- package/dist/core/modules/migrate/migration-runner.js +3 -0
- package/dist/core/modules/migrate/migration-runner.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 +21 -13
- package/migration-guides/11.41.3-to-11.41.4.md +172 -0
- package/migration-guides/11.41.4-to-11.41.5.md +144 -0
- package/package.json +35 -34
- 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 +122 -6
- 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/migrate/README.md +15 -7
- package/src/core/modules/migrate/migration-runner.ts +9 -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
|
@@ -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.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Import-free leaf on purpose: guards of three modules (auth, better-auth, tenant) read these values,
|
|
2
|
+
// and a leaf can never be mid-evaluation when one of them imports it (see .claude/rules/architecture.md
|
|
3
|
+
// → "DI Token Placement (SWC-Safe)").
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Injection token / Mongoose model name for API tokens.
|
|
7
|
+
*/
|
|
8
|
+
export const API_TOKEN_MODEL_TOKEN = 'ApiToken';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Metadata key for the `@ApiTokenScopes()` decorator.
|
|
12
|
+
*/
|
|
13
|
+
export const API_TOKEN_SCOPES_KEY = 'apiTokenScopes';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Who an API token belongs to.
|
|
17
|
+
*
|
|
18
|
+
* - `USER`: owned by a user; acts with that user's CURRENT rights (never global roles such as ADMIN),
|
|
19
|
+
* optionally narrowed to scopes, one tenant and a maximum tenant role. Works with and without
|
|
20
|
+
* multi-tenancy.
|
|
21
|
+
* - `TENANT`: owned by a tenant, independent of any person; managed by the tenant's administrators,
|
|
22
|
+
* acts with the lowest tenant role and only inside its own tenant. Requires multi-tenancy.
|
|
23
|
+
*/
|
|
24
|
+
export enum ApiTokenKind {
|
|
25
|
+
TENANT = 'tenant',
|
|
26
|
+
USER = 'user',
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { API_TOKEN_SCOPES_KEY } from './core-api-token.constants';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Method/class decorator that opens an endpoint to API tokens (`apiTokens` config).
|
|
7
|
+
*
|
|
8
|
+
* Tokens are DENIED on every route by default — user tokens and tenant tokens alike. A route accepts
|
|
9
|
+
* one only when it names the scopes that may call it; a token holding ANY one of them passes. A
|
|
10
|
+
* method-level declaration replaces the class-level one rather than adding to it, so a class can open
|
|
11
|
+
* itself for `'read'` while a single method narrows to `'export'`.
|
|
12
|
+
*
|
|
13
|
+
* Opening a route does not widen what a token may do there, it only stops the blanket refusal:
|
|
14
|
+
* - a USER token then acts as its user (without global roles), bounded by the token's own limits;
|
|
15
|
+
* - a TENANT token acts as a member of its tenant with the LOWEST role of the hierarchy, and
|
|
16
|
+
* `S_EVERYONE` / `S_USER` / `S_VERIFIED` count as satisfied for it.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* @ApiTokenScopes('upload')
|
|
21
|
+
* @Roles(RoleEnum.S_USER)
|
|
22
|
+
* @Post('documents')
|
|
23
|
+
* async upload(@CurrentUser() caller: any) {
|
|
24
|
+
* const token = getApiTokenContext(caller); // undefined for a session, set for a token
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export const ApiTokenScopes = (scope: string, ...moreScopes: string[]) =>
|
|
29
|
+
SetMetadata(API_TOKEN_SCOPES_KEY, [scope, ...moreScopes]);
|