@friggframework/core 2.0.0--canary.671.e50fe9c.0 → 2.0.0--canary.678.58db4b2.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/CLAUDE.md CHANGED
@@ -332,12 +332,14 @@ class MyIntegration extends IntegrationBase {
332
332
  - Password-based (bcrypt hashed)
333
333
  - Token-based (Bearer tokens)
334
334
  - App-based (external app user IDs)
335
+ - Adopter JWT (`authModes.adopterJwt`, `jwtConfig`; `jose`, JWKS or HMAC; see `README.md`)
335
336
 
336
337
  **Use Cases**:
337
338
  - `login-user.js` - User authentication
338
339
  - `create-individual-user.js` - Create personal account
339
340
  - `create-organization-user.js` - Create business account
340
341
  - `get-user-from-bearer-token.js` - Token authentication
342
+ - `get-user-from-adopter-jwt.js` - Adopter JWT verification, find-or-create (ADR-056 §2); `adopter-jwt-config.js` validates `jwtConfig` at load
341
343
 
342
344
  **Repositories**:
343
345
  - `user-repository-factory.js` - Creates database-specific repositories
package/README.md CHANGED
@@ -305,6 +305,7 @@ Comprehensive user authentication and authorization system supporting both indiv
305
305
  - **Password-based**: Traditional username/password authentication
306
306
  - **Token-based**: Bearer token authentication with session management
307
307
  - **App-based**: External app user ID authentication (passwordless)
308
+ - **Adopter token (JWT)**: A token issued by your own identity system, verified by Frigg and safe to send from a browser (see below)
308
309
 
309
310
  **Usage:**
310
311
  ```javascript
@@ -352,6 +353,62 @@ console.log('Individual user:', user.getIndividualUser());
352
353
  console.log('Organization user:', user.getOrganizationUser());
353
354
  ```
354
355
 
356
+ #### Adopter token (JWT) authentication
357
+
358
+ For a multi-tenant product (ADR-056 §2), let the browser send the token your identity system
359
+ already issues. Frigg verifies the signature and claims, then finds or creates the individual
360
+ user and the organization user the token names. Unlike `sharedSecret` (`x-frigg-*` headers), a
361
+ token cannot name a user other than the one it was issued for, so it is safe in a browser.
362
+
363
+ ```javascript
364
+ // backend/index.js
365
+ const appDefinition = {
366
+ user: {
367
+ primary: 'organization',
368
+ individualUserRequired: true,
369
+ organizationUserRequired: true,
370
+ authModes: { adopterJwt: true, friggToken: false, sharedSecret: false },
371
+ jwtConfig: {
372
+ jwksUri: 'https://login.example.com/.well-known/jwks.json',
373
+ issuer: 'https://login.example.com/',
374
+ audience: 'my-frigg-api', // required with jwksUri
375
+ },
376
+ },
377
+ };
378
+ ```
379
+
380
+ The client sends `Authorization: Bearer <token>`. `x-frigg-appuserid` / `x-frigg-apporgid`, if
381
+ also sent, must match the token or the request is refused with 403. That match rule lives in
382
+ `user/use-cases/authenticate-user.js` (`validateUserMatch`), not in the token verifier. A bearer
383
+ value that is not a three-part JWT is not offered to the verifier at all; it falls through to
384
+ `friggToken` (or is a 401 when that mode is off).
385
+
386
+ | `jwtConfig` field | Default | Meaning |
387
+ |---|---|---|
388
+ | `jwksUri` | | Remote JWKS (rotating keys, one per verifier, cached by jose). Must be `https`; `http` only for `localhost`, `127.0.0.0/8` or `::1`. Set exactly one of this and `secret`. |
389
+ | `secret` | | Shared HMAC secret. Read it from an environment variable. |
390
+ | `issuer` | not checked | Required `iss`. With `jwksUri`, a startup warning is logged when it is unset. |
391
+ | `audience` | not checked with `secret`; **required with `jwksUri`** | Accepted `aud` (a string or a list). |
392
+ | `algorithms` | `['ES256','RS256']` with `jwksUri`, `['HS256']` with `secret` | Allow-list. HS* is only accepted with `secret`, never with `jwksUri`. The older single `algorithm` is a deprecated alias; setting both is an error. |
393
+ | `clockToleranceSec` | `30` | Skew allowed on `exp` and `nbf`. |
394
+ | `userIdClaim` | `sub` | App user id (required in the token). |
395
+ | `orgIdClaim` | `org_id` | App organization id (optional). |
396
+ | `roleClaim` | `org_role` | Read and exposed as `user.getOrgRole()`; not stored yet. |
397
+
398
+ Rules worth knowing:
399
+
400
+ - Tokens must carry `exp`. `alg: none` and any algorithm outside the allow-list are refused.
401
+ - Startup fails (`loadAppDefinition` throws) when `adopterJwt` is on and the config is unusable:
402
+ neither or both of `jwksUri` and `secret`, a `jwksUri` that is not https (or loopback http),
403
+ no `audience` with `jwksUri`, or an algorithm list that does not fit the key type.
404
+ - Failures are `401` with a stable `data.reason`: `token_expired`, `token_not_yet_valid`,
405
+ `token_invalid_signature`, `token_wrong_issuer`, `token_wrong_audience`,
406
+ `token_missing_user_claim`, `token_malformed`. The message never contains the token or its claims.
407
+ Any other verification failure, such as an unreachable or unresolvable JWKS, is `503`
408
+ `jwks_unavailable`, not a 401, so clients do not drop good sessions.
409
+ - A person belongs to one organization for now: a token naming a second one gets `403`
410
+ `multi_org_unsupported` and nothing is moved (ADR-056 §3.6 replaces this with memberships).
411
+
355
412
  ### 8. Lambda Utilities (`/lambda`)
356
413
 
357
414
  AWS Lambda-specific utilities and helpers.
@@ -2,6 +2,7 @@ const { findNearestBackendPackageJson } = require('@friggframework/core/utils');
2
2
  const path = require('node:path');
3
3
  const fs = require('fs-extra');
4
4
  const { resolveTelemetryConfig } = require('../telemetry/telemetry-config');
5
+ const { assertAdopterJwtConfig } = require('../user/adopter-jwt-config');
5
6
  const { registerDeniedKeys } = require('../logs/denied-keys');
6
7
  const {
7
8
  extractCredentialFieldsFromModules,
@@ -36,6 +37,7 @@ function registerCredentialLogKeys(appDefinition, integrations) {
36
37
  * @returns {{integrations: Array<object>, userConfig: object | null, adminScripts: Array<object>, reports: Array<object>, admin: object, telemetry: object, logging: object | null, managementApi: object}} An object containing the application definition.
37
38
  * @throws {Error} Throws error if backend package.json cannot be found.
38
39
  * @throws {Error} Throws error if index.js file cannot be found in the backend directory.
40
+ * @throws {Error} Throws error if user.authModes.adopterJwt is enabled without a usable user.jwtConfig.
39
41
  * @example
40
42
  * const { integrations, userConfig, telemetry } = loadAppDefinition();
41
43
  * console.log(`Found ${integrations.length} integrations`);
@@ -65,6 +67,10 @@ function loadAppDefinition() {
65
67
  managementApi = {},
66
68
  } = appDefinition;
67
69
 
70
+ // Fail at load, not on the first request: an adopter-token mode with no
71
+ // key source would otherwise start cleanly and answer every login with 500.
72
+ assertAdopterJwtConfig(userConfig);
73
+
68
74
  registerCredentialLogKeys(appDefinition, integrations);
69
75
 
70
76
  // Degrade consistently: an invalid telemetry block must never take down a
@@ -63,6 +63,9 @@ const {
63
63
  const {
64
64
  GetUserFromAdopterJwt,
65
65
  } = require('../user/use-cases/get-user-from-adopter-jwt');
66
+ const {
67
+ createAdopterTokenVerifier,
68
+ } = require('../user/adopter-token-verifier');
66
69
  const {
67
70
  AuthenticateWithSharedSecret,
68
71
  } = require('../user/use-cases/authenticate-with-shared-secret');
@@ -94,6 +97,9 @@ function createIntegrationRouter() {
94
97
  const getUserFromAdopterJwt = new GetUserFromAdopterJwt({
95
98
  userRepository,
96
99
  userConfig,
100
+ tokenVerifier: createAdopterTokenVerifier({
101
+ jwtConfig: userConfig?.jwtConfig,
102
+ }),
97
103
  });
98
104
 
99
105
  const authenticateWithSharedSecret = new AuthenticateWithSharedSecret();
@@ -22,6 +22,7 @@ function buildV2Dependencies({ appDefinition, config }) {
22
22
  const { GetUserFromBearerToken } = require('../../user/use-cases/get-user-from-bearer-token');
23
23
  const { GetUserFromXFriggHeaders } = require('../../user/use-cases/get-user-from-x-frigg-headers');
24
24
  const { GetUserFromAdopterJwt } = require('../../user/use-cases/get-user-from-adopter-jwt');
25
+ const { createAdopterTokenVerifier } = require('../../user/adopter-token-verifier');
25
26
  const {
26
27
  AuthenticateWithSharedSecret,
27
28
  } = require('../../user/use-cases/authenticate-with-shared-secret');
@@ -82,7 +83,11 @@ function buildV2Dependencies({ appDefinition, config }) {
82
83
  const authenticateUser = new AuthenticateUser({
83
84
  getUserFromBearerToken: new GetUserFromBearerToken({ userRepository, userConfig }),
84
85
  getUserFromXFriggHeaders: new GetUserFromXFriggHeaders({ userRepository, userConfig }),
85
- getUserFromAdopterJwt: new GetUserFromAdopterJwt({ userRepository, userConfig }),
86
+ getUserFromAdopterJwt: new GetUserFromAdopterJwt({
87
+ userRepository,
88
+ userConfig,
89
+ tokenVerifier: createAdopterTokenVerifier({ jwtConfig: userConfig.jwtConfig }),
90
+ }),
86
91
  authenticateWithSharedSecret: new AuthenticateWithSharedSecret(),
87
92
  userConfig,
88
93
  });
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.671.e50fe9c.0",
4
+ "version": "2.0.0--canary.678.58db4b2.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -28,6 +28,7 @@
28
28
  "express-async-handler": "^1.2.0",
29
29
  "form-data": "^4.0.6",
30
30
  "fs-extra": "^11.2.0",
31
+ "jose": "^5.10.0",
31
32
  "lodash": "4.18.1",
32
33
  "lodash.get": "^4.4.2",
33
34
  "node-fetch": "^2.6.7",
@@ -47,9 +48,9 @@
47
48
  }
48
49
  },
49
50
  "devDependencies": {
50
- "@friggframework/eslint-config": "2.0.0--canary.671.e50fe9c.0",
51
- "@friggframework/prettier-config": "2.0.0--canary.671.e50fe9c.0",
52
- "@friggframework/test": "2.0.0--canary.671.e50fe9c.0",
51
+ "@friggframework/eslint-config": "2.0.0--canary.678.58db4b2.0",
52
+ "@friggframework/prettier-config": "2.0.0--canary.678.58db4b2.0",
53
+ "@friggframework/test": "2.0.0--canary.678.58db4b2.0",
53
54
  "@prisma/client": "^6.19.3",
54
55
  "@types/lodash": "4.17.15",
55
56
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -89,5 +90,5 @@
89
90
  "publishConfig": {
90
91
  "access": "public"
91
92
  },
92
- "gitHead": "e50fe9c3d910653eef00ca7248658e7a7f637493"
93
+ "gitHead": "58db4b2bc17529f46b8dae1110771f5df39033ed"
93
94
  }
package/types/index.d.ts CHANGED
@@ -12,3 +12,4 @@
12
12
  /// <reference path="./eslint-config/index.d.ts" />
13
13
  /// <reference path="./associations/index.d.ts" />
14
14
  /// <reference path="./syncs/index.d.ts" />
15
+ /// <reference path="./user/index.d.ts" />
@@ -0,0 +1,67 @@
1
+ declare module "@friggframework/core/user" {
2
+ export type AdopterJwtAlgorithm =
3
+ | "ES256"
4
+ | "ES384"
5
+ | "RS256"
6
+ | "RS384"
7
+ | "RS512"
8
+ | "HS256"
9
+ | "HS384"
10
+ | "HS512";
11
+
12
+ /**
13
+ * Verification of tokens issued by the adopter's identity system
14
+ * (`authModes.adopterJwt`, ADR-056 §2). Set exactly one of `jwksUri` or `secret`.
15
+ */
16
+ export interface AdopterJwtConfig {
17
+ /** https URL of the issuer's JWKS (http only for localhost, 127.0.0.0/8, ::1). */
18
+ jwksUri?: string;
19
+ /** Shared HMAC secret for HS* tokens. */
20
+ secret?: string;
21
+ /** Required `iss`. With `jwksUri`, a startup warning is logged when unset. */
22
+ issuer?: string;
23
+ /** Accepted `aud`. Required with `jwksUri`. */
24
+ audience?: string | string[];
25
+ /** Allow-list. Default ES256 and RS256 with `jwksUri`, HS256 with `secret`. */
26
+ algorithms?: AdopterJwtAlgorithm[];
27
+ /** @deprecated Use `algorithms`. Setting both is an error. */
28
+ algorithm?: AdopterJwtAlgorithm;
29
+ /** Skew allowed on `exp` and `nbf`. Default 30. */
30
+ clockToleranceSec?: number;
31
+ /** Default `sub`. */
32
+ userIdClaim?: string;
33
+ /** Default `org_id`. */
34
+ orgIdClaim?: string;
35
+ /** Default `org_role`. Exposed as `User#getOrgRole()`; not stored. */
36
+ roleClaim?: string;
37
+ }
38
+
39
+ export interface UserAuthModes {
40
+ friggToken?: boolean;
41
+ sharedSecret?: boolean;
42
+ adopterJwt?: boolean;
43
+ }
44
+
45
+ /** The `user` block of the app definition. */
46
+ export interface UserConfig {
47
+ usePassword?: boolean;
48
+ primary?: "individual" | "organization";
49
+ individualUserRequired?: boolean;
50
+ organizationUserRequired?: boolean;
51
+ strictUserValidation?: boolean;
52
+ authModes?: UserAuthModes;
53
+ jwtConfig?: AdopterJwtConfig;
54
+ }
55
+
56
+ export class User {
57
+ getId(): string | undefined;
58
+ getAppUserId(): string | null;
59
+ getAppOrgId(): string | null;
60
+ getIndividualUser(): any;
61
+ getOrganizationUser(): any;
62
+ getPrimaryUser(): any;
63
+ /** The organization role from the adopter token, or null. */
64
+ getOrgRole(): string | null;
65
+ setOrgRole(orgRole: string | null): void;
66
+ }
67
+ }
@@ -0,0 +1,177 @@
1
+ const { getLogger } = require('../logs');
2
+
3
+ const log = getLogger('frigg.user.adopter_jwt');
4
+
5
+ /**
6
+ * Normalises and validates `user.jwtConfig` from the app definition (ADR-056 §2).
7
+ * Called once at startup by the app definition loader and once per verifier,
8
+ * never per request.
9
+ */
10
+
11
+ const ASYMMETRIC_ALGORITHMS = ['ES256', 'ES384', 'RS256', 'RS384', 'RS512'];
12
+ const HMAC_ALGORITHMS = ['HS256', 'HS384', 'HS512'];
13
+ const SUPPORTED_ALGORITHMS = [...ASYMMETRIC_ALGORITHMS, ...HMAC_ALGORITHMS];
14
+
15
+ const DEFAULTS = Object.freeze({
16
+ jwksAlgorithms: Object.freeze(['ES256', 'RS256']),
17
+ secretAlgorithms: Object.freeze(['HS256']),
18
+ clockToleranceSec: 30,
19
+ userIdClaim: 'sub',
20
+ orgIdClaim: 'org_id',
21
+ roleClaim: 'org_role',
22
+ });
23
+
24
+ const isNonEmptyString = (value) =>
25
+ typeof value === 'string' && value.trim() !== '';
26
+
27
+ const hasAudience = (audience) =>
28
+ isNonEmptyString(audience) ||
29
+ (Array.isArray(audience) &&
30
+ audience.length > 0 &&
31
+ audience.every(isNonEmptyString));
32
+
33
+ const LOOPBACK_IPV4 = /^127(\.\d{1,3}){3}$/;
34
+
35
+ function isLoopbackHost(hostname) {
36
+ return (
37
+ hostname === 'localhost' ||
38
+ hostname === '[::1]' ||
39
+ LOOPBACK_IPV4.test(hostname)
40
+ );
41
+ }
42
+
43
+ /**
44
+ * The JWKS is the trust root: fetched over https, or over http only to the
45
+ * machine itself (a local identity provider in development).
46
+ */
47
+ function assertJwksUri(jwksUri) {
48
+ let url;
49
+ try {
50
+ url = new URL(jwksUri);
51
+ } catch {
52
+ throw new Error('user.jwtConfig.jwksUri is not a valid URL');
53
+ }
54
+ if (url.protocol === 'https:') return;
55
+ if (url.protocol === 'http:' && isLoopbackHost(url.hostname)) return;
56
+ throw new Error(
57
+ 'user.jwtConfig.jwksUri must use https (http is allowed only for localhost, 127.0.0.0/8 and ::1)'
58
+ );
59
+ }
60
+
61
+ /**
62
+ * @param {Object} [jwtConfig] `userConfig.jwtConfig`
63
+ * @returns {{jwksUri?: string, secret?: string, issuer?: string,
64
+ * audience?: string|string[], algorithms: string[], clockToleranceSec: number,
65
+ * userIdClaim: string, orgIdClaim: string, roleClaim: string}}
66
+ * @throws {Error} When the config cannot verify any token safely.
67
+ */
68
+ function resolveAdopterJwtConfig(jwtConfig) {
69
+ const config = jwtConfig && typeof jwtConfig === 'object' ? jwtConfig : {};
70
+ const hasJwks = isNonEmptyString(config.jwksUri);
71
+ const hasSecret = isNonEmptyString(config.secret);
72
+
73
+ if (!hasJwks && !hasSecret) {
74
+ throw new Error(
75
+ 'user.authModes.adopterJwt is enabled but user.jwtConfig has neither jwksUri nor secret'
76
+ );
77
+ }
78
+ if (hasJwks && hasSecret) {
79
+ throw new Error(
80
+ 'user.jwtConfig sets both jwksUri and secret; set one, so a token cannot choose which key family verifies it'
81
+ );
82
+ }
83
+ if (hasJwks) {
84
+ assertJwksUri(config.jwksUri);
85
+ // Without an audience, a token minted by the same issuer for another
86
+ // service verifies here. HMAC mode shares its secret with one issuer,
87
+ // so it stays lenient.
88
+ if (!hasAudience(config.audience)) {
89
+ throw new Error(
90
+ 'user.jwtConfig.audience is required when jwksUri is used'
91
+ );
92
+ }
93
+ }
94
+ if (config.algorithm !== undefined && config.algorithms !== undefined) {
95
+ throw new Error(
96
+ 'user.jwtConfig sets both algorithm and algorithms; use algorithms (algorithm is a deprecated alias)'
97
+ );
98
+ }
99
+
100
+ const configured =
101
+ config.algorithms ??
102
+ (config.algorithm ? [config.algorithm] : undefined);
103
+ const algorithms =
104
+ configured ??
105
+ (hasSecret ? DEFAULTS.secretAlgorithms : DEFAULTS.jwksAlgorithms);
106
+
107
+ if (
108
+ !Array.isArray(algorithms) ||
109
+ algorithms.length === 0 ||
110
+ algorithms.some((alg) => !SUPPORTED_ALGORITHMS.includes(alg))
111
+ ) {
112
+ throw new Error(
113
+ `user.jwtConfig.algorithms must be a non-empty list drawn from ${SUPPORTED_ALGORITHMS.join(
114
+ ', '
115
+ )}`
116
+ );
117
+ }
118
+ // A key family only verifies its own algorithms; mixing them is how
119
+ // algorithm-confusion attacks are built.
120
+ if (hasSecret && algorithms.some((alg) => !HMAC_ALGORITHMS.includes(alg))) {
121
+ throw new Error(
122
+ 'user.jwtConfig.algorithms may only list HS* algorithms when secret is used'
123
+ );
124
+ }
125
+ if (hasJwks && algorithms.some((alg) => HMAC_ALGORITHMS.includes(alg))) {
126
+ throw new Error(
127
+ 'user.jwtConfig.algorithms may not list HS* algorithms when jwksUri is used'
128
+ );
129
+ }
130
+
131
+ const clockToleranceSec =
132
+ config.clockToleranceSec ?? DEFAULTS.clockToleranceSec;
133
+ if (!Number.isInteger(clockToleranceSec) || clockToleranceSec < 0) {
134
+ throw new Error(
135
+ 'user.jwtConfig.clockToleranceSec must be an integer >= 0'
136
+ );
137
+ }
138
+
139
+ return {
140
+ jwksUri: hasJwks ? config.jwksUri : undefined,
141
+ secret: hasSecret ? config.secret : undefined,
142
+ issuer: config.issuer || undefined,
143
+ audience: config.audience || undefined,
144
+ algorithms,
145
+ clockToleranceSec,
146
+ userIdClaim: config.userIdClaim || DEFAULTS.userIdClaim,
147
+ orgIdClaim: config.orgIdClaim || DEFAULTS.orgIdClaim,
148
+ roleClaim: config.roleClaim || DEFAULTS.roleClaim,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Startup check: throws when `authModes.adopterJwt` is on and `jwtConfig`
154
+ * is unusable, and warns when a jwksUri has no issuer. A no-op when the mode
155
+ * is off, so apps that never enabled it are not affected by a stray `jwtConfig`.
156
+ *
157
+ * @param {Object|null} [userConfig] `appDefinition.user`
158
+ * @throws {Error}
159
+ */
160
+ function assertAdopterJwtConfig(userConfig) {
161
+ if (userConfig?.authModes?.adopterJwt !== true) return;
162
+ const config = resolveAdopterJwtConfig(userConfig.jwtConfig);
163
+ if (config.jwksUri && !config.issuer) {
164
+ log.warn(
165
+ 'Adopter JWT issuer is not set; any issuer on this JWKS is accepted',
166
+ {
167
+ eventName: `${log.name}.issuer_unset`,
168
+ }
169
+ );
170
+ }
171
+ }
172
+
173
+ module.exports = {
174
+ resolveAdopterJwtConfig,
175
+ assertAdopterJwtConfig,
176
+ SUPPORTED_ALGORITHMS,
177
+ };
@@ -0,0 +1,65 @@
1
+ /* eslint-disable camelcase -- the reason codes are snake_case by spec */
2
+ const Boom = require('@hapi/boom');
3
+
4
+ /**
5
+ * Every failure adopter-token authentication can produce, keyed by the stable
6
+ * reason code clients see in `error.data.reason`. Messages are fixed text:
7
+ * the token and its claims never reach a message, `data` or a log record.
8
+ */
9
+ const ADOPTER_JWT_ERRORS = Object.freeze({
10
+ token_expired: Object.freeze({ status: 401, message: 'Token has expired' }),
11
+ token_not_yet_valid: Object.freeze({
12
+ status: 401,
13
+ message: 'Token is not yet valid',
14
+ }),
15
+ token_invalid_signature: Object.freeze({
16
+ status: 401,
17
+ message: 'Token signature could not be verified',
18
+ }),
19
+ token_wrong_issuer: Object.freeze({
20
+ status: 401,
21
+ message: 'Token issuer is not accepted',
22
+ }),
23
+ token_wrong_audience: Object.freeze({
24
+ status: 401,
25
+ message: 'Token audience is not accepted',
26
+ }),
27
+ token_missing_user_claim: Object.freeze({
28
+ status: 401,
29
+ message: 'Token does not identify a user',
30
+ }),
31
+ token_malformed: Object.freeze({
32
+ status: 401,
33
+ message: 'Token is malformed',
34
+ }),
35
+ // Our dependency failing, not the caller's token: not a 401, so a client
36
+ // does not discard a good session during a JWKS outage.
37
+ jwks_unavailable: Object.freeze({
38
+ status: 503,
39
+ message: 'Token verification keys are unavailable',
40
+ }),
41
+ multi_org_unsupported: Object.freeze({
42
+ status: 403,
43
+ message:
44
+ 'This user already belongs to a different organization. ' +
45
+ 'Membership in more than one organization is not supported yet (ADR-056 §3.6).',
46
+ }),
47
+ jwt_config_invalid: Object.freeze({
48
+ status: 500,
49
+ message: 'Adopter JWT authentication is misconfigured',
50
+ }),
51
+ });
52
+
53
+ /**
54
+ * @param {keyof typeof ADOPTER_JWT_ERRORS} code
55
+ * @returns {Boom.Boom} with `data.reason` set to `code`
56
+ */
57
+ function fail(code) {
58
+ const { status, message } = ADOPTER_JWT_ERRORS[code];
59
+ return new Boom.Boom(message, {
60
+ statusCode: status,
61
+ data: { reason: code },
62
+ });
63
+ }
64
+
65
+ module.exports = { ADOPTER_JWT_ERRORS, fail };
@@ -0,0 +1,122 @@
1
+ const jose = require('jose');
2
+ const { getLogger } = require('../logs');
3
+ const { resolveAdopterJwtConfig } = require('./adopter-jwt-config');
4
+ const { fail } = require('./adopter-jwt-errors');
5
+
6
+ const log = getLogger('frigg.user.adopter_jwt');
7
+
8
+ /**
9
+ * The only failures that are the caller's token. Anything else thrown while
10
+ * verifying (a refused connection, a DNS failure, a non-200 from the JWKS, a
11
+ * generic JOSE error) is our key source failing, not a bad token.
12
+ */
13
+ function tokenFailureCode(error) {
14
+ const { errors } = jose;
15
+ if (error instanceof errors.JWTExpired) return 'token_expired';
16
+ if (error instanceof errors.JWTClaimValidationFailed) {
17
+ if (error.claim === 'iss') return 'token_wrong_issuer';
18
+ if (error.claim === 'aud') return 'token_wrong_audience';
19
+ if (error.claim === 'nbf') return 'token_not_yet_valid';
20
+ return 'token_malformed';
21
+ }
22
+ if (
23
+ error instanceof errors.JWSSignatureVerificationFailed ||
24
+ error instanceof errors.JOSEAlgNotAllowed ||
25
+ error instanceof errors.JWKSNoMatchingKey ||
26
+ error instanceof errors.JWKSMultipleMatchingKeys
27
+ ) {
28
+ return 'token_invalid_signature';
29
+ }
30
+ if (
31
+ error instanceof errors.JWSInvalid ||
32
+ error instanceof errors.JWTInvalid
33
+ ) {
34
+ return 'token_malformed';
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Verifies adopter-issued tokens (ADR-056 §2). Owns config resolution, the key
41
+ * source and the mapping from jose failures to typed Boom errors.
42
+ *
43
+ * Build one per app definition: the config is resolved once here, and the
44
+ * remote JWKS is an instance field, so jose's key cache lives as long as the
45
+ * verifier does. A bad config does not throw here (apps with adopterJwt off
46
+ * build a verifier too); `verify` answers 500 and logs the real reason.
47
+ *
48
+ * @param {Object} params
49
+ * @param {Object} [params.jwtConfig] `userConfig.jwtConfig`
50
+ * @param {Function|Object|Uint8Array} [params.keySource] Replaces the key
51
+ * derived from the config: a jose key or a getKey function such as
52
+ * `jose.createLocalJWKSet(jwks)`. Tests and custom key stores use it.
53
+ * @returns {{verify: (token: string) => Promise<Object>,
54
+ * claimNames: {userIdClaim: string, orgIdClaim: string, roleClaim: string}}}
55
+ */
56
+ function createAdopterTokenVerifier({ jwtConfig, keySource } = {}) {
57
+ let config = null;
58
+ let configError = null;
59
+ try {
60
+ config = resolveAdopterJwtConfig(jwtConfig);
61
+ } catch (error) {
62
+ configError = error;
63
+ }
64
+
65
+ let key = null;
66
+ if (config) {
67
+ key =
68
+ keySource ??
69
+ (config.jwksUri
70
+ ? jose.createRemoteJWKSet(new URL(config.jwksUri))
71
+ : new TextEncoder().encode(config.secret));
72
+ }
73
+
74
+ const requireConfig = () => {
75
+ if (config) return config;
76
+ log.error('Adopter JWT config is invalid', {
77
+ eventName: `${log.name}.config_invalid`,
78
+ validationMessage: configError.message,
79
+ });
80
+ throw fail('jwt_config_invalid');
81
+ };
82
+
83
+ return {
84
+ get claimNames() {
85
+ const { userIdClaim, orgIdClaim, roleClaim } = requireConfig();
86
+ return { userIdClaim, orgIdClaim, roleClaim };
87
+ },
88
+
89
+ /**
90
+ * @param {string} token
91
+ * @returns {Promise<Object>} the verified claims
92
+ * @throws {Boom} 401 with `data.reason` for a token the verifier refuses
93
+ * @throws {Boom} 503 `jwks_unavailable` for any other verification failure
94
+ * @throws {Boom} 500 `jwt_config_invalid` when the config is unusable
95
+ */
96
+ async verify(token) {
97
+ const resolved = requireConfig();
98
+ try {
99
+ const { payload } = await jose.jwtVerify(token, key, {
100
+ algorithms: resolved.algorithms,
101
+ issuer: resolved.issuer,
102
+ audience: resolved.audience,
103
+ clockTolerance: resolved.clockToleranceSec,
104
+ // A token that never expires is not browser-safe.
105
+ requiredClaims: ['exp'],
106
+ });
107
+ return payload;
108
+ } catch (error) {
109
+ const code = tokenFailureCode(error) ?? 'jwks_unavailable';
110
+ if (code !== 'jwks_unavailable') {
111
+ log.debug('Adopter token rejected', {
112
+ eventName: `${log.name}.rejected`,
113
+ reason: code,
114
+ });
115
+ }
116
+ throw fail(code);
117
+ }
118
+ },
119
+ };
120
+ }
121
+
122
+ module.exports = { createAdopterTokenVerifier };
@@ -0,0 +1,73 @@
1
+ const { User } = require('./user');
2
+
3
+ /**
4
+ * Find-or-create steps shared by the paths that identify a user by an app user
5
+ * id and/or app organization id (x-frigg headers, adopter token). Each path
6
+ * keeps its own policy between "find" and "create".
7
+ */
8
+
9
+ const wantsIndividual = (userConfig) =>
10
+ userConfig.individualUserRequired !== false;
11
+
12
+ const wantsOrganization = (userConfig, appOrgId) =>
13
+ Boolean(appOrgId) && Boolean(userConfig.organizationUserRequired);
14
+
15
+ async function findAppUsers(
16
+ userRepository,
17
+ userConfig,
18
+ { appUserId, appOrgId }
19
+ ) {
20
+ const individual =
21
+ appUserId && wantsIndividual(userConfig)
22
+ ? await userRepository.findIndividualUserByAppUserId(appUserId)
23
+ : null;
24
+ const organization = wantsOrganization(userConfig, appOrgId)
25
+ ? await userRepository.findOrganizationUserByAppOrgId(appOrgId)
26
+ : null;
27
+ return { individual, organization };
28
+ }
29
+
30
+ /**
31
+ * Creates whichever of the two users is wanted and missing. Does not link
32
+ * them: linking is policy.
33
+ */
34
+ async function createMissingAppUsers(
35
+ userRepository,
36
+ userConfig,
37
+ { appUserId, appOrgId, individual, organization }
38
+ ) {
39
+ let createdOrganization = false;
40
+ if (!individual && appUserId && wantsIndividual(userConfig)) {
41
+ individual = await userRepository.createIndividualUser({
42
+ appUserId,
43
+ username: `app-user-${appUserId}`,
44
+ email: `${appUserId}@app.local`,
45
+ });
46
+ }
47
+ if (!organization && wantsOrganization(userConfig, appOrgId)) {
48
+ organization = await userRepository.createOrganizationUser({
49
+ appOrgId,
50
+ });
51
+ createdOrganization = true;
52
+ }
53
+ return { individual, organization, createdOrganization };
54
+ }
55
+
56
+ function buildUser(userConfig, individual, organization) {
57
+ return new User(
58
+ individual,
59
+ organization,
60
+ userConfig.usePassword,
61
+ userConfig.primary,
62
+ userConfig.individualUserRequired,
63
+ userConfig.organizationUserRequired
64
+ );
65
+ }
66
+
67
+ module.exports = {
68
+ wantsIndividual,
69
+ wantsOrganization,
70
+ findAppUsers,
71
+ createMissingAppUsers,
72
+ buildUser,
73
+ };
@@ -0,0 +1,130 @@
1
+ /* eslint-disable camelcase -- JWT claim names (org_id, org_role) and reason codes are snake_case by spec */
2
+ const jose = require('jose');
3
+
4
+ /**
5
+ * Key material and token signing for adopter-JWT tests. Real keys and real
6
+ * signatures: nothing here fakes the verifier.
7
+ */
8
+ async function createKeys() {
9
+ const es = await jose.generateKeyPair('ES256');
10
+ const rs = await jose.generateKeyPair('RS256');
11
+ const attacker = await jose.generateKeyPair('ES256');
12
+ const publicJwks = {
13
+ keys: [
14
+ {
15
+ ...(await jose.exportJWK(es.publicKey)),
16
+ kid: 'es-1',
17
+ alg: 'ES256',
18
+ use: 'sig',
19
+ },
20
+ {
21
+ ...(await jose.exportJWK(rs.publicKey)),
22
+ kid: 'rs-1',
23
+ alg: 'RS256',
24
+ use: 'sig',
25
+ },
26
+ ],
27
+ };
28
+ return {
29
+ es,
30
+ rs,
31
+ attacker,
32
+ publicJwks,
33
+ localJwks: jose.createLocalJWKSet(publicJwks),
34
+ hmacSecret: 'a-shared-secret-that-is-long-enough-for-hs256',
35
+ };
36
+ }
37
+
38
+ /**
39
+ * @param {Object} claims
40
+ * @param {Object} opts
41
+ * @param {Object|Uint8Array} opts.key - signing key
42
+ * @param {string} [opts.alg='ES256']
43
+ * @param {string} [opts.kid]
44
+ * @param {string|null} [opts.exp='5m'] - null omits exp
45
+ */
46
+ async function signToken(
47
+ claims,
48
+ { key, alg = 'ES256', kid, exp = '5m', iss, aud, nbf } = {}
49
+ ) {
50
+ const jwt = new jose.SignJWT(claims)
51
+ .setProtectedHeader(kid ? { alg, kid } : { alg })
52
+ .setIssuedAt();
53
+ if (exp !== null) jwt.setExpirationTime(exp);
54
+ if (iss) jwt.setIssuer(iss);
55
+ if (aud) jwt.setAudience(aud);
56
+ if (nbf) jwt.setNotBefore(nbf);
57
+ return jwt.sign(key);
58
+ }
59
+
60
+ const b64url = (value) =>
61
+ Buffer.from(JSON.stringify(value)).toString('base64url');
62
+
63
+ /** An unsigned token: `alg: none`, empty signature. */
64
+ function unsignedToken(claims) {
65
+ return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url({
66
+ ...claims,
67
+ exp: Math.floor(Date.now() / 1000) + 300,
68
+ })}.`;
69
+ }
70
+
71
+ /**
72
+ * Stateful fake of the user repository, built from jest.fn so every call is
73
+ * observable. Individuals carry `organizationId`, as the real repositories
74
+ * return it.
75
+ */
76
+ function createUserRepositoryDouble({
77
+ individuals = [],
78
+ organizations = [],
79
+ } = {}) {
80
+ const individualUsers = new Map(
81
+ individuals.map((u) => [u.appUserId, { ...u }])
82
+ );
83
+ const organizationUsers = new Map(
84
+ organizations.map((o) => [o.appOrgId, { ...o }])
85
+ );
86
+ let seq = 0;
87
+
88
+ const repo = {
89
+ findIndividualUserByAppUserId: jest.fn(
90
+ async (appUserId) => individualUsers.get(appUserId) ?? null
91
+ ),
92
+ findOrganizationUserByAppOrgId: jest.fn(
93
+ async (appOrgId) => organizationUsers.get(appOrgId) ?? null
94
+ ),
95
+ createIndividualUser: jest.fn(async (params) => {
96
+ const created = {
97
+ id: `ind-${++seq}`,
98
+ organizationId: undefined,
99
+ ...params,
100
+ };
101
+ individualUsers.set(created.appUserId, created);
102
+ return created;
103
+ }),
104
+ createOrganizationUser: jest.fn(async (params) => {
105
+ const created = { id: `org-${++seq}`, ...params };
106
+ organizationUsers.set(created.appOrgId, created);
107
+ return created;
108
+ }),
109
+ linkIndividualToOrganization: jest.fn(
110
+ async (individualId, organizationId) => {
111
+ for (const u of individualUsers.values()) {
112
+ if (u.id === individualId) {
113
+ u.organizationId = organizationId;
114
+ return { ...u };
115
+ }
116
+ }
117
+ throw new Error('individual not found');
118
+ }
119
+ ),
120
+ };
121
+ repo.state = { individualUsers, organizationUsers };
122
+ return repo;
123
+ }
124
+
125
+ module.exports = {
126
+ createKeys,
127
+ signToken,
128
+ unsignedToken,
129
+ createUserRepositoryDouble,
130
+ };
@@ -1,149 +1,136 @@
1
- const Boom = require('@hapi/boom');
1
+ const { getLogger } = require('../../logs');
2
+ const { fail } = require('../adopter-jwt-errors');
3
+ const {
4
+ findAppUsers,
5
+ createMissingAppUsers,
6
+ buildUser,
7
+ wantsOrganization,
8
+ } = require('../app-user-helpers');
9
+
10
+ const log = getLogger('frigg.user.adopter_jwt');
11
+
12
+ const idFromClaim = (value) => {
13
+ if (typeof value === 'string' && value.trim() !== '') return value;
14
+ if (typeof value === 'number' && Number.isFinite(value))
15
+ return String(value);
16
+ return null;
17
+ };
18
+
19
+ const hasOrganization = (individual) => {
20
+ const id = individual?.organizationId;
21
+ return id !== undefined && id !== null && id !== '';
22
+ };
2
23
 
3
24
  /**
4
- * STUB: Use case for retrieving a user from adopter-provided JWT token.
5
- *
6
- * This is a stub implementation for future JWT authentication support.
7
- * When implemented, this will allow adopters to use their own JWT tokens
8
- * for authentication instead of Frigg's native token system.
9
- *
10
- * FUTURE IMPLEMENTATION REQUIREMENTS:
11
- * - Validate JWT signature using jwtConfig.secret from app definition
12
- * - Support configurable signing algorithms (HS256, HS384, HS512, RS256, RS384, RS512)
13
- * - Extract user identifiers from JWT claims based on jwtConfig.userIdClaim and jwtConfig.orgIdClaim
14
- * - Find or create user based on extracted claim values
15
- * - Handle token expiration and validation errors
16
- * - Support refresh tokens (optional)
17
- * - Validate user ID conflicts if both individual and org IDs present in JWT
25
+ * Interim rule until the membership table (ADR-056 §3.6): a person belongs to
26
+ * one organization. Callers run this before creating anything, so a refused
27
+ * request leaves no new organization behind.
18
28
  *
19
- * RECOMMENDED IMPLEMENTATION:
20
- * - Use 'jsonwebtoken' package for JWT parsing and validation
21
- * - Cache JWT public keys for RS* algorithms
22
- * - Add comprehensive error handling for invalid tokens
23
- * - Log authentication attempts for security auditing
29
+ * @param {Object|null} individual The individual user as the repository returns it.
30
+ * @param {Object|null} organization The organization the token names, or null
31
+ * when it does not exist yet.
32
+ * @throws {Boom} 403 `multi_org_unsupported`
33
+ */
34
+ function assertSingleOrganization(individual, organization) {
35
+ if (!hasOrganization(individual)) return;
36
+ if (
37
+ organization &&
38
+ String(individual.organizationId) === String(organization.id)
39
+ ) {
40
+ return;
41
+ }
42
+ log.debug('Adopter token names a second organization', {
43
+ eventName: `${log.name}.multi_org_refused`,
44
+ });
45
+ throw fail('multi_org_unsupported');
46
+ }
47
+
48
+ /**
49
+ * Authenticates a request by a token issued by the adopter's own identity
50
+ * system (ADR-056 §2): verifies it, then finds or creates the individual and
51
+ * organization users it names.
24
52
  *
25
- * @todo Implement JWT validation with jsonwebtoken package
26
- * @todo Add unit tests for JWT parsing and claim extraction
27
- * @todo Document adopter JWT integration guide in Frigg docs
28
- * @todo Add support for JWT refresh tokens
29
- * @todo Implement JWT public key caching for RS* algorithms
53
+ * Two limits, both replaced by the membership package (F4): the first request
54
+ * for a new person can race another and create the user twice (no upsert), and
55
+ * the individual-to-organization link is one-way and set once.
30
56
  *
31
57
  * @class GetUserFromAdopterJwt
32
58
  */
33
59
  class GetUserFromAdopterJwt {
34
60
  /**
35
- * Creates a new GetUserFromAdopterJwt instance.
36
- * @param {Object} params - Configuration parameters.
37
- * @param {import('../repositories/user-repository-interface').UserRepositoryInterface} params.userRepository - Repository for user data operations.
61
+ * @param {Object} params
62
+ * @param {import('../repositories/user-repository-interface').UserRepositoryInterface} params.userRepository
38
63
  * @param {Object} params.userConfig - The user config in the app definition.
64
+ * @param {{verify: Function, claimNames: Object}} params.tokenVerifier -
65
+ * from `createAdopterTokenVerifier`.
39
66
  */
40
- constructor({ userRepository, userConfig }) {
67
+ constructor({ userRepository, userConfig, tokenVerifier }) {
41
68
  this.userRepository = userRepository;
42
69
  this.userConfig = userConfig;
70
+ this.tokenVerifier = tokenVerifier;
43
71
  }
44
72
 
45
73
  /**
46
- * Executes the use case.
47
74
  * @async
48
- * @param {string} jwtToken - The JWT token from the Authorization header.
49
- * @returns {Promise<import('../user').User>} The authenticated user object.
50
- * @throws {Boom} 501 Not Implemented - This feature is not yet available.
75
+ * @param {string} token - The JWT from the Authorization header.
76
+ * @returns {Promise<import('../user').User>}
77
+ * @throws {Boom} 401 with `data.reason` for a token the verifier refuses
78
+ * (including `token_missing_user_claim`).
79
+ * @throws {Boom} 403 `multi_org_unsupported`.
80
+ * @throws {Boom} 503 `jwks_unavailable`, 500 `jwt_config_invalid` (from the verifier).
51
81
  */
52
- async execute(jwtToken) {
53
- throw Boom.notImplemented(
54
- 'Adopter JWT authentication is not yet implemented. ' +
55
- 'This feature is planned for a future Frigg release. ' +
56
- 'Please use one of the supported authentication modes instead: ' +
57
- 'friggToken (native bearer token) or xFriggHeaders (backend-to-backend with x-frigg-appUserId/appOrgId headers).'
58
- );
82
+ async execute(token) {
83
+ const claims = await this.tokenVerifier.verify(token);
84
+ const { userIdClaim, orgIdClaim, roleClaim } =
85
+ this.tokenVerifier.claimNames;
59
86
 
60
- /* FUTURE IMPLEMENTATION PSEUDOCODE:
87
+ const appUserId = idFromClaim(claims[userIdClaim]);
88
+ if (!appUserId) throw fail('token_missing_user_claim');
61
89
 
62
- const jwt = require('jsonwebtoken');
63
-
64
- // Validate JWT configuration exists
65
- if (!this.userConfig.jwtConfig || !this.userConfig.jwtConfig.secret) {
66
- throw Boom.badImplementation('JWT configuration is required when adopterJwt auth mode is enabled');
90
+ const rawOrgId = claims[orgIdClaim];
91
+ const appOrgId = idFromClaim(rawOrgId);
92
+ if (rawOrgId !== undefined && rawOrgId !== null && !appOrgId) {
93
+ throw fail('token_malformed');
67
94
  }
68
-
69
- try {
70
- // Verify and decode JWT
71
- const decoded = jwt.verify(jwtToken, this.userConfig.jwtConfig.secret, {
72
- algorithms: [this.userConfig.jwtConfig.algorithm || 'HS256']
95
+ const role = claims[roleClaim];
96
+
97
+ const user = await this._findOrCreateUser(appUserId, appOrgId);
98
+ user.setOrgRole(typeof role === 'string' && role !== '' ? role : null);
99
+
100
+ log.debug('Adopter token accepted', {
101
+ eventName: `${log.name}.accepted`,
102
+ hasOrganization: Boolean(user.getOrganizationUser()),
103
+ });
104
+ return user;
105
+ }
106
+
107
+ async _findOrCreateUser(appUserId, appOrgId) {
108
+ const cfg = this.userConfig;
109
+ const found = await findAppUsers(this.userRepository, cfg, {
110
+ appUserId,
111
+ appOrgId,
112
+ });
113
+
114
+ if (wantsOrganization(cfg, appOrgId)) {
115
+ assertSingleOrganization(found.individual, found.organization);
116
+ }
117
+
118
+ const { individual: createdIndividual, organization } =
119
+ await createMissingAppUsers(this.userRepository, cfg, {
120
+ appUserId,
121
+ appOrgId,
122
+ ...found,
73
123
  });
74
-
75
- // Extract user identifiers from claims
76
- const appUserId = decoded[this.userConfig.jwtConfig.userIdClaim || 'sub'];
77
- const appOrgId = decoded[this.userConfig.jwtConfig.orgIdClaim || 'org_id'];
78
-
79
- // At least one identifier required
80
- if (!appUserId && !appOrgId) {
81
- throw Boom.badRequest('JWT must contain user or organization identifier claims');
82
- }
83
-
84
- // Find existing users
85
- let individualUserData = null;
86
- let organizationUserData = null;
87
-
88
- if (appUserId) {
89
- individualUserData = await this.userRepository.findIndividualUserByAppUserId(appUserId);
90
- }
91
-
92
- if (appOrgId) {
93
- organizationUserData = await this.userRepository.findOrganizationUserByAppOrgId(appOrgId);
94
- }
95
-
96
- // Validate no conflicts if both IDs present
97
- if (appUserId && appOrgId && individualUserData && organizationUserData) {
98
- const individualOrgId = individualUserData.organizationUser?.toString();
99
- const expectedOrgId = organizationUserData.id?.toString();
100
-
101
- if (individualOrgId !== expectedOrgId) {
102
- throw Boom.badRequest(
103
- 'User ID mismatch: JWT claims refer to different users. ' +
104
- 'Individual and organization IDs must belong to the same user.'
105
- );
106
- }
107
- }
108
-
109
- // Auto-create if not found
110
- if (!individualUserData && !organizationUserData) {
111
- if (appUserId) {
112
- individualUserData = await this.userRepository.createIndividualUser({
113
- appUserId,
114
- username: `jwt-user-${appUserId}`,
115
- email: decoded.email || `${appUserId}@jwt.local`,
116
- });
117
- } else {
118
- organizationUserData = await this.userRepository.createOrganizationUser({
119
- appOrgId,
120
- });
121
- }
122
- }
123
-
124
- return new User(
125
- individualUserData,
126
- organizationUserData,
127
- this.userConfig.usePassword,
128
- this.userConfig.primary,
129
- this.userConfig.individualUserRequired,
130
- this.userConfig.organizationUserRequired
124
+ let individual = createdIndividual;
125
+
126
+ if (individual && organization && !hasOrganization(individual)) {
127
+ individual = await this.userRepository.linkIndividualToOrganization(
128
+ individual.id,
129
+ organization.id
131
130
  );
132
-
133
- } catch (error) {
134
- if (error.name === 'TokenExpiredError') {
135
- throw Boom.unauthorized('JWT token has expired');
136
- } else if (error.name === 'JsonWebTokenError') {
137
- throw Boom.unauthorized('Invalid JWT token');
138
- } else if (error.isBoom) {
139
- throw error;
140
- }
141
- throw Boom.unauthorized('JWT authentication failed');
142
131
  }
143
- */
132
+ return buildUser(cfg, individual, organization);
144
133
  }
145
134
  }
146
135
 
147
- module.exports = { GetUserFromAdopterJwt };
148
-
149
-
136
+ module.exports = { GetUserFromAdopterJwt, assertSingleOrganization };
@@ -1,5 +1,9 @@
1
1
  const Boom = require('@hapi/boom');
2
- const { User } = require('../user');
2
+ const {
3
+ findAppUsers,
4
+ createMissingAppUsers,
5
+ buildUser,
6
+ } = require('../app-user-helpers');
3
7
 
4
8
  /**
5
9
  * Use case for retrieving or creating a user from x-frigg header identifiers.
@@ -36,22 +40,11 @@ class GetUserFromXFriggHeaders {
36
40
  }
37
41
 
38
42
  // Find users by both IDs if both are provided
39
- let individualUserData = null;
40
- let organizationUserData = null;
41
-
42
- if (appUserId && this.userConfig.individualUserRequired !== false) {
43
- individualUserData =
44
- await this.userRepository.findIndividualUserByAppUserId(
45
- appUserId
46
- );
47
- }
48
-
49
- if (appOrgId && this.userConfig.organizationUserRequired) {
50
- organizationUserData =
51
- await this.userRepository.findOrganizationUserByAppOrgId(
52
- appOrgId
53
- );
54
- }
43
+ let { individual: individualUserData, organization: organizationUserData } =
44
+ await findAppUsers(this.userRepository, this.userConfig, {
45
+ appUserId,
46
+ appOrgId,
47
+ });
55
48
 
56
49
  // VALIDATION/AUTO-LINKING: If both IDs provided and both users exist, handle mismatch
57
50
  if (
@@ -84,48 +77,33 @@ class GetUserFromXFriggHeaders {
84
77
  }
85
78
 
86
79
  // Auto-create users independently if they don't exist and are required
87
- if (
88
- !individualUserData &&
89
- appUserId &&
90
- this.userConfig.individualUserRequired !== false
91
- ) {
92
- individualUserData =
93
- await this.userRepository.createIndividualUser({
94
- appUserId,
95
- username: `app-user-${appUserId}`,
96
- email: `${appUserId}@app.local`,
97
- });
98
- }
99
-
100
- if (
101
- !organizationUserData &&
102
- appOrgId &&
103
- this.userConfig.organizationUserRequired
104
- ) {
105
- organizationUserData =
106
- await this.userRepository.createOrganizationUser({
107
- appOrgId,
108
- });
80
+ const created = await createMissingAppUsers(
81
+ this.userRepository,
82
+ this.userConfig,
83
+ {
84
+ appUserId,
85
+ appOrgId,
86
+ individual: individualUserData,
87
+ organization: organizationUserData,
88
+ }
89
+ );
90
+ individualUserData = created.individual;
91
+ organizationUserData = created.organization;
109
92
 
110
- // Link individual user to newly created org user if individual exists
111
- if (individualUserData && organizationUserData) {
112
- individualUserData = await this.userRepository.linkIndividualToOrganization(
93
+ // Link individual user to newly created org user if individual exists
94
+ if (created.createdOrganization && individualUserData) {
95
+ individualUserData =
96
+ await this.userRepository.linkIndividualToOrganization(
113
97
  individualUserData.id,
114
98
  organizationUserData.id
115
99
  );
116
- }
117
100
  }
118
101
 
119
- const user = new User(
102
+ return buildUser(
103
+ this.userConfig,
120
104
  individualUserData,
121
- organizationUserData,
122
- this.userConfig.usePassword,
123
- this.userConfig.primary,
124
- this.userConfig.individualUserRequired,
125
- this.userConfig.organizationUserRequired
105
+ organizationUserData
126
106
  );
127
-
128
- return user;
129
107
  }
130
108
  }
131
109
 
package/user/user.js CHANGED
@@ -18,6 +18,9 @@ class User {
18
18
  this.individualUser = individualUser;
19
19
  this.organizationUser = organizationUser;
20
20
  this.usePassword = usePassword;
21
+ // Organization role from an adopter token claim (ADR-056 §2). Read-only
22
+ // for now: nothing persists it until the membership package lands.
23
+ this.orgRole = null;
21
24
 
22
25
  this.config = {
23
26
  primary,
@@ -65,6 +68,14 @@ class User {
65
68
  return this.config.individualUserRequired;
66
69
  }
67
70
 
71
+ getOrgRole() {
72
+ return this.orgRole;
73
+ }
74
+
75
+ setOrgRole(orgRole) {
76
+ this.orgRole = orgRole ?? null;
77
+ }
78
+
68
79
  getIndividualUser() {
69
80
  return this.individualUser;
70
81
  }