@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,19 +1,9 @@
1
+ const express = require('express');
1
2
  const { createAppHandler } = require('../app-handler-helpers');
3
+ const { checkRequiredParams } = require('@friggframework/core');
2
4
  const {
3
5
  createUserRepository,
4
6
  } = require('../../user/repositories/user-repository-factory');
5
- const {
6
- createModuleRepository,
7
- } = require('../../modules/repositories/module-repository-factory');
8
- const {
9
- createCredentialRepository,
10
- } = require('../../credential/repositories/credential-repository-factory');
11
- const {
12
- createIntegrationRepository,
13
- } = require('../../integrations/repositories/integration-repository-factory');
14
- const {
15
- getModulesDefinitionFromIntegrationClasses,
16
- } = require('../../integrations/utils/map-integration-dto');
17
7
  const {
18
8
  CreateIndividualUser,
19
9
  } = require('../../user/use-cases/create-individual-user');
@@ -21,101 +11,52 @@ const { LoginUser } = require('../../user/use-cases/login-user');
21
11
  const {
22
12
  CreateTokenForUserId,
23
13
  } = require('../../user/use-cases/create-token-for-user-id');
24
- const {
25
- GetUserFromXFriggHeaders,
26
- } = require('../../user/use-cases/get-user-from-x-frigg-headers');
27
- const { LoginWithApiKey } = require('../../user/use-cases/login-with-api-key');
28
- const {
29
- validateApiKeyAuthMode,
30
- } = require('../../user/use-cases/validate-api-key-auth-mode');
31
- const {
32
- ProcessAuthorizationCallback,
33
- } = require('../../modules/use-cases/process-authorization-callback');
34
- const { FixedWindowRateLimiter } = require('../rate-limiter');
14
+ const catchAsyncError = require('express-async-handler');
35
15
  const { loadAppDefinition } = require('../app-definition-loader');
36
- const { buildUserRouter } = require('./user-router');
37
-
38
- // ---------------------------------------------------------------------------
39
- // Module-scope wiring (production). Kept thin; all logic lives in use cases.
40
- // The route factory (buildUserRouter) is side-effect-free and lives in
41
- // ./user-router.js so it can be unit-tested without loading an app definition.
42
- // ---------------------------------------------------------------------------
43
- const { integrations: integrationClasses, userConfig } = loadAppDefinition();
44
- const moduleDefinitions =
45
- getModulesDefinitionFromIntegrationClasses(integrationClasses);
46
-
47
- // Fail fast if apiKey mode names a module the app does not have (no-op when off).
48
- validateApiKeyAuthMode(userConfig, moduleDefinitions);
49
-
50
- const apiKeyModeEnabled = Boolean(userConfig?.authModes?.apiKey);
51
16
 
52
- // One-time wiring-time warning: apiKey mode is enabled but no Origin/Referer
53
- // allowlist is configured, so the CSRF check (assertOriginAllowed) is a no-op
54
- // and the SameSite=strict session cookie is the only residual protection. This
55
- // is intentionally NOT a hard failure — it must not break unconfigured local
56
- // dev — but adopters serving a browser SPA should set allowedOrigins (ADR-034 §7).
57
- if (
58
- apiKeyModeEnabled &&
59
- !Array.isArray(userConfig?.authModes?.apiKey?.allowedOrigins)
60
- ) {
61
- // eslint-disable-next-line no-console
62
- console.warn(
63
- '[Frigg] apiKey auth mode is enabled without user.authModes.apiKey.allowedOrigins. ' +
64
- 'CSRF Origin/Referer enforcement is OFF; the SameSite=strict session cookie is the only ' +
65
- 'residual protection. Set allowedOrigins to a list of trusted browser origins to lock this down (ADR-034 §7).'
66
- );
67
- }
17
+ const router = express();
18
+ const { userConfig } = loadAppDefinition();
68
19
  const userRepository = createUserRepository();
69
20
  const createIndividualUser = new CreateIndividualUser({
70
21
  userRepository,
71
22
  userConfig,
72
23
  });
