@friggframework/core 2.0.0--canary.663.8de669c.0 → 2.0.0--canary.686.821f8a3.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 +11 -0
- package/CLAUDE.md +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +20 -10
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/application/index.js +6 -2
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/repositories/credential-repository-documentdb.js +34 -0
- package/credential/repositories/credential-repository-interface.js +15 -0
- package/credential/repositories/credential-repository-mongo.js +23 -0
- package/credential/repositories/credential-repository-postgres.js +28 -0
- package/credential/use-cases/credential-operations-for-user.js +202 -0
- package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
- package/database/config.js +48 -24
- package/database/documentdb-utils.js +6 -1
- package/database/encryption/README.md +2 -0
- package/database/encryption/encryption-config.js +10 -31
- package/database/encryption/encryption-schema-registry.js +121 -24
- package/database/encryption/field-encryption-service.js +69 -5
- package/database/encryption/test-support/fake-ciphertext.js +15 -0
- 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 +43 -4
- package/generated/prisma-mongodb/index-browser.js +40 -1
- package/generated/prisma-mongodb/index.d.ts +4761 -133
- package/generated/prisma-mongodb/index.js +43 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +92 -0
- package/generated/prisma-mongodb/wasm.js +43 -4
- package/generated/prisma-postgresql/edge.js +43 -4
- package/generated/prisma-postgresql/index-browser.js +40 -1
- package/generated/prisma-postgresql/index.d.ts +4961 -28
- package/generated/prisma-postgresql/index.js +43 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +87 -0
- package/generated/prisma-postgresql/wasm.js +43 -4
- package/handlers/app-definition-loader.js +121 -19
- package/handlers/app-handler-helpers.js +25 -3
- package/handlers/backend-utils.js +31 -25
- package/handlers/middleware/admin-auth.js +14 -1
- package/handlers/routers/auth.js +46 -10
- package/handlers/routers/health.js +13 -4
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +102 -64
- package/handlers/workers/integration-defined-workers.js +5 -2
- package/index.js +65 -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 +1 -0
- package/integrations/integration-router.js +131 -96
- 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 +256 -0
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/utils/map-integration-dto.js +43 -14
- package/management-api/create-management-api-router.js +49 -0
- package/management-api/error-response.js +116 -0
- package/management-api/management-api-config.js +151 -0
- package/management-api/meta-router.js +76 -0
- package/management-api/openapi/build-openapi.js +144 -0
- package/management-api/openapi/components.js +408 -0
- package/management-api/openapi/v1-operations.js +83 -0
- package/management-api/openapi/v2-operations.js +264 -0
- package/management-api/route-registry.js +411 -0
- package/management-api/test-support/http-client.js +39 -0
- package/management-api/use-cases/get-management-api-meta.js +57 -0
- package/management-api/v1-deprecation.js +120 -0
- package/management-api/v2/create-v2-router.js +85 -0
- package/management-api/v2/dependencies.js +281 -0
- package/management-api/v2/dto.js +143 -0
- package/management-api/v2/handlers/authorization.js +94 -0
- package/management-api/v2/handlers/credentials.js +33 -0
- package/management-api/v2/handlers/entities.js +77 -0
- package/management-api/v2/handlers/integrations.js +128 -0
- package/management-api/v2/handlers/proxy.js +34 -0
- package/management-api/v2/request.js +85 -0
- package/management-api/v2/test-support/fake-dependencies.js +103 -0
- package/modules/domain/authorization-session.js +144 -0
- package/modules/entity-scope.js +15 -0
- package/modules/entity-type-registry.js +311 -0
- package/modules/index.js +14 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +53 -2
- package/modules/proxy/proxy-errors.js +20 -0
- package/modules/proxy/proxy-path.js +179 -0
- package/modules/proxy/proxy-policy.js +248 -0
- package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
- package/modules/repositories/authorization-session-repository-factory.js +31 -0
- package/modules/repositories/authorization-session-repository-interface.js +47 -0
- package/modules/repositories/authorization-session-repository-prisma.js +87 -0
- 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/requester/proxy-request.js +244 -0
- package/modules/requester/requester.js +27 -2
- 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/in-memory-authorization-session-repository.js +45 -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 +162 -0
- package/modules/use-cases/entity-types.js +139 -0
- package/modules/use-cases/execute-entity-proxy-request.js +286 -0
- package/modules/use-cases/get-authorization-requirements.js +100 -0
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +113 -0
- 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 +329 -0
- 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 +92 -0
- package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -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 +87 -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 +2 -0
- package/types/modules/index.d.ts +197 -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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { describeRecord } = require('../scope-visibility');
|
|
3
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
4
|
+
const { isEntityNotFound } = require('../repositories/entity-not-found');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* True when `user` may act on records owned by `ownerId`. Uses the User
|
|
8
|
+
* domain object's rule (individual or organisation user model) when present.
|
|
9
|
+
* This is the owner-only rule: it ignores the organization a record belongs
|
|
10
|
+
* to, so it no longer decides access to an entity or an integration
|
|
11
|
+
* (modules/scope-visibility.js does). Credentials still use it.
|
|
12
|
+
* @param {Object} user - authenticated User (or a plain { getId } double)
|
|
13
|
+
* @param {string|number} ownerId
|
|
14
|
+
*/
|
|
15
|
+
function userOwns(user, ownerId) {
|
|
16
|
+
if (ownerId === undefined || ownerId === null || !user) return false;
|
|
17
|
+
if (typeof user.ownsUserId === 'function') return user.ownsUserId(ownerId);
|
|
18
|
+
return String(user.getId()) === String(ownerId);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const defaultCreateModule = (params) => {
|
|
22
|
+
const { Module } = require('../module');
|
|
23
|
+
return Module.create(params);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Loads an entity the caller owns and builds its Module (API client
|
|
28
|
+
* hydrated from the persisted entity and credential). Management API v2.
|
|
29
|
+
*
|
|
30
|
+
* A missing entity and one owned by someone else are the same 404, so ids
|
|
31
|
+
* cannot be probed (ADR-052 §3).
|
|
32
|
+
*/
|
|
33
|
+
class GetEntityModuleForUser {
|
|
34
|
+
/**
|
|
35
|
+
* @param {Object} params
|
|
36
|
+
* @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
37
|
+
* @param {Array<Object>} params.moduleDefinitions
|
|
38
|
+
* @param {Function} [params.createModule] - builds a Module; injectable for tests.
|
|
39
|
+
* @param {Object} [params.scopePolicy] - from createScopePolicy; with none the
|
|
40
|
+
* app configures nothing (ADR-056 §3.1).
|
|
41
|
+
*/
|
|
42
|
+
constructor({ moduleRepository, moduleDefinitions, createModule, scopePolicy }) {
|
|
43
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
44
|
+
this.moduleRepository = moduleRepository;
|
|
45
|
+
this.moduleDefinitions = moduleDefinitions || [];
|
|
46
|
+
this.createModule = createModule || defaultCreateModule;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} entityId
|
|
51
|
+
* @param {Object} user - authenticated User
|
|
52
|
+
* @returns {Promise<{ entity: Object, definition: Object, module: Object }>}
|
|
53
|
+
*/
|
|
54
|
+
async execute(entityId, user) {
|
|
55
|
+
const entity = await this.loadOwnedEntity(entityId, user);
|
|
56
|
+
const definition = this.moduleDefinitions.find(
|
|
57
|
+
(def) => def.moduleName === entity.moduleName
|
|
58
|
+
);
|
|
59
|
+
if (!definition) {
|
|
60
|
+
throw Boom.notFound(
|
|
61
|
+
`Entity type ${entity.moduleName} is not configured in this app`,
|
|
62
|
+
{ code: 'ENTITY_TYPE_NOT_FOUND' }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const module = await this.createModule({
|
|
66
|
+
userId: entity.userId,
|
|
67
|
+
entity,
|
|
68
|
+
definition,
|
|
69
|
+
});
|
|
70
|
+
return { entity, definition, module };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The entity record, or a 404 when it is missing or the caller may not use
|
|
75
|
+
* it: not theirs, or paired with another organization (ADR-056 §3.6). A
|
|
76
|
+
* `global` entity is never the tenant's to load.
|
|
77
|
+
*/
|
|
78
|
+
async loadOwnedEntity(entityId, user) {
|
|
79
|
+
let entity = null;
|
|
80
|
+
try {
|
|
81
|
+
entity = await this.moduleRepository.findEntityById(entityId);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// Every adapter throws for a missing row, and a malformed id (e.g.
|
|
84
|
+
// not an integer on Postgres) is "not found" too. Both must be the
|
|
85
|
+
// 404 another tenant's entity gets, or the status says which ids
|
|
86
|
+
// exist. Anything else is a real failure.
|
|
87
|
+
if (
|
|
88
|
+
isEntityNotFound(error) ||
|
|
89
|
+
/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')
|
|
90
|
+
) {
|
|
91
|
+
entity = null;
|
|
92
|
+
} else {
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!entity || !this.describe(entity, user)) {
|
|
97
|
+
throw Boom.notFound(`Entity ${entityId} not found`, {
|
|
98
|
+
code: 'ENTITY_NOT_FOUND',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return entity;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `{ scope, owner, organizationId, access }` of an entity to this caller,
|
|
106
|
+
* or null when they may not see it (§3.4).
|
|
107
|
+
*/
|
|
108
|
+
describe(entity, user) {
|
|
109
|
+
return describeRecord(entity, this.scopePolicy.accessContext(user), 'entity');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { GetEntityModuleForUser, userOwns };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
2
3
|
|
|
3
4
|
class GetEntityOptionsById {
|
|
4
5
|
/**
|
|
@@ -6,9 +7,11 @@ class GetEntityOptionsById {
|
|
|
6
7
|
* @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
7
8
|
* @param {} params.moduleDefinitions
|
|
8
9
|
*/
|
|
9
|
-
constructor({ moduleRepository, moduleDefinitions }) {
|
|
10
|
+
constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
|
|
10
11
|
this.moduleRepository = moduleRepository;
|
|
11
12
|
this.moduleDefinitions = moduleDefinitions;
|
|
13
|
+
// Who may load an entity follows its scope (ADR-056 §3.6).
|
|
14
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
/**
|
|
@@ -35,7 +38,7 @@ class GetEntityOptionsById {
|
|
|
35
38
|
|
|
36
39
|
// Validate entity ownership
|
|
37
40
|
const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
|
|
38
|
-
?
|
|
41
|
+
? this.scopePolicy.canUseEntity(userIdOrUser, entity)
|
|
39
42
|
: entity.userId?.toString() === userId?.toString();
|
|
40
43
|
|
|
41
44
|
if (!isOwned) {
|
|
@@ -57,8 +60,11 @@ class GetEntityOptionsById {
|
|
|
57
60
|
);
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
64
|
+
// organization's entity, and a token refresh during this call must
|
|
65
|
+
// update the owner's credential, not create one under the caller.
|
|
66
|
+
const module = await Module.create({
|
|
67
|
+
userId: entity.userId ?? userId,
|
|
62
68
|
entity,
|
|
63
69
|
definition: moduleDefinition,
|
|
64
70
|
});
|
|
@@ -21,7 +21,7 @@ class GetEntityOptionsByType {
|
|
|
21
21
|
if (!moduleDefinition) {
|
|
22
22
|
throw new Error(`Module definition not found for type: ${type}`);
|
|
23
23
|
}
|
|
24
|
-
const moduleInstance =
|
|
24
|
+
const moduleInstance = await Module.create({
|
|
25
25
|
userId,
|
|
26
26
|
definition: moduleDefinition,
|
|
27
27
|
});
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
2
3
|
|
|
3
4
|
class GetModule {
|
|
4
|
-
constructor({ moduleRepository, moduleDefinitions }) {
|
|
5
|
+
constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
|
|
5
6
|
this.moduleRepository = moduleRepository;
|
|
6
7
|
this.moduleDefinitions = moduleDefinitions;
|
|
8
|
+
// Who may load an entity follows its scope (ADR-056 §3.6).
|
|
9
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
/**
|
|
@@ -32,7 +35,7 @@ class GetModule {
|
|
|
32
35
|
// If User object provided, use ownsUserId to check linked users
|
|
33
36
|
// Otherwise fall back to simple equality check
|
|
34
37
|
const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
|
|
35
|
-
?
|
|
38
|
+
? this.scopePolicy.canUseEntity(userIdOrUser, entity)
|
|
36
39
|
: entity.userId?.toString() === userId?.toString();
|
|
37
40
|
|
|
38
41
|
if (!isOwned) {
|
|
@@ -53,8 +56,11 @@ class GetModule {
|
|
|
53
56
|
);
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
60
|
+
// organization's entity, and a token refresh during this call must
|
|
61
|
+
// update the owner's credential, not create one under the caller.
|
|
62
|
+
const module = await Module.create({
|
|
63
|
+
userId: entity.userId ?? userId,
|
|
58
64
|
entity,
|
|
59
65
|
definition: moduleDefinition,
|
|
60
66
|
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { ACCESS } = require('../scope-resolution');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one check behind every "may this caller change or delete it" gate
|
|
6
|
+
* (ADR-056 §3 permissions): the caller may use it but not manage it, so 403
|
|
7
|
+
* `ORG_ADMIN_REQUIRED`. `access` comes from describeRecord (entities,
|
|
8
|
+
* integrations) or scopePolicy.accessToOwnedBy (credentials).
|
|
9
|
+
*
|
|
10
|
+
* @param {string|null} access - ACCESS value
|
|
11
|
+
* @param {string} subject - e.g. `entity 5`, for the message
|
|
12
|
+
*/
|
|
13
|
+
function assertCanManage(access, subject) {
|
|
14
|
+
if (access !== ACCESS.MANAGE) {
|
|
15
|
+
throw Boom.forbidden(`You may use ${subject} but not change or delete it`, {
|
|
16
|
+
code: 'ORG_ADMIN_REQUIRED',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { assertCanManage };
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
2
|
+
const { FetchError } = require('../../errors/fetch-error');
|
|
3
|
+
const { getLogger } = require('../../logs');
|
|
4
|
+
const { AuthorizationSession } = require('../domain/authorization-session');
|
|
5
|
+
const { findDefinition, getAuthStepCount } = require('./entity-types');
|
|
6
|
+
const { describeRecord } = require('../scope-visibility');
|
|
7
|
+
const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
|
|
8
|
+
|
|
9
|
+
const log = getLogger('frigg.api.authorize');
|
|
10
|
+
|
|
11
|
+
const defaultCreateModule = (params) => {
|
|
12
|
+
const { Module } = require('../module');
|
|
13
|
+
return Module.create(params);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const isMultiStep = (definition) =>
|
|
17
|
+
getAuthStepCount(definition) > 1 ||
|
|
18
|
+
typeof definition.processAuthorizationStep === 'function';
|
|
19
|
+
|
|
20
|
+
const sessionNotFound = () =>
|
|
21
|
+
Boom.notFound('Authorization session not found or expired', {
|
|
22
|
+
code: 'AUTHORIZATION_SESSION_NOT_FOUND',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maps a failure from the module or the provider to a client-facing error.
|
|
27
|
+
* Boom errors pass through. A provider HTTP failure is a 502; a failed auth
|
|
28
|
+
* test or a ClientSafeError is a 400. Anything else stays a 500.
|
|
29
|
+
*/
|
|
30
|
+
function toAuthorizationError(error, { passMessage = false } = {}) {
|
|
31
|
+
if (error?.isBoom) return error;
|
|
32
|
+
if (error instanceof FetchError) {
|
|
33
|
+
return Boom.badGateway('The provider rejected the authorization request', {
|
|
34
|
+
code: 'UPSTREAM_ERROR',
|
|
35
|
+
expose: true,
|
|
36
|
+
details: { upstreamStatus: error.statusCode ?? null },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (error?.isClientSafe || passMessage) {
|
|
40
|
+
return Boom.badRequest(error?.message || 'Authorization failed', {
|
|
41
|
+
code: 'AUTHORIZATION_FAILED',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (error?.message === 'Authorization failed') {
|
|
45
|
+
return Boom.badRequest(
|
|
46
|
+
'Authorization failed: the credentials did not pass the module auth test',
|
|
47
|
+
{ code: 'AUTHORIZATION_FAILED' }
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Loads the session a caller continues, checking it belongs to them, to the
|
|
55
|
+
* entity type and (for re-authorization) to the credential.
|
|
56
|
+
*/
|
|
57
|
+
async function loadSession(repository, sessionId, binding) {
|
|
58
|
+
if (typeof sessionId !== 'string' || !sessionId) throw sessionNotFound();
|
|
59
|
+
const session = await repository.findBySessionId(sessionId);
|
|
60
|
+
if (!session || session.isExpired() || !session.belongsTo(binding)) {
|
|
61
|
+
throw sessionNotFound();
|
|
62
|
+
}
|
|
63
|
+
if (session.completed) {
|
|
64
|
+
throw Boom.conflict('Authorization session is already complete', {
|
|
65
|
+
code: 'AUTHORIZATION_SESSION_COMPLETED',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return session;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function startSession(repository, binding, maxSteps) {
|
|
72
|
+
// Expired sessions are removed opportunistically; PostgreSQL has no TTL.
|
|
73
|
+
Promise.resolve()
|
|
74
|
+
.then(() => repository.deleteExpired())
|
|
75
|
+
.catch((error) =>
|
|
76
|
+
log.warn('Expired authorization sessions were not removed', {
|
|
77
|
+
eventName: `${log.name}.session_cleanup_failed`,
|
|
78
|
+
error,
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
return repository.create(AuthorizationSession.start({ ...binding, maxSteps }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* GET /api/v2/authorize (and GET /api/v2/credentials/:id/reauthorize): what
|
|
86
|
+
* the caller must submit next. A multi-step flow gets a session on step 1;
|
|
87
|
+
* later steps need that sessionId and must be the session's current step.
|
|
88
|
+
*/
|
|
89
|
+
class GetAuthorizationStep {
|
|
90
|
+
/**
|
|
91
|
+
* @param {Object} params
|
|
92
|
+
* @param {Array<Object>} params.moduleDefinitions
|
|
93
|
+
* @param {import('./get-authorization-requirements').GetAuthorizationRequirements} params.getAuthorizationRequirements
|
|
94
|
+
* @param {import('../repositories/authorization-session-repository-interface').AuthorizationSessionRepositoryInterface} params.authorizationSessionRepository
|
|
95
|
+
*/
|
|
96
|
+
constructor({ moduleDefinitions, getAuthorizationRequirements, authorizationSessionRepository }) {
|
|
97
|
+
this.moduleDefinitions = moduleDefinitions || [];
|
|
98
|
+
this.getAuthorizationRequirements = getAuthorizationRequirements;
|
|
99
|
+
this.sessions = authorizationSessionRepository;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async execute({ user, entityType, step = 1, sessionId, credentialId = null, state }) {
|
|
103
|
+
const definition = findDefinition(this.moduleDefinitions, entityType);
|
|
104
|
+
const userId = user.getId();
|
|
105
|
+
const requirementsFor = (n) =>
|
|
106
|
+
this.getAuthorizationRequirements.execute({ entityType, step: n, userId, state });
|
|
107
|
+
|
|
108
|
+
if (!isMultiStep(definition)) return requirementsFor(step);
|
|
109
|
+
|
|
110
|
+
const binding = { userId, entityType, credentialId };
|
|
111
|
+
let session;
|
|
112
|
+
if (sessionId) {
|
|
113
|
+
session = await loadSession(this.sessions, sessionId, binding);
|
|
114
|
+
if (step !== session.currentStep) {
|
|
115
|
+
throw Boom.conflict(
|
|
116
|
+
`This session expects step ${session.currentStep}`,
|
|
117
|
+
{ code: 'STEP_OUT_OF_ORDER', details: { expectedStep: session.currentStep } }
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
if (step !== 1) {
|
|
122
|
+
throw Boom.badRequest('sessionId is required after step 1', {
|
|
123
|
+
code: 'SESSION_REQUIRED',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
session = await startSession(this.sessions, binding, getAuthStepCount(definition));
|
|
127
|
+
}
|
|
128
|
+
return { ...(await requirementsFor(step)), sessionId: session.sessionId };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* POST /api/v2/authorize (and POST /api/v2/credentials/:id/reauthorize).
|
|
134
|
+
*
|
|
135
|
+
* Single-step modules complete at once through ProcessAuthorizationCallback
|
|
136
|
+
* (the v1 authorize logic). Multi-step modules run
|
|
137
|
+
* definition.processAuthorizationStep(api, step, data, stepData) per step;
|
|
138
|
+
* when it reports `completed`, its `authData` goes through the same
|
|
139
|
+
* callback. Returns either
|
|
140
|
+
* `{ status: 'complete', entity, credential }` (domain records; the handler
|
|
141
|
+
* masks them) or
|
|
142
|
+
* `{ status: 'pending', step, totalSteps, sessionId, requirements, message? }`.
|
|
143
|
+
*/
|
|
144
|
+
class SubmitAuthorizationStep {
|
|
145
|
+
constructor({
|
|
146
|
+
moduleDefinitions,
|
|
147
|
+
getAuthorizationRequirements,
|
|
148
|
+
authorizationSessionRepository,
|
|
149
|
+
authorizeEntityForUser,
|
|
150
|
+
moduleRepository,
|
|
151
|
+
credentialRepository,
|
|
152
|
+
createModule,
|
|
153
|
+
scopePolicy,
|
|
154
|
+
}) {
|
|
155
|
+
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
156
|
+
this.moduleDefinitions = moduleDefinitions || [];
|
|
157
|
+
this.getAuthorizationRequirements = getAuthorizationRequirements;
|
|
158
|
+
this.sessions = authorizationSessionRepository;
|
|
159
|
+
this.authorizeEntityForUser = authorizeEntityForUser;
|
|
160
|
+
this.moduleRepository = moduleRepository;
|
|
161
|
+
this.credentialRepository = credentialRepository;
|
|
162
|
+
this.createModule = createModule || defaultCreateModule;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {Object} params
|
|
167
|
+
* @param {string} [params.scope] - the scope the caller asks for, from the
|
|
168
|
+
* deciding level's allow list (ADR-056 §3.1). Never `global`.
|
|
169
|
+
* @param {string} [params.fixedScope] - re-authorization keeps the scope the
|
|
170
|
+
* credential already has, whatever the config says now.
|
|
171
|
+
*/
|
|
172
|
+
async execute({ user, entityType, data, step = 1, sessionId, credentialId = null, scope, fixedScope }) {
|
|
173
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
174
|
+
throw Boom.badRequest('data must be an object', {
|
|
175
|
+
code: 'VALIDATION_ERROR',
|
|
176
|
+
details: { field: 'data' },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const definition = findDefinition(this.moduleDefinitions, entityType);
|
|
180
|
+
// `userId` binds a session to the caller and is what GET /authorize
|
|
181
|
+
// computes too. The owner of the entity this creates is decided by
|
|
182
|
+
// scope (§3.2), not by user.primary, and is `ownership.ownerId`.
|
|
183
|
+
const userId = user.getId();
|
|
184
|
+
const ownership = this.authorizeEntityForUser.resolveOwnership({
|
|
185
|
+
user,
|
|
186
|
+
entityType,
|
|
187
|
+
scope,
|
|
188
|
+
fixedScope,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (!isMultiStep(definition)) {
|
|
192
|
+
if (step !== 1) {
|
|
193
|
+
throw Boom.badRequest(`${entityType} has a single authorization step`, {
|
|
194
|
+
code: 'INVALID_STEP',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return this.complete({ ownership, user, entityType, params: data, credentialId });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const totalSteps = getAuthStepCount(definition);
|
|
201
|
+
const binding = { userId, entityType, credentialId };
|
|
202
|
+
let session;
|
|
203
|
+
if (sessionId) {
|
|
204
|
+
session = await loadSession(this.sessions, sessionId, binding);
|
|
205
|
+
if (step === 1 && session.currentStep !== 1) {
|
|
206
|
+
session.restart();
|
|
207
|
+
} else if (step !== session.currentStep) {
|
|
208
|
+
throw Boom.conflict(`This session expects step ${session.currentStep}`, {
|
|
209
|
+
code: 'STEP_OUT_OF_ORDER',
|
|
210
|
+
details: { expectedStep: session.currentStep },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
if (step !== 1) {
|
|
215
|
+
throw Boom.badRequest('sessionId is required after step 1', {
|
|
216
|
+
code: 'SESSION_REQUIRED',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
session = await startSession(this.sessions, binding, totalSteps);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (typeof definition.processAuthorizationStep !== 'function') {
|
|
223
|
+
throw Boom.badImplementation(
|
|
224
|
+
`Module ${entityType} declares ${totalSteps} steps but no processAuthorizationStep`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const module = await this.createModule({ userId: ownership.ownerId, definition });
|
|
229
|
+
let result;
|
|
230
|
+
try {
|
|
231
|
+
result = await definition.processAuthorizationStep(
|
|
232
|
+
module.api,
|
|
233
|
+
step,
|
|
234
|
+
data,
|
|
235
|
+
session.stepData
|
|
236
|
+
);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
// processAuthorizationStep errors are written for the end user
|
|
239
|
+
// (docs/MULTI_STEP_AUTH_MIGRATION_GUIDE.md), e.g. "Invalid code".
|
|
240
|
+
throw toAuthorizationError(error, { passMessage: true });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (result?.completed) {
|
|
244
|
+
session.markComplete();
|
|
245
|
+
await this.sessions.update(session);
|
|
246
|
+
const outcome = await this.complete({
|
|
247
|
+
ownership,
|
|
248
|
+
user,
|
|
249
|
+
entityType,
|
|
250
|
+
params: result.authData || {},
|
|
251
|
+
credentialId,
|
|
252
|
+
});
|
|
253
|
+
// The collected data is no longer needed once the credential exists.
|
|
254
|
+
await Promise.resolve()
|
|
255
|
+
.then(() => this.sessions.deleteBySessionId(session.sessionId))
|
|
256
|
+
.catch(() => {});
|
|
257
|
+
return outcome;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const nextStep = result?.nextStep ?? step + 1;
|
|
261
|
+
try {
|
|
262
|
+
session.advanceTo(nextStep, result?.stepData);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
throw Boom.badImplementation(
|
|
265
|
+
`Module ${entityType} returned an invalid next step: ${error.message}`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
await this.sessions.update(session);
|
|
269
|
+
|
|
270
|
+
const requirements = await this.getAuthorizationRequirements.execute({
|
|
271
|
+
entityType,
|
|
272
|
+
step: nextStep,
|
|
273
|
+
userId,
|
|
274
|
+
});
|
|
275
|
+
return {
|
|
276
|
+
status: 'pending',
|
|
277
|
+
step: nextStep,
|
|
278
|
+
totalSteps,
|
|
279
|
+
sessionId: session.sessionId,
|
|
280
|
+
requirements,
|
|
281
|
+
...(result?.message && { message: String(result.message) }),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async complete({ ownership, user, entityType, params, credentialId }) {
|
|
286
|
+
let callback;
|
|
287
|
+
try {
|
|
288
|
+
({ result: callback } = await this.authorizeEntityForUser.execute({
|
|
289
|
+
user,
|
|
290
|
+
entityType,
|
|
291
|
+
data: params,
|
|
292
|
+
ownership,
|
|
293
|
+
}));
|
|
294
|
+
} catch (error) {
|
|
295
|
+
throw toAuthorizationError(error);
|
|
296
|
+
}
|
|
297
|
+
const [entity, credential] = await Promise.all([
|
|
298
|
+
this.moduleRepository.findEntityById(callback.entity_id),
|
|
299
|
+
this.credentialRepository.findCredentialById(callback.credential_id),
|
|
300
|
+
]);
|
|
301
|
+
// What the new entity is to this caller (§3.4), so a client need not list again.
|
|
302
|
+
const tenancy = entity
|
|
303
|
+
? describeRecord(entity, this.scopePolicy.accessContext(user), 'entity')
|
|
304
|
+
: null;
|
|
305
|
+
const outcome = {
|
|
306
|
+
status: 'complete',
|
|
307
|
+
entity: entity && tenancy ? { ...entity, tenancy } : entity,
|
|
308
|
+
credential: credential && {
|
|
309
|
+
...credential,
|
|
310
|
+
type: entityType,
|
|
311
|
+
entityIds: entity ? [String(entity.id)] : [],
|
|
312
|
+
...(tenancy && { tenancy }),
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
if (credentialId && String(callback.credential_id) !== String(credentialId)) {
|
|
316
|
+
// The provider account differs from the one being re-authorized,
|
|
317
|
+
// so a separate credential was stored.
|
|
318
|
+
outcome.previousCredentialId = String(credentialId);
|
|
319
|
+
}
|
|
320
|
+
return outcome;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
module.exports = {
|
|
325
|
+
GetAuthorizationStep,
|
|
326
|
+
SubmitAuthorizationStep,
|
|
327
|
+
toAuthorizationError,
|
|
328
|
+
isMultiStep,
|
|
329
|
+
};
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
const { Module } = require('../module');
|
|
2
2
|
const { ModuleConstants } = require('../ModuleConstants');
|
|
3
|
+
const { pickExistingEntity } = require('../scope-resolution');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What an entity belongs to when the caller says nothing: the person's own,
|
|
7
|
+
* outside any organization.
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_TENANCY = Object.freeze({
|
|
10
|
+
scope: 'individual',
|
|
11
|
+
organizationId: null,
|
|
12
|
+
legacyOrgId: null,
|
|
13
|
+
});
|
|
3
14
|
const { getLogger } = require('../../logs');
|
|
4
15
|
|
|
5
16
|
const log = getLogger('frigg.modules.authorization_callback');
|
|
@@ -30,7 +41,17 @@ class ProcessAuthorizationCallback {
|
|
|
30
41
|
this.integrationRepository = integrationRepository;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
|
-
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} userId - the OWNER of what this creates (ADR-056 §3.2): the
|
|
46
|
+
* person for `individual`, the organization's user for `organization`.
|
|
47
|
+
* Not necessarily the caller; callers resolve it from the scope.
|
|
48
|
+
* @param {string} entityType
|
|
49
|
+
* @param {Object} params - what the module's authorization needs
|
|
50
|
+
* @param {{scope: string, organizationId: (string|null), legacyOrgId: (string|null)}} [tenancy]
|
|
51
|
+
* the entity's scope and organization (§3.6); default: the person's own,
|
|
52
|
+
* with no organization
|
|
53
|
+
*/
|
|
54
|
+
async execute(userId, entityType, params, tenancy = DEFAULT_TENANCY) {
|
|
34
55
|
log.debug('Authorization callback started', {
|
|
35
56
|
eventName: `${log.name}.started`,
|
|
36
57
|
userId,
|
|
@@ -57,10 +78,7 @@ class ProcessAuthorizationCallback {
|
|
|
57
78
|
userId,
|
|
58
79
|
entityType
|
|
59
80
|
);
|
|
60
|
-
const existingEntity =
|
|
61
|
-
existingEntities && existingEntities.length > 0
|
|
62
|
-
? existingEntities[0]
|
|
63
|
-
: null;
|
|
81
|
+
const existingEntity = pickExistingEntity(existingEntities, tenancy);
|
|
64
82
|
|
|
65
83
|
if (existingEntity) {
|
|
66
84
|
log.debug('Existing entity found', {
|
|
@@ -70,7 +88,7 @@ class ProcessAuthorizationCallback {
|
|
|
70
88
|
});
|
|
71
89
|
}
|
|
72
90
|
|
|
73
|
-
const module =
|
|
91
|
+
const module = await Module.create({
|
|
74
92
|
userId,
|
|
75
93
|
entity: existingEntity,
|
|
76
94
|
definition: moduleDefinition,
|
|
@@ -156,7 +174,8 @@ class ProcessAuthorizationCallback {
|
|
|
156
174
|
const persistedEntity = await this.findOrCreateEntity(
|
|
157
175
|
entityDetails,
|
|
158
176
|
entityType,
|
|
159
|
-
module.credential.id
|
|
177
|
+
module.credential.id,
|
|
178
|
+
tenancy
|
|
160
179
|
);
|
|
161
180
|
log.debug('Authorization callback completed', {
|
|
162
181
|
eventName: `${log.name}.completed`,
|
|
@@ -233,7 +252,12 @@ class ProcessAuthorizationCallback {
|
|
|
233
252
|
module.credential = persisted;
|
|
234
253
|
}
|
|
235
254
|
|
|
236
|
-
async findOrCreateEntity(
|
|
255
|
+
async findOrCreateEntity(
|
|
256
|
+
entityDetails,
|
|
257
|
+
moduleName,
|
|
258
|
+
credentialId,
|
|
259
|
+
tenancy = DEFAULT_TENANCY
|
|
260
|
+
) {
|
|
237
261
|
const { identifiers, details } = entityDetails;
|
|
238
262
|
|
|
239
263
|
// Support both 'user' and 'userId' field names from module definitions
|
|
@@ -247,11 +271,16 @@ class ProcessAuthorizationCallback {
|
|
|
247
271
|
);
|
|
248
272
|
}
|
|
249
273
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
274
|
+
// The same provider account in two organizations is two entities
|
|
275
|
+
// (ADR-056 §3.6), so the match is per organization.
|
|
276
|
+
const existingEntity = pickExistingEntity(
|
|
277
|
+
await this.moduleRepository.findEntities({
|
|
278
|
+
externalId: identifiers.externalId,
|
|
279
|
+
user: userId,
|
|
280
|
+
moduleName: moduleName,
|
|
281
|
+
}),
|
|
282
|
+
tenancy
|
|
283
|
+
);
|
|
255
284
|
|
|
256
285
|
if (existingEntity) {
|
|
257
286
|
// Repoint the entity's credentialId when re-auth produced a
|
|
@@ -282,12 +311,17 @@ class ProcessAuthorizationCallback {
|
|
|
282
311
|
return existingEntity;
|
|
283
312
|
}
|
|
284
313
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
314
|
+
// `organizationId` is not part of the entity data: that carries provider
|
|
315
|
+
// fields, and one that happens to be called organizationId is theirs.
|
|
316
|
+
return await this.moduleRepository.createEntity(
|
|
317
|
+
{
|
|
318
|
+
...identifiers,
|
|
319
|
+
...details,
|
|
320
|
+
moduleName: moduleName,
|
|
321
|
+
credential: credentialId,
|
|
322
|
+
},
|
|
323
|
+
{ organizationId: tenancy.organizationId ?? null }
|
|
324
|
+
);
|
|
291
325
|
}
|
|
292
326
|
}
|
|
293
327
|
|