@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
- * Best-effort client IP for the per-IP rate-limit bucket. Prefers the first hop
10
- * of X-Forwarded-For (set by API Gateway / proxies), falls back to the socket.
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 getClientIp(req) {
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
- return xff.split(',')[0].trim();
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 access token is ALSO returned in the body
54
- * so token-only (header-bearer) clients work without reading the cookie.
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
- res.cookie('frigg_session', token, {
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
- const { allowed } = apiKeyLoginLimiter.check(getClientIp(req));
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
- setSessionCookie(res, token);
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;
@@ -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 {debug, initDebugLog, flushDebugLog} = require('./logger');
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
- // Log initial event
29
- debug(...initMessages);
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 = { debug, initDebugLog, flushDebugLog };
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.5ac10b7.0",
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.5ac10b7.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.643.5ac10b7.0",
53
- "@friggframework/test": "2.0.0--canary.643.5ac10b7.0",
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": "5ac10b78dec8cf766dd80584f201c7e132aeee12"
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 if present, else the single
122
- // `module`. Never an open set.
123
- const allowlist = Array.isArray(config.modules)
124
- ? config.modules
125
- : config.module
126
- ? [config.module]
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
- if (!isValid) {
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 === undefined ||
218
- externalId === null ||
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 ? externalId : undefined;
258
- const appUserId = useOrg ? undefined : externalId;
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(userId, moduleName, {
273
- api_key: apiKey,
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 };