@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,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--canary.643.45b1fbc.0",
4
+ "version": "2.0.0--canary.636.7dc8dca.0",
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--canary.643.45b1fbc.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.643.45b1fbc.0",
53
- "@friggframework/test": "2.0.0--canary.643.45b1fbc.0",
51
+ "@friggframework/eslint-config": "2.0.0--canary.636.7dc8dca.0",
52
+ "@friggframework/prettier-config": "2.0.0--canary.636.7dc8dca.0",
53
+ "@friggframework/test": "2.0.0--canary.636.7dc8dca.0",
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": "45b1fbc6d4d5ef082814f0cfa848dad2838a97ca"
93
+ "gitHead": "7dc8dca491feccbc4bc9933b971ac8044155efb3"
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
  }
@@ -1,90 +0,0 @@
1
- /**
2
- * Minimal, dependency-free, in-process fixed-window rate limiter for the
3
- * unauthenticated login route (ADR-034 §Security requirement 3).
4
- *
5
- * Two independent windows are enforced per call:
6
- * - per key (typically the client IP), and
7
- * - a global counter across all keys.
8
- *
9
- * Purpose is to blunt use of the endpoint as a key-validation oracle against the
10
- * upstream provider. In a multi-instance serverless deployment each container
11
- * holds its own counters, so this is a floor, not a global guarantee — pair it
12
- * with an infra-level limit (API Gateway / WAF) for a hard ceiling. It is
13
- * deliberately self-contained and unit-testable.
14
- *
15
- * @class FixedWindowRateLimiter
16
- */
17
- class FixedWindowRateLimiter {
18
- /**
19
- * @param {Object} [options]
20
- * @param {number} [options.windowMs=60000] - Window length in milliseconds.
21
- * @param {number} [options.maxPerKey=10] - Max attempts per key per window.
22
- * @param {number} [options.maxGlobal=1000] - Max attempts across all keys per window.
23
- * @param {() => number} [options.now] - Clock (injectable for tests).
24
- */
25
- constructor({
26
- windowMs = 60000,
27
- maxPerKey = 10,
28
- maxGlobal = 1000,
29
- now = () => Date.now(),
30
- } = {}) {
31
- this.windowMs = windowMs;
32
- this.maxPerKey = maxPerKey;
33
- this.maxGlobal = maxGlobal;
34
- this.now = now;
35
- this.buckets = new Map(); // key -> { count, windowStart }
36
- this.global = { count: 0, windowStart: 0 };
37
- }
38
-
39
- _rollGlobal(ts) {
40
- if (ts - this.global.windowStart >= this.windowMs) {
41
- this.global = { count: 0, windowStart: ts };
42
- }
43
- }
44
-
45
- _rollKey(bucket, ts) {
46
- if (!bucket || ts - bucket.windowStart >= this.windowMs) {
47
- return { count: 0, windowStart: ts };
48
- }
49
- return bucket;
50
- }
51
-
52
- /**
53
- * Record an attempt for `key` and report whether it is allowed.
54
- * @param {string} key - Identity for the per-key window (e.g. client IP).
55
- * @returns {{ allowed: boolean, scope?: 'key'|'global' }}
56
- */
57
- check(key) {
58
- const ts = this.now();
59
- const bucketKey = key || 'unknown';
60
-
61
- this._rollGlobal(ts);
62
- if (this.global.count >= this.maxGlobal) {
63
- return { allowed: false, scope: 'global' };
64
- }
65
-
66
- let bucket = this._rollKey(this.buckets.get(bucketKey), ts);
67
- if (bucket.count >= this.maxPerKey) {
68
- this.buckets.set(bucketKey, bucket);
69
- return { allowed: false, scope: 'key' };
70
- }
71
-
72
- bucket = { count: bucket.count + 1, windowStart: bucket.windowStart };
73
- this.buckets.set(bucketKey, bucket);
74
- this.global = {
75
- count: this.global.count + 1,
76
- windowStart: this.global.windowStart,
77
- };
78
-
79
- // Opportunistic cleanup so the Map cannot grow unbounded across windows.
80
- if (this.buckets.size > 10000) {
81
- for (const [k, b] of this.buckets) {
82
- if (ts - b.windowStart >= this.windowMs) this.buckets.delete(k);
83
- }
84
- }
85
-
86
- return { allowed: true };
87
- }
88
- }
89
-
90
- module.exports = { FixedWindowRateLimiter };
@@ -1,226 +0,0 @@
1
- const express = require('express');
2
- const Boom = require('@hapi/boom');
3
- const { checkRequiredParams } = require('@friggframework/core');
4
- const catchAsyncError = require('express-async-handler');
5
-
6
- const LOCAL_STAGES = ['dev', 'test', 'local'];
7
-
8
- /**
9
- * Number of trusted proxies (API Gateway, ALB, CloudFront, …) in front of the
10
- * app. The client IP is taken this many hops from the RIGHT of X-Forwarded-For.
11
- * Defaults to 1 (the single trusted hop AWS API Gateway adds). Only a positive
12
- * finite integer is honored; anything else falls back to 1.
13
- */
14
- function trustedProxyDepth(userConfig) {
15
- const configured =
16
- userConfig?.authModes?.apiKey?.rateLimit?.trustedProxyDepth;
17
- if (
18
- typeof configured === 'number' &&
19
- Number.isInteger(configured) &&
20
- configured > 0
21
- ) {
22
- return configured;
23
- }
24
- return 1;
25
- }
26
-
27
- /**
28
- * Client IP for the per-IP rate-limit bucket, derived from a TRUSTED position in
29
- * X-Forwarded-For.
30
- *
31
- * X-Forwarded-For is `client, proxy1, …, proxyN`, where each trusted proxy
32
- * APPENDS the address it received the request from. The LEFTMOST entry is
33
- * therefore attacker-controlled (a client can pre-seed it), so keying the limiter
34
- * off `split(',')[0]` let an attacker mint a fresh bucket per request and defeat
35
- * the per-IP cap entirely. We instead read the entry `trustedProxyDepth` hops
36
- * from the right — the value stamped by the first trusted proxy — which the
37
- * client cannot forge. Falls back to the socket address when no XFF is present.
38
- *
39
- * NOTE: `maxGlobal` on the limiter is the only hard in-process ceiling this
40
- * endpoint has, and even that is per-container in a multi-instance serverless
41
- * deployment. The real per-IP control belongs at the edge (WAF / API Gateway
42
- * throttling); this limiter is a floor, not a guarantee.
43
- */
44
- function getClientIp(req, userConfig) {
45
- const xff = req.headers['x-forwarded-for'];
46
- if (typeof xff === 'string' && xff.length > 0) {
47
- const parts = xff
48
- .split(',')
49
- .map((p) => p.trim())
50
- .filter(Boolean);
51
- if (parts.length > 0) {
52
- const depth = trustedProxyDepth(userConfig);
53
- const idx = Math.max(0, parts.length - depth);
54
- return parts[idx];
55
- }
56
- }
57
- return req.ip || req.connection?.remoteAddress || 'unknown';
58
- }
59
-
60
- /**
61
- * CSRF Origin/Referer allowlist for the cookie-bearing apiKey login (ADR-034 §7).
62
- * Enforced only when the app configures `authModes.apiKey.allowedOrigins`.
63
- * Default (unconfigured): no origin restriction beyond the SameSite cookie —
64
- * documented, and appropriate for token-only (non-cookie) SPA usage.
65
- */
66
- function assertOriginAllowed(req, userConfig) {
67
- const allowed = userConfig?.authModes?.apiKey?.allowedOrigins;
68
- if (!Array.isArray(allowed) || allowed.length === 0) {
69
- return; // not configured → rely on SameSite; see ADR-034 §7.
70
- }
71
- const origin = req.headers.origin;
72
- const referer = req.headers.referer || req.headers.referrer;
73
- const candidate =
74
- origin ||
75
- (referer
76
- ? (() => {
77
- try {
78
- const u = new URL(referer);
79
- return `${u.protocol}//${u.host}`;
80
- } catch {
81
- return null;
82
- }
83
- })()
84
- : null);
85
-
86
- if (!candidate || !allowed.includes(candidate)) {
87
- throw Boom.forbidden('Origin not allowed');
88
- }
89
- }
90
-
91
- /**
92
- * Set the session cookie with the hygiene ADR-034 §7 requires: httpOnly, secure
93
- * in non-local stages, SameSite. The cookie lifetime is aligned to the session
94
- * token TTL so the browser drops the cookie exactly when the token stops being
95
- * valid (no stale cookie outliving its token, and no token outliving its cookie).
96
- * The access token is ALSO returned in the body so token-only (header-bearer)
97
- * clients work without reading the cookie.
98
- *
99
- * @param {import('express').Response} res
100
- * @param {string} token
101
- * @param {number} [ttlMinutes=120] - Session token TTL; drives Max-Age/Expires.
102
- */
103
- function setSessionCookie(res, token, ttlMinutes = 120) {
104
- const isLocal = LOCAL_STAGES.includes(process.env.STAGE);
105
- const options = {
106
- httpOnly: true,
107
- secure: !isLocal,
108
- sameSite: 'strict',
109
- path: '/',
110
- };
111
- // Align cookie lifetime to the token TTL (express sets both Max-Age and
112
- // Expires from maxAge). Guard against a non-positive/NaN TTL.
113
- if (Number.isFinite(ttlMinutes) && ttlMinutes > 0) {
114
- options.maxAge = ttlMinutes * 60 * 1000;
115
- }
116
- res.cookie('frigg_session', token, options);
117
- }
118
-
119
- /**
120
- * Build the user router. Dependencies are injected so the routes can be tested
121
- * in isolation. `POST /user/login` is polymorphic (ADR-034):
122
- * { username, password } → friggToken (unchanged)
123
- * { apiKey } → apiKey mode (module-validated), when enabled.
124
- *
125
- * This module has NO import-time side effects — the production wiring lives in
126
- * `user.js`, which loads the app definition and calls this factory.
127
- *
128
- * @param {Object} deps
129
- * @param {Object} deps.userConfig
130
- * @param {import('../../user/use-cases/login-user').LoginUser} deps.loginUser
131
- * @param {import('../../user/use-cases/create-individual-user').CreateIndividualUser} deps.createIndividualUser
132
- * @param {import('../../user/use-cases/create-token-for-user-id').CreateTokenForUserId} deps.createTokenForUserId
133
- * @param {import('../../user/use-cases/login-with-api-key').LoginWithApiKey|null} deps.loginWithApiKey - null when apiKey mode is off.
134
- * @param {import('../rate-limiter').FixedWindowRateLimiter} deps.apiKeyLoginLimiter
135
- * @returns {express.Router}
136
- */
137
- function buildUserRouter({
138
- userConfig,
139
- loginUser,
140
- createIndividualUser,
141
- createTokenForUserId,
142
- loginWithApiKey,
143
- apiKeyLoginLimiter,
144
- }) {
145
- const router = express();
146
- const apiKeyModeEnabled = Boolean(loginWithApiKey);
147
-
148
- router.route('/user/login').post(
149
- catchAsyncError(async (req, res) => {
150
- const body = req.body || {};
151
-
152
- // Dispatch: an { apiKey } body selects the apiKey mode. The bodies
153
- // are disjoint, so a password login is never affected by this branch.
154
- if (typeof body.apiKey === 'string') {
155
- // Generic rejection when the mode is not enabled — no enumeration.
156
- if (!apiKeyModeEnabled) {
157
- throw Boom.unauthorized('Invalid credentials');
158
- }
159
-
160
- // Rate limit BEFORE any provider work (oracle protection). The
161
- // bucket key is derived from a trusted XFF position so a client
162
- // cannot rotate it to escape the per-IP cap.
163
- const { allowed } = apiKeyLoginLimiter.check(
164
- getClientIp(req, userConfig)
165
- );
166
- if (!allowed) {
167
- throw Boom.tooManyRequests('Too many requests');
168
- }
169
-
170
- // CSRF: cookie-bearing route.
171
- assertOriginAllowed(req, userConfig);
172
-
173
- const { token } = await loginWithApiKey.execute({
174
- apiKey: body.apiKey,
175
- module: body.module,
176
- });
177
-
178
- // Align the cookie lifetime to the minted token's TTL.
179
- setSessionCookie(
180
- res,
181
- token,
182
- loginWithApiKey.tokenExpiryMinutes ?? 120
183
- );
184
- res.status(201);
185
- res.json({ token });
186
- return;
187
- }
188
-
189
- // friggToken path — UNCHANGED.
190
- const { username, password } = checkRequiredParams(req.body, [
191
- 'username',
192
- 'password',
193
- ]);
194
- const user = await loginUser.execute({ username, password });
195
- const token = await createTokenForUserId.execute(user.getId(), 120);
196
- res.status(201);
197
- res.json({ token });
198
- })
199
- );
200
-
201
- router.route('/user/create').post(
202
- catchAsyncError(async (req, res) => {
203
- const { username, password } = checkRequiredParams(req.body, [
204
- 'username',
205
- 'password',
206
- ]);
207
-
208
- const user = await createIndividualUser.execute({
209
- username,
210
- password,
211
- });
212
- const token = await createTokenForUserId.execute(user.getId(), 120);
213
- res.status(201);
214
- res.json({ token });
215
- })
216
- );
217
-
218
- return router;
219
- }
220
-
221
- module.exports = {
222
- buildUserRouter,
223
- getClientIp,
224
- assertOriginAllowed,
225
- setSessionCookie,
226
- };