@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,822 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BadRequestException,
|
|
5
|
+
ForbiddenException,
|
|
6
|
+
Injectable,
|
|
7
|
+
Logger,
|
|
8
|
+
NotFoundException,
|
|
9
|
+
OnModuleDestroy,
|
|
10
|
+
OnModuleInit,
|
|
11
|
+
Optional,
|
|
12
|
+
UnauthorizedException,
|
|
13
|
+
} from '@nestjs/common';
|
|
14
|
+
import { InjectConnection, InjectModel } from '@nestjs/mongoose';
|
|
15
|
+
import { Connection, Model, Types } from 'mongoose';
|
|
16
|
+
|
|
17
|
+
import { RoleEnum } from '../../common/enums/role.enum';
|
|
18
|
+
import { ConfigService } from '../../common/services/config.service';
|
|
19
|
+
import { CoreRedisService } from '../../common/services/core-redis.service';
|
|
20
|
+
import { InMemoryRateLimitStore, RateLimitStore, RedisRateLimitStore } from '../../common/services/rate-limit-store';
|
|
21
|
+
import { ErrorCode } from '../error-code/error-codes';
|
|
22
|
+
import { checkRoleAccess, getRoleHierarchy, resolveGlobalAndTenantRoles } from '../tenant/core-tenant.helpers';
|
|
23
|
+
import { CoreTenantService } from '../tenant/core-tenant.service';
|
|
24
|
+
import { API_TOKEN_MODEL_TOKEN, ApiTokenKind } from './core-api-token.constants';
|
|
25
|
+
import {
|
|
26
|
+
assertApiTokenConfigIsUsable,
|
|
27
|
+
attachApiTokenContext,
|
|
28
|
+
checkApiTokenAssertionTiming,
|
|
29
|
+
createTenantApiTokenPrincipal,
|
|
30
|
+
decodeApiTokenAssertion,
|
|
31
|
+
generateApiToken,
|
|
32
|
+
generateApiTokenSigningKey,
|
|
33
|
+
getApiTokenConfig,
|
|
34
|
+
getApiTokenContext,
|
|
35
|
+
getApiTokenManageRole,
|
|
36
|
+
hashApiTokenSecret,
|
|
37
|
+
IApiTokenContext,
|
|
38
|
+
IApiTokenCredential,
|
|
39
|
+
IResolvedApiTokenConfig,
|
|
40
|
+
parseApiToken,
|
|
41
|
+
resolveApiTokenEncryptionKey,
|
|
42
|
+
safeEqualHex,
|
|
43
|
+
verifyApiTokenAssertionSignature,
|
|
44
|
+
} from './core-api-token.helpers';
|
|
45
|
+
import { CoreApiTokenModel } from './core-api-token.model';
|
|
46
|
+
import { ApiTokenOwnerIdentity, setApiTokenRevoker } from './core-api-token.registry';
|
|
47
|
+
|
|
48
|
+
/** Fields a caller can never set through a create/update input. */
|
|
49
|
+
const PROTECTED_FIELDS = new Set([
|
|
50
|
+
'_id',
|
|
51
|
+
'__v',
|
|
52
|
+
'createdAt',
|
|
53
|
+
'createdBy',
|
|
54
|
+
'id',
|
|
55
|
+
'kind',
|
|
56
|
+
'lastUsedAt',
|
|
57
|
+
'publicId',
|
|
58
|
+
'revokedAt',
|
|
59
|
+
'secretHash',
|
|
60
|
+
'signingKeyEncrypted',
|
|
61
|
+
'tenant',
|
|
62
|
+
'updatedAt',
|
|
63
|
+
'updatedBy',
|
|
64
|
+
'user',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/** User fields never loaded onto a token-authenticated request user. */
|
|
68
|
+
const USER_SECRET_PROJECTION = {
|
|
69
|
+
password: 0,
|
|
70
|
+
passwordResetToken: 0,
|
|
71
|
+
passwordResetTokenExpiresAt: 0,
|
|
72
|
+
refreshTokens: 0,
|
|
73
|
+
tempTokens: 0,
|
|
74
|
+
verificationToken: 0,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Input for creating a USER token. Additional (project) fields are stored as given. */
|
|
78
|
+
export interface ICreateUserApiTokenInput {
|
|
79
|
+
[projectField: string]: unknown;
|
|
80
|
+
description?: string;
|
|
81
|
+
/** Expiry (Date or ISO string); must lie in the future. Omit for no expiry. */
|
|
82
|
+
expiresAt?: Date | null | string;
|
|
83
|
+
/** Cap for the tenant role the token may act with (multi-tenancy only; a hierarchy role). */
|
|
84
|
+
maxTenantRole?: null | string;
|
|
85
|
+
name: string;
|
|
86
|
+
/** Subset of `apiTokens.scopes`. Omit to receive the whole vocabulary. */
|
|
87
|
+
scopes?: string[];
|
|
88
|
+
/** Restrict the token to one tenant the user is an active member of (multi-tenancy only). */
|
|
89
|
+
tenantId?: null | string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Input for creating a TENANT token. Additional (project) fields are stored as given. */
|
|
93
|
+
export interface ICreateTenantApiTokenInput {
|
|
94
|
+
[projectField: string]: unknown;
|
|
95
|
+
description?: string;
|
|
96
|
+
/** Expiry (Date or ISO string); must lie in the future. Omit for no expiry. */
|
|
97
|
+
expiresAt?: Date | null | string;
|
|
98
|
+
name: string;
|
|
99
|
+
/** Subset of `apiTokens.scopes`; at least one. */
|
|
100
|
+
scopes: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Input for updating a token. Only the given fields change; `null` clears an optional one. */
|
|
104
|
+
export interface IUpdateApiTokenInput {
|
|
105
|
+
[projectField: string]: unknown;
|
|
106
|
+
description?: null | string;
|
|
107
|
+
expiresAt?: Date | null | string;
|
|
108
|
+
/** USER tokens only. */
|
|
109
|
+
maxTenantRole?: null | string;
|
|
110
|
+
name?: string;
|
|
111
|
+
scopes?: string[];
|
|
112
|
+
/** USER tokens only. */
|
|
113
|
+
tenantId?: null | string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A token as returned by the service — never with its hash or signing key. */
|
|
117
|
+
export type ApiTokenInfo = Omit<CoreApiTokenModel, 'secretHash' | 'securityCheck' | 'signingKeyEncrypted'> & {
|
|
118
|
+
[projectField: string]: unknown;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Result of creating a token. `token` and `signingKey` exist only here — they are never shown again. */
|
|
122
|
+
export interface ICreatedApiToken {
|
|
123
|
+
apiToken: ApiTokenInfo;
|
|
124
|
+
/** For signed assertions (64 hex characters), see README → "Signed assertions". */
|
|
125
|
+
signingKey: string;
|
|
126
|
+
/** The credential itself: `Authorization: Bearer <token>` or `x-api-key: <token>`. */
|
|
127
|
+
token: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* API tokens: authentication (used by `CoreApiTokenMiddleware`) and management.
|
|
132
|
+
*
|
|
133
|
+
* Management is split by owner:
|
|
134
|
+
* - USER tokens — the user manages their own (`*UserToken*`),
|
|
135
|
+
* - TENANT tokens — people holding `apiTokens.manageRole` in the tenant, or platform admins
|
|
136
|
+
* (`*TenantToken*`).
|
|
137
|
+
* Every management method takes the acting user and checks the right itself, so a project controller
|
|
138
|
+
* only forwards `@CurrentUser()` (and `@CurrentTenant()`). A request authenticated WITH a token is
|
|
139
|
+
* always refused: tokens never manage tokens.
|
|
140
|
+
*
|
|
141
|
+
* Extend via the Module Inheritance Pattern and register the subclass with
|
|
142
|
+
* `CoreModule.forRoot(env, { apiToken: { service } })`.
|
|
143
|
+
*/
|
|
144
|
+
@Injectable()
|
|
145
|
+
export class CoreApiTokenService implements OnModuleDestroy, OnModuleInit {
|
|
146
|
+
protected readonly logger = new Logger(CoreApiTokenService.name);
|
|
147
|
+
|
|
148
|
+
/** Last `lastUsedAt` write per token in this process. */
|
|
149
|
+
protected readonly lastUsedWrites = new Map<string, number>();
|
|
150
|
+
protected rateLimitStore?: RateLimitStore;
|
|
151
|
+
private warnedAboutDevelopmentKey = false;
|
|
152
|
+
private disposeRevoker?: () => void;
|
|
153
|
+
|
|
154
|
+
/** Minimum interval between two `lastUsedAt` writes for the same token. */
|
|
155
|
+
protected static readonly LAST_USED_INTERVAL_MS = 60_000;
|
|
156
|
+
/** Size cap of the `lastUsedAt` throttle map. */
|
|
157
|
+
protected static readonly LAST_USED_MAX_ENTRIES = 5000;
|
|
158
|
+
|
|
159
|
+
constructor(
|
|
160
|
+
@InjectModel(API_TOKEN_MODEL_TOKEN) protected readonly apiTokenModel: Model<CoreApiTokenModel>,
|
|
161
|
+
@Optional() @InjectConnection() protected readonly connection?: Connection,
|
|
162
|
+
@Optional() protected readonly tenantService?: CoreTenantService,
|
|
163
|
+
@Optional() protected readonly redisService?: CoreRedisService,
|
|
164
|
+
) {}
|
|
165
|
+
|
|
166
|
+
onModuleInit(): void {
|
|
167
|
+
assertApiTokenConfigIsUsable();
|
|
168
|
+
// Let the password-reset paths (Better-Auth hook, legacy resetPassword) revoke user tokens
|
|
169
|
+
// without importing this optional module — see core-api-token.registry.ts.
|
|
170
|
+
this.disposeRevoker = setApiTokenRevoker((identity) => this.revokeAllForOwner(identity));
|
|
171
|
+
const config = getApiTokenConfig();
|
|
172
|
+
if (config.enabled && config.scopes.length === 0) {
|
|
173
|
+
this.logger.warn(
|
|
174
|
+
'apiTokens is enabled but apiTokens.scopes is empty: no token can be created and no route can be opened ' +
|
|
175
|
+
'to one. Declare the scopes your routes use, e.g. scopes: ["read", "upload"].',
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
onModuleDestroy(): void {
|
|
181
|
+
this.disposeRevoker?.();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ===================================================================================================================
|
|
185
|
+
// Authentication
|
|
186
|
+
// ===================================================================================================================
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolve a credential to the request user, or `null` for anything that must be refused — unknown,
|
|
190
|
+
* malformed, revoked, expired, wrong secret, bad signature, disabled kind, deleted owner. Never throws
|
|
191
|
+
* for a bad credential, never logs one.
|
|
192
|
+
*
|
|
193
|
+
* - TENANT token → a principal (`createTenantApiTokenPrincipal`)
|
|
194
|
+
* - USER token → the owning user, freshly loaded, without global roles
|
|
195
|
+
* Both carry the token context (`getApiTokenContext`).
|
|
196
|
+
*/
|
|
197
|
+
async authenticate(credential: IApiTokenCredential): Promise<null | object> {
|
|
198
|
+
const config = getApiTokenConfig();
|
|
199
|
+
if (!config.enabled) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (credential.kind === 'token') {
|
|
204
|
+
const parsed = parseApiToken(credential.value, config.prefix);
|
|
205
|
+
if (!parsed) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const token = await this.findUsableToken(parsed.publicId, config);
|
|
209
|
+
if (!token || !safeEqualHex(hashApiTokenSecret(parsed.secret), token.secretHash)) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
return this.buildRequestUser(token);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const decoded = decodeApiTokenAssertion(credential.value, config.prefix);
|
|
216
|
+
if (!decoded || checkApiTokenAssertionTiming(decoded.payload, config.maxAssertionLifetimeSeconds) !== 'ok') {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
const token = await this.findUsableToken(decoded.payload.tid, config);
|
|
220
|
+
if (!token?.signingKeyEncrypted) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
let signingKey: string;
|
|
224
|
+
try {
|
|
225
|
+
signingKey = this.decryptSigningKey(token.signingKeyEncrypted);
|
|
226
|
+
} catch {
|
|
227
|
+
// Logged inside decryptSigningKey without the value; a key mismatch refuses the request.
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
if (!verifyApiTokenAssertionSignature(decoded.payloadSegment, decoded.signature, signingKey)) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return this.buildRequestUser(token, {
|
|
234
|
+
claims: decoded.payload.claims,
|
|
235
|
+
expiresAt: new Date(decoded.payload.exp * 1000),
|
|
236
|
+
nonce: decoded.payload.nonce,
|
|
237
|
+
subject: decoded.payload.sub,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Count one request against the token's limit. Returns `{ retryAfter }` once the limit is exceeded,
|
|
243
|
+
* `undefined` otherwise (or when rate limiting is off). Shared across replicas with `redis`.
|
|
244
|
+
*/
|
|
245
|
+
async consumeRateLimit(tokenId: string): Promise<undefined | { retryAfter: number }> {
|
|
246
|
+
const { rateLimit } = getApiTokenConfig();
|
|
247
|
+
if (!rateLimit) {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
const { count, resetIn } = await this.getRateLimitStore().hit(tokenId, rateLimit.windowSeconds);
|
|
251
|
+
return count > rateLimit.max ? { retryAfter: Math.max(1, Math.ceil(resetIn)) } : undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Record a use — at most once per minute and token in this process, never awaited by the request. */
|
|
255
|
+
touchLastUsed(tokenId: string): void {
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
const last = this.lastUsedWrites.get(tokenId);
|
|
258
|
+
if (last !== undefined && now - last < CoreApiTokenService.LAST_USED_INTERVAL_MS) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (this.lastUsedWrites.size >= CoreApiTokenService.LAST_USED_MAX_ENTRIES) {
|
|
262
|
+
this.lastUsedWrites.clear();
|
|
263
|
+
}
|
|
264
|
+
this.lastUsedWrites.set(tokenId, now);
|
|
265
|
+
this.apiTokenModel
|
|
266
|
+
.updateOne({ _id: tokenId }, { $set: { lastUsedAt: new Date(now) } }, { timestamps: false })
|
|
267
|
+
.exec()
|
|
268
|
+
.catch((error: Error) => this.logger.debug(`lastUsedAt update failed: ${error.message}`));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A token that may authenticate right now: known, not revoked, not expired, kind enabled. */
|
|
272
|
+
protected async findUsableToken(
|
|
273
|
+
publicId: string,
|
|
274
|
+
config: IResolvedApiTokenConfig,
|
|
275
|
+
): Promise<CoreApiTokenModel | null> {
|
|
276
|
+
const token = (await this.apiTokenModel
|
|
277
|
+
.findOne({ publicId })
|
|
278
|
+
.select('+secretHash +signingKeyEncrypted')
|
|
279
|
+
.lean()
|
|
280
|
+
.exec()) as (CoreApiTokenModel & { _id: Types.ObjectId }) | null;
|
|
281
|
+
if (!token || token.revokedAt || (token.expiresAt && new Date(token.expiresAt).getTime() <= Date.now())) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
if (
|
|
285
|
+
token.kind === ApiTokenKind.TENANT ? !config.tenantTokens || !token.tenant : !config.userTokens || !token.user
|
|
286
|
+
) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
return { ...token, id: token._id.toString() } as CoreApiTokenModel;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Build `request.user` for a usable token. `null` when the owner of a user token no longer exists. */
|
|
293
|
+
protected async buildRequestUser(
|
|
294
|
+
token: CoreApiTokenModel,
|
|
295
|
+
assertion?: IApiTokenContext['assertion'],
|
|
296
|
+
): Promise<null | object> {
|
|
297
|
+
const context: Omit<IApiTokenContext, 'kind'> = {
|
|
298
|
+
assertion,
|
|
299
|
+
maxTenantRole: token.maxTenantRole || undefined,
|
|
300
|
+
name: token.name,
|
|
301
|
+
publicId: token.publicId,
|
|
302
|
+
scopes: token.scopes ?? [],
|
|
303
|
+
tenantId: token.tenant || undefined,
|
|
304
|
+
tokenId: token.id,
|
|
305
|
+
userId: token.user || undefined,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
if (token.kind === ApiTokenKind.TENANT) {
|
|
309
|
+
return createTenantApiTokenPrincipal({ ...context, tenantId: token.tenant });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const user = await this.loadTokenUser(token.user);
|
|
313
|
+
return user ? attachApiTokenContext(user, { ...context, kind: ApiTokenKind.USER }) : null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Load the owner of a USER token as a request user — the same `users` document Better-Auth and the
|
|
318
|
+
* legacy JWT strategy resolve, so the token acts as exactly that person.
|
|
319
|
+
*
|
|
320
|
+
* Global roles (RoleEnum.ADMIN, `multiTenancy.globalOnlyRoles`) are REMOVED: a long-lived bearer
|
|
321
|
+
* string must not carry platform authority, and without them there is no admin bypass across tenant
|
|
322
|
+
* boundaries either. Secrets (password hash, reset/verification tokens) are never loaded.
|
|
323
|
+
* `protected` so a project with a different user store can override it.
|
|
324
|
+
*/
|
|
325
|
+
protected async loadTokenUser(userId: string): Promise<null | Record<string, any>> {
|
|
326
|
+
if (!this.connection || !userId || !Types.ObjectId.isValid(userId)) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const doc = await this.connection
|
|
330
|
+
.collection('users')
|
|
331
|
+
.findOne({ _id: new Types.ObjectId(userId) }, { projection: USER_SECRET_PROJECTION });
|
|
332
|
+
if (!doc) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const roles = (Array.isArray(doc.roles) ? doc.roles : []).filter(
|
|
337
|
+
(role: unknown): role is string =>
|
|
338
|
+
typeof role === 'string' && resolveGlobalAndTenantRoles([role]).global.length === 0,
|
|
339
|
+
);
|
|
340
|
+
return {
|
|
341
|
+
...doc,
|
|
342
|
+
hasRole: (required: string | string[]): boolean =>
|
|
343
|
+
(Array.isArray(required) ? required : [required]).some((role) => roles.includes(role)),
|
|
344
|
+
id: doc._id.toString(),
|
|
345
|
+
roles,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
protected getRateLimitStore(): RateLimitStore {
|
|
350
|
+
if (!this.rateLimitStore) {
|
|
351
|
+
this.rateLimitStore = this.redisService?.enabled
|
|
352
|
+
? new RedisRateLimitStore(this.redisService, 'api-token')
|
|
353
|
+
: new InMemoryRateLimitStore(10_000);
|
|
354
|
+
}
|
|
355
|
+
return this.rateLimitStore;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ===================================================================================================================
|
|
359
|
+
// USER tokens — managed by their owner
|
|
360
|
+
// ===================================================================================================================
|
|
361
|
+
|
|
362
|
+
/** Create a USER token for the acting user. The plaintext token and signing key are returned only here. */
|
|
363
|
+
async createUserToken(input: ICreateUserApiTokenInput, currentUser: any): Promise<ICreatedApiToken> {
|
|
364
|
+
const config = this.requireKind(ApiTokenKind.USER);
|
|
365
|
+
this.assertPerson(currentUser);
|
|
366
|
+
|
|
367
|
+
const scopes = this.validateScopes(input?.scopes ?? config.scopes, config);
|
|
368
|
+
const tenantId = await this.validateUserTokenTenant(input?.tenantId, currentUser, config);
|
|
369
|
+
const maxTenantRole = this.validateMaxTenantRole(input?.maxTenantRole, config);
|
|
370
|
+
|
|
371
|
+
return this.issue(
|
|
372
|
+
{
|
|
373
|
+
...this.projectFields(input),
|
|
374
|
+
description: this.validateDescription(input?.description),
|
|
375
|
+
expiresAt: this.validateExpiry(input?.expiresAt),
|
|
376
|
+
kind: ApiTokenKind.USER,
|
|
377
|
+
maxTenantRole,
|
|
378
|
+
name: this.validateName(input?.name),
|
|
379
|
+
scopes,
|
|
380
|
+
tenant: tenantId,
|
|
381
|
+
user: String(currentUser.id),
|
|
382
|
+
},
|
|
383
|
+
currentUser,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The acting user's own USER tokens. */
|
|
388
|
+
async findUserTokens(currentUser: any): Promise<ApiTokenInfo[]> {
|
|
389
|
+
this.requireKind(ApiTokenKind.USER);
|
|
390
|
+
this.assertPerson(currentUser);
|
|
391
|
+
return this.list({ kind: ApiTokenKind.USER, user: String(currentUser.id) });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Change one of the acting user's own USER tokens. */
|
|
395
|
+
async updateUserToken(tokenId: string, input: IUpdateApiTokenInput, currentUser: any): Promise<ApiTokenInfo> {
|
|
396
|
+
const config = this.requireKind(ApiTokenKind.USER);
|
|
397
|
+
this.assertPerson(currentUser);
|
|
398
|
+
const filter = this.userTokenFilter(tokenId, currentUser);
|
|
399
|
+
const $set: Record<string, unknown> = this.commonUpdate(input, config);
|
|
400
|
+
if (input?.tenantId !== undefined) {
|
|
401
|
+
$set.tenant = await this.validateUserTokenTenant(input.tenantId, currentUser, config);
|
|
402
|
+
}
|
|
403
|
+
if (input?.maxTenantRole !== undefined) {
|
|
404
|
+
$set.maxTenantRole = this.validateMaxTenantRole(input.maxTenantRole, config);
|
|
405
|
+
}
|
|
406
|
+
return this.applyUpdate(filter, $set, currentUser);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Revoke one of the acting user's own USER tokens (idempotent). */
|
|
410
|
+
async revokeUserToken(tokenId: string, currentUser: any): Promise<ApiTokenInfo> {
|
|
411
|
+
this.requireKind(ApiTokenKind.USER);
|
|
412
|
+
this.assertPerson(currentUser);
|
|
413
|
+
return this.applyRevoke(this.userTokenFilter(tokenId, currentUser), currentUser);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Delete one of the acting user's own USER tokens. */
|
|
417
|
+
async deleteUserToken(tokenId: string, currentUser: any): Promise<ApiTokenInfo> {
|
|
418
|
+
this.requireKind(ApiTokenKind.USER);
|
|
419
|
+
this.assertPerson(currentUser);
|
|
420
|
+
return this.applyDelete(this.userTokenFilter(tokenId, currentUser));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Revoke every token of a user (system operation, no rights check) — e.g. after an account compromise
|
|
425
|
+
* or when the user is blocked. Returns the number of tokens revoked.
|
|
426
|
+
*/
|
|
427
|
+
async revokeAllForUser(userId: string): Promise<number> {
|
|
428
|
+
const result = await this.apiTokenModel
|
|
429
|
+
.updateMany(
|
|
430
|
+
{ kind: ApiTokenKind.USER, revokedAt: null, user: String(userId) },
|
|
431
|
+
{ $set: { revokedAt: new Date() } },
|
|
432
|
+
)
|
|
433
|
+
.exec();
|
|
434
|
+
return result.modifiedCount ?? 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Revoke every token of a user identified by `users` id, Better-Auth (IAM) id or email — what the
|
|
439
|
+
* password-reset paths know. Resolved the way the Better-Auth user mapper resolves a user
|
|
440
|
+
* (`email` or `iamId`). Returns the number of tokens revoked; `0` for an unknown user.
|
|
441
|
+
*/
|
|
442
|
+
async revokeAllForOwner(identity: ApiTokenOwnerIdentity): Promise<number> {
|
|
443
|
+
let userId = identity?.userId;
|
|
444
|
+
if (!userId && this.connection && (identity?.email || identity?.iamId)) {
|
|
445
|
+
const or: Record<string, string>[] = [];
|
|
446
|
+
if (identity.email) or.push({ email: identity.email });
|
|
447
|
+
if (identity.iamId) or.push({ iamId: identity.iamId });
|
|
448
|
+
const doc = await this.connection.collection('users').findOne({ $or: or }, { projection: { _id: 1 } });
|
|
449
|
+
userId = doc?._id?.toString();
|
|
450
|
+
}
|
|
451
|
+
return userId ? this.revokeAllForUser(userId) : 0;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Delete every token of a user (system operation, no rights check) — call it when deleting the user. */
|
|
455
|
+
async deleteAllForUser(userId: string): Promise<number> {
|
|
456
|
+
const result = await this.apiTokenModel.deleteMany({ kind: ApiTokenKind.USER, user: String(userId) }).exec();
|
|
457
|
+
return result.deletedCount ?? 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ===================================================================================================================
|
|
461
|
+
// TENANT tokens — managed by the tenant's administrators
|
|
462
|
+
// ===================================================================================================================
|
|
463
|
+
|
|
464
|
+
/** Create a TENANT token. The plaintext token and signing key are returned only here. */
|
|
465
|
+
async createTenantToken(
|
|
466
|
+
tenantId: string,
|
|
467
|
+
input: ICreateTenantApiTokenInput,
|
|
468
|
+
currentUser: any,
|
|
469
|
+
): Promise<ICreatedApiToken> {
|
|
470
|
+
const config = this.requireKind(ApiTokenKind.TENANT);
|
|
471
|
+
await this.assertCanManageTenant(tenantId, currentUser, config);
|
|
472
|
+
if (!input?.scopes?.length) {
|
|
473
|
+
throw new BadRequestException('A tenant token needs at least one scope');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return this.issue(
|
|
477
|
+
{
|
|
478
|
+
...this.projectFields(input),
|
|
479
|
+
description: this.validateDescription(input.description),
|
|
480
|
+
expiresAt: this.validateExpiry(input.expiresAt),
|
|
481
|
+
kind: ApiTokenKind.TENANT,
|
|
482
|
+
name: this.validateName(input.name),
|
|
483
|
+
scopes: this.validateScopes(input.scopes, config),
|
|
484
|
+
tenant: tenantId,
|
|
485
|
+
},
|
|
486
|
+
currentUser,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The tenant's TENANT tokens. */
|
|
491
|
+
async findTenantTokens(tenantId: string, currentUser: any): Promise<ApiTokenInfo[]> {
|
|
492
|
+
const config = this.requireKind(ApiTokenKind.TENANT);
|
|
493
|
+
await this.assertCanManageTenant(tenantId, currentUser, config);
|
|
494
|
+
return this.list({ kind: ApiTokenKind.TENANT, tenant: tenantId });
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Change a TENANT token of the tenant. */
|
|
498
|
+
async updateTenantToken(
|
|
499
|
+
tenantId: string,
|
|
500
|
+
tokenId: string,
|
|
501
|
+
input: IUpdateApiTokenInput,
|
|
502
|
+
currentUser: any,
|
|
503
|
+
): Promise<ApiTokenInfo> {
|
|
504
|
+
const config = this.requireKind(ApiTokenKind.TENANT);
|
|
505
|
+
await this.assertCanManageTenant(tenantId, currentUser, config);
|
|
506
|
+
if (input?.tenantId !== undefined || input?.maxTenantRole !== undefined) {
|
|
507
|
+
throw new BadRequestException('tenantId and maxTenantRole apply to user tokens only');
|
|
508
|
+
}
|
|
509
|
+
const $set = this.commonUpdate(input, config);
|
|
510
|
+
if (Array.isArray($set.scopes) && !$set.scopes.length) {
|
|
511
|
+
throw new BadRequestException('A tenant token needs at least one scope');
|
|
512
|
+
}
|
|
513
|
+
return this.applyUpdate(this.tenantTokenFilter(tenantId, tokenId), $set, currentUser);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Revoke a TENANT token of the tenant (idempotent). */
|
|
517
|
+
async revokeTenantToken(tenantId: string, tokenId: string, currentUser: any): Promise<ApiTokenInfo> {
|
|
518
|
+
const config = this.requireKind(ApiTokenKind.TENANT);
|
|
519
|
+
await this.assertCanManageTenant(tenantId, currentUser, config);
|
|
520
|
+
return this.applyRevoke(this.tenantTokenFilter(tenantId, tokenId), currentUser);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Delete a TENANT token of the tenant. */
|
|
524
|
+
async deleteTenantToken(tenantId: string, tokenId: string, currentUser: any): Promise<ApiTokenInfo> {
|
|
525
|
+
const config = this.requireKind(ApiTokenKind.TENANT);
|
|
526
|
+
await this.assertCanManageTenant(tenantId, currentUser, config);
|
|
527
|
+
return this.applyDelete(this.tenantTokenFilter(tenantId, tokenId));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Delete every token bound to a tenant — its TENANT tokens and the USER tokens restricted to it
|
|
532
|
+
* (system operation, no rights check). Call it when deleting the tenant; the core has no tenant model
|
|
533
|
+
* of its own to hook into. Returns the number of tokens deleted.
|
|
534
|
+
*/
|
|
535
|
+
async deleteAllForTenant(tenantId: string): Promise<number> {
|
|
536
|
+
if (!tenantId?.trim()) {
|
|
537
|
+
throw new BadRequestException('tenantId must not be empty');
|
|
538
|
+
}
|
|
539
|
+
const result = await this.apiTokenModel.deleteMany({ tenant: tenantId }).exec();
|
|
540
|
+
return result.deletedCount ?? 0;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* May the acting user manage the tenant's tokens? Platform admins may while `adminBypass` is on;
|
|
545
|
+
* everyone else needs an ACTIVE membership whose role reaches `apiTokens.manageRole`.
|
|
546
|
+
*/
|
|
547
|
+
async assertCanManageTenant(tenantId: string, currentUser: any, config = getApiTokenConfig()): Promise<void> {
|
|
548
|
+
if (!tenantId?.trim()) {
|
|
549
|
+
throw new BadRequestException('tenantId must not be empty');
|
|
550
|
+
}
|
|
551
|
+
this.assertPerson(currentUser);
|
|
552
|
+
|
|
553
|
+
const adminBypass = ConfigService.configFastButReadOnly?.multiTenancy?.adminBypass !== false;
|
|
554
|
+
if (adminBypass && Array.isArray(currentUser.roles) && currentUser.roles.includes(RoleEnum.ADMIN)) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const membership = await this.tenantService?.getActiveMembership(tenantId, String(currentUser.id));
|
|
559
|
+
if (!membership || !checkRoleAccess([getApiTokenManageRole(config)], undefined, membership.role)) {
|
|
560
|
+
throw new ForbiddenException(ErrorCode.ACCESS_DENIED);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ===================================================================================================================
|
|
565
|
+
// Signing key encryption (AES-256-GCM, same shape as AiCryptoService)
|
|
566
|
+
// ===================================================================================================================
|
|
567
|
+
|
|
568
|
+
/** Encrypt a signing key for storage: `"<iv>.<tag>.<ciphertext>"`, base64 each. */
|
|
569
|
+
protected encryptSigningKey(plain: string): string {
|
|
570
|
+
const iv = randomBytes(12);
|
|
571
|
+
const cipher = createCipheriv('aes-256-gcm', this.getEncryptionKey(), iv);
|
|
572
|
+
const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
|
573
|
+
return [iv.toString('base64'), cipher.getAuthTag().toString('base64'), encrypted.toString('base64')].join('.');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Decrypt a stored signing key. Throws on a key mismatch or a corrupted value. */
|
|
577
|
+
protected decryptSigningKey(stored: string): string {
|
|
578
|
+
const [ivB64, tagB64, dataB64] = (stored ?? '').split('.');
|
|
579
|
+
try {
|
|
580
|
+
const decipher = createDecipheriv('aes-256-gcm', this.getEncryptionKey(), Buffer.from(ivB64, 'base64'));
|
|
581
|
+
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
|
|
582
|
+
return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString('utf8');
|
|
583
|
+
} catch (error) {
|
|
584
|
+
this.logger.error('Signing key decryption failed — apiTokens.encryptionKey changed or value corrupted');
|
|
585
|
+
throw new Error('Signing key decryption failed', { cause: error });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** The 32-byte AES key. `protected` so a project can source it from a KMS instead. */
|
|
590
|
+
protected getEncryptionKey(): Buffer {
|
|
591
|
+
const secret = resolveApiTokenEncryptionKey();
|
|
592
|
+
if (!secret) {
|
|
593
|
+
if (!this.warnedAboutDevelopmentKey) {
|
|
594
|
+
this.warnedAboutDevelopmentKey = true;
|
|
595
|
+
this.logger.warn(
|
|
596
|
+
'No apiTokens.encryptionKey / SECRETS_ENCRYPTION_KEY set — signing keys are encrypted with an insecure ' +
|
|
597
|
+
'development default. Never run this in production (the boot refuses it there).',
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
return createHash('sha256').update('lt-nest-server-api-token-dev-only-insecure-default').digest();
|
|
601
|
+
}
|
|
602
|
+
return createHash('sha256').update(secret, 'utf8').digest();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ===================================================================================================================
|
|
606
|
+
// Internals
|
|
607
|
+
// ===================================================================================================================
|
|
608
|
+
|
|
609
|
+
protected requireKind(kind: ApiTokenKind): IResolvedApiTokenConfig {
|
|
610
|
+
const config = getApiTokenConfig();
|
|
611
|
+
if (kind === ApiTokenKind.USER ? !config.userTokens : !config.tenantTokens) {
|
|
612
|
+
throw new BadRequestException(
|
|
613
|
+
kind === ApiTokenKind.USER
|
|
614
|
+
? 'User tokens are not enabled (apiTokens.userTokens)'
|
|
615
|
+
: 'Tenant tokens are not enabled (apiTokens.tenantTokens, requires multiTenancy)',
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
return config;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** A person — authenticated, and not through a token. Tokens never manage tokens. */
|
|
622
|
+
protected assertPerson(currentUser: any): void {
|
|
623
|
+
if (!currentUser?.id) {
|
|
624
|
+
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
|
|
625
|
+
}
|
|
626
|
+
if (getApiTokenContext(currentUser)) {
|
|
627
|
+
throw new ForbiddenException(ErrorCode.ACCESS_DENIED);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
protected async issue(document: Record<string, unknown>, currentUser: any): Promise<ICreatedApiToken> {
|
|
632
|
+
const config = getApiTokenConfig();
|
|
633
|
+
const { publicId, secret, token } = generateApiToken(config.prefix);
|
|
634
|
+
const signingKey = generateApiTokenSigningKey();
|
|
635
|
+
const created = await this.apiTokenModel.create({
|
|
636
|
+
...document,
|
|
637
|
+
createdBy: String(currentUser.id),
|
|
638
|
+
publicId,
|
|
639
|
+
revokedAt: null,
|
|
640
|
+
secretHash: hashApiTokenSecret(secret),
|
|
641
|
+
signingKeyEncrypted: this.encryptSigningKey(signingKey),
|
|
642
|
+
updatedBy: String(currentUser.id),
|
|
643
|
+
});
|
|
644
|
+
return { apiToken: this.toInfo(created.toObject()), signingKey, token };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
protected async list(filter: Record<string, unknown>): Promise<ApiTokenInfo[]> {
|
|
648
|
+
const tokens = await this.apiTokenModel.find(filter).sort({ createdAt: -1 }).lean().exec();
|
|
649
|
+
return tokens.map((token) => this.toInfo(token));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
protected async applyUpdate(
|
|
653
|
+
filter: Record<string, unknown>,
|
|
654
|
+
$set: Record<string, unknown>,
|
|
655
|
+
currentUser: any,
|
|
656
|
+
): Promise<ApiTokenInfo> {
|
|
657
|
+
const updated = await this.apiTokenModel
|
|
658
|
+
.findOneAndUpdate(filter, { $set: { ...$set, updatedBy: String(currentUser.id) } }, { returnDocument: 'after' })
|
|
659
|
+
.lean()
|
|
660
|
+
.exec();
|
|
661
|
+
if (!updated) {
|
|
662
|
+
throw new NotFoundException('API token not found');
|
|
663
|
+
}
|
|
664
|
+
return this.toInfo(updated);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
protected async applyRevoke(filter: Record<string, unknown>, currentUser: any): Promise<ApiTokenInfo> {
|
|
668
|
+
const revoked = await this.apiTokenModel
|
|
669
|
+
.findOneAndUpdate(
|
|
670
|
+
{ ...filter, revokedAt: null },
|
|
671
|
+
{ $set: { revokedAt: new Date(), updatedBy: String(currentUser.id) } },
|
|
672
|
+
{ returnDocument: 'after' },
|
|
673
|
+
)
|
|
674
|
+
.lean()
|
|
675
|
+
.exec();
|
|
676
|
+
if (revoked) {
|
|
677
|
+
return this.toInfo(revoked);
|
|
678
|
+
}
|
|
679
|
+
// Already revoked: answer with the current state rather than an error.
|
|
680
|
+
const existing = await this.apiTokenModel.findOne(filter).lean().exec();
|
|
681
|
+
if (!existing) {
|
|
682
|
+
throw new NotFoundException('API token not found');
|
|
683
|
+
}
|
|
684
|
+
return this.toInfo(existing);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
protected async applyDelete(filter: Record<string, unknown>): Promise<ApiTokenInfo> {
|
|
688
|
+
const deleted = await this.apiTokenModel.findOneAndDelete(filter).lean().exec();
|
|
689
|
+
if (!deleted) {
|
|
690
|
+
throw new NotFoundException('API token not found');
|
|
691
|
+
}
|
|
692
|
+
return this.toInfo(deleted);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
protected userTokenFilter(tokenId: string, currentUser: any): Record<string, unknown> {
|
|
696
|
+
return { _id: this.objectId(tokenId), kind: ApiTokenKind.USER, user: String(currentUser.id) };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
protected tenantTokenFilter(tenantId: string, tokenId: string): Record<string, unknown> {
|
|
700
|
+
return { _id: this.objectId(tokenId), kind: ApiTokenKind.TENANT, tenant: tenantId };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** A token id that is not an ObjectId is simply not found — never a 500 from a cast error. */
|
|
704
|
+
protected objectId(tokenId: string): Types.ObjectId {
|
|
705
|
+
if (typeof tokenId !== 'string' || !/^[0-9a-f]{24}$/i.test(tokenId)) {
|
|
706
|
+
throw new NotFoundException('API token not found');
|
|
707
|
+
}
|
|
708
|
+
return new Types.ObjectId(tokenId);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
protected commonUpdate(input: IUpdateApiTokenInput, config: IResolvedApiTokenConfig): Record<string, unknown> {
|
|
712
|
+
const $set: Record<string, unknown> = { ...this.projectFields(input) };
|
|
713
|
+
if (input?.name !== undefined) $set.name = this.validateName(input.name);
|
|
714
|
+
if (input?.description !== undefined) $set.description = this.validateDescription(input.description);
|
|
715
|
+
if (input?.scopes !== undefined) $set.scopes = this.validateScopes(input.scopes, config);
|
|
716
|
+
if (input?.expiresAt !== undefined) $set.expiresAt = this.validateExpiry(input.expiresAt);
|
|
717
|
+
return $set;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Project-specific fields of an input: everything that is neither protected nor handled explicitly.
|
|
722
|
+
*
|
|
723
|
+
* Judged by the ROOT of a key: Mongoose casts a dotted key as a path, so `scopes.0` would write the
|
|
724
|
+
* scope list around its validation and `tenant.x` would address a protected field.
|
|
725
|
+
*/
|
|
726
|
+
protected projectFields(input: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
727
|
+
const handled = new Set(['description', 'expiresAt', 'maxTenantRole', 'name', 'scopes', 'tenantId']);
|
|
728
|
+
return Object.fromEntries(
|
|
729
|
+
Object.entries(input ?? {}).filter(([key]) => {
|
|
730
|
+
const segments = key.split('.');
|
|
731
|
+
return (
|
|
732
|
+
!PROTECTED_FIELDS.has(segments[0]) &&
|
|
733
|
+
!handled.has(segments[0]) &&
|
|
734
|
+
!segments.some((segment) => segment.startsWith('$'))
|
|
735
|
+
);
|
|
736
|
+
}),
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
protected validateName(name: unknown): string {
|
|
741
|
+
if (typeof name !== 'string' || !name.trim() || name.trim().length > 200) {
|
|
742
|
+
throw new BadRequestException('name must be 1-200 characters');
|
|
743
|
+
}
|
|
744
|
+
return name.trim();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
protected validateDescription(description: unknown): null | string {
|
|
748
|
+
if (description === undefined || description === null || description === '') {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
if (typeof description !== 'string' || description.length > 1000) {
|
|
752
|
+
throw new BadRequestException('description must be a string of at most 1000 characters');
|
|
753
|
+
}
|
|
754
|
+
return description;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
protected validateExpiry(expiresAt: unknown): Date | null {
|
|
758
|
+
if (expiresAt === undefined || expiresAt === null || expiresAt === '') {
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
const date = expiresAt instanceof Date ? expiresAt : new Date(expiresAt as string);
|
|
762
|
+
if (Number.isNaN(date.getTime()) || date.getTime() <= Date.now()) {
|
|
763
|
+
throw new BadRequestException('expiresAt must be a valid date in the future');
|
|
764
|
+
}
|
|
765
|
+
return date;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
protected validateScopes(scopes: unknown, config: IResolvedApiTokenConfig): string[] {
|
|
769
|
+
if (!Array.isArray(scopes) || scopes.some((scope) => typeof scope !== 'string')) {
|
|
770
|
+
throw new BadRequestException('scopes must be an array of strings');
|
|
771
|
+
}
|
|
772
|
+
const unique = [...new Set(scopes as string[])];
|
|
773
|
+
if (!unique.length) {
|
|
774
|
+
throw new BadRequestException('A token needs at least one scope');
|
|
775
|
+
}
|
|
776
|
+
const unknown = unique.filter((scope) => !config.scopes.includes(scope));
|
|
777
|
+
if (unknown.length) {
|
|
778
|
+
throw new BadRequestException(
|
|
779
|
+
`Unknown scope(s) [${unknown.join(', ')}]; allowed: [${config.scopes.join(', ')}] (apiTokens.scopes)`,
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
return unique;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** A user token may only be restricted to a tenant its owner is an ACTIVE member of. */
|
|
786
|
+
protected async validateUserTokenTenant(
|
|
787
|
+
tenantId: unknown,
|
|
788
|
+
currentUser: any,
|
|
789
|
+
config: IResolvedApiTokenConfig,
|
|
790
|
+
): Promise<null | string> {
|
|
791
|
+
if (tenantId === undefined || tenantId === null || tenantId === '') {
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
if (!config.multiTenancy || typeof tenantId !== 'string') {
|
|
795
|
+
throw new BadRequestException('tenantId requires multiTenancy and must be a string');
|
|
796
|
+
}
|
|
797
|
+
const membership = await this.tenantService?.getActiveMembership(tenantId, String(currentUser.id));
|
|
798
|
+
if (!membership) {
|
|
799
|
+
throw new ForbiddenException(ErrorCode.ACCESS_DENIED);
|
|
800
|
+
}
|
|
801
|
+
return tenantId;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** The cap must be a hierarchy role — only those can be compared. */
|
|
805
|
+
protected validateMaxTenantRole(role: unknown, config: IResolvedApiTokenConfig): null | string {
|
|
806
|
+
if (role === undefined || role === null || role === '') {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
if (!config.multiTenancy || typeof role !== 'string' || !(role in getRoleHierarchy())) {
|
|
810
|
+
throw new BadRequestException(
|
|
811
|
+
`maxTenantRole must be a role of multiTenancy.roleHierarchy: [${Object.keys(getRoleHierarchy()).join(', ')}]`,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
return role;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Strip everything secret and normalise the id. */
|
|
818
|
+
protected toInfo(document: Record<string, any>): ApiTokenInfo {
|
|
819
|
+
const { __v, _id, secretHash: _secretHash, signingKeyEncrypted: _signingKeyEncrypted, ...rest } = document ?? {};
|
|
820
|
+
return { ...rest, id: (_id ?? rest.id)?.toString() } as ApiTokenInfo;
|
|
821
|
+
}
|
|
822
|
+
}
|