@friggframework/core 2.0.0-next.109 → 2.0.0-next.110

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/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.
@@ -1,4 +1,5 @@
1
1
  const fetch = require('node-fetch');
2
+ const { AsyncLocalStorage } = require('async_hooks');
2
3
  const { Delegate } = require('../../core');
3
4
  const { FetchError } = require('../../errors');
4
5
  const { get } = require('../../assertions');
@@ -7,6 +8,14 @@ const { getTelemetry } = require('../../telemetry/telemetry-runtime');
7
8
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
8
9
  const MAX_AUTH_RETRIES = 3;
9
10
 
11
+ // This context marks the async call chain that holds the refresh slot, and
12
+ // names the requester that holds it. Token requests re-enter _rawRequest
13
+ // through this._post. A 401 from inside that chain must fail fast: if it
14
+ // joins the refresh in flight, it awaits its own promise and hangs. The
15
+ // identity keeps a second requester used inside the chain out of the check.
16
+ // AsyncLocalStorage reaches the nested calls without help from the subclasses.
17
+ const refreshContext = new AsyncLocalStorage();
18
+
10
19
  class Requester extends Delegate {
11
20
  constructor(params) {
12
21
  super(params);
@@ -14,6 +23,11 @@ class Requester extends Delegate {
14
23
  this.isRefreshable = false;
15
24
  this.refreshCount = 0;
16
25
  this.authGraceRetryCount = 0;
26
+ // Concurrent 401s share one refreshAuth() run. See _refreshAuthOnce().
27
+ this._inFlightRefresh = null;
28
+ // This counter increases when the tokens change. A stale 401 (the
29
+ // token changed already) then retries and does not refresh again.
30
+ this._authGeneration = 0;
17
31
  this.DLGT_INVALID_AUTH = 'INVALID_AUTH';
18
32
  this.delegateTypes.push(this.DLGT_INVALID_AUTH);
19
33
  this.agent = get(params, 'agent', null);
@@ -184,6 +198,10 @@ class Requester extends Delegate {
184
198
 
185
199
  options.headers = await this.addAuthHeaders(options.headers);
186
200
 
201
+ // A 401 that arrives after a concurrent refresh is stale. It is not
202
+ // proof that the new token failed.
203
+ const authGenerationAtDispatch = this._authGeneration;
204
+
187
205
  if (this.agent) options.agent = this.agent;
188
206
 
189
207
  // Per-attempt timeout — fresh AbortController per call so the retry
@@ -266,6 +284,26 @@ class Requester extends Delegate {
266
284
  }
267
285
 
268
286
  if (status === 401) {
287
+ // A 401 from inside the refresh flow means the provider
288
+ // rejected the credential itself (invalid_client). A new
289
+ // refresh cannot help. A join would await this same call.
290
+ if (this._isInsideRefreshFlow()) {
291
+ throw await this._invalidateAuth(
292
+ encodedUrl,
293
+ options,
294
+ response
295
+ );
296
+ }
297
+
298
+ const tokenReplacedWhileInFlight =
299
+ this._authGeneration !== authGenerationAtDispatch;
300
+ if (this.isRefreshable && tokenReplacedWhileInFlight) {
301
+ // This request did not try the current token. Retry with
302
+ // it. Do not spend one more provider-side rotation.
303
+ clearRequestTimer();
304
+ return this._rawRequest(url, options, attempt + 1);
305
+ }
306
+
269
307
  if (!this.isRefreshable) {
270
308
  // Up to MAX_AUTH_RETRIES grace retries before invalidating
271
309
  // — a 401 alone isn't proof the credential is bad.
@@ -290,14 +328,15 @@ class Requester extends Delegate {
290
328
  );
291
329
  }
292
330
 
293
- if (this.refreshCount < MAX_AUTH_RETRIES) {
294
- this.refreshCount++;
295
- const refreshSucceeded = await this.refreshAuth();
296
- if (refreshSucceeded) {
297
- clearRequestTimer();
298
- return this._rawRequest(url, options, attempt + 1);
299
- }
331
+ // Concurrent 401s share one refresh. Independent refreshes
332
+ // kill each other, because many providers use single-use
333
+ // refresh tokens. Only the initiator spends the retry budget.
334
+ const refreshAlreadyInFlight = Boolean(this._inFlightRefresh);
300
335
 
336
+ if (
337
+ !refreshAlreadyInFlight &&
338
+ this.refreshCount >= MAX_AUTH_RETRIES
339
+ ) {
301
340
  throw await this._invalidateAuth(
302
341
  encodedUrl,
303
342
  options,
@@ -305,6 +344,16 @@ class Requester extends Delegate {
305
344
  );
306
345
  }
307
346
 
347
+ if (!refreshAlreadyInFlight) {
348
+ this.refreshCount++;
349
+ }
350
+
351
+ const refreshSucceeded = await this._refreshAuthOnce();
352
+ if (refreshSucceeded) {
353
+ clearRequestTimer();
354
+ return this._rawRequest(url, options, attempt + 1);
355
+ }
356
+
308
357
  throw await this._invalidateAuth(encodedUrl, options, response);
309
358
  }
310
359
 
@@ -427,6 +476,64 @@ class Requester extends Delegate {
427
476
  return this._request(options.url, fetchOptions);
428
477
  }
429
478
 
479
+ /**
480
+ * Runs one refreshAuth() at a time. The first caller starts the refresh.
481
+ * Callers that arrive during the refresh await the same promise. The
482
+ * check-and-store step is synchronous. Thus two concurrent callers
483
+ * cannot both start a refresh.
484
+ *
485
+ * @returns {Promise<boolean>} True if the refresh succeeded.
486
+ */
487
+ _refreshAuthOnce() {
488
+ if (!this._inFlightRefresh) {
489
+ // Keep .finally last. The stored promise must be the promise
490
+ // that clears the slot. Then the slot is free before a waiter
491
+ // resumes.
492
+ this._inFlightRefresh = this._adoptOrRefresh().finally(() => {
493
+ this._inFlightRefresh = null;
494
+ });
495
+ }
496
+ return this._inFlightRefresh;
497
+ }
498
+
499
+ /**
500
+ * Adopts a newer stored credential, or refreshes. Both steps run inside
501
+ * the marker, because the marker must cover the whole time this instance
502
+ * holds the slot. The adoption lives here, not in refreshAuth(), so a
503
+ * module that overrides refreshAuth() still gets it.
504
+ *
505
+ * @returns {Promise<boolean>} True if the instance holds a usable token.
506
+ */
507
+ async _adoptOrRefresh() {
508
+ const refreshSucceeded = await refreshContext.run(
509
+ { requester: this },
510
+ async () => {
511
+ if (await this._adoptNewerCredential()) return true;
512
+ return this.refreshAuth();
513
+ }
514
+ );
515
+ if (refreshSucceeded) this._authGeneration++;
516
+ return refreshSucceeded;
517
+ }
518
+
519
+ /**
520
+ * Hook for requesters that hold a rotating stored credential. Return true
521
+ * when the instance adopted a newer stored credential and needs no
522
+ * refresh. A module backed by a vendor SDK overrides this, calls super,
523
+ * and then copies the adopted tokens into its SDK client.
524
+ *
525
+ * @returns {Promise<boolean>} True if the instance adopted a newer
526
+ * credential.
527
+ */
528
+ async _adoptNewerCredential() {
529
+ return false;
530
+ }
531
+
532
+ /** True while this instance runs its own refresh. */
533
+ _isInsideRefreshFlow() {
534
+ return refreshContext.getStore()?.requester === this;
535
+ }
536
+
430
537
  async refreshAuth() {
431
538
  throw new Error('refreshAuth not yet defined in child of Requester');
432
539
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0-next.109",
4
+ "version": "2.0.0-next.110",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -48,9 +48,9 @@
48
48
  }
49
49
  },
50
50
  "devDependencies": {
51
- "@friggframework/eslint-config": "2.0.0-next.109",
52
- "@friggframework/prettier-config": "2.0.0-next.109",
53
- "@friggframework/test": "2.0.0-next.109",
51
+ "@friggframework/eslint-config": "2.0.0-next.110",
52
+ "@friggframework/prettier-config": "2.0.0-next.110",
53
+ "@friggframework/test": "2.0.0-next.110",
54
54
  "@prisma/client": "^6.19.3",
55
55
  "@types/lodash": "4.17.15",
56
56
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -90,5 +90,5 @@
90
90
  "publishConfig": {
91
91
  "access": "public"
92
92
  },
93
- "gitHead": "193f4e024490cd25256c0fa33fd05bc8f323a58e"
93
+ "gitHead": "90c538f9523764b80b71a49d2d037e89cf426e0a"
94
94
  }
@@ -41,6 +41,7 @@ declare module "@friggframework/module-plugin" {
41
41
  ): Promise<any>;
42
42
  parseBody(response: any): Promise<any>;
43
43
  refreshAuth(): Promise<any>;
44
+ _adoptNewerCredential(): Promise<boolean>;
44
45
 
45
46
  delegate: any;
46
47
  delegateTypes: any[];
@@ -72,6 +73,7 @@ declare module "@friggframework/module-plugin" {
72
73
  _put(options: RequestOptions): Promise<any>;
73
74
  _delete(options: RequestOptions): Promise<any>;
74
75
  refreshAuth(): Promise<any>;
76
+ _adoptNewerCredential(): Promise<boolean>;
75
77
  }
76
78
 
77
79
  type RequestOptions = {
@@ -201,6 +203,7 @@ declare module "@friggframework/module-plugin" {
201
203
  addAuthHeaders(headers: object): Promise<object>;
202
204
  isAuthenticated(): boolean;
203
205
  refreshAuth(): Promise<void>;
206
+ _adoptNewerCredential(): Promise<boolean>;
204
207
  getTokenFromUsernamePassword(): Promise<Token>;
205
208
  getTokenFromClientCredentials(): Promise<Token>;
206
209
  }