@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.
- package/handlers/routers/user.js +39 -98
- package/index.js +0 -6
- package/logs/index.js +3 -11
- package/logs/logger.js +3 -133
- package/modules/module.js +20 -0
- package/modules/requester/oauth-2.js +147 -3
- package/modules/requester/requester.js +114 -7
- package/package.json +5 -5
- package/types/module-plugin/index.d.ts +3 -0
- package/handlers/rate-limiter.js +0 -90
- package/handlers/routers/user-router.js +0 -226
- package/user/use-cases/login-with-api-key.js +0 -325
- package/user/use-cases/validate-api-key-auth-mode.js +0 -103
|
@@ -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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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.
|
|
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.
|
|
52
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
53
|
-
"@friggframework/test": "2.0.0--canary.
|
|
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": "
|
|
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
|
}
|
package/handlers/rate-limiter.js
DELETED
|
@@ -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
|
-
};
|