73
- const loginUser = new LoginUser({ userRepository, userConfig });
74
- const createTokenForUserId = new CreateTokenForUserId({ userRepository });
75
-
76
- // apiKey-mode collaborators are only wired when the mode is enabled, so an app
77
- // that never opts in pays nothing and behaves exactly as before.
78
- let loginWithApiKey = null;
79
- if (apiKeyModeEnabled) {
80
- const moduleRepository = createModuleRepository();
81
- const credentialRepository = createCredentialRepository();
82
- const integrationRepository = createIntegrationRepository();
83
-
84
- const getUserFromXFriggHeaders = new GetUserFromXFriggHeaders({
85
- userRepository,
86
- userConfig,
87
- });
88
- const processAuthorizationCallback = new ProcessAuthorizationCallback({
89
- moduleRepository,
90
- credentialRepository,
91
- integrationRepository,
92
- moduleDefinitions,
93
- });
94
-
95
- loginWithApiKey = new LoginWithApiKey({
96
- userConfig,
97
- moduleDefinitions,
98
- getUserFromXFriggHeaders,
99
- processAuthorizationCallback,
100
- createTokenForUserId,
101
- });
102
- }
103
-
104
- const rlConfig = userConfig?.authModes?.apiKey?.rateLimit || {};
105
- const apiKeyLoginLimiter = new FixedWindowRateLimiter({
106
- windowMs: rlConfig.windowMs ?? 60000,
107
- maxPerKey: rlConfig.maxPerKey ?? 10,
108
- maxGlobal: rlConfig.maxGlobal ?? 1000,
109
- });
110
-
111
- const router = buildUserRouter({
24
+ const loginUser = new LoginUser({
25
+ userRepository,
112
26
  userConfig,
113
- loginUser,
114
- createIndividualUser,
115
- createTokenForUserId,
116
- loginWithApiKey,
117
- apiKeyLoginLimiter,
118
27
  });
28
+ const createTokenForUserId = new CreateTokenForUserId({ userRepository });
29
+
30
+ // define the login endpoint
31
+ router.route('/user/login').post(
32
+ catchAsyncError(async (req, res) => {
33
+ const { username, password } = checkRequiredParams(req.body, [
34
+ 'username',
35
+ 'password',
36
+ ]);
37
+ const user = await loginUser.execute({ username, password });
38
+ const token = await createTokenForUserId.execute(user.getId(), 120);
39
+ res.status(201);
40
+ res.json({ token });
41
+ })
42
+ );
43
+
44
+ router.route('/user/create').post(
45
+ catchAsyncError(async (req, res) => {
46
+ const { username, password } = checkRequiredParams(req.body, [
47
+ 'username',
48
+ 'password',
49
+ ]);
50
+
51
+ const user = await createIndividualUser.execute({
52
+ username,
53
+ password,
54
+ });
55
+ const token = await createTokenForUserId.execute(user.getId(), 120);
56
+ res.status(201);
57
+ res.json({ token });
58
+ })
59
+ );
119
60
 
120
61
  const handler = createAppHandler('HTTP Event: User', router);
121
62
 
package/index.js CHANGED
@@ -31,10 +31,6 @@ const {
31
31
  GetUserFromAdopterJwt,
32
32
  } = require('./user/use-cases/get-user-from-adopter-jwt');
33
33
  const { AuthenticateUser } = require('./user/use-cases/authenticate-user');
34
- const { LoginWithApiKey } = require('./user/use-cases/login-with-api-key');
35
- const {
36
- validateApiKeyAuthMode,
37
- } = require('./user/use-cases/validate-api-key-auth-mode');
38
34
 
39
35
  const {
40
36
  CredentialRepository,
@@ -125,8 +121,6 @@ module.exports = {
125
121
  GetUserFromXFriggHeaders,
126
122
  GetUserFromAdopterJwt,
127
123
  AuthenticateUser,
128
- LoginWithApiKey,
129
- validateApiKeyAuthMode,
130
124
  CredentialRepository,
131
125
  ModuleRepository,
132
126
  IntegrationMappingRepository,
package/logs/index.js CHANGED
@@ -1,15 +1,7 @@
1
- const {
2
- debug,
3
- initDebugLog,
4
- flushDebugLog,
5
- redactSensitive,
6
- SENSITIVE_KEYS,
7
- } = require('./logger');
1
+ const {debug, initDebugLog, flushDebugLog} = require('./logger');
8
2
 
9
3
  module.exports = {
10
4
  debug,
11
5
  initDebugLog,
12
- flushDebugLog,
13
- redactSensitive,
14
- SENSITIVE_KEYS,
15
- };
6
+ flushDebugLog
7
+ }
package/logs/logger.js CHANGED
@@ -6,127 +6,6 @@ const util = require('util');
6
6
  const logs = [];
7
7
  let flushCalled = false;
8
8
 
9
- /**
10
- * Keys whose values must never reach the logs. Matched case-insensitively.
11
- * These are the credential-bearing fields a request body / headers can carry —
12
- * the apiKey-login body (`{ apiKey }`), the friggToken body (`{ password }`),
13
- * OAuth material, and Authorization headers. Buffered debug output (and the
14
- * verbose `DEBUG_VERBOSE=1` path) is dumped verbatim on any 5xx, so a raw secret
15
- * in `event.body` would otherwise land in CloudWatch (ADR-034 §4).
16
- * @constant {Set<string>}
17
- */
18
- const SENSITIVE_KEYS = new Set([
19
- 'apikey',
20
- 'api_key',
21
- 'password',
22
- 'token',
23
- 'authorization',
24
- 'refresh_token',
25
- 'access_token',
26
- ]);
27
-
28
- const REDACTED = '[REDACTED]';
29
- // Bound recursion and body-parse cost so a pathological event can never hang or
30
- // blow the stack inside the logger. The logger must never throw.
31
- const MAX_REDACT_DEPTH = 8;
32
- const MAX_BODY_PARSE_LENGTH = 100000;
33
-
34
- function isSensitiveKey(key) {
35
- return typeof key === 'string' && SENSITIVE_KEYS.has(key.toLowerCase());
36
- }
37
-
38
- /**
39
- * Substring/regex fallback for a request body we could not (or should not)
40
- * JSON-parse: a non-JSON body, a form-urlencoded body, or one too large to parse
41
- * cheaply. Masks `"key":"value"` (JSON-ish) and `key=value` (form) shapes for the
42
- * denylisted keys. Best-effort — never throws.
43
- */
44
- function redactBodyStringFallback(body) {
45
- let out = body;
46
- for (const key of SENSITIVE_KEYS) {
47
- // JSON-ish: "apiKey": "secret" -> "apiKey":"[REDACTED]"
48
- out = out.replace(
49
- new RegExp(`("${key}"\\s*:\\s*)"(?:[^"\\\\]|\\\\.)*"`, 'gi'),
50
- `$1"${REDACTED}"`
51
- );
52
- // Form-urlencoded: apiKey=secret -> apiKey=[REDACTED]
53
- out = out.replace(
54
- new RegExp(`(${key}=)[^&\\s]*`, 'gi'),
55
- `$1${REDACTED}`
56
- );
57
- }
58
- return out;
59
- }
60
-
61
- /**
62
- * Redact a serialized request `body` string. JSON bodies are parsed, deep-redacted
63
- * and re-serialized; non-JSON / oversized bodies fall back to pattern masking.
64
- * Never throws.
65
- */
66
- function redactBodyString(body) {
67
- if (body.length <= MAX_BODY_PARSE_LENGTH) {
68
- try {
69
- const parsed = JSON.parse(body);
70
- if (parsed && typeof parsed === 'object') {
71
- return JSON.stringify(redactValue(parsed, 0, new Set()));
72
- }
73
- } catch (_) {
74
- // Not JSON — fall through to pattern masking below.
75
- }
76
- }
77
- return redactBodyStringFallback(body);
78
- }
79
-
80
- /**
81
- * Deep-clone `value`, masking any denylisted key anywhere in the structure and
82
- * redacting an embedded `body` string (the Lambda/API-Gateway convention).
83
- * Returns a NEW object so the caller's data is never mutated; circular refs and
84
- * excessive depth are handled defensively. Never throws (guarded by the public
85
- * `redactSensitive`).
86
- */
87
- function redactValue(value, depth, seen) {
88
- if (value === null || typeof value !== 'object') {
89
- return value;
90
- }
91
- if (depth > MAX_REDACT_DEPTH || seen.has(value)) {
92
- return value;
93
- }
94
- seen.add(value);
95
-
96
- if (Array.isArray(value)) {
97
- return value.map((v) => redactValue(v, depth + 1, seen));
98
- }
99
-
100
- const out = {};
101
- for (const [k, v] of Object.entries(value)) {
102
- if (isSensitiveKey(k)) {
103
- out[k] = REDACTED;
104
- } else if (k.toLowerCase() === 'body' && typeof v === 'string') {
105
- out[k] = redactBodyString(v);
106
- } else {
107
- out[k] = redactValue(v, depth + 1, seen);
108
- }
109
- }
110
- return out;
111
- }
112
-
113
- /**
114
- * Framework-wide redaction applied to anything buffered for logging. Strips
115
- * credential-bearing fields from objects (e.g. a buffered Lambda event) before
116
- * they are serialized. Non-object arguments (the event name, plain strings) pass
117
- * through untouched. Guaranteed not to throw.
118
- * @param {*} value
119
- * @returns {*}
120
- */
121
- function redactSensitive(value) {
122
- try {
123
- return redactValue(value, 0, new Set());
124
- } catch (_) {
125
- // A logger must never break the request it is trying to describe.
126
- return value;
127
- }
128
- }
129
-
130
9
  function debug(...messages) {
131
10
  if (messages.length) {
132
11
  const date = new Date();
@@ -146,11 +25,8 @@ function initDebugLog(...initMessages) {
146
25
  // Hacky but fast way to empty an array.
147
26
  logs.length = 0;
148
27
 
149
- // Redact credential-bearing fields (e.g. the login request body buffered in
150
- // the Lambda event) BEFORE they are serialized and buffered. This is the one
151
- // choke point every handler passes its raw event through, so masking here
152
- // protects both the buffered dump and the DEBUG_VERBOSE=1 immediate path.
153
- debug(...initMessages.map(redactSensitive));
28
+ // Log initial event
29
+ debug(...initMessages);
154
30
  }
155
31
 
156
32
  function flushDebugLog(error) {
@@ -186,10 +62,4 @@ function flushDebugLog(error) {
186
62
  }
187
63
  }
188
64
 
189
- module.exports = {
190
- debug,
191
- initDebugLog,
192
- flushDebugLog,
193
- redactSensitive,
194
- SENSITIVE_KEYS,
195
- };
65
+ module.exports = { debug, initDebugLog, flushDebugLog };
package/modules/module.js CHANGED
@@ -148,9 +148,29 @@ class Module extends Delegate {
148
148
  await this.deauthorize();
149
149
  } else if (delegateString === this.api.DLGT_INVALID_AUTH) {
150
150
  await this.markCredentialsInvalid(object);
151
+ } else if (delegateString === this.api.DLGT_CREDENTIAL_RELOAD) {
152
+ return this.reloadCredential();
151
153
  }
152
154
  }
153
155
 
156
+ /**
157
+ * Re-reads the credential row from the database. The requester can then
158
+ * adopt a concurrent invocation's refresh and does not race it.
159
+ * @returns {Promise<Object|null>} The persisted token fields, or null
160
+ * when no credential row is available. The requester treats null as
161
+ * "nothing to adopt" and continues with its own refresh.
162
+ */
163
+ async reloadCredential() {
164
+ if (!this.credential?.id) return null;
165
+ const freshFromDatabase =
166
+ await this.credentialRepository.findCredentialById(
167
+ this.credential.id
168
+ );
169
+ if (!freshFromDatabase) return null;
170
+ this.credential = freshFromDatabase;
171
+ return this.apiParamsFromCredential(freshFromDatabase);
172
+ }
173
+
154
174
  async markCredentialsInvalid(diagnosticInfo = null) {
155
175
  if (!this.credential) return;
156
176
 
@@ -60,9 +60,26 @@ class OAuth2Requester extends Requester {
60
60
  this.DLGT_TOKEN_UPDATE = 'TOKEN_UPDATE';
61
61
  /** @type {string} Delegate type for token deauthorization notifications */
62
62
  this.DLGT_TOKEN_DEAUTHORIZED = 'TOKEN_DEAUTHORIZED';
63
+ /**
64
+ * @type {string} Delegate type that asks the Module for the stored
65
+ * credential. The requester can then adopt a concurrent invocation's
66
+ * refresh and does not race it. See _adoptNewerCredential.
67
+ */
68
+ this.DLGT_CREDENTIAL_RELOAD = 'CREDENTIAL_RELOAD';
63
69
 
64
70
  this.delegateTypes.push(this.DLGT_TOKEN_UPDATE);
65
71
  this.delegateTypes.push(this.DLGT_TOKEN_DEAUTHORIZED);
72
+ this.delegateTypes.push(this.DLGT_CREDENTIAL_RELOAD);
73
+
74
+ /**
75
+ * Re-read delays after an invalid_grant, in ms. The winner's write
76
+ * can arrive after the loser's rejection (716 ms measured in
77
+ * production). Wait between re-reads before you decide that the
78
+ * credential is dead. Tests can inject other values.
79
+ */
80
+ this.credentialReloadBackoffMs = params?.credentialReloadBackoffMs ?? [
81
+ 500, 1000, 1500,
82
+ ];
66
83
 
67
84
  /** @type {string} OAuth grant type */
68
85
  this.grant_type = get(params, 'grant_type', 'authorization_code');
@@ -295,6 +312,16 @@ class OAuth2Requester extends Requester {
295
312
  * @returns {Promise<boolean>} True if refresh succeeded, false if failed
296
313
  */
297
314
  async refreshAuth() {
315
+ // The wrapper runs this check before it calls refreshAuth(). Callers
316
+ // that reach this method directly skip the wrapper, so the check runs
317
+ // here for them. The guard keeps it at one read per refresh.
318
+ if (
319
+ !this._isInsideRefreshFlow() &&
320
+ (await this._adoptNewerCredential())
321
+ ) {
322
+ return true;
323
+ }
324
+
298
325
  try {
299
326
  console.log('[Frigg] Starting token refresh', {
300
327
  grant_type: this.grant_type,
@@ -310,7 +337,11 @@ class OAuth2Requester extends Requester {
310
337
  refresh_token: this.refresh_token,
311
338
  });
312
339
  } else {
313
- await this.getTokenFromClientCredentials();
340
+ // getTokenFromClientCredentials() reports a failed token
341
+ // request itself and resolves to undefined. Without this
342
+ // check, the refresh counts as a success with no new token.
343
+ const tokenRes = await this.getTokenFromClientCredentials();
344
+ if (!tokenRes) return false;
314
345
  }
315
346
  console.log('[Frigg] Token refresh succeeded');
316
347
  return true;
@@ -322,8 +353,21 @@ class OAuth2Requester extends Requester {
322
353
  response_status: error?.response?.status,
323
354
  response_data: error?.response?.data,
324
355
  });
325
- // Status only: the refresh body carries client_secret, and
326
- // FetchError embeds the body in its message outside prod.
356
+
357
+ if (!this._isDefinitiveAuthRejection(error)) {
358
+ throw this._transportFailureError(error, moduleName);
359
+ }
360
+
361
+ if (await this._adoptNewerCredentialWithBackoff()) return true;
362
+
363
+ // The provider rejected the grant, and the store has nothing
364
+ // newer. The credential is dead.
365
+ this.telemetry?.count?.('frigg.auth.refresh_race_lost', 1, {
366
+ module: this._telemetryModuleLabel(),
367
+ });
368
+ // Send the status only. The refresh body contains the
369
+ // client_secret, and FetchError puts the body in its message
370
+ // outside prod.
327
371
  await this.notify(this.DLGT_INVALID_AUTH, {
328
372
  statusCode: error?.statusCode,
329
373
  });
@@ -331,6 +375,106 @@ class OAuth2Requester extends Requester {
331
375
  }
332
376
  }
333
377
 
378
+ /**
379
+ * A timeout, a 429, or a 5xx from the token endpoint says nothing about
380
+ * the credential. The error must stay retryable: the caller fails loudly
381
+ * (worker throw → SQS retry → DLQ) and does not flag a healthy
382
+ * credential. This is a fresh Error on purpose. Outside prod, the
383
+ * original message can contain the request body, and the body carries
384
+ * the client_secret.
385
+ */
386
+ _transportFailureError(error, moduleName) {
387
+ const status =
388
+ error?.statusCode ?? error?.status ?? error?.response?.status;
389
+ const transportError = new Error(
390
+ `[Frigg] Token refresh transport failure for ${moduleName}` +
391
+ (status != null ? ` (status ${status})` : '')
392
+ );
393
+ transportError.statusCode = status;
394
+ transportError.isTokenRefreshTransportFailure = true;
395
+ return transportError;
396
+ }
397
+
398
+ /**
399
+ * A definitive rejection can mean that another invocation consumed this
400
+ * refresh token first. That invocation's write can be unreadable for a
401
+ * short time. Re-read with a bounded backoff before you decide that the
402
+ * credential is dead.
403
+ */
404
+ async _adoptNewerCredentialWithBackoff() {
405
+ for (const delayMs of this.credentialReloadBackoffMs) {
406
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
407
+ if (await this._adoptNewerCredential()) {
408
+ console.log(
409
+ '[Frigg] Adopted a newer credential after a refresh rejection',
410
+ { module: this._telemetryModuleLabel() }
411
+ );
412
+ this.telemetry?.count?.('frigg.auth.refresh_race_recovered', 1, {
413
+ module: this._telemetryModuleLabel(),
414
+ });
415
+ return true;
416
+ }
417
+ }
418
+ return false;
419
+ }
420
+
421
+ /**
422
+ * True when the token endpoint refused the grant. RFC 6749 §5.2 sets
423
+ * the status: 400, or 401 for invalid_client. A 429 or a 5xx is never a
424
+ * verdict on the credential. Body markers are only a fallback for SDK
425
+ * errors that have no status code. Production FetchErrors have a
426
+ * sanitized body, so a marker cannot be the primary signal.
427
+ */
428
+ _isDefinitiveAuthRejection(error) {
429
+ const status =
430
+ error?.statusCode ?? error?.status ?? error?.response?.status;
431
+ if (status !== undefined && status !== null) {
432
+ return status === 400 || status === 401;
433
+ }
434
+ const haystack = [
435
+ error?.message,
436
+ error?.body,
437
+ typeof error?.error === 'string' ? error.error : null,
438
+ error?.response?.data && JSON.stringify(error.response.data),
439
+ ]
440
+ .filter(Boolean)
441
+ .join(' ');
442
+ return /\b(invalid_grant|invalid_client)\b/i.test(haystack);
443
+ }
444
+
445
+ /**
446
+ * Adopts the credential stored in the database if it is newer than the
447
+ * credential from the instance. The refresh token decides "newer": a
448
+ * provider can rotate it and return an identical access-token string.
449
+ * The reload only reads. A reload failure is not fatal: a database blip
450
+ * must not change the auth behavior.
451
+ *
452
+ * @returns {Promise<boolean>} True if the module adopted a newer
453
+ * credential.
454
+ */
455
+ async _adoptNewerCredential() {
456
+ let stored = null;
457
+ try {
458
+ stored = await this.notify(this.DLGT_CREDENTIAL_RELOAD);
459
+ } catch (_) {
460
+ return false;
461
+ }
462
+ if (!stored?.refresh_token) return false;
463
+ if (stored.refresh_token === this.refresh_token) return false;
464
+
465
+ if (stored.access_token) {
466
+ this.access_token = stored.access_token;
467
+ }
468
+ this.refresh_token = stored.refresh_token;
469
+ if (stored.accessTokenExpire !== undefined) {
470
+ this.accessTokenExpire = stored.accessTokenExpire;
471
+ }
472
+ if (stored.refreshTokenExpire !== undefined) {
473
+ this.refreshTokenExpire = stored.refreshTokenExpire;
474
+ }
475
+ return true;
476
+ }
477
+
334
478
  /**
335
479
  * Obtains tokens using the Resource Owner Password Credentials grant.
336
480
  * Requires username and password to be set.