@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,711 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
|
4
|
+
|
|
5
|
+
import { isForbiddenMembershipRole, RoleEnum } from '../../common/enums/role.enum';
|
|
6
|
+
import { isProductionLikeEnv } from '../../common/helpers/cookies.helper';
|
|
7
|
+
import type { IApiTokens, IMultiTenancy } from '../../common/interfaces/server-options.interface';
|
|
8
|
+
import { ConfigService } from '../../common/services/config.service';
|
|
9
|
+
import { ErrorCode } from '../error-code/error-codes';
|
|
10
|
+
import { DEFAULT_ROLE_HIERARCHY } from '../tenant/core-tenant.enums';
|
|
11
|
+
import {
|
|
12
|
+
checkRoleAccess,
|
|
13
|
+
isSystemRole,
|
|
14
|
+
mergeRolesMetadata,
|
|
15
|
+
tenantSatisfiableRoles,
|
|
16
|
+
} from '../tenant/core-tenant.helpers';
|
|
17
|
+
import { API_TOKEN_SCOPES_KEY, ApiTokenKind } from './core-api-token.constants';
|
|
18
|
+
|
|
19
|
+
// =====================================================================================================================
|
|
20
|
+
// Configuration
|
|
21
|
+
// =====================================================================================================================
|
|
22
|
+
|
|
23
|
+
/** Defaults of the `apiTokens` config. */
|
|
24
|
+
export const API_TOKEN_DEFAULTS = {
|
|
25
|
+
maxAssertionLifetimeSeconds: 900,
|
|
26
|
+
prefix: 'ltt',
|
|
27
|
+
rateLimit: { max: 600, windowSeconds: 60 },
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
/** Clock skew tolerated on both edges of an assertion's lifetime. */
|
|
31
|
+
export const API_TOKEN_CLOCK_TOLERANCE_SECONDS = 30;
|
|
32
|
+
|
|
33
|
+
/** Upper bound for an assertion, so a request cannot make the server parse an arbitrarily large payload. */
|
|
34
|
+
export const API_TOKEN_MAX_ASSERTION_LENGTH = 8192;
|
|
35
|
+
|
|
36
|
+
/** Header accepted besides `Authorization: Bearer`, matching the convention of Better-Auth's API-key plugin. */
|
|
37
|
+
export const API_TOKEN_HEADER = 'x-api-key';
|
|
38
|
+
|
|
39
|
+
const PREFIX_PATTERN = /^[a-z][a-z0-9]{1,15}$/;
|
|
40
|
+
const SCOPE_PATTERN = /^[A-Za-z0-9:._-]{1,64}$/;
|
|
41
|
+
const PUBLIC_ID_PATTERN = /^[0-9a-f]{24}$/;
|
|
42
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
43
|
+
|
|
44
|
+
/** `apiTokens` after defaults and normalisation. */
|
|
45
|
+
export interface IResolvedApiTokenConfig {
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
encryptionKey?: string;
|
|
48
|
+
manageRole?: string;
|
|
49
|
+
maxAssertionLifetimeSeconds: number;
|
|
50
|
+
/** Whether multi-tenancy is active — tenant tokens and tenant restrictions exist only then. */
|
|
51
|
+
multiTenancy: boolean;
|
|
52
|
+
prefix: string;
|
|
53
|
+
rateLimit: false | { max: number; windowSeconds: number };
|
|
54
|
+
scopes: string[];
|
|
55
|
+
/** Tenant tokens allowed AND multi-tenancy active. */
|
|
56
|
+
tenantTokens: boolean;
|
|
57
|
+
userTokens: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalise the server config into the effective API-token configuration.
|
|
62
|
+
*
|
|
63
|
+
* Boolean shorthand: absent / `false` / `{ enabled: false }` → off; `true` / `{}` → on with defaults.
|
|
64
|
+
* Pure — reads nothing but its argument; {@link getApiTokenConfig} reads the running config.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveApiTokenConfig(
|
|
67
|
+
config: null | undefined | { apiTokens?: boolean | IApiTokens; multiTenancy?: IMultiTenancy },
|
|
68
|
+
): IResolvedApiTokenConfig {
|
|
69
|
+
const raw = config?.apiTokens;
|
|
70
|
+
const options: IApiTokens = typeof raw === 'object' && raw !== null ? raw : {};
|
|
71
|
+
const enabled = raw === true || (typeof raw === 'object' && raw !== null && raw.enabled !== false);
|
|
72
|
+
const multiTenancy = !!config?.multiTenancy && config.multiTenancy.enabled !== false;
|
|
73
|
+
|
|
74
|
+
const lifetime = Number(options.maxAssertionLifetimeSeconds);
|
|
75
|
+
return {
|
|
76
|
+
enabled,
|
|
77
|
+
encryptionKey:
|
|
78
|
+
typeof options.encryptionKey === 'string' && options.encryptionKey ? options.encryptionKey : undefined,
|
|
79
|
+
manageRole: typeof options.manageRole === 'string' && options.manageRole ? options.manageRole : undefined,
|
|
80
|
+
// An invalid value falls back to the default, never to "unbounded": this knob bounds the lifetime
|
|
81
|
+
// of a credential, so a typo must not be the thing that switches the bound off.
|
|
82
|
+
maxAssertionLifetimeSeconds:
|
|
83
|
+
options.maxAssertionLifetimeSeconds !== null &&
|
|
84
|
+
typeof options.maxAssertionLifetimeSeconds !== 'boolean' &&
|
|
85
|
+
Number.isFinite(lifetime) &&
|
|
86
|
+
lifetime > 0
|
|
87
|
+
? Math.floor(lifetime)
|
|
88
|
+
: API_TOKEN_DEFAULTS.maxAssertionLifetimeSeconds,
|
|
89
|
+
multiTenancy,
|
|
90
|
+
prefix: typeof options.prefix === 'string' ? options.prefix : API_TOKEN_DEFAULTS.prefix,
|
|
91
|
+
rateLimit: resolveRateLimit(options.rateLimit),
|
|
92
|
+
scopes: Array.isArray(options.scopes) ? [...options.scopes] : [],
|
|
93
|
+
tenantTokens: enabled && multiTenancy && options.tenantTokens !== false,
|
|
94
|
+
userTokens: enabled && options.userTokens !== false,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveRateLimit(value: unknown): IResolvedApiTokenConfig['rateLimit'] {
|
|
99
|
+
if (value === false || (typeof value === 'object' && value !== null && (value as any).enabled === false)) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const options =
|
|
103
|
+
typeof value === 'object' && value !== null ? (value as { max?: unknown; windowSeconds?: unknown }) : {};
|
|
104
|
+
const max = Number(options.max);
|
|
105
|
+
const windowSeconds = Number(options.windowSeconds);
|
|
106
|
+
return {
|
|
107
|
+
max: Number.isFinite(max) && max > 0 ? Math.floor(max) : API_TOKEN_DEFAULTS.rateLimit.max,
|
|
108
|
+
windowSeconds:
|
|
109
|
+
Number.isFinite(windowSeconds) && windowSeconds > 0
|
|
110
|
+
? Math.floor(windowSeconds)
|
|
111
|
+
: API_TOKEN_DEFAULTS.rateLimit.windowSeconds,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The effective API-token configuration of the running server. */
|
|
116
|
+
export function getApiTokenConfig(): IResolvedApiTokenConfig {
|
|
117
|
+
return resolveApiTokenConfig(ConfigService.configFastButReadOnly);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Configured role hierarchy (or the default). */
|
|
121
|
+
function hierarchy(): Record<string, number> {
|
|
122
|
+
return ConfigService.configFastButReadOnly?.multiTenancy?.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The tenant role a TENANT token acts with: the LOWEST role of the hierarchy. A tenant token is a
|
|
127
|
+
* member of its tenant, never more — it cannot reach the role that manages tokens (enforced at boot).
|
|
128
|
+
*/
|
|
129
|
+
export function getApiTokenTenantMemberRole(): string {
|
|
130
|
+
const entries = Object.entries(hierarchy());
|
|
131
|
+
if (entries.length === 0) {
|
|
132
|
+
return 'member';
|
|
133
|
+
}
|
|
134
|
+
return entries.reduce((a, b) => (a[1] <= b[1] ? a : b))[0];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The tenant role required to manage tenant tokens: configured, or the highest role of the hierarchy. */
|
|
138
|
+
export function getApiTokenManageRole(config: IResolvedApiTokenConfig = getApiTokenConfig()): string {
|
|
139
|
+
if (config.manageRole) {
|
|
140
|
+
return config.manageRole;
|
|
141
|
+
}
|
|
142
|
+
const entries = Object.entries(hierarchy());
|
|
143
|
+
if (entries.length === 0) {
|
|
144
|
+
return 'owner';
|
|
145
|
+
}
|
|
146
|
+
return entries.reduce((a, b) => (a[1] >= b[1] ? a : b))[0];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The encryption pass-phrase for signing keys, or `undefined` when none is configured. */
|
|
150
|
+
export function resolveApiTokenEncryptionKey(
|
|
151
|
+
config: IResolvedApiTokenConfig = getApiTokenConfig(),
|
|
152
|
+
): string | undefined {
|
|
153
|
+
return config.encryptionKey || process.env.SECRETS_ENCRYPTION_KEY || undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Refuse, at boot, a token configuration that cannot be enforced as written. Each condition describes
|
|
158
|
+
* a setup whose failure would otherwise surface only at the first request — or never.
|
|
159
|
+
*/
|
|
160
|
+
export function assertApiTokenConfigIsUsable(): void {
|
|
161
|
+
const config = getApiTokenConfig();
|
|
162
|
+
if (!config.enabled) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!PREFIX_PATTERN.test(config.prefix)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`apiTokens.prefix "${config.prefix}" is invalid: use 2-16 lowercase letters and digits, starting with ` +
|
|
169
|
+
'a letter. The prefix is how the server tells a token apart from every other credential.',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const invalidScopes = config.scopes.filter((scope) => typeof scope !== 'string' || !SCOPE_PATTERN.test(scope));
|
|
174
|
+
if (invalidScopes.length) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`apiTokens.scopes contains invalid scope(s) ${JSON.stringify(invalidScopes)}: ` +
|
|
177
|
+
'use 1-64 characters of letters, digits, ":", ".", "_" or "-".',
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (config.tenantTokens) {
|
|
182
|
+
const multiTenancy = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
183
|
+
const declared = new Set([...Object.keys(hierarchy()), ...(multiTenancy?.additionalMembershipRoles ?? [])]);
|
|
184
|
+
const manageRole = getApiTokenManageRole(config);
|
|
185
|
+
if (isForbiddenMembershipRole(manageRole) || !declared.has(manageRole)) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`apiTokens.manageRole "${manageRole}" is not a declared tenant role. ` +
|
|
188
|
+
`Declared: [${[...declared].sort().join(', ')}]. Managing tenant tokens is a tenant right, so it ` +
|
|
189
|
+
'needs a role from roleHierarchy or additionalMembershipRoles — never a global or system role.',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const levels = hierarchy();
|
|
194
|
+
const memberRole = getApiTokenTenantMemberRole();
|
|
195
|
+
if (manageRole in levels && levels[memberRole] >= levels[manageRole]) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`apiTokens needs a tenant role hierarchy with a role below "${manageRole}": a tenant token acts with ` +
|
|
198
|
+
`the lowest role ("${memberRole}"), and in this hierarchy that role already reaches the role that ` +
|
|
199
|
+
'manages tokens — a token would count as a tenant administrator. Set apiTokens.tenantTokens: false ' +
|
|
200
|
+
'if you only need user tokens.',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (isProductionLikeEnv(ConfigService.configFastButReadOnly?.env) && !resolveApiTokenEncryptionKey(config)) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
'apiTokens.encryptionKey (or SECRETS_ENCRYPTION_KEY) is required in production/staging. ' +
|
|
208
|
+
'Without it, the signing keys of all tokens are encrypted with a public development default. ' +
|
|
209
|
+
'Set a random value of 32+ characters.',
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// =====================================================================================================================
|
|
215
|
+
// Credential formats
|
|
216
|
+
// =====================================================================================================================
|
|
217
|
+
|
|
218
|
+
/** A credential found in a request header. */
|
|
219
|
+
export interface IApiTokenCredential {
|
|
220
|
+
kind: 'assertion' | 'token';
|
|
221
|
+
value: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function classify(value: string, prefix: string): IApiTokenCredential | undefined {
|
|
225
|
+
if (value.startsWith(`${prefix}_`)) {
|
|
226
|
+
return { kind: 'token', value };
|
|
227
|
+
}
|
|
228
|
+
if (value.startsWith(`${prefix}s_`)) {
|
|
229
|
+
return { kind: 'assertion', value };
|
|
230
|
+
}
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Read an API credential from an `Authorization` header value.
|
|
236
|
+
*
|
|
237
|
+
* Recognised purely by prefix — `<prefix>_` for a token, `<prefix>s_` for an assertion — so Better-Auth
|
|
238
|
+
* sessions, JWTs and legacy tokens are never claimed by mistake. `Bearer` is matched case-insensitively
|
|
239
|
+
* (the framework's TestHelper sends `bearer`).
|
|
240
|
+
*/
|
|
241
|
+
export function readApiTokenCredential(
|
|
242
|
+
authorization: string | string[] | undefined,
|
|
243
|
+
prefix: string,
|
|
244
|
+
): IApiTokenCredential | undefined {
|
|
245
|
+
if (typeof authorization !== 'string') {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
const match = /^bearer\s+(\S+)\s*$/i.exec(authorization);
|
|
249
|
+
return match ? classify(match[1], prefix) : undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Read an API credential from a request's headers: `Authorization: Bearer <credential>` or
|
|
254
|
+
* `x-api-key: <credential>`. Two DIFFERENT credentials in the two headers are ambiguous and answered
|
|
255
|
+
* with `conflict` — the caller must refuse, never pick one.
|
|
256
|
+
*/
|
|
257
|
+
export function readApiTokenCredentialFromHeaders(
|
|
258
|
+
headers: Record<string, any> | undefined,
|
|
259
|
+
prefix: string,
|
|
260
|
+
): 'conflict' | IApiTokenCredential | undefined {
|
|
261
|
+
const fromAuthorization = readApiTokenCredential(headers?.authorization, prefix);
|
|
262
|
+
const apiKeyHeader = headers?.[API_TOKEN_HEADER];
|
|
263
|
+
const fromApiKey = typeof apiKeyHeader === 'string' ? classify(apiKeyHeader.trim(), prefix) : undefined;
|
|
264
|
+
if (fromAuthorization && fromApiKey && fromAuthorization.value !== fromApiKey.value) {
|
|
265
|
+
return 'conflict';
|
|
266
|
+
}
|
|
267
|
+
return fromAuthorization ?? fromApiKey;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Does this request carry an API credential? Always `false` while the feature is off, so a token-shaped
|
|
272
|
+
* value on a server without API tokens is handled like any other unknown credential.
|
|
273
|
+
*/
|
|
274
|
+
export function hasApiTokenCredential(request: { headers?: Record<string, any> } | undefined): boolean {
|
|
275
|
+
const config = getApiTokenConfig();
|
|
276
|
+
return config.enabled && !!readApiTokenCredentialFromHeaders(request?.headers, config.prefix);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Generate a new token: `<prefix>_<publicId: 24 hex>_<secret: 64 hex>` (32 bytes of secret entropy). */
|
|
280
|
+
export function generateApiToken(prefix: string): { publicId: string; secret: string; token: string } {
|
|
281
|
+
const publicId = randomBytes(12).toString('hex');
|
|
282
|
+
const secret = randomBytes(32).toString('hex');
|
|
283
|
+
return { publicId, secret, token: `${prefix}_${publicId}_${secret}` };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Split a token into its public id and secret — only the exact shape for the given prefix is accepted. */
|
|
287
|
+
export function parseApiToken(value: string, prefix: string): undefined | { publicId: string; secret: string } {
|
|
288
|
+
// The prefix is interpolated into a pattern, so it must be the validated shape — never raw config.
|
|
289
|
+
if (!PREFIX_PATTERN.test(prefix)) {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
const match = new RegExp(`^${prefix}_([0-9a-f]{24})_([0-9a-f]{64})$`).exec(value ?? '');
|
|
293
|
+
return match ? { publicId: match[1], secret: match[2] } : undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Hash a token secret for storage. SHA-256 rather than a slow KDF on purpose: the secret carries 256
|
|
298
|
+
* bits of randomness, so there is nothing for a slow hash to protect, and every request pays for it.
|
|
299
|
+
*/
|
|
300
|
+
export function hashApiTokenSecret(secret: string): string {
|
|
301
|
+
return createHash('sha256').update(secret, 'utf8').digest('hex');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Constant-time comparison of two hex strings (different lengths compare unequal). */
|
|
305
|
+
export function safeEqualHex(a: string, b: string): boolean {
|
|
306
|
+
const left = Buffer.from(a ?? '', 'hex');
|
|
307
|
+
const right = Buffer.from(b ?? '', 'hex');
|
|
308
|
+
return left.length > 0 && left.length === right.length && timingSafeEqual(left, right);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Generate a signing key: 32 random bytes as 64 hex characters. */
|
|
312
|
+
export function generateApiTokenSigningKey(): string {
|
|
313
|
+
return randomBytes(32).toString('hex');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// =====================================================================================================================
|
|
317
|
+
// Signed assertions
|
|
318
|
+
// =====================================================================================================================
|
|
319
|
+
|
|
320
|
+
/** The payload of a signed assertion. */
|
|
321
|
+
export interface IApiTokenAssertionPayload {
|
|
322
|
+
/** Free-form claims for the audit trail (e.g. the embedding application's company). */
|
|
323
|
+
claims?: Record<string, unknown>;
|
|
324
|
+
/** Expiry, Unix seconds. */
|
|
325
|
+
exp: number;
|
|
326
|
+
/** Carried for the audit trail only — NOT enforced as single-use, an embedded page reuses one assertion. */
|
|
327
|
+
nonce?: string;
|
|
328
|
+
/** Who, inside the embedding application, the assertion speaks for (e.g. a user name). */
|
|
329
|
+
sub?: string;
|
|
330
|
+
/** Public id of the token whose signing key signed the assertion. */
|
|
331
|
+
tid: string;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Options for {@link signApiTokenAssertion}. */
|
|
335
|
+
export interface ISignApiTokenAssertionOptions {
|
|
336
|
+
claims?: Record<string, unknown>;
|
|
337
|
+
/** Absolute expiry (Date or epoch milliseconds). Takes precedence over `expiresInSeconds`. */
|
|
338
|
+
expiresAt?: Date | number;
|
|
339
|
+
/** Relative expiry. @default 300 */
|
|
340
|
+
expiresInSeconds?: number;
|
|
341
|
+
nonce?: string;
|
|
342
|
+
/** @default 'ltt' */
|
|
343
|
+
prefix?: string;
|
|
344
|
+
publicId: string;
|
|
345
|
+
signingKey: string;
|
|
346
|
+
subject?: string;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Mint a signed assertion — for Node integrators and tests. Other languages follow the same recipe:
|
|
351
|
+
*
|
|
352
|
+
* 1. payload = base64url (no padding) of the UTF-8 JSON `{ claims?, exp, nonce?, sub?, tid }`
|
|
353
|
+
* 2. signature = base64url (no padding) of HMAC-SHA256, key = the UTF-8 bytes of the signing key
|
|
354
|
+
* exactly as issued (64 hex characters), data = the ASCII bytes of the payload string from step 1
|
|
355
|
+
* 3. assertion = `<prefix>s_<payload>.<signature>`
|
|
356
|
+
*
|
|
357
|
+
* The server verifies the signature over the payload string as received, so key order and whitespace
|
|
358
|
+
* inside the JSON do not matter.
|
|
359
|
+
*/
|
|
360
|
+
export function signApiTokenAssertion(options: ISignApiTokenAssertionOptions): string {
|
|
361
|
+
const prefix = options.prefix ?? API_TOKEN_DEFAULTS.prefix;
|
|
362
|
+
const expiresAtMs =
|
|
363
|
+
options.expiresAt !== undefined
|
|
364
|
+
? new Date(options.expiresAt).getTime()
|
|
365
|
+
: Date.now() + (options.expiresInSeconds ?? 300) * 1000;
|
|
366
|
+
|
|
367
|
+
// Alphabetical key order, so the documented example can be reproduced byte for byte.
|
|
368
|
+
const payload: Record<string, unknown> = {};
|
|
369
|
+
if (options.claims !== undefined) payload.claims = options.claims;
|
|
370
|
+
payload.exp = Math.floor(expiresAtMs / 1000);
|
|
371
|
+
if (options.nonce !== undefined) payload.nonce = options.nonce;
|
|
372
|
+
if (options.subject !== undefined) payload.sub = options.subject;
|
|
373
|
+
payload.tid = options.publicId;
|
|
374
|
+
|
|
375
|
+
const payloadSegment = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
376
|
+
return `${prefix}s_${payloadSegment}.${signAssertionPayload(payloadSegment, options.signingKey).toString('base64url')}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function signAssertionPayload(payloadSegment: string, signingKey: string): Buffer {
|
|
380
|
+
return createHmac('sha256', Buffer.from(signingKey, 'utf8')).update(payloadSegment, 'ascii').digest();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Parse an assertion WITHOUT verifying it. Returns `undefined` for anything that is not structurally a
|
|
385
|
+
* valid assertion for the prefix; the signature and the timing are checked separately.
|
|
386
|
+
*/
|
|
387
|
+
export function decodeApiTokenAssertion(
|
|
388
|
+
value: string,
|
|
389
|
+
prefix: string,
|
|
390
|
+
): undefined | { payload: IApiTokenAssertionPayload; payloadSegment: string; signature: string } {
|
|
391
|
+
const head = `${prefix}s_`;
|
|
392
|
+
if (typeof value !== 'string' || value.length > API_TOKEN_MAX_ASSERTION_LENGTH || !value.startsWith(head)) {
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
const parts = value.slice(head.length).split('.');
|
|
396
|
+
if (parts.length !== 2) {
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
const [payloadSegment, signature] = parts;
|
|
400
|
+
if (!BASE64URL_PATTERN.test(payloadSegment) || !BASE64URL_PATTERN.test(signature)) {
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let payload: any;
|
|
405
|
+
try {
|
|
406
|
+
payload = JSON.parse(Buffer.from(payloadSegment, 'base64url').toString('utf8'));
|
|
407
|
+
} catch {
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
if (typeof payload.tid !== 'string' || !PUBLIC_ID_PATTERN.test(payload.tid) || !Number.isInteger(payload.exp)) {
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
for (const field of ['sub', 'nonce'] as const) {
|
|
417
|
+
if (payload[field] !== undefined && (typeof payload[field] !== 'string' || payload[field].length > 256)) {
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (
|
|
422
|
+
payload.claims !== undefined &&
|
|
423
|
+
(payload.claims === null || typeof payload.claims !== 'object' || Array.isArray(payload.claims))
|
|
424
|
+
) {
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
payload: { claims: payload.claims, exp: payload.exp, nonce: payload.nonce, sub: payload.sub, tid: payload.tid },
|
|
430
|
+
payloadSegment,
|
|
431
|
+
signature,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Verify an assertion's HMAC in constant time. */
|
|
436
|
+
export function verifyApiTokenAssertionSignature(
|
|
437
|
+
payloadSegment: string,
|
|
438
|
+
signature: string,
|
|
439
|
+
signingKey: string,
|
|
440
|
+
): boolean {
|
|
441
|
+
const expected = signAssertionPayload(payloadSegment, signingKey);
|
|
442
|
+
const provided = Buffer.from(signature, 'base64url');
|
|
443
|
+
return provided.length === expected.length && timingSafeEqual(provided, expected);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Check an assertion's lifetime: not expired, and not valid for longer than the configured maximum
|
|
448
|
+
* from now. Both edges tolerate {@link API_TOKEN_CLOCK_TOLERANCE_SECONDS} of clock skew.
|
|
449
|
+
*/
|
|
450
|
+
export function checkApiTokenAssertionTiming(
|
|
451
|
+
payload: Pick<IApiTokenAssertionPayload, 'exp'> & Partial<IApiTokenAssertionPayload>,
|
|
452
|
+
maxLifetimeSeconds: number,
|
|
453
|
+
now: number = Date.now(),
|
|
454
|
+
): 'expired' | 'ok' | 'too-long' {
|
|
455
|
+
const nowSeconds = Math.floor(now / 1000);
|
|
456
|
+
if (payload.exp + API_TOKEN_CLOCK_TOLERANCE_SECONDS < nowSeconds) {
|
|
457
|
+
return 'expired';
|
|
458
|
+
}
|
|
459
|
+
if (payload.exp - nowSeconds > maxLifetimeSeconds + API_TOKEN_CLOCK_TOLERANCE_SECONDS) {
|
|
460
|
+
return 'too-long';
|
|
461
|
+
}
|
|
462
|
+
return 'ok';
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// =====================================================================================================================
|
|
466
|
+
// Token context on request.user
|
|
467
|
+
// =====================================================================================================================
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Module-private marker. A token is recognised by this symbol, never by a data field: a user document
|
|
471
|
+
* that happened to carry `tenantId` and `scopes` must not be mistaken for a token — it would be bound to
|
|
472
|
+
* that tenant without being a member of it. JSON drops symbols, so the marker cannot travel through a
|
|
473
|
+
* database or a request body.
|
|
474
|
+
*/
|
|
475
|
+
const API_TOKEN_CONTEXT = Symbol('apiTokenContext');
|
|
476
|
+
|
|
477
|
+
/** What the framework knows about the token a request was authenticated with. */
|
|
478
|
+
export interface IApiTokenContext {
|
|
479
|
+
/** Present when the request carried a signed assertion instead of the token itself. */
|
|
480
|
+
assertion?: {
|
|
481
|
+
claims?: Record<string, unknown>;
|
|
482
|
+
expiresAt: Date;
|
|
483
|
+
nonce?: string;
|
|
484
|
+
subject?: string;
|
|
485
|
+
};
|
|
486
|
+
kind: ApiTokenKind;
|
|
487
|
+
/** USER tokens: the highest tenant role the token may act with (a cap, never a grant). */
|
|
488
|
+
maxTenantRole?: string;
|
|
489
|
+
name: string;
|
|
490
|
+
publicId: string;
|
|
491
|
+
scopes: string[];
|
|
492
|
+
/** TENANT tokens: the owning tenant. USER tokens: the one tenant the token is restricted to, if any. */
|
|
493
|
+
tenantId?: string;
|
|
494
|
+
tokenId: string;
|
|
495
|
+
/** USER tokens: the owning user. */
|
|
496
|
+
userId?: string;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** What `request.user` holds for a request authenticated with a TENANT token. */
|
|
500
|
+
export interface ITenantApiTokenPrincipal {
|
|
501
|
+
/** Always false — a tenant token holds no global role. */
|
|
502
|
+
hasRole: (roles?: string | string[]) => boolean;
|
|
503
|
+
/** The token document id (also written to `createdBy` / `updatedBy` by the audit plugin). */
|
|
504
|
+
id: string;
|
|
505
|
+
name: string;
|
|
506
|
+
/** Always empty — a tenant token holds no global role. */
|
|
507
|
+
roles: string[];
|
|
508
|
+
scopes: string[];
|
|
509
|
+
tenantId: string;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* The token a request was authenticated with, or `undefined` for a session, a JWT or an anonymous
|
|
514
|
+
* request. Works for both kinds: pass `request.user` / `@CurrentUser()`.
|
|
515
|
+
*/
|
|
516
|
+
export function getApiTokenContext(user: unknown): IApiTokenContext | undefined {
|
|
517
|
+
return user && typeof user === 'object' ? ((user as any)[API_TOKEN_CONTEXT] as IApiTokenContext) : undefined;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Attach a token context to a request user. Only the framework's authentication path should need this. */
|
|
521
|
+
export function attachApiTokenContext<T extends object>(user: T, context: IApiTokenContext): T {
|
|
522
|
+
(user as any)[API_TOKEN_CONTEXT] = Object.freeze({ ...context, scopes: [...context.scopes] });
|
|
523
|
+
return user;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Build the principal of a TENANT token. Only the framework's authentication path should need this. */
|
|
527
|
+
export function createTenantApiTokenPrincipal(
|
|
528
|
+
context: Omit<IApiTokenContext, 'kind' | 'maxTenantRole' | 'userId'> & { tenantId: string },
|
|
529
|
+
): ITenantApiTokenPrincipal {
|
|
530
|
+
const principal: ITenantApiTokenPrincipal = {
|
|
531
|
+
hasRole: () => false,
|
|
532
|
+
id: context.tokenId,
|
|
533
|
+
name: context.name,
|
|
534
|
+
roles: [],
|
|
535
|
+
scopes: [...context.scopes],
|
|
536
|
+
tenantId: context.tenantId,
|
|
537
|
+
};
|
|
538
|
+
return attachApiTokenContext(principal, { ...context, kind: ApiTokenKind.TENANT });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Is this `request.user` the principal of a TENANT token? */
|
|
542
|
+
export function isTenantApiTokenPrincipal(user: unknown): user is ITenantApiTokenPrincipal {
|
|
543
|
+
return getApiTokenContext(user)?.kind === ApiTokenKind.TENANT;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// =====================================================================================================================
|
|
547
|
+
// Route access
|
|
548
|
+
// =====================================================================================================================
|
|
549
|
+
|
|
550
|
+
function denied(): ForbiddenException {
|
|
551
|
+
return new ForbiddenException(ErrorCode.ACCESS_DENIED);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function tenantHeaderName(): string {
|
|
555
|
+
return (ConfigService.configFastButReadOnly?.multiTenancy?.headerName ?? 'x-tenant-id').toLowerCase();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Refuse a token on a route that did not release it, or that releases other scopes only.
|
|
560
|
+
* `@ApiTokenScopes()` on the method replaces the class-level declaration.
|
|
561
|
+
*/
|
|
562
|
+
export function assertApiTokenScopes(context: IApiTokenContext, handler: any, controllerClass: any): void {
|
|
563
|
+
const handlerScopes = handler
|
|
564
|
+
? (Reflect.getMetadata(API_TOKEN_SCOPES_KEY, handler) as string[] | undefined)
|
|
565
|
+
: undefined;
|
|
566
|
+
const requiredScopes =
|
|
567
|
+
handlerScopes ??
|
|
568
|
+
(controllerClass
|
|
569
|
+
? (Reflect.getMetadata(API_TOKEN_SCOPES_KEY, controllerClass) as string[] | undefined)
|
|
570
|
+
: undefined);
|
|
571
|
+
if (!requiredScopes?.length || !requiredScopes.some((scope) => context.scopes.includes(scope))) {
|
|
572
|
+
throw denied();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Decide the roles of a route for a TENANT token and bind the request to the token's tenant.
|
|
578
|
+
*
|
|
579
|
+
* `S_NO_ONE` refuses; `S_EVERYONE` / `S_USER` / `S_VERIFIED` count as satisfied (the route released
|
|
580
|
+
* tokens explicitly); other roles are resolved against the LOWEST tenant role — global roles never
|
|
581
|
+
* match. A route guarded ONLY by object-level system roles (`S_SELF`, `S_CREATOR`) refuses, because
|
|
582
|
+
* those compare a person with a record and a tenant token is no person. An `X-Tenant-Id` header may only name the token's own tenant. On success the request carries
|
|
583
|
+
* `tenantId` / `tenantRole` exactly as a membership would set them.
|
|
584
|
+
*/
|
|
585
|
+
export function assertTenantApiTokenRouteAccess(options: {
|
|
586
|
+
context: IApiTokenContext;
|
|
587
|
+
controllerClass: any;
|
|
588
|
+
handler: any;
|
|
589
|
+
request: any;
|
|
590
|
+
}): void {
|
|
591
|
+
const { context, controllerClass, handler, request } = options;
|
|
592
|
+
|
|
593
|
+
const handlerRoles = handler ? (Reflect.getMetadata('roles', handler) as string[] | undefined) : undefined;
|
|
594
|
+
const classRoles = controllerClass
|
|
595
|
+
? (Reflect.getMetadata('roles', controllerClass) as string[] | undefined)
|
|
596
|
+
: undefined;
|
|
597
|
+
const roles = mergeRolesMetadata([handlerRoles, classRoles]);
|
|
598
|
+
if (roles.includes(RoleEnum.S_NO_ONE)) {
|
|
599
|
+
throw denied();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Same precedence as CoreTenantGuard: method-level system roles decide over class-level ones.
|
|
603
|
+
const systemCheckRoles = handlerRoles?.length ? handlerRoles : roles;
|
|
604
|
+
const memberRole = getApiTokenTenantMemberRole();
|
|
605
|
+
const openedBySystemRole = [RoleEnum.S_EVERYONE, RoleEnum.S_USER, RoleEnum.S_VERIFIED].some((role) =>
|
|
606
|
+
systemCheckRoles.includes(role),
|
|
607
|
+
);
|
|
608
|
+
if (!openedBySystemRole) {
|
|
609
|
+
const checkable = roles.filter((role) => !isSystemRole(role));
|
|
610
|
+
// Only object-level system roles left (S_SELF, S_CREATOR, …): they need a person to compare with,
|
|
611
|
+
// and a tenant token is nobody's "self" and created nothing. Skipping them would grant for ANY target.
|
|
612
|
+
if (roles.length && !checkable.length) {
|
|
613
|
+
throw denied();
|
|
614
|
+
}
|
|
615
|
+
if (checkable.length) {
|
|
616
|
+
const tenantRoles = tenantSatisfiableRoles(checkable);
|
|
617
|
+
if (!tenantRoles.length || !checkRoleAccess(tenantRoles, undefined, memberRole)) {
|
|
618
|
+
throw denied();
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
assertTenantHeaderMatches(request, context.tenantId);
|
|
624
|
+
|
|
625
|
+
if (request) {
|
|
626
|
+
request.tenantId = context.tenantId;
|
|
627
|
+
request.tenantRole = memberRole;
|
|
628
|
+
request.isAdminBypass = false;
|
|
629
|
+
request.tenantIds = undefined;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** A tenant header may only name the tenant a token is bound to. No header is fine — the binding applies. */
|
|
634
|
+
function assertTenantHeaderMatches(request: any, tenantId: string | undefined): void {
|
|
635
|
+
const header = request?.headers?.[tenantHeaderName()];
|
|
636
|
+
if (header !== undefined && (typeof header !== 'string' || header.trim() !== tenantId)) {
|
|
637
|
+
throw denied();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Guard entry point, shared by RolesGuard, BetterAuthRolesGuard and CoreTenantGuard so the policy
|
|
643
|
+
* cannot drift between them and holds whichever guard runs first or alone.
|
|
644
|
+
*
|
|
645
|
+
* Returns the kind of the token the request was authenticated with, or `undefined` when it does not
|
|
646
|
+
* concern tokens at all:
|
|
647
|
+
* - `TENANT` → fully decided here; the guard grants.
|
|
648
|
+
* - `USER` → scopes and tenant restriction checked here; the guard continues with its ordinary checks,
|
|
649
|
+
* because a user token acts as its user.
|
|
650
|
+
*
|
|
651
|
+
* A request that carries a token credential but reached a guard without a token context — the
|
|
652
|
+
* authentication middleware did not run — is refused with 401 rather than treated as anonymous.
|
|
653
|
+
*/
|
|
654
|
+
export function enforceApiTokenRoute(options: {
|
|
655
|
+
controllerClass: any;
|
|
656
|
+
handler: any;
|
|
657
|
+
request: any;
|
|
658
|
+
}): ApiTokenKind | undefined {
|
|
659
|
+
const { request } = options;
|
|
660
|
+
if (!request) {
|
|
661
|
+
return undefined;
|
|
662
|
+
}
|
|
663
|
+
const context = getApiTokenContext(request.user);
|
|
664
|
+
if (!context) {
|
|
665
|
+
if (!request.user && hasApiTokenCredential(request)) {
|
|
666
|
+
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
|
|
667
|
+
}
|
|
668
|
+
return undefined;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
assertApiTokenScopes(context, options.handler, options.controllerClass);
|
|
672
|
+
|
|
673
|
+
if (context.kind === ApiTokenKind.TENANT) {
|
|
674
|
+
assertTenantApiTokenRouteAccess({ ...options, context });
|
|
675
|
+
return ApiTokenKind.TENANT;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (context.tenantId) {
|
|
679
|
+
assertTenantHeaderMatches(request, context.tenantId);
|
|
680
|
+
}
|
|
681
|
+
return ApiTokenKind.USER;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// =====================================================================================================================
|
|
685
|
+
// Tenant integration for USER tokens (used by CoreTenantGuard)
|
|
686
|
+
// =====================================================================================================================
|
|
687
|
+
|
|
688
|
+
/** The one tenant a USER token is restricted to, if any. */
|
|
689
|
+
export function getApiTokenTenantRestriction(user: unknown): string | undefined {
|
|
690
|
+
const context = getApiTokenContext(user);
|
|
691
|
+
return context?.kind === ApiTokenKind.USER ? context.tenantId : undefined;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* The tenant role a request may act with, given the membership role of its user.
|
|
696
|
+
*
|
|
697
|
+
* For a USER token with `maxTenantRole` the lower of the two by hierarchy level; for everything else the
|
|
698
|
+
* membership role unchanged. A membership role outside the hierarchy cannot be compared with a cap, so a
|
|
699
|
+
* capped token gets NO tenant role then (`null`) — failing closed rather than guessing which is higher.
|
|
700
|
+
*/
|
|
701
|
+
export function capApiTokenTenantRole(user: unknown, membershipRole: string): null | string {
|
|
702
|
+
const context = getApiTokenContext(user);
|
|
703
|
+
if (context?.kind !== ApiTokenKind.USER || !context.maxTenantRole) {
|
|
704
|
+
return membershipRole;
|
|
705
|
+
}
|
|
706
|
+
const levels = hierarchy();
|
|
707
|
+
if (!(membershipRole in levels) || !(context.maxTenantRole in levels)) {
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
return levels[membershipRole] <= levels[context.maxTenantRole] ? membershipRole : context.maxTenantRole;
|
|
711
|
+
}
|