@friggframework/core 2.0.0--canary.640.5140601.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.
- package/handlers/rate-limiter.js +90 -0
- package/handlers/routers/user-router.js +226 -0
- package/handlers/routers/user.js +98 -39
- package/index.js +6 -0
- package/logs/index.js +11 -3
- package/logs/logger.js +133 -3
- package/package.json +5 -5
- package/user/use-cases/login-with-api-key.js +325 -0
- package/user/use-cases/validate-api-key-auth-mode.js +103 -0
|
@@ -0,0 +1,90 @@
|
|
|
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 };
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
};
|
package/handlers/routers/user.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
const express = require('express');
|
|
2
1
|
const { createAppHandler } = require('../app-handler-helpers');
|
|
3
|
-
const { checkRequiredParams } = require('@friggframework/core');
|
|
4
2
|
const {
|
|
5
3
|
createUserRepository,
|
|
6
4
|
} = require('../../user/repositories/user-repository-factory');
|
|
5
|
+
const {
|
|
6
|
+
createModuleRepository,
|
|
7
|
+
} = require('../../modules/repositories/module-repository-factory');
|
|
8
|
+
const {
|
|
9
|
+
createCredentialRepository,
|
|
10
|
+
} = require('../../credential/repositories/credential-repository-factory');
|
|
11
|
+
const {
|
|
12
|
+
createIntegrationRepository,
|
|
13
|
+
} = require('../../integrations/repositories/integration-repository-factory');
|
|
14
|
+
const {
|
|
15
|
+
getModulesDefinitionFromIntegrationClasses,
|
|
16
|
+
} = require('../../integrations/utils/map-integration-dto');
|
|
7
17
|
const {
|
|
8
18
|
CreateIndividualUser,
|
|
9
19
|
} = require('../../user/use-cases/create-individual-user');
|
|
@@ -11,52 +21,101 @@ const { LoginUser } = require('../../user/use-cases/login-user');
|
|
|
11
21
|
const {
|
|
12
22
|
CreateTokenForUserId,
|
|
13
23
|
} = require('../../user/use-cases/create-token-for-user-id');
|
|
14
|
-
const
|
|
24
|
+
const {
|
|
25
|
+
GetUserFromXFriggHeaders,
|
|
26
|
+
} = require('../../user/use-cases/get-user-from-x-frigg-headers');
|
|
27
|
+
const { LoginWithApiKey } = require('../../user/use-cases/login-with-api-key');
|
|
28
|
+
const {
|
|
29
|
+
validateApiKeyAuthMode,
|
|
30
|
+
} = require('../../user/use-cases/validate-api-key-auth-mode');
|
|
31
|
+
const {
|
|
32
|
+
ProcessAuthorizationCallback,
|
|
33
|
+
} = require('../../modules/use-cases/process-authorization-callback');
|
|
34
|
+
const { FixedWindowRateLimiter } = require('../rate-limiter');
|
|
15
35
|
const { loadAppDefinition } = require('../app-definition-loader');
|
|
36
|
+
const { buildUserRouter } = require('./user-router');
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Module-scope wiring (production). Kept thin; all logic lives in use cases.
|
|
40
|
+
// The route factory (buildUserRouter) is side-effect-free and lives in
|
|
41
|
+
// ./user-router.js so it can be unit-tested without loading an app definition.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
const { integrations: integrationClasses, userConfig } = loadAppDefinition();
|
|
44
|
+
const moduleDefinitions =
|
|
45
|
+
getModulesDefinitionFromIntegrationClasses(integrationClasses);
|
|
46
|
+
|
|
47
|
+
// Fail fast if apiKey mode names a module the app does not have (no-op when off).
|
|
48
|
+
validateApiKeyAuthMode(userConfig, moduleDefinitions);
|
|
49
|
+
|
|
50
|
+
const apiKeyModeEnabled = Boolean(userConfig?.authModes?.apiKey);
|
|
16
51
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
}
|
|
19
68
|
const userRepository = createUserRepository();
|
|
20
69
|
const createIndividualUser = new CreateIndividualUser({
|
|
21
70
|
userRepository,
|
|
22
71
|
userConfig,
|
|
23
72
|
});
|
|
24
|
-
const loginUser = new LoginUser({
|
|
25
|
-
userRepository,
|
|
26
|
-
userConfig,
|
|
27
|
-
});
|
|
73
|
+
const loginUser = new LoginUser({ userRepository, userConfig });
|
|
28
74
|
const createTokenForUserId = new CreateTokenForUserId({ userRepository });
|
|
29
75
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
// apiKey-mode collaborators are only wired when the mode is enabled, so an app
|
|
77
|
+
// that never opts in pays nothing and behaves exactly as before.
|
|
78
|
+
let loginWithApiKey = null;
|
|
79
|
+
if (apiKeyModeEnabled) {
|
|
80
|
+
const moduleRepository = createModuleRepository();
|
|
81
|
+
const credentialRepository = createCredentialRepository();
|
|
82
|
+
const integrationRepository = createIntegrationRepository();
|
|
83
|
+
|
|
84
|
+
const getUserFromXFriggHeaders = new GetUserFromXFriggHeaders({
|
|
85
|
+
userRepository,
|
|
86
|
+
userConfig,
|
|
87
|
+
});
|
|
88
|
+
const processAuthorizationCallback = new ProcessAuthorizationCallback({
|
|
89
|
+
moduleRepository,
|
|
90
|
+
credentialRepository,
|
|
91
|
+
integrationRepository,
|
|
92
|
+
moduleDefinitions,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
loginWithApiKey = new LoginWithApiKey({
|
|
96
|
+
userConfig,
|
|
97
|
+
moduleDefinitions,
|
|
98
|
+
getUserFromXFriggHeaders,
|
|
99
|
+
processAuthorizationCallback,
|
|
100
|
+
createTokenForUserId,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rlConfig = userConfig?.authModes?.apiKey?.rateLimit || {};
|
|
105
|
+
const apiKeyLoginLimiter = new FixedWindowRateLimiter({
|
|
106
|
+
windowMs: rlConfig.windowMs ?? 60000,
|
|
107
|
+
maxPerKey: rlConfig.maxPerKey ?? 10,
|
|
108
|
+
maxGlobal: rlConfig.maxGlobal ?? 1000,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const router = buildUserRouter({
|
|
112
|
+
userConfig,
|
|
113
|
+
loginUser,
|
|
114
|
+
createIndividualUser,
|
|
115
|
+
createTokenForUserId,
|
|
116
|
+
loginWithApiKey,
|
|
117
|
+
apiKeyLoginLimiter,
|
|
118
|
+
});
|
|
60
119
|
|
|
61
120
|
const handler = createAppHandler('HTTP Event: User', router);
|
|
62
121
|
|
package/index.js
CHANGED
|
@@ -31,6 +31,10 @@ const {
|
|
|
31
31
|
GetUserFromAdopterJwt,
|
|
32
32
|
} = require('./user/use-cases/get-user-from-adopter-jwt');
|
|
33
33
|
const { AuthenticateUser } = require('./user/use-cases/authenticate-user');
|
|
34
|
+
const { LoginWithApiKey } = require('./user/use-cases/login-with-api-key');
|
|
35
|
+
const {
|
|
36
|
+
validateApiKeyAuthMode,
|
|
37
|
+
} = require('./user/use-cases/validate-api-key-auth-mode');
|
|
34
38
|
|
|
35
39
|
const {
|
|
36
40
|
CredentialRepository,
|
|
@@ -121,6 +125,8 @@ module.exports = {
|
|
|
121
125
|
GetUserFromXFriggHeaders,
|
|
122
126
|
GetUserFromAdopterJwt,
|
|
123
127
|
AuthenticateUser,
|
|
128
|
+
LoginWithApiKey,
|
|
129
|
+
validateApiKeyAuthMode,
|
|
124
130
|
CredentialRepository,
|
|
125
131
|
ModuleRepository,
|
|
126
132
|
IntegrationMappingRepository,
|
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.
|
|
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.
|
|
52
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
53
|
-
"@friggframework/test": "2.0.0--canary.
|
|
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
|
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { Module } = require('../../modules/module');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Maximum accepted length of a submitted API key. Capped before any provider
|
|
6
|
+
* work so the endpoint cannot be used to smuggle arbitrarily large payloads or
|
|
7
|
+
* amplify an oracle attack. Generous enough for JWT-shaped or concatenated keys.
|
|
8
|
+
* @constant {number}
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_MAX_API_KEY_LENGTH = 8192;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Classify an error thrown by the module's Requester while validating or
|
|
14
|
+
* identifying a key, per ADR-034 §Security requirement 6 ("Outage ≠ invalid").
|
|
15
|
+
*
|
|
16
|
+
* A definitive provider rejection (401/403, or any other non-5xx client error)
|
|
17
|
+
* means the key is bad. A 5xx, a network failure, or a timeout means the
|
|
18
|
+
* provider is unavailable and MUST NOT be reported as an invalid key nor allowed
|
|
19
|
+
* to mint a session.
|
|
20
|
+
*
|
|
21
|
+
* @param {*} err - The thrown error.
|
|
22
|
+
* @returns {'invalid'|'unavailable'} classification
|
|
23
|
+
*/
|
|
24
|
+
function classifyProviderError(err) {
|
|
25
|
+
const status =
|
|
26
|
+
err?.statusCode ?? err?.response?.status ?? err?.status ?? undefined;
|
|
27
|
+
|
|
28
|
+
if (typeof status === 'number' && status >= 400 && status < 500) {
|
|
29
|
+
// 401/403 and any other definitive 4xx from the provider → bad key.
|
|
30
|
+
return 'invalid';
|
|
31
|
+
}
|
|
32
|
+
// 5xx, or no status at all (timeout / DNS / socket error) → outage.
|
|
33
|
+
return 'unavailable';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generic invalid-credentials error. Deliberately identical for every failure
|
|
38
|
+
* reason on the bad-key path so the endpoint never enumerates users or keys
|
|
39
|
+
* (ADR-034 §Security requirement 3).
|
|
40
|
+
* @returns {Boom} 401
|
|
41
|
+
*/
|
|
42
|
+
function invalidCredentials() {
|
|
43
|
+
return Boom.unauthorized('Invalid credentials');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Provider-unavailable error, distinct from invalid credentials. No session is
|
|
48
|
+
* created and no cookie is cleared when this is thrown (ADR-034 §6).
|
|
49
|
+
* @returns {Boom} 503
|
|
50
|
+
*/
|
|
51
|
+
function providerUnavailable() {
|
|
52
|
+
return Boom.serverUnavailable('Identity provider unavailable');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Use case implementing the ADR-034 `apiKey` auth mode: log a browser end user
|
|
57
|
+
* in with their own product API key, validated *through the api-module itself*.
|
|
58
|
+
*
|
|
59
|
+
* Flow (see ADR-034):
|
|
60
|
+
* 1. Validate + identify the key via the configured identity module's
|
|
61
|
+
* Requester (`testAuthRequest`, then `getEntityDetails`).
|
|
62
|
+
* 2. Derive a provider-authoritative tenant identity — NEVER from client input.
|
|
63
|
+
* 3. Find-or-create the Frigg user from that identity (ordinary app user).
|
|
64
|
+
* 4. Create the Credential + Entity via `ProcessAuthorizationCallback`.
|
|
65
|
+
* 5. Mint a short-lived Frigg session token and return it.
|
|
66
|
+
*
|
|
67
|
+
* @class LoginWithApiKey
|
|
68
|
+
*/
|
|
69
|
+
class LoginWithApiKey {
|
|
70
|
+
/**
|
|
71
|
+
* @param {Object} params
|
|
72
|
+
* @param {Object} params.userConfig - App-definition `user` config (reads `authModes.apiKey`).
|
|
73
|
+
* @param {Array<Object>} params.moduleDefinitions - Module definitions available to the app.
|
|
74
|
+
* @param {import('./get-user-from-x-frigg-headers').GetUserFromXFriggHeaders} params.getUserFromXFriggHeaders - Reused find-or-create path.
|
|
75
|
+
* @param {import('../../modules/use-cases/process-authorization-callback').ProcessAuthorizationCallback} params.processAuthorizationCallback - Reused credential/entity creation.
|
|
76
|
+
* @param {import('./create-token-for-user-id').CreateTokenForUserId} params.createTokenForUserId - Reused session-token minting.
|
|
77
|
+
* @param {number} [params.tokenExpiryMinutes=120] - Access token TTL. Short by design (ADR-034 §5): revocation latency is bounded by this.
|
|
78
|
+
* @param {number} [params.maxApiKeyLength=8192] - Length cap enforced before any provider work.
|
|
79
|
+
* @param {typeof Module} [params.ModuleClass=Module] - Injectable Module class (for testing without a real Requester).
|
|
80
|
+
*/
|
|
81
|
+
constructor({
|
|
82
|
+
userConfig,
|
|
83
|
+
moduleDefinitions,
|
|
84
|
+
getUserFromXFriggHeaders,
|
|
85
|
+
processAuthorizationCallback,
|
|
86
|
+
createTokenForUserId,
|
|
87
|
+
tokenExpiryMinutes = 120,
|
|
88
|
+
maxApiKeyLength = DEFAULT_MAX_API_KEY_LENGTH,
|
|
89
|
+
ModuleClass = Module,
|
|
90
|
+
}) {
|
|
91
|
+
this.userConfig = userConfig || {};
|
|
92
|
+
this.moduleDefinitions = moduleDefinitions || [];
|
|
93
|
+
this.getUserFromXFriggHeaders = getUserFromXFriggHeaders;
|
|
94
|
+
this.processAuthorizationCallback = processAuthorizationCallback;
|
|
95
|
+
this.createTokenForUserId = createTokenForUserId;
|
|
96
|
+
this.tokenExpiryMinutes = tokenExpiryMinutes;
|
|
97
|
+
this.maxApiKeyLength = maxApiKeyLength;
|
|
98
|
+
this.ModuleClass = ModuleClass;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve which identity module a request may use.
|
|
103
|
+
*
|
|
104
|
+
* The module is fixed by config (`authModes.apiKey.module`). A multi-identity
|
|
105
|
+
* app MAY configure an allowlist (`authModes.apiKey.modules`) and let the
|
|
106
|
+
* client pick one via the request body — but only from that allowlist. A
|
|
107
|
+
* client-supplied module that is not allowlisted is rejected generically, so
|
|
108
|
+
* the endpoint cannot be steered at an arbitrary module.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} [requestedModule] - Optional module name from the request body.
|
|
111
|
+
* @returns {string} The resolved module name.
|
|
112
|
+
* @throws {Boom} 401 generic if apiKey mode is unconfigured or the request names a non-allowlisted module.
|
|
113
|
+
*/
|
|
114
|
+
resolveModuleName(requestedModule) {
|
|
115
|
+
const config = this.userConfig.authModes?.apiKey;
|
|
116
|
+
if (!config) {
|
|
117
|
+
// apiKey mode not enabled for this app.
|
|
118
|
+
throw invalidCredentials();
|
|
119
|
+
}
|
|
120
|
+
|
|
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
|
+
];
|
|
131
|
+
|
|
132
|
+
if (allowlist.length === 0) {
|
|
133
|
+
throw invalidCredentials();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (requestedModule) {
|
|
137
|
+
if (!allowlist.includes(requestedModule)) {
|
|
138
|
+
throw invalidCredentials();
|
|
139
|
+
}
|
|
140
|
+
return requestedModule;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// No client-specified module: only unambiguous when exactly one is configured.
|
|
144
|
+
if (allowlist.length === 1) {
|
|
145
|
+
return allowlist[0];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Multi-identity app but the client did not name a module.
|
|
149
|
+
throw invalidCredentials();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Validate the key against the provider and derive a provider-authoritative
|
|
154
|
+
* tenant identity. Reused by login (and available for refresh re-validation,
|
|
155
|
+
* ADR-034 §5): a revoked key stops validating here.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} apiKey
|
|
158
|
+
* @param {string} moduleName
|
|
159
|
+
* @returns {Promise<{ externalId: string, moduleDefinition: Object }>}
|
|
160
|
+
* @throws {Boom} 401 invalid credentials on a bad/rejected key or missing identifier; 503 on provider outage.
|
|
161
|
+
*/
|
|
162
|
+
async validateAndIdentify(apiKey, moduleName) {
|
|
163
|
+
const moduleDefinition = this.moduleDefinitions.find(
|
|
164
|
+
(def) => def.moduleName === moduleName
|
|
165
|
+
);
|
|
166
|
+
if (!moduleDefinition) {
|
|
167
|
+
// Should be caught at config-validation time; treat a runtime miss
|
|
168
|
+
// as a server misconfiguration rather than leaking specifics.
|
|
169
|
+
throw Boom.badImplementation(
|
|
170
|
+
`apiKey identity module '${moduleName}' is not registered`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const module = new this.ModuleClass({ definition: moduleDefinition });
|
|
175
|
+
|
|
176
|
+
// Seed the api client with the key without persisting anything.
|
|
177
|
+
const setAuthParams =
|
|
178
|
+
moduleDefinition.requiredAuthMethods?.setAuthParams;
|
|
179
|
+
if (typeof setAuthParams === 'function') {
|
|
180
|
+
await setAuthParams(module.api, { api_key: apiKey });
|
|
181
|
+
} else if (typeof module.api?.setApiKey === 'function') {
|
|
182
|
+
module.api.setApiKey(apiKey);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 1) Validity. Call testAuthRequest directly (NOT module.testAuth, which
|
|
186
|
+
// swallows the error and would collapse 401 and 503 into one `false`).
|
|
187
|
+
let isValid;
|
|
188
|
+
try {
|
|
189
|
+
isValid =
|
|
190
|
+
await moduleDefinition.requiredAuthMethods.testAuthRequest(
|
|
191
|
+
module.api
|
|
192
|
+
);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
throw classifyProviderError(err) === 'unavailable'
|
|
195
|
+
? providerUnavailable()
|
|
196
|
+
: invalidCredentials();
|
|
197
|
+
}
|
|
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) {
|
|
203
|
+
throw invalidCredentials();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 2) Identity. Derive the tenant id from the provider response only.
|
|
207
|
+
let entityDetails;
|
|
208
|
+
try {
|
|
209
|
+
entityDetails =
|
|
210
|
+
await moduleDefinition.requiredAuthMethods.getEntityDetails(
|
|
211
|
+
module.api,
|
|
212
|
+
{ api_key: apiKey },
|
|
213
|
+
undefined,
|
|
214
|
+
undefined
|
|
215
|
+
);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
throw classifyProviderError(err) === 'unavailable'
|
|
218
|
+
? providerUnavailable()
|
|
219
|
+
: invalidCredentials();
|
|
220
|
+
}
|
|
221
|
+
|
|
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).
|
|
227
|
+
if (
|
|
228
|
+
(typeof externalId !== 'string' &&
|
|
229
|
+
typeof externalId !== 'number') ||
|
|
230
|
+
String(externalId).trim() === ''
|
|
231
|
+
) {
|
|
232
|
+
throw invalidCredentials();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { externalId: String(externalId), moduleDefinition };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Execute the api-key login.
|
|
240
|
+
*
|
|
241
|
+
* @param {Object} input
|
|
242
|
+
* @param {string} input.apiKey - The submitted product API key.
|
|
243
|
+
* @param {string} [input.module] - Optional module name (multi-identity apps only; allowlisted).
|
|
244
|
+
* @returns {Promise<{ token: string, userId: string, module: string }>} The minted session token and principal.
|
|
245
|
+
* @throws {Boom} 401 generic on invalid key / missing identifier / unconfigured mode; 503 on provider outage.
|
|
246
|
+
*/
|
|
247
|
+
async execute({ apiKey, module: requestedModule } = {}) {
|
|
248
|
+
// Cap length and shape BEFORE any provider work (ADR-034 §3).
|
|
249
|
+
if (typeof apiKey !== 'string' || apiKey.length === 0) {
|
|
250
|
+
throw invalidCredentials();
|
|
251
|
+
}
|
|
252
|
+
if (apiKey.length > this.maxApiKeyLength) {
|
|
253
|
+
throw invalidCredentials();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const moduleName = this.resolveModuleName(requestedModule);
|
|
257
|
+
|
|
258
|
+
const { externalId } = await this.validateAndIdentify(
|
|
259
|
+
apiKey,
|
|
260
|
+
moduleName
|
|
261
|
+
);
|
|
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
|
+
|
|
271
|
+
// Find-or-create the Frigg user from the PROVIDER-DERIVED identity only.
|
|
272
|
+
// A client-supplied appOrgId/appUserId is never read here — the caller
|
|
273
|
+
// passes nothing but the key and (optionally) the allowlisted module.
|
|
274
|
+
const useOrg = this.userConfig.organizationUserRequired === true;
|
|
275
|
+
const appOrgId = useOrg ? identity : undefined;
|
|
276
|
+
const appUserId = useOrg ? undefined : identity;
|
|
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.
|
|
283
|
+
const user = await this.getUserFromXFriggHeaders.execute(
|
|
284
|
+
appUserId,
|
|
285
|
+
appOrgId
|
|
286
|
+
);
|
|
287
|
+
const userId = user.getId();
|
|
288
|
+
if (!userId) {
|
|
289
|
+
throw invalidCredentials();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Create/refresh the Credential + Entity through the same path
|
|
293
|
+
// /api/authorize uses. The key is persisted only as the encrypted
|
|
294
|
+
// Credential — never returned, never logged, never a JWT claim.
|
|
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
|
+
}
|
|
310
|
+
|
|
311
|
+
// Mint an ordinary, short-lived app-user session token (never admin).
|
|
312
|
+
const token = await this.createTokenForUserId.execute(
|
|
313
|
+
userId,
|
|
314
|
+
this.tokenExpiryMinutes
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
return { token, userId, module: moduleName };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
module.exports = {
|
|
322
|
+
LoginWithApiKey,
|
|
323
|
+
classifyProviderError,
|
|
324
|
+
DEFAULT_MAX_API_KEY_LENGTH,
|
|
325
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate the `user.authModes.apiKey` block of an app definition against the
|
|
3
|
+
* app's registered module definitions (ADR-034 §Config).
|
|
4
|
+
*
|
|
5
|
+
* Default-off: when `authModes.apiKey` is absent this is a no-op, so apps that
|
|
6
|
+
* do not opt in are completely unaffected. When present, every named identity
|
|
7
|
+
* module MUST exist in the app's modules, or the app is misconfigured and we
|
|
8
|
+
* fail fast at wiring time rather than at first login.
|
|
9
|
+
*
|
|
10
|
+
* Accepts either:
|
|
11
|
+
* - `authModes.apiKey.module` — a single identity module (the common case), or
|
|
12
|
+
* - `authModes.apiKey.modules` — an allowlist of identity modules (multi-identity apps).
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} userConfig - The app definition's `user` config (may be null).
|
|
15
|
+
* @param {Array<Object>} moduleDefinitions - Registered module definitions (each with `moduleName`).
|
|
16
|
+
* @throws {Error} If apiKey mode is declared but names no module, or names a module the app does not register.
|
|
17
|
+
* @returns {void}
|
|
18
|
+
*/
|
|
19
|
+
function validateApiKeyAuthMode(userConfig, moduleDefinitions = []) {
|
|
20
|
+
const config = userConfig?.authModes?.apiKey;
|
|
21
|
+
if (!config) {
|
|
22
|
+
return; // apiKey mode not enabled — nothing to validate.
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const named = [];
|
|
26
|
+
if (Array.isArray(config.modules)) {
|
|
27
|
+
named.push(...config.modules);
|
|
28
|
+
}
|
|
29
|
+
if (config.module) {
|
|
30
|
+
named.push(config.module);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (named.length === 0) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'Invalid app definition: user.authModes.apiKey is enabled but names no identity module. ' +
|
|
36
|
+
"Set authModes.apiKey.module = '<moduleName>' (or authModes.apiKey.modules = ['<moduleName>', ...])."
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const known = new Set(
|
|
41
|
+
(moduleDefinitions || [])
|
|
42
|
+
.map((def) => def && def.moduleName)
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
for (const moduleName of named) {
|
|
47
|
+
if (typeof moduleName !== 'string' || moduleName.trim() === '') {
|
|
48
|
+
throw new Error(
|
|
49
|
+
'Invalid app definition: user.authModes.apiKey names a non-string module.'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (!known.has(moduleName)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Invalid app definition: user.authModes.apiKey.module '${moduleName}' is not a registered module. ` +
|
|
55
|
+
`Registered modules: ${[...known].join(', ') || '(none)'}.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
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
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { validateApiKeyAuthMode };
|