@friggframework/core 2.0.0--canary.643.5ac10b7.0 → 2.0.0--canary.643.35eaec0.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.
|
@@ -6,13 +6,53 @@ const catchAsyncError = require('express-async-handler');
|
|
|
6
6
|
const LOCAL_STAGES = ['dev', 'test', 'local'];
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
11
13
|
*/
|
|
12
|
-
function
|
|
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) {
|
|
13
45
|
const xff = req.headers['x-forwarded-for'];
|
|
14
46
|
if (typeof xff === 'string' && xff.length > 0) {
|
|
15
|
-
|
|
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
|
+
}
|
|
16
56
|
}
|
|
17
57
|
return req.ip || req.connection?.remoteAddress || 'unknown';
|
|
18
58
|
}
|
|
@@ -50,17 +90,30 @@ function assertOriginAllowed(req, userConfig) {
|
|
|
50
90
|
|
|
51
91
|
/**
|
|
52
92
|
* Set the session cookie with the hygiene ADR-034 §7 requires: httpOnly, secure
|
|
53
|
-
* in non-local stages, SameSite. The
|
|
54
|
-
* so
|
|
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.
|
|
55
102
|
*/
|
|
56
|
-
function setSessionCookie(res, token) {
|
|
103
|
+
function setSessionCookie(res, token, ttlMinutes = 120) {
|
|
57
104
|
const isLocal = LOCAL_STAGES.includes(process.env.STAGE);
|
|
58
|
-
|
|
105
|
+
const options = {
|
|
59
106
|
httpOnly: true,
|
|
60
107
|
secure: !isLocal,
|
|
61
108
|
sameSite: 'strict',
|
|
62
109
|
path: '/',
|
|
63
|
-
}
|
|
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);
|
|
64
117
|
}
|
|
65
118
|
|
|
66
119
|
/**
|
|
@@ -104,8 +157,12 @@ function buildUserRouter({
|
|
|
104
157
|
throw Boom.unauthorized('Invalid credentials');
|
|
105
158
|
}
|
|
106
159
|
|
|
107
|
-
// Rate limit BEFORE any provider work (oracle protection).
|
|
108
|
-
|
|
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
|
+
);
|
|
109
166
|
if (!allowed) {
|
|
110
167
|
throw Boom.tooManyRequests('Too many requests');
|
|
111
168
|
}
|
|
@@ -118,7 +175,12 @@ function buildUserRouter({
|
|
|
118
175
|
module: body.module,
|
|
119
176
|
});
|
|
120
177
|
|
|
121
|
-
|
|
178
|
+
// Align the cookie lifetime to the minted token's TTL.
|
|
179
|
+
setSessionCookie(
|
|
180
|
+
res,
|
|
181
|
+
token,
|
|
182
|
+
loginWithApiKey.tokenExpiryMinutes ?? 120
|
|
183
|
+
);
|
|
122
184
|
res.status(201);
|
|
123
185
|
res.json({ token });
|
|
124
186
|
return;
|
package/handlers/routers/user.js
CHANGED
|
@@ -48,6 +48,23 @@ const moduleDefinitions =
|
|
|
48
48
|
validateApiKeyAuthMode(userConfig, moduleDefinitions);
|
|
49
49
|
|
|
50
50
|
const apiKeyModeEnabled = Boolean(userConfig?.authModes?.apiKey);
|
|
51
|
+
|
|
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
|
+
}
|
|
51
68
|
const userRepository = createUserRepository();
|
|
52
69
|
const createIndividualUser = new CreateIndividualUser({
|
|
53
70
|
userRepository,
|
package/logs/index.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
debug,
|
|
3
|
+
initDebugLog,
|
|
4
|
+
flushDebugLog,
|
|
5
|
+
redactSensitive,
|
|
6
|
+
SENSITIVE_KEYS,
|
|
7
|
+
} = require('./logger');
|
|
2
8
|
|
|
3
9
|
module.exports = {
|
|
4
10
|
debug,
|
|
5
11
|
initDebugLog,
|
|
6
|
-
flushDebugLog
|
|
7
|
-
|
|
12
|
+
flushDebugLog,
|
|
13
|
+
redactSensitive,
|
|
14
|
+
SENSITIVE_KEYS,
|
|
15
|
+
};
|
package/logs/logger.js
CHANGED
|
@@ -6,6 +6,127 @@ 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
|
+
|
|
9
130
|
function debug(...messages) {
|
|
10
131
|
if (messages.length) {
|
|
11
132
|
const date = new Date();
|
|
@@ -25,8 +146,11 @@ function initDebugLog(...initMessages) {
|
|
|
25
146
|
// Hacky but fast way to empty an array.
|
|
26
147
|
logs.length = 0;
|
|
27
148
|
|
|
28
|
-
//
|
|
29
|
-
|
|
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));
|
|
30
154
|
}
|
|
31
155
|
|
|
32
156
|
function flushDebugLog(error) {
|
|
@@ -62,4 +186,10 @@ function flushDebugLog(error) {
|
|
|
62
186
|
}
|
|
63
187
|
}
|
|
64
188
|
|
|
65
|
-
module.exports = {
|
|
189
|
+
module.exports = {
|
|
190
|
+
debug,
|
|
191
|
+
initDebugLog,
|
|
192
|
+
flushDebugLog,
|
|
193
|
+
redactSensitive,
|
|
194
|
+
SENSITIVE_KEYS,
|
|
195
|
+
};
|
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.
|
|
4
|
+
"version": "2.0.0--canary.643.35eaec0.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.
|
|
52
|
-
"@friggframework/prettier-config": "2.0.0--canary.643.
|
|
53
|
-
"@friggframework/test": "2.0.0--canary.643.
|
|
51
|
+
"@friggframework/eslint-config": "2.0.0--canary.643.35eaec0.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.643.35eaec0.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.643.35eaec0.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": "35eaec004dc09e6263633014ba019d7b12ba07ad"
|
|
94
94
|
}
|
|
@@ -118,13 +118,16 @@ class LoginWithApiKey {
|
|
|
118
118
|
throw invalidCredentials();
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
// Allowlist = the explicit `modules` array
|
|
122
|
-
// `module
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
];
|
|
128
131
|
|
|
129
132
|
if (allowlist.length === 0) {
|
|
130
133
|
throw invalidCredentials();
|
|
@@ -192,7 +195,11 @@ class LoginWithApiKey {
|
|
|
192
195
|
? providerUnavailable()
|
|
193
196
|
: invalidCredentials();
|
|
194
197
|
}
|
|
195
|
-
|
|
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) {
|
|
196
203
|
throw invalidCredentials();
|
|
197
204
|
}
|
|
198
205
|
|
|
@@ -213,12 +220,15 @@ class LoginWithApiKey {
|
|
|
213
220
|
}
|
|
214
221
|
|
|
215
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).
|
|
216
227
|
if (
|
|
217
|
-
externalId
|
|
218
|
-
|
|
228
|
+
(typeof externalId !== 'string' &&
|
|
229
|
+
typeof externalId !== 'number') ||
|
|
219
230
|
String(externalId).trim() === ''
|
|
220
231
|
) {
|
|
221
|
-
// No stable identifier → reject (ADR-034 §Security requirement 1).
|
|
222
232
|
throw invalidCredentials();
|
|
223
233
|
}
|
|
224
234
|
|
|
@@ -250,13 +260,26 @@ class LoginWithApiKey {
|
|
|
250
260
|
moduleName
|
|
251
261
|
);
|
|
252
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
|
+
|
|
253
271
|
// Find-or-create the Frigg user from the PROVIDER-DERIVED identity only.
|
|
254
272
|
// A client-supplied appOrgId/appUserId is never read here — the caller
|
|
255
273
|
// passes nothing but the key and (optionally) the allowlisted module.
|
|
256
274
|
const useOrg = this.userConfig.organizationUserRequired === true;
|
|
257
|
-
const appOrgId = useOrg ?
|
|
258
|
-
const appUserId = useOrg ? undefined :
|
|
275
|
+
const appOrgId = useOrg ? identity : undefined;
|
|
276
|
+
const appUserId = useOrg ? undefined : identity;
|
|
259
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.
|
|
260
283
|
const user = await this.getUserFromXFriggHeaders.execute(
|
|
261
284
|
appUserId,
|
|
262
285
|
appOrgId
|
|
@@ -269,9 +292,21 @@ class LoginWithApiKey {
|
|
|
269
292
|
// Create/refresh the Credential + Entity through the same path
|
|
270
293
|
// /api/authorize uses. The key is persisted only as the encrypted
|
|
271
294
|
// Credential — never returned, never logged, never a JWT claim.
|
|
272
|
-
await this.processAuthorizationCallback.execute(
|
|
273
|
-
|
|
274
|
-
|
|
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
|
+
}
|
|
275
310
|
|
|
276
311
|
// Mint an ordinary, short-lived app-user session token (never admin).
|
|
277
312
|
const token = await this.createTokenForUserId.execute(
|
|
@@ -56,6 +56,48 @@ function validateApiKeyAuthMode(userConfig, moduleDefinitions = []) {
|
|
|
56
56
|
);
|
|
57
57
|
}
|
|
58
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
|
+
}
|
|
59
101
|
}
|
|
60
102
|
|
|
61
103
|
module.exports = { validateApiKeyAuthMode };
|