@friggframework/core 2.0.0--canary.643.45b1fbc.0 → 2.0.0--canary.636.7dc8dca.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,325 +0,0 @@
1
- const Boom = require('@hapi/boom');
2
- const { Module } = require('../../modules/module');
3
-
4
- /**
5
- * Maximum accepted length of a submitted API key. Capped before any provider
6
- * work so the endpoint cannot be used to smuggle arbitrarily large payloads or
7
- * amplify an oracle attack. Generous enough for JWT-shaped or concatenated keys.
8
- * @constant {number}
9
- */
10
- const DEFAULT_MAX_API_KEY_LENGTH = 8192;
11
-
12
- /**
13
- * Classify an error thrown by the module's Requester while validating or
14
- * identifying a key, per ADR-034 §Security requirement 6 ("Outage ≠ invalid").
15
- *
16
- * A definitive provider rejection (401/403, or any other non-5xx client error)
17
- * means the key is bad. A 5xx, a network failure, or a timeout means the
18
- * provider is unavailable and MUST NOT be reported as an invalid key nor allowed
19
- * to mint a session.
20
- *
21
- * @param {*} err - The thrown error.
22
- * @returns {'invalid'|'unavailable'} classification
23
- */
24
- function classifyProviderError(err) {
25
- const status =
26
- err?.statusCode ?? err?.response?.status ?? err?.status ?? undefined;
27
-
28
- if (typeof status === 'number' && status >= 400 && status < 500) {
29
- // 401/403 and any other definitive 4xx from the provider → bad key.
30
- return 'invalid';
31
- }
32
- // 5xx, or no status at all (timeout / DNS / socket error) → outage.
33
- return 'unavailable';
34
- }
35
-
36
- /**
37
- * Generic invalid-credentials error. Deliberately identical for every failure
38
- * reason on the bad-key path so the endpoint never enumerates users or keys
39
- * (ADR-034 §Security requirement 3).
40
- * @returns {Boom} 401
41
- */
42
- function invalidCredentials() {
43
- return Boom.unauthorized('Invalid credentials');
44
- }
45
-
46
- /**
47
- * Provider-unavailable error, distinct from invalid credentials. No session is
48
- * created and no cookie is cleared when this is thrown (ADR-034 §6).
49
- * @returns {Boom} 503
50
- */
51
- function providerUnavailable() {
52
- return Boom.serverUnavailable('Identity provider unavailable');
53
- }
54
-
55
- /**
56
- * Use case implementing the ADR-034 `apiKey` auth mode: log a browser end user
57
- * in with their own product API key, validated *through the api-module itself*.
58
- *
59
- * Flow (see ADR-034):
60
- * 1. Validate + identify the key via the configured identity module's
61
- * Requester (`testAuthRequest`, then `getEntityDetails`).
62
- * 2. Derive a provider-authoritative tenant identity — NEVER from client input.
63
- * 3. Find-or-create the Frigg user from that identity (ordinary app user).
64
- * 4. Create the Credential + Entity via `ProcessAuthorizationCallback`.
65
- * 5. Mint a short-lived Frigg session token and return it.
66
- *
67
- * @class LoginWithApiKey
68
- */
69
- class LoginWithApiKey {
70
- /**
71
- * @param {Object} params
72
- * @param {Object} params.userConfig - App-definition `user` config (reads `authModes.apiKey`).
73
- * @param {Array<Object>} params.moduleDefinitions - Module definitions available to the app.
74
- * @param {import('./get-user-from-x-frigg-headers').GetUserFromXFriggHeaders} params.getUserFromXFriggHeaders - Reused find-or-create path.
75
- * @param {import('../../modules/use-cases/process-authorization-callback').ProcessAuthorizationCallback} params.processAuthorizationCallback - Reused credential/entity creation.
76
- * @param {import('./create-token-for-user-id').CreateTokenForUserId} params.createTokenForUserId - Reused session-token minting.
77
- * @param {number} [params.tokenExpiryMinutes=120] - Access token TTL. Short by design (ADR-034 §5): revocation latency is bounded by this.
78
- * @param {number} [params.maxApiKeyLength=8192] - Length cap enforced before any provider work.
79
- * @param {typeof Module} [params.ModuleClass=Module] - Injectable Module class (for testing without a real Requester).
80
- */
81
- constructor({
82
- userConfig,
83
- moduleDefinitions,
84
- getUserFromXFriggHeaders,
85
- processAuthorizationCallback,
86
- createTokenForUserId,
87
- tokenExpiryMinutes = 120,
88
- maxApiKeyLength = DEFAULT_MAX_API_KEY_LENGTH,
89
- ModuleClass = Module,
90
- }) {
91
- this.userConfig = userConfig || {};
92
- this.moduleDefinitions = moduleDefinitions || [];
93
- this.getUserFromXFriggHeaders = getUserFromXFriggHeaders;
94
- this.processAuthorizationCallback = processAuthorizationCallback;
95
- this.createTokenForUserId = createTokenForUserId;
96
- this.tokenExpiryMinutes = tokenExpiryMinutes;
97
- this.maxApiKeyLength = maxApiKeyLength;
98
- this.ModuleClass = ModuleClass;
99
- }
100
-
101
- /**
102
- * Resolve which identity module a request may use.
103
- *
104
- * The module is fixed by config (`authModes.apiKey.module`). A multi-identity
105
- * app MAY configure an allowlist (`authModes.apiKey.modules`) and let the
106
- * client pick one via the request body — but only from that allowlist. A
107
- * client-supplied module that is not allowlisted is rejected generically, so
108
- * the endpoint cannot be steered at an arbitrary module.
109
- *
110
- * @param {string} [requestedModule] - Optional module name from the request body.
111
- * @returns {string} The resolved module name.
112
- * @throws {Boom} 401 generic if apiKey mode is unconfigured or the request names a non-allowlisted module.
113
- */
114
- resolveModuleName(requestedModule) {
115
- const config = this.userConfig.authModes?.apiKey;
116
- if (!config) {
117
- // apiKey mode not enabled for this app.
118
- throw invalidCredentials();
119
- }
120
-
121
- // Allowlist = the UNION of the explicit `modules` array and the single
122
- // `module` (matching validateApiKeyAuthMode's own union). This keeps the
123
- // resolver and the wiring-time validator in agreement: a config like
124
- // `{ modules: [], module: 'reevo' }` passes validation AND resolves to
125
- // ['reevo'] here, rather than validation passing while the resolver saw
126
- // an empty list and rejected every login. Never an open set.
127
- const allowlist = [
128
- ...(Array.isArray(config.modules) ? config.modules : []),
129
- ...(config.module ? [config.module] : []),
130
- ];
131
-
132
- if (allowlist.length === 0) {
133
- throw invalidCredentials();
134
- }
135
-
136
- if (requestedModule) {
137
- if (!allowlist.includes(requestedModule)) {
138
- throw invalidCredentials();
139
- }
140
- return requestedModule;
141
- }
142
-
143
- // No client-specified module: only unambiguous when exactly one is configured.
144
- if (allowlist.length === 1) {
145
- return allowlist[0];
146
- }
147
-
148
- // Multi-identity app but the client did not name a module.
149
- throw invalidCredentials();
150
- }
151
-
152
- /**
153
- * Validate the key against the provider and derive a provider-authoritative
154
- * tenant identity. Reused by login (and available for refresh re-validation,
155
- * ADR-034 §5): a revoked key stops validating here.
156
- *
157
- * @param {string} apiKey
158
- * @param {string} moduleName
159
- * @returns {Promise<{ externalId: string, moduleDefinition: Object }>}
160
- * @throws {Boom} 401 invalid credentials on a bad/rejected key or missing identifier; 503 on provider outage.
161
- */
162
- async validateAndIdentify(apiKey, moduleName) {
163
- const moduleDefinition = this.moduleDefinitions.find(
164
- (def) => def.moduleName === moduleName
165
- );
166
- if (!moduleDefinition) {
167
- // Should be caught at config-validation time; treat a runtime miss
168
- // as a server misconfiguration rather than leaking specifics.
169
- throw Boom.badImplementation(
170
- `apiKey identity module '${moduleName}' is not registered`
171
- );
172
- }
173
-
174
- const module = new this.ModuleClass({ definition: moduleDefinition });
175
-
176
- // Seed the api client with the key without persisting anything.
177
- const setAuthParams =
178
- moduleDefinition.requiredAuthMethods?.setAuthParams;
179
- if (typeof setAuthParams === 'function') {
180
- await setAuthParams(module.api, { api_key: apiKey });
181
- } else if (typeof module.api?.setApiKey === 'function') {
182
- module.api.setApiKey(apiKey);
183
- }
184
-
185
- // 1) Validity. Call testAuthRequest directly (NOT module.testAuth, which
186
- // swallows the error and would collapse 401 and 503 into one `false`).
187
- let isValid;
188
- try {
189
- isValid =
190
- await moduleDefinition.requiredAuthMethods.testAuthRequest(
191
- module.api
192
- );
193
- } catch (err) {
194
- throw classifyProviderError(err) === 'unavailable'
195
- ? providerUnavailable()
196
- : invalidCredentials();
197
- }
198
- // Require a STRICT boolean pass. A module that returns a truthy value on
199
- // a bad key (e.g. an error object, a non-empty string, a response body)
200
- // must NOT clear the validity gate — only an explicit `true` does. The
201
- // login-path contract for testAuthRequest is: throw, or return `true`.
202
- if (isValid !== true) {
203
- throw invalidCredentials();
204
- }
205
-
206
- // 2) Identity. Derive the tenant id from the provider response only.
207
- let entityDetails;
208
- try {
209
- entityDetails =
210
- await moduleDefinition.requiredAuthMethods.getEntityDetails(
211
- module.api,
212
- { api_key: apiKey },
213
- undefined,
214
- undefined
215
- );
216
- } catch (err) {
217
- throw classifyProviderError(err) === 'unavailable'
218
- ? providerUnavailable()
219
- : invalidCredentials();
220
- }
221
-
222
- const externalId = entityDetails?.identifiers?.externalId;
223
- // Require a scalar, string-or-number identifier. Anything else (an
224
- // object, array, boolean, null/undefined) is NOT a stable identifier and
225
- // must be rejected rather than String()-coerced into a bogus one like
226
- // "[object Object]" (ADR-034 §Security requirement 1).
227
- if (
228
- (typeof externalId !== 'string' &&
229
- typeof externalId !== 'number') ||
230
- String(externalId).trim() === ''
231
- ) {
232
- throw invalidCredentials();
233
- }
234
-
235
- return { externalId: String(externalId), moduleDefinition };
236
- }
237
-
238
- /**
239
- * Execute the api-key login.
240
- *
241
- * @param {Object} input
242
- * @param {string} input.apiKey - The submitted product API key.
243
- * @param {string} [input.module] - Optional module name (multi-identity apps only; allowlisted).
244
- * @returns {Promise<{ token: string, userId: string, module: string }>} The minted session token and principal.
245
- * @throws {Boom} 401 generic on invalid key / missing identifier / unconfigured mode; 503 on provider outage.
246
- */
247
- async execute({ apiKey, module: requestedModule } = {}) {
248
- // Cap length and shape BEFORE any provider work (ADR-034 §3).
249
- if (typeof apiKey !== 'string' || apiKey.length === 0) {
250
- throw invalidCredentials();
251
- }
252
- if (apiKey.length > this.maxApiKeyLength) {
253
- throw invalidCredentials();
254
- }
255
-
256
- const moduleName = this.resolveModuleName(requestedModule);
257
-
258
- const { externalId } = await this.validateAndIdentify(
259
- apiKey,
260
- moduleName
261
- );
262
-
263
- // Namespace the provider identity by the RESOLVED module. In a
264
- // multi-module allowlist two different providers can legitimately return
265
- // the SAME externalId (e.g. both use the numeric account id "42"); a bare
266
- // externalId would then collapse those two distinct tenants onto one
267
- // Frigg user. Prefixing with the module keeps them separate for BOTH the
268
- // org-user and individual-user identity.
269
- const identity = `${moduleName}:${externalId}`;
270
-
271
- // Find-or-create the Frigg user from the PROVIDER-DERIVED identity only.
272
- // A client-supplied appOrgId/appUserId is never read here — the caller
273
- // passes nothing but the key and (optionally) the allowlisted module.
274
- const useOrg = this.userConfig.organizationUserRequired === true;
275
- const appOrgId = useOrg ? identity : undefined;
276
- const appUserId = useOrg ? undefined : identity;
277
-
278
- // NOTE (accepted cleanup debt): the user is found-or-created before the
279
- // credential is provisioned below. If ProcessAuthorizationCallback fails,
280
- // a user with no credential/entity is left behind. Reordering to create
281
- // the credential first is intentionally out of scope here; orphaned users
282
- // on partial failure are tolerated and cleaned up out of band.
283
- const user = await this.getUserFromXFriggHeaders.execute(
284
- appUserId,
285
- appOrgId
286
- );
287
- const userId = user.getId();
288
- if (!userId) {
289
- throw invalidCredentials();
290
- }
291
-
292
- // Create/refresh the Credential + Entity through the same path
293
- // /api/authorize uses. The key is persisted only as the encrypted
294
- // Credential — never returned, never logged, never a JWT claim.
295
- const callbackResult = await this.processAuthorizationCallback.execute(
296
- userId,
297
- moduleName,
298
- { api_key: apiKey }
299
- );
300
-
301
- // Defense-in-depth: never mint a session unless the credential was
302
- // actually persisted. A callback that returns without a credential id
303
- // means the key was not connected; minting anyway would hand out a
304
- // session over a half-provisioned tenant. Fail 500-class, not 401.
305
- if (!callbackResult || !callbackResult.credential_id) {
306
- throw Boom.badImplementation(
307
- 'Login failed to create a credential for the api-key identity'
308
- );
309
- }
310
-
311
- // Mint an ordinary, short-lived app-user session token (never admin).
312
- const token = await this.createTokenForUserId.execute(
313
- userId,
314
- this.tokenExpiryMinutes
315
- );
316
-
317
- return { token, userId, module: moduleName };
318
- }
319
- }
320
-
321
- module.exports = {
322
- LoginWithApiKey,
323
- classifyProviderError,
324
- DEFAULT_MAX_API_KEY_LENGTH,
325
- };
@@ -1,103 +0,0 @@
1
- /**
2
- * Validate the `user.authModes.apiKey` block of an app definition against the
3
- * app's registered module definitions (ADR-034 §Config).
4
- *
5
- * Default-off: when `authModes.apiKey` is absent this is a no-op, so apps that
6
- * do not opt in are completely unaffected. When present, every named identity
7
- * module MUST exist in the app's modules, or the app is misconfigured and we
8
- * fail fast at wiring time rather than at first login.
9
- *
10
- * Accepts either:
11
- * - `authModes.apiKey.module` — a single identity module (the common case), or
12
- * - `authModes.apiKey.modules` — an allowlist of identity modules (multi-identity apps).
13
- *
14
- * @param {Object} userConfig - The app definition's `user` config (may be null).
15
- * @param {Array<Object>} moduleDefinitions - Registered module definitions (each with `moduleName`).
16
- * @throws {Error} If apiKey mode is declared but names no module, or names a module the app does not register.
17
- * @returns {void}
18
- */
19
- function validateApiKeyAuthMode(userConfig, moduleDefinitions = []) {
20
- const config = userConfig?.authModes?.apiKey;
21
- if (!config) {
22
- return; // apiKey mode not enabled — nothing to validate.
23
- }
24
-
25
- const named = [];
26
- if (Array.isArray(config.modules)) {
27
- named.push(...config.modules);
28
- }
29
- if (config.module) {
30
- named.push(config.module);
31
- }
32
-
33
- if (named.length === 0) {
34
- throw new Error(
35
- 'Invalid app definition: user.authModes.apiKey is enabled but names no identity module. ' +
36
- "Set authModes.apiKey.module = '<moduleName>' (or authModes.apiKey.modules = ['<moduleName>', ...])."
37
- );
38
- }
39
-
40
- const known = new Set(
41
- (moduleDefinitions || [])
42
- .map((def) => def && def.moduleName)
43
- .filter(Boolean)
44
- );
45
-
46
- for (const moduleName of named) {
47
- if (typeof moduleName !== 'string' || moduleName.trim() === '') {
48
- throw new Error(
49
- 'Invalid app definition: user.authModes.apiKey names a non-string module.'
50
- );
51
- }
52
- if (!known.has(moduleName)) {
53
- throw new Error(
54
- `Invalid app definition: user.authModes.apiKey.module '${moduleName}' is not a registered module. ` +
55
- `Registered modules: ${[...known].join(', ') || '(none)'}.`
56
- );
57
- }
58
- }
59
-
60
- // allowedOrigins, when present, MUST be an array. A bare string would be
61
- // iterated character-by-character by the Origin/Referer allowlist check
62
- // (`allowed.includes(candidate)` on a string tests substrings), silently
63
- // widening or breaking CSRF enforcement.
64
- if (
65
- config.allowedOrigins !== undefined &&
66
- !Array.isArray(config.allowedOrigins)
67
- ) {
68
- throw new Error(
69
- 'Invalid app definition: user.authModes.apiKey.allowedOrigins must be an array of origin strings.'
70
- );
71
- }
72
-
73
- // rateLimit, when present, must be an object whose numeric knobs are positive
74
- // finite numbers. A `0`, negative, or NaN would disable or corrupt the
75
- // limiter (e.g. maxPerKey:0 rejects every request; windowMs:NaN never rolls),
76
- // so fail fast at wiring time rather than shipping a broken oracle guard.
77
- if (config.rateLimit !== undefined) {
78
- if (
79
- typeof config.rateLimit !== 'object' ||
80
- config.rateLimit === null ||
81
- Array.isArray(config.rateLimit)
82
- ) {
83
- throw new Error(
84
- 'Invalid app definition: user.authModes.apiKey.rateLimit must be an object.'
85
- );
86
- }
87
- for (const field of ['maxPerKey', 'maxGlobal', 'windowMs']) {
88
- const value = config.rateLimit[field];
89
- if (
90
- value !== undefined &&
91
- (typeof value !== 'number' ||
92
- !Number.isFinite(value) ||
93
- value <= 0)
94
- ) {
95
- throw new Error(
96
- `Invalid app definition: user.authModes.apiKey.rateLimit.${field} must be a positive finite number.`
97
- );
98
- }
99
- }
100
- }
101
- }
102
-
103
- module.exports = { validateApiKeyAuthMode };