@friggframework/core 2.0.0--canary.680.daae24e.0 → 2.0.0--canary.686.e94de4d.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/CHANGELOG.md +0 -11
- package/CLAUDE.md +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +2 -2
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/use-cases/credential-operations-for-user.js +110 -25
- package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
- package/database/config.js +48 -24
- package/database/documentdb-utils.js +6 -1
- package/database/encryption/README.md +0 -2
- package/database/encryption/encryption-config.js +10 -31
- package/database/encryption/encryption-schema-registry.js +99 -47
- package/database/encryption/field-encryption-service.js +5 -69
- package/database/organization-where.js +18 -0
- package/database/prisma.js +4 -2
- package/database/unique-violation.js +25 -0
- package/extensions/bootstrap-runner.js +43 -0
- package/extensions/extension-loader.js +187 -0
- package/extensions/index.js +41 -0
- package/extensions/initialize-app.js +123 -0
- package/extensions/route-mounter.js +103 -0
- package/extensions/schema-composer.js +188 -0
- package/generated/prisma-mongodb/edge.js +27 -4
- package/generated/prisma-mongodb/index-browser.js +24 -1
- package/generated/prisma-mongodb/index.d.ts +3227 -47
- package/generated/prisma-mongodb/index.js +27 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +65 -0
- package/generated/prisma-mongodb/wasm.js +27 -4
- package/generated/prisma-postgresql/edge.js +27 -4
- package/generated/prisma-postgresql/index-browser.js +24 -1
- package/generated/prisma-postgresql/index.d.ts +3519 -67
- package/generated/prisma-postgresql/index.js +27 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +61 -0
- package/generated/prisma-postgresql/wasm.js +27 -4
- package/handlers/app-definition-loader.js +103 -12
- package/handlers/routers/auth.js +40 -12
- package/handlers/routers/health.js +4 -2
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +102 -64
- package/index.js +63 -1
- package/infrastructure/scheduler/index.js +19 -6
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
- package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
- package/integrations/index.js +5 -0
- package/integrations/integration-router.js +115 -73
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +35 -3
- package/integrations/repositories/integration-repository-interface.js +17 -1
- package/integrations/repositories/integration-repository-mongo.js +59 -76
- package/integrations/repositories/integration-repository-postgres.js +62 -76
- package/integrations/schedule-config.js +80 -0
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +55 -20
- package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
- package/integrations/use-cases/get-integrations-for-user.js +80 -40
- package/integrations/use-cases/index.js +7 -1
- package/integrations/use-cases/integration-operations-for-user.js +55 -24
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/utils/map-integration-dto.js +44 -1
- package/management-api/management-api-config.js +71 -2
- package/management-api/meta-router.js +21 -2
- package/management-api/openapi/components.js +40 -0
- package/management-api/openapi/v2-operations.js +16 -4
- package/management-api/route-registry.js +123 -6
- package/management-api/v2/dependencies.js +40 -15
- package/management-api/v2/dto.js +20 -0
- package/management-api/v2/handlers/authorization.js +1 -0
- package/management-api/v2/handlers/credentials.js +6 -2
- package/management-api/v2/handlers/entities.js +10 -7
- package/management-api/v2/handlers/integrations.js +6 -3
- package/management-api/v2/request.js +32 -1
- package/modules/entity-scope.js +15 -0
- package/modules/index.js +8 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +53 -2
- package/modules/repositories/entity-not-found.js +18 -0
- package/modules/repositories/module-repository-documentdb.js +51 -6
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +67 -74
- package/modules/repositories/module-repository-postgres.js +66 -74
- package/modules/repositories/module-repository.js +2 -1
- package/modules/scope-config.js +192 -0
- package/modules/scope-policy.js +112 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +247 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +61 -0
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +50 -13
- package/modules/use-cases/entity-types.js +31 -7
- package/modules/use-cases/execute-entity-proxy-request.js +50 -3
- package/modules/use-cases/get-authorization-requirements.js +2 -2
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +34 -7
- package/modules/use-cases/get-entity-options-by-id.js +10 -4
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +10 -4
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +40 -13
- package/modules/use-cases/process-authorization-callback.js +53 -19
- package/modules/use-cases/refresh-entity-options.js +10 -4
- package/modules/use-cases/test-module-auth.js +10 -4
- package/package.json +6 -5
- package/prisma-mongodb/schema.prisma +65 -0
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
- package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
- package/prisma-postgresql/schema.prisma +61 -0
- package/providers/index.js +23 -0
- package/providers/resolve-provider.js +93 -0
- package/providers/runtime-detection.js +124 -0
- package/queues/index.js +25 -0
- package/queues/providers/index.js +11 -0
- package/queues/providers/netlify-background-provider.js +150 -0
- package/queues/providers/qstash-queue-provider.js +219 -0
- package/queues/providers/sqs-queue-provider.js +70 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-message-auth.js +193 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +65 -0
- package/queues/sqs-queue-client.js +81 -0
- package/types/index.d.ts +1 -0
- package/types/modules/index.d.ts +139 -0
- package/types/user/index.d.ts +134 -0
- package/user/adopter-jwt-config.js +177 -0
- package/user/adopter-jwt-errors.js +59 -0
- package/user/adopter-token-verifier.js +122 -0
- package/user/app-user-helpers.js +97 -0
- package/user/create-user-authentication.js +64 -0
- package/user/membership.js +51 -0
- package/user/repositories/organization-membership-record.js +19 -0
- package/user/repositories/organization-membership-repository-documentdb.js +106 -0
- package/user/repositories/organization-membership-repository-factory.js +42 -0
- package/user/repositories/organization-membership-repository-interface.js +57 -0
- package/user/repositories/organization-membership-repository-mongo.js +13 -0
- package/user/repositories/organization-membership-repository-postgres.js +29 -0
- package/user/repositories/organization-membership-repository-prisma.js +81 -0
- package/user/repositories/user-repository-documentdb.js +27 -2
- package/user/repositories/user-repository-interface.js +18 -0
- package/user/repositories/user-repository-mongo.js +24 -0
- package/user/repositories/user-repository-postgres.js +18 -0
- package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/use-cases/ensure-organization-membership.js +92 -0
- package/user/use-cases/find-individual-users-by-organization.js +54 -0
- package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +64 -1
- package/database/encryption/test-support/fake-ciphertext.js +0 -15
|
@@ -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,59 @@
|
|
|
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
|
+
jwt_config_invalid: Object.freeze({
|
|
42
|
+
status: 500,
|
|
43
|
+
message: 'Adopter JWT authentication is misconfigured',
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {keyof typeof ADOPTER_JWT_ERRORS} code
|
|
49
|
+
* @returns {Boom.Boom} with `data.reason` set to `code`
|
|
50
|
+
*/
|
|
51
|
+
function fail(code) {
|
|
52
|
+
const { status, message } = ADOPTER_JWT_ERRORS[code];
|
|
53
|
+
return new Boom.Boom(message, {
|
|
54
|
+
statusCode: status,
|
|
55
|
+
data: { reason: code },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
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,97 @@
|
|
|
1
|
+
const { User } = require('./user');
|
|
2
|
+
const { isUniqueViolation } = require('../database/unique-violation');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Find-or-create steps shared by the paths that identify a user by an app user
|
|
6
|
+
* id and/or app organization id (x-frigg headers, adopter token). Each path
|
|
7
|
+
* keeps its own policy between "find" and "create".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const wantsIndividual = (userConfig) =>
|
|
11
|
+
userConfig.individualUserRequired !== false;
|
|
12
|
+
|
|
13
|
+
const wantsOrganization = (userConfig, appOrgId) =>
|
|
14
|
+
Boolean(appOrgId) && Boolean(userConfig.organizationUserRequired);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs `create`; if it loses a first-login race (a unique-constraint
|
|
18
|
+
* violation, see database/unique-violation.js), runs `find` once, since the
|
|
19
|
+
* winner has created the row by now. Anything else, and a violation with
|
|
20
|
+
* nothing to find, is rethrown.
|
|
21
|
+
*/
|
|
22
|
+
async function createOrFindOnRace(create, find) {
|
|
23
|
+
try {
|
|
24
|
+
return await create();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (!isUniqueViolation(error)) throw error;
|
|
27
|
+
const found = await find();
|
|
28
|
+
if (!found) throw error;
|
|
29
|
+
return found;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function findAppUsers(
|
|
34
|
+
userRepository,
|
|
35
|
+
userConfig,
|
|
36
|
+
{ appUserId, appOrgId }
|
|
37
|
+
) {
|
|
38
|
+
const individual =
|
|
39
|
+
appUserId && wantsIndividual(userConfig)
|
|
40
|
+
? await userRepository.findIndividualUserByAppUserId(appUserId)
|
|
41
|
+
: null;
|
|
42
|
+
const organization = wantsOrganization(userConfig, appOrgId)
|
|
43
|
+
? await userRepository.findOrganizationUserByAppOrgId(appOrgId)
|
|
44
|
+
: null;
|
|
45
|
+
return { individual, organization };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Creates whichever of the two users is wanted and missing. Does not link
|
|
50
|
+
* them: linking is policy.
|
|
51
|
+
*/
|
|
52
|
+
async function createMissingAppUsers(
|
|
53
|
+
userRepository,
|
|
54
|
+
userConfig,
|
|
55
|
+
{ appUserId, appOrgId, individual, organization }
|
|
56
|
+
) {
|
|
57
|
+
if (!individual && appUserId && wantsIndividual(userConfig)) {
|
|
58
|
+
individual = await createOrFindOnRace(
|
|
59
|
+
() =>
|
|
60
|
+
userRepository.createIndividualUser({
|
|
61
|
+
appUserId,
|
|
62
|
+
username: `app-user-${appUserId}`,
|
|
63
|
+
email: `${appUserId}@app.local`,
|
|
64
|
+
}),
|
|
65
|
+
() => userRepository.findIndividualUserByAppUserId(appUserId)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (!organization && wantsOrganization(userConfig, appOrgId)) {
|
|
69
|
+
// No race retry here: `appOrgId` has no unique index (MongoDB cannot
|
|
70
|
+
// express one that ignores individuals), so a lost race cannot raise a
|
|
71
|
+
// violation to recover from. Two simultaneous first logins for a new
|
|
72
|
+
// organization can still create it twice.
|
|
73
|
+
organization = await userRepository.createOrganizationUser({
|
|
74
|
+
appOrgId,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return { individual, organization };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildUser(userConfig, individual, organization) {
|
|
81
|
+
return new User(
|
|
82
|
+
individual,
|
|
83
|
+
organization,
|
|
84
|
+
userConfig.usePassword,
|
|
85
|
+
userConfig.primary,
|
|
86
|
+
userConfig.individualUserRequired,
|
|
87
|
+
userConfig.organizationUserRequired
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
wantsIndividual,
|
|
93
|
+
wantsOrganization,
|
|
94
|
+
findAppUsers,
|
|
95
|
+
createMissingAppUsers,
|
|
96
|
+
buildUser,
|
|
97
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const {
|
|
2
|
+
createUserRepository,
|
|
3
|
+
} = require('./repositories/user-repository-factory');
|
|
4
|
+
const {
|
|
5
|
+
createOrganizationMembershipRepository,
|
|
6
|
+
} = require('./repositories/organization-membership-repository-factory');
|
|
7
|
+
const { createAdopterTokenVerifier } = require('./adopter-token-verifier');
|
|
8
|
+
const { GetUserFromBearerToken } = require('./use-cases/get-user-from-bearer-token');
|
|
9
|
+
const {
|
|
10
|
+
GetUserFromXFriggHeaders,
|
|
11
|
+
} = require('./use-cases/get-user-from-x-frigg-headers');
|
|
12
|
+
const { GetUserFromAdopterJwt } = require('./use-cases/get-user-from-adopter-jwt');
|
|
13
|
+
const {
|
|
14
|
+
EnsureOrganizationMembership,
|
|
15
|
+
} = require('./use-cases/ensure-organization-membership');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Composition root for the three "who is this request" use cases, so the
|
|
19
|
+
* Lambda integration router and the Management API v2 wire them identically.
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} params
|
|
22
|
+
* @param {Object} params.userConfig The `user` block of the app definition.
|
|
23
|
+
* @param {Object} [params.userRepository] Defaults to the configured database's.
|
|
24
|
+
* @param {Object} [params.organizationMembershipRepository] Defaults to the configured database's.
|
|
25
|
+
* @param {{verify: Function, claimNames: Object}} [params.tokenVerifier]
|
|
26
|
+
* Defaults to a verifier for `userConfig.jwtConfig`.
|
|
27
|
+
* @returns {{
|
|
28
|
+
* getUserFromBearerToken: GetUserFromBearerToken,
|
|
29
|
+
* getUserFromXFriggHeaders: GetUserFromXFriggHeaders,
|
|
30
|
+
* getUserFromAdopterJwt: GetUserFromAdopterJwt,
|
|
31
|
+
* }}
|
|
32
|
+
*/
|
|
33
|
+
function buildUserAuthentication({
|
|
34
|
+
userConfig,
|
|
35
|
+
userRepository = createUserRepository(),
|
|
36
|
+
organizationMembershipRepository = createOrganizationMembershipRepository(),
|
|
37
|
+
tokenVerifier = createAdopterTokenVerifier({
|
|
38
|
+
jwtConfig: userConfig?.jwtConfig,
|
|
39
|
+
}),
|
|
40
|
+
}) {
|
|
41
|
+
const ensureOrganizationMembership = new EnsureOrganizationMembership({
|
|
42
|
+
userRepository,
|
|
43
|
+
organizationMembershipRepository,
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
getUserFromBearerToken: new GetUserFromBearerToken({
|
|
47
|
+
userRepository,
|
|
48
|
+
userConfig,
|
|
49
|
+
}),
|
|
50
|
+
getUserFromXFriggHeaders: new GetUserFromXFriggHeaders({
|
|
51
|
+
userRepository,
|
|
52
|
+
ensureOrganizationMembership,
|
|
53
|
+
userConfig,
|
|
54
|
+
}),
|
|
55
|
+
getUserFromAdopterJwt: new GetUserFromAdopterJwt({
|
|
56
|
+
userRepository,
|
|
57
|
+
ensureOrganizationMembership,
|
|
58
|
+
userConfig,
|
|
59
|
+
tokenVerifier,
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { buildUserAuthentication };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Organization membership rules that are not persistence (ADR-056 §3.6). Pure.
|
|
3
|
+
*
|
|
4
|
+
* `IndividualUser.organizationId` is the legacy single-org pointer. It is not
|
|
5
|
+
* copied into membership rows; it is read as an implicit membership here, in
|
|
6
|
+
* the domain, so the repositories stay atomic (a row or nothing). See
|
|
7
|
+
* docs/guides/organization-memberships.md for why this is a read-time
|
|
8
|
+
* fallback rather than a data migration.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The role a membership has when nothing says otherwise. */
|
|
12
|
+
const DEFAULT_ORG_ROLE = 'MEMBER';
|
|
13
|
+
|
|
14
|
+
const isPresent = (value) =>
|
|
15
|
+
value !== undefined && value !== null && value !== '';
|
|
16
|
+
|
|
17
|
+
const idString = (value) => (isPresent(value) ? String(value) : null);
|
|
18
|
+
|
|
19
|
+
/** Ids compare as strings: stores return numbers or strings. */
|
|
20
|
+
const sameId = (a, b) => isPresent(a) && isPresent(b) && String(a) === String(b);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The membership the legacy `organizationId` pointer stands for in
|
|
24
|
+
* `organizationUserId`, or null when the person has no pointer or it names a
|
|
25
|
+
* different organization.
|
|
26
|
+
*
|
|
27
|
+
* @param {Object|null} individual The individual user as the repository returns it.
|
|
28
|
+
* @param {string|number} organizationUserId
|
|
29
|
+
* @returns {{id: null, individualUserId: string|null, organizationUserId: string, orgRole: string, createdAt: *, implicit: true}|null}
|
|
30
|
+
*/
|
|
31
|
+
function implicitMembership(individual, organizationUserId) {
|
|
32
|
+
if (!individual || !sameId(individual.organizationId, organizationUserId)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
id: null,
|
|
37
|
+
individualUserId: idString(individual.id),
|
|
38
|
+
organizationUserId: idString(individual.organizationId),
|
|
39
|
+
orgRole: DEFAULT_ORG_ROLE,
|
|
40
|
+
createdAt: individual.createdAt,
|
|
41
|
+
implicit: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
DEFAULT_ORG_ROLE,
|
|
47
|
+
isPresent,
|
|
48
|
+
idString,
|
|
49
|
+
sameId,
|
|
50
|
+
implicitMembership,
|
|
51
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const { idString } = require('../membership');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A stored membership row as the application sees it: every id a string
|
|
5
|
+
* whatever the database holds. `implicit` is always false here: only the
|
|
6
|
+
* domain (`user/membership.js`) produces an implicit membership.
|
|
7
|
+
*/
|
|
8
|
+
function toMembership(row) {
|
|
9
|
+
return {
|
|
10
|
+
id: idString(row.id ?? row._id),
|
|
11
|
+
individualUserId: idString(row.individualUserId),
|
|
12
|
+
organizationUserId: idString(row.organizationUserId),
|
|
13
|
+
orgRole: row.orgRole,
|
|
14
|
+
createdAt: row.createdAt,
|
|
15
|
+
implicit: false,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { toMembership };
|