@friggframework/core 2.0.0--canary.640.b31eb4a.0 → 2.0.0--canary.643.5ac10b7.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.
|
@@ -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,164 @@
|
|
|
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
|
+
* 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.
|
|
11
|
+
*/
|
|
12
|
+
function getClientIp(req) {
|
|
13
|
+
const xff = req.headers['x-forwarded-for'];
|
|
14
|
+
if (typeof xff === 'string' && xff.length > 0) {
|
|
15
|
+
return xff.split(',')[0].trim();
|
|
16
|
+
}
|
|
17
|
+
return req.ip || req.connection?.remoteAddress || 'unknown';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* CSRF Origin/Referer allowlist for the cookie-bearing apiKey login (ADR-034 §7).
|
|
22
|
+
* Enforced only when the app configures `authModes.apiKey.allowedOrigins`.
|
|
23
|
+
* Default (unconfigured): no origin restriction beyond the SameSite cookie —
|
|
24
|
+
* documented, and appropriate for token-only (non-cookie) SPA usage.
|
|
25
|
+
*/
|
|
26
|
+
function assertOriginAllowed(req, userConfig) {
|
|
27
|
+
const allowed = userConfig?.authModes?.apiKey?.allowedOrigins;
|
|
28
|
+
if (!Array.isArray(allowed) || allowed.length === 0) {
|
|
29
|
+
return; // not configured → rely on SameSite; see ADR-034 §7.
|
|
30
|
+
}
|
|
31
|
+
const origin = req.headers.origin;
|
|
32
|
+
const referer = req.headers.referer || req.headers.referrer;
|
|
33
|
+
const candidate =
|
|
34
|
+
origin ||
|
|
35
|
+
(referer
|
|
36
|
+
? (() => {
|
|
37
|
+
try {
|
|
38
|
+
const u = new URL(referer);
|
|
39
|
+
return `${u.protocol}//${u.host}`;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
})()
|
|
44
|
+
: null);
|
|
45
|
+
|
|
46
|
+
if (!candidate || !allowed.includes(candidate)) {
|
|
47
|
+
throw Boom.forbidden('Origin not allowed');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 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.
|
|
55
|
+
*/
|
|
56
|
+
function setSessionCookie(res, token) {
|
|
57
|
+
const isLocal = LOCAL_STAGES.includes(process.env.STAGE);
|
|
58
|
+
res.cookie('frigg_session', token, {
|
|
59
|
+
httpOnly: true,
|
|
60
|
+
secure: !isLocal,
|
|
61
|
+
sameSite: 'strict',
|
|
62
|
+
path: '/',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the user router. Dependencies are injected so the routes can be tested
|
|
68
|
+
* in isolation. `POST /user/login` is polymorphic (ADR-034):
|
|
69
|
+
* { username, password } → friggToken (unchanged)
|
|
70
|
+
* { apiKey } → apiKey mode (module-validated), when enabled.
|
|
71
|
+
*
|
|
72
|
+
* This module has NO import-time side effects — the production wiring lives in
|
|
73
|
+
* `user.js`, which loads the app definition and calls this factory.
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} deps
|
|
76
|
+
* @param {Object} deps.userConfig
|
|
77
|
+
* @param {import('../../user/use-cases/login-user').LoginUser} deps.loginUser
|
|
78
|
+
* @param {import('../../user/use-cases/create-individual-user').CreateIndividualUser} deps.createIndividualUser
|
|
79
|
+
* @param {import('../../user/use-cases/create-token-for-user-id').CreateTokenForUserId} deps.createTokenForUserId
|
|
80
|
+
* @param {import('../../user/use-cases/login-with-api-key').LoginWithApiKey|null} deps.loginWithApiKey - null when apiKey mode is off.
|
|
81
|
+
* @param {import('../rate-limiter').FixedWindowRateLimiter} deps.apiKeyLoginLimiter
|
|
82
|
+
* @returns {express.Router}
|
|
83
|
+
*/
|
|
84
|
+
function buildUserRouter({
|
|
85
|
+
userConfig,
|
|
86
|
+
loginUser,
|
|
87
|
+
createIndividualUser,
|
|
88
|
+
createTokenForUserId,
|
|
89
|
+
loginWithApiKey,
|
|
90
|
+
apiKeyLoginLimiter,
|
|
91
|
+
}) {
|
|
92
|
+
const router = express();
|
|
93
|
+
const apiKeyModeEnabled = Boolean(loginWithApiKey);
|
|
94
|
+
|
|
95
|
+
router.route('/user/login').post(
|
|
96
|
+
catchAsyncError(async (req, res) => {
|
|
97
|
+
const body = req.body || {};
|
|
98
|
+
|
|
99
|
+
// Dispatch: an { apiKey } body selects the apiKey mode. The bodies
|
|
100
|
+
// are disjoint, so a password login is never affected by this branch.
|
|
101
|
+
if (typeof body.apiKey === 'string') {
|
|
102
|
+
// Generic rejection when the mode is not enabled — no enumeration.
|
|
103
|
+
if (!apiKeyModeEnabled) {
|
|
104
|
+
throw Boom.unauthorized('Invalid credentials');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Rate limit BEFORE any provider work (oracle protection).
|
|
108
|
+
const { allowed } = apiKeyLoginLimiter.check(getClientIp(req));
|
|
109
|
+
if (!allowed) {
|
|
110
|
+
throw Boom.tooManyRequests('Too many requests');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// CSRF: cookie-bearing route.
|
|
114
|
+
assertOriginAllowed(req, userConfig);
|
|
115
|
+
|
|
116
|
+
const { token } = await loginWithApiKey.execute({
|
|
117
|
+
apiKey: body.apiKey,
|
|
118
|
+
module: body.module,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
setSessionCookie(res, token);
|
|
122
|
+
res.status(201);
|
|
123
|
+
res.json({ token });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// friggToken path — UNCHANGED.
|
|
128
|
+
const { username, password } = checkRequiredParams(req.body, [
|
|
129
|
+
'username',
|
|
130
|
+
'password',
|
|
131
|
+
]);
|
|
132
|
+
const user = await loginUser.execute({ username, password });
|
|
133
|
+
const token = await createTokenForUserId.execute(user.getId(), 120);
|
|
134
|
+
res.status(201);
|
|
135
|
+
res.json({ token });
|
|
136
|
+
})
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
router.route('/user/create').post(
|
|
140
|
+
catchAsyncError(async (req, res) => {
|
|
141
|
+
const { username, password } = checkRequiredParams(req.body, [
|
|
142
|
+
'username',
|
|
143
|
+
'password',
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
const user = await createIndividualUser.execute({
|
|
147
|
+
username,
|
|
148
|
+
password,
|
|
149
|
+
});
|
|
150
|
+
const token = await createTokenForUserId.execute(user.getId(), 120);
|
|
151
|
+
res.status(201);
|
|
152
|
+
res.json({ token });
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
return router;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
buildUserRouter,
|
|
161
|
+
getClientIp,
|
|
162
|
+
assertOriginAllowed,
|
|
163
|
+
setSessionCookie,
|
|
164
|
+
};
|
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,84 @@ 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);
|
|
16
46
|
|
|
17
|
-
|
|
18
|
-
|
|
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);
|
|
19
51
|
const userRepository = createUserRepository();
|
|
20
52
|
const createIndividualUser = new CreateIndividualUser({
|
|
21
53
|
userRepository,
|
|
22
54
|
userConfig,
|
|
23
55
|
});
|
|
24
|
-
const loginUser = new LoginUser({
|
|
25
|
-
userRepository,
|
|
26
|
-
userConfig,
|
|
27
|
-
});
|
|
56
|
+
const loginUser = new LoginUser({ userRepository, userConfig });
|
|
28
57
|
const createTokenForUserId = new CreateTokenForUserId({ userRepository });
|
|
29
58
|
|
|
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
|
-
|
|
59
|
+
// apiKey-mode collaborators are only wired when the mode is enabled, so an app
|
|
60
|
+
// that never opts in pays nothing and behaves exactly as before.
|
|
61
|
+
let loginWithApiKey = null;
|
|
62
|
+
if (apiKeyModeEnabled) {
|
|
63
|
+
const moduleRepository = createModuleRepository();
|
|
64
|
+
const credentialRepository = createCredentialRepository();
|
|
65
|
+
const integrationRepository = createIntegrationRepository();
|
|
66
|
+
|
|
67
|
+
const getUserFromXFriggHeaders = new GetUserFromXFriggHeaders({
|
|
68
|
+
userRepository,
|
|
69
|
+
userConfig,
|
|
70
|
+
});
|
|
71
|
+
const processAuthorizationCallback = new ProcessAuthorizationCallback({
|
|
72
|
+
moduleRepository,
|
|
73
|
+
credentialRepository,
|
|
74
|
+
integrationRepository,
|
|
75
|
+
moduleDefinitions,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
loginWithApiKey = new LoginWithApiKey({
|
|
79
|
+
userConfig,
|
|
80
|
+
moduleDefinitions,
|
|
81
|
+
getUserFromXFriggHeaders,
|
|
82
|
+
processAuthorizationCallback,
|
|
83
|
+
createTokenForUserId,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rlConfig = userConfig?.authModes?.apiKey?.rateLimit || {};
|
|
88
|
+
const apiKeyLoginLimiter = new FixedWindowRateLimiter({
|
|
89
|
+
windowMs: rlConfig.windowMs ?? 60000,
|
|
90
|
+
maxPerKey: rlConfig.maxPerKey ?? 10,
|
|
91
|
+
maxGlobal: rlConfig.maxGlobal ?? 1000,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const router = buildUserRouter({
|
|
95
|
+
userConfig,
|
|
96
|
+
loginUser,
|
|
97
|
+
createIndividualUser,
|
|
98
|
+
createTokenForUserId,
|
|
99
|
+
loginWithApiKey,
|
|
100
|
+
apiKeyLoginLimiter,
|
|
101
|
+
});
|
|
60
102
|
|
|
61
103
|
const handler = createAppHandler('HTTP Event: User', router);
|
|
62
104
|
|
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/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.5ac10b7.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.5ac10b7.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.643.5ac10b7.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.643.5ac10b7.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": "5ac10b78dec8cf766dd80584f201c7e132aeee12"
|
|
94
94
|
}
|
|
@@ -0,0 +1,290 @@
|
|
|
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 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
|
+
: [];
|
|
128
|
+
|
|
129
|
+
if (allowlist.length === 0) {
|
|
130
|
+
throw invalidCredentials();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (requestedModule) {
|
|
134
|
+
if (!allowlist.includes(requestedModule)) {
|
|
135
|
+
throw invalidCredentials();
|
|
136
|
+
}
|
|
137
|
+
return requestedModule;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// No client-specified module: only unambiguous when exactly one is configured.
|
|
141
|
+
if (allowlist.length === 1) {
|
|
142
|
+
return allowlist[0];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Multi-identity app but the client did not name a module.
|
|
146
|
+
throw invalidCredentials();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Validate the key against the provider and derive a provider-authoritative
|
|
151
|
+
* tenant identity. Reused by login (and available for refresh re-validation,
|
|
152
|
+
* ADR-034 §5): a revoked key stops validating here.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} apiKey
|
|
155
|
+
* @param {string} moduleName
|
|
156
|
+
* @returns {Promise<{ externalId: string, moduleDefinition: Object }>}
|
|
157
|
+
* @throws {Boom} 401 invalid credentials on a bad/rejected key or missing identifier; 503 on provider outage.
|
|
158
|
+
*/
|
|
159
|
+
async validateAndIdentify(apiKey, moduleName) {
|
|
160
|
+
const moduleDefinition = this.moduleDefinitions.find(
|
|
161
|
+
(def) => def.moduleName === moduleName
|
|
162
|
+
);
|
|
163
|
+
if (!moduleDefinition) {
|
|
164
|
+
// Should be caught at config-validation time; treat a runtime miss
|
|
165
|
+
// as a server misconfiguration rather than leaking specifics.
|
|
166
|
+
throw Boom.badImplementation(
|
|
167
|
+
`apiKey identity module '${moduleName}' is not registered`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const module = new this.ModuleClass({ definition: moduleDefinition });
|
|
172
|
+
|
|
173
|
+
// Seed the api client with the key without persisting anything.
|
|
174
|
+
const setAuthParams =
|
|
175
|
+
moduleDefinition.requiredAuthMethods?.setAuthParams;
|
|
176
|
+
if (typeof setAuthParams === 'function') {
|
|
177
|
+
await setAuthParams(module.api, { api_key: apiKey });
|
|
178
|
+
} else if (typeof module.api?.setApiKey === 'function') {
|
|
179
|
+
module.api.setApiKey(apiKey);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 1) Validity. Call testAuthRequest directly (NOT module.testAuth, which
|
|
183
|
+
// swallows the error and would collapse 401 and 503 into one `false`).
|
|
184
|
+
let isValid;
|
|
185
|
+
try {
|
|
186
|
+
isValid =
|
|
187
|
+
await moduleDefinition.requiredAuthMethods.testAuthRequest(
|
|
188
|
+
module.api
|
|
189
|
+
);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
throw classifyProviderError(err) === 'unavailable'
|
|
192
|
+
? providerUnavailable()
|
|
193
|
+
: invalidCredentials();
|
|
194
|
+
}
|
|
195
|
+
if (!isValid) {
|
|
196
|
+
throw invalidCredentials();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 2) Identity. Derive the tenant id from the provider response only.
|
|
200
|
+
let entityDetails;
|
|
201
|
+
try {
|
|
202
|
+
entityDetails =
|
|
203
|
+
await moduleDefinition.requiredAuthMethods.getEntityDetails(
|
|
204
|
+
module.api,
|
|
205
|
+
{ api_key: apiKey },
|
|
206
|
+
undefined,
|
|
207
|
+
undefined
|
|
208
|
+
);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
throw classifyProviderError(err) === 'unavailable'
|
|
211
|
+
? providerUnavailable()
|
|
212
|
+
: invalidCredentials();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const externalId = entityDetails?.identifiers?.externalId;
|
|
216
|
+
if (
|
|
217
|
+
externalId === undefined ||
|
|
218
|
+
externalId === null ||
|
|
219
|
+
String(externalId).trim() === ''
|
|
220
|
+
) {
|
|
221
|
+
// No stable identifier → reject (ADR-034 §Security requirement 1).
|
|
222
|
+
throw invalidCredentials();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { externalId: String(externalId), moduleDefinition };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Execute the api-key login.
|
|
230
|
+
*
|
|
231
|
+
* @param {Object} input
|
|
232
|
+
* @param {string} input.apiKey - The submitted product API key.
|
|
233
|
+
* @param {string} [input.module] - Optional module name (multi-identity apps only; allowlisted).
|
|
234
|
+
* @returns {Promise<{ token: string, userId: string, module: string }>} The minted session token and principal.
|
|
235
|
+
* @throws {Boom} 401 generic on invalid key / missing identifier / unconfigured mode; 503 on provider outage.
|
|
236
|
+
*/
|
|
237
|
+
async execute({ apiKey, module: requestedModule } = {}) {
|
|
238
|
+
// Cap length and shape BEFORE any provider work (ADR-034 §3).
|
|
239
|
+
if (typeof apiKey !== 'string' || apiKey.length === 0) {
|
|
240
|
+
throw invalidCredentials();
|
|
241
|
+
}
|
|
242
|
+
if (apiKey.length > this.maxApiKeyLength) {
|
|
243
|
+
throw invalidCredentials();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const moduleName = this.resolveModuleName(requestedModule);
|
|
247
|
+
|
|
248
|
+
const { externalId } = await this.validateAndIdentify(
|
|
249
|
+
apiKey,
|
|
250
|
+
moduleName
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// Find-or-create the Frigg user from the PROVIDER-DERIVED identity only.
|
|
254
|
+
// A client-supplied appOrgId/appUserId is never read here — the caller
|
|
255
|
+
// passes nothing but the key and (optionally) the allowlisted module.
|
|
256
|
+
const useOrg = this.userConfig.organizationUserRequired === true;
|
|
257
|
+
const appOrgId = useOrg ? externalId : undefined;
|
|
258
|
+
const appUserId = useOrg ? undefined : externalId;
|
|
259
|
+
|
|
260
|
+
const user = await this.getUserFromXFriggHeaders.execute(
|
|
261
|
+
appUserId,
|
|
262
|
+
appOrgId
|
|
263
|
+
);
|
|
264
|
+
const userId = user.getId();
|
|
265
|
+
if (!userId) {
|
|
266
|
+
throw invalidCredentials();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Create/refresh the Credential + Entity through the same path
|
|
270
|
+
// /api/authorize uses. The key is persisted only as the encrypted
|
|
271
|
+
// Credential — never returned, never logged, never a JWT claim.
|
|
272
|
+
await this.processAuthorizationCallback.execute(userId, moduleName, {
|
|
273
|
+
api_key: apiKey,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Mint an ordinary, short-lived app-user session token (never admin).
|
|
277
|
+
const token = await this.createTokenForUserId.execute(
|
|
278
|
+
userId,
|
|
279
|
+
this.tokenExpiryMinutes
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
return { token, userId, module: moduleName };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = {
|
|
287
|
+
LoginWithApiKey,
|
|
288
|
+
classifyProviderError,
|
|
289
|
+
DEFAULT_MAX_API_KEY_LENGTH,
|
|
290
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
|
|
61
|
+
module.exports = { validateApiKeyAuthMode };
|