@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
+ };
@@ -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 catchAsyncError = require('express-async-handler');
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
- const router = express();
18
- const { userConfig } = loadAppDefinition();
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
- // define the login endpoint
31
- router.route('/user/login').post(
32
- catchAsyncError(async (req, res) => {
33
- const { username, password } = checkRequiredParams(req.body, [
34
- 'username',
35
- 'password',
36
- ]);
37
- const user = await loginUser.execute({ username, password });
38
- const token = await createTokenForUserId.execute(user.getId(), 120);
39
- res.status(201);
40
- res.json({ token });
41
- })
42
- );
43
-
44
- router.route('/user/create').post(
45
- catchAsyncError(async (req, res) => {
46
- const { username, password } = checkRequiredParams(req.body, [
47
- 'username',
48
- 'password',
49
- ]);
50
-
51
- const user = await createIndividualUser.execute({
52
- username,
53
- password,
54
- });
55
- const token = await createTokenForUserId.execute(user.getId(), 120);
56
- res.status(201);
57
- res.json({ token });
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.640.b31eb4a.0",
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.640.b31eb4a.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.640.b31eb4a.0",
53
- "@friggframework/test": "2.0.0--canary.640.b31eb4a.0",
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": "b31eb4a3ddaeae1044d98050b6a436f6d9c08db6"
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 };