@friggframework/core 2.0.0--canary.684.0da024b.0 → 2.0.0--canary.679.a2bcc2f.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 +0 -2
- package/README.md +0 -105
- package/application/commands/integration-commands.js +2 -2
- package/application/commands/user-commands.js +5 -17
- package/credential/use-cases/credential-operations-for-user.js +25 -110
- package/credential/use-cases/reauthorize-credential-for-user.js +8 -15
- package/database/documentdb-utils.js +1 -6
- package/generated/prisma-mongodb/edge.js +4 -15
- package/generated/prisma-mongodb/index-browser.js +1 -12
- package/generated/prisma-mongodb/index.d.ts +86 -2027
- package/generated/prisma-mongodb/index.js +4 -15
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +0 -47
- package/generated/prisma-mongodb/wasm.js +4 -15
- package/generated/prisma-postgresql/edge.js +4 -15
- package/generated/prisma-postgresql/index-browser.js +1 -12
- package/generated/prisma-postgresql/index.d.ts +63 -2162
- package/generated/prisma-postgresql/index.js +4 -15
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +0 -44
- package/generated/prisma-postgresql/wasm.js +4 -15
- package/handlers/app-definition-loader.js +1 -15
- package/index.js +0 -18
- package/integrations/integration-router.js +73 -115
- package/integrations/repositories/integration-repository-documentdb.js +3 -35
- package/integrations/repositories/integration-repository-interface.js +1 -17
- package/integrations/repositories/integration-repository-mongo.js +76 -59
- package/integrations/repositories/integration-repository-postgres.js +76 -62
- package/integrations/tests/doubles/test-integration-repository.js +1 -12
- package/integrations/use-cases/create-integration.js +20 -55
- package/integrations/use-cases/get-integrations-for-user.js +40 -80
- package/integrations/use-cases/index.js +1 -5
- package/integrations/use-cases/integration-operations-for-user.js +24 -55
- package/management-api/management-api-config.js +0 -3
- package/management-api/openapi/components.js +0 -40
- package/management-api/openapi/v2-operations.js +1 -13
- package/management-api/v2/dependencies.js +15 -40
- package/management-api/v2/dto.js +0 -20
- package/management-api/v2/handlers/authorization.js +0 -1
- package/management-api/v2/handlers/credentials.js +2 -6
- package/management-api/v2/handlers/entities.js +7 -10
- package/management-api/v2/handlers/integrations.js +3 -6
- package/management-api/v2/request.js +1 -32
- package/modules/index.js +0 -8
- package/modules/repositories/module-repository-documentdb.js +4 -48
- package/modules/repositories/module-repository-interface.js +2 -19
- package/modules/repositories/module-repository-mongo.js +73 -65
- package/modules/repositories/module-repository-postgres.js +73 -64
- package/modules/use-cases/entity-operations-for-user.js +13 -50
- package/modules/use-cases/entity-types.js +7 -31
- package/modules/use-cases/execute-entity-proxy-request.js +0 -15
- package/modules/use-cases/get-entities-for-user.js +5 -16
- package/modules/use-cases/get-entity-module-for-user.js +3 -23
- package/modules/use-cases/get-entity-options-by-id.js +3 -9
- package/modules/use-cases/get-module.js +3 -9
- package/modules/use-cases/multi-step-authorization.js +12 -39
- package/modules/use-cases/process-authorization-callback.js +18 -52
- package/modules/use-cases/refresh-entity-options.js +3 -9
- package/modules/use-cases/test-module-auth.js +3 -9
- package/package.json +5 -6
- package/prisma-mongodb/schema.prisma +0 -47
- package/prisma-postgresql/schema.prisma +0 -44
- package/types/index.d.ts +0 -1
- package/types/modules/index.d.ts +0 -87
- package/user/repositories/user-repository-documentdb.js +2 -27
- package/user/repositories/user-repository-interface.js +0 -18
- package/user/repositories/user-repository-mongo.js +0 -24
- package/user/repositories/user-repository-postgres.js +0 -18
- package/user/use-cases/authenticate-user.js +1 -7
- package/user/use-cases/get-user-from-adopter-jwt.js +124 -95
- package/user/use-cases/get-user-from-x-frigg-headers.js +80 -83
- package/user/user.js +1 -64
- package/database/organization-where.js +0 -18
- package/database/unique-violation.js +0 -25
- package/integrations/repositories/integration-not-found.js +0 -14
- package/integrations/use-cases/resolve-integration-ownership.js +0 -177
- package/modules/entity-scope.js +0 -15
- package/modules/scope-config.js +0 -192
- package/modules/scope-policy.js +0 -112
- package/modules/scope-resolution.js +0 -288
- package/modules/scope-visibility.js +0 -239
- package/modules/tests/doubles/ownership-matching.js +0 -18
- package/modules/use-cases/authorize-entity-for-user.js +0 -61
- package/modules/use-cases/create-entity-from-credential.js +0 -113
- package/modules/use-cases/manage-access.js +0 -21
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +0 -26
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +0 -25
- package/types/user/index.d.ts +0 -134
- package/user/adopter-jwt-config.js +0 -177
- package/user/adopter-jwt-errors.js +0 -59
- package/user/adopter-token-verifier.js +0 -122
- package/user/app-user-helpers.js +0 -97
- package/user/create-user-authentication.js +0 -64
- package/user/membership.js +0 -51
- package/user/repositories/organization-membership-record.js +0 -19
- package/user/repositories/organization-membership-repository-documentdb.js +0 -106
- package/user/repositories/organization-membership-repository-factory.js +0 -42
- package/user/repositories/organization-membership-repository-interface.js +0 -57
- package/user/repositories/organization-membership-repository-mongo.js +0 -13
- package/user/repositories/organization-membership-repository-postgres.js +0 -29
- package/user/repositories/organization-membership-repository-prisma.js +0 -81
- package/user/tests/doubles/adopter-jwt-fixtures.js +0 -220
- package/user/use-cases/ensure-organization-membership.js +0 -92
- package/user/use-cases/find-individual-users-by-organization.js +0 -54
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
const Boom = require('@hapi/boom');
|
|
2
|
-
const { Module } = require('../module');
|
|
3
|
-
|
|
4
|
-
const sameId = (a, b) =>
|
|
5
|
-
a !== undefined && a !== null && b !== undefined && b !== null &&
|
|
6
|
-
String(a) === String(b);
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Creates (or finds) the entity for a credential the caller already holds:
|
|
10
|
-
* the v1 `POST /api/entity`. The owner and organization follow the scope of
|
|
11
|
-
* `entityType`, exactly as an authorization does (ADR-056 §3.2, §3.6), so the
|
|
12
|
-
* owner is decided by `AuthorizeEntityForUser.resolveOwnership` and never by
|
|
13
|
-
* `user.getId()`. Whatever that resolution refuses (a missing identity, and
|
|
14
|
-
* an organization-level connection by a non-admin) is refused here too.
|
|
15
|
-
*
|
|
16
|
-
* The credential must belong to that owner: an organization entity is built
|
|
17
|
-
* on the organization's credential, an individual one on the person's own.
|
|
18
|
-
*/
|
|
19
|
-
class CreateEntityFromCredential {
|
|
20
|
-
/**
|
|
21
|
-
* @param {Object} params
|
|
22
|
-
* @param {import('./authorize-entity-for-user').AuthorizeEntityForUser} params.authorizeEntityForUser
|
|
23
|
-
* @param {import('./process-authorization-callback').ProcessAuthorizationCallback} params.processAuthorizationCallback
|
|
24
|
-
* @param {Object} params.credentialRepository
|
|
25
|
-
* @param {Array<Object>} params.moduleDefinitions
|
|
26
|
-
* @param {Function} [params.createModule] - builds a Module; injectable for tests
|
|
27
|
-
*/
|
|
28
|
-
constructor({
|
|
29
|
-
authorizeEntityForUser,
|
|
30
|
-
processAuthorizationCallback,
|
|
31
|
-
credentialRepository,
|
|
32
|
-
moduleDefinitions,
|
|
33
|
-
createModule,
|
|
34
|
-
}) {
|
|
35
|
-
this.authorizeEntityForUser = authorizeEntityForUser;
|
|
36
|
-
this.processAuthorizationCallback = processAuthorizationCallback;
|
|
37
|
-
this.credentialRepository = credentialRepository;
|
|
38
|
-
this.moduleDefinitions = moduleDefinitions || [];
|
|
39
|
-
this.createModule = createModule || ((params) => new Module(params));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* @param {Object} params
|
|
44
|
-
* @param {Object} params.user - authenticated User
|
|
45
|
-
* @param {string} params.entityType
|
|
46
|
-
* @param {string} params.credentialId
|
|
47
|
-
* @returns {Promise<Object>} the persisted entity
|
|
48
|
-
*/
|
|
49
|
-
async execute({ user, entityType, credentialId }) {
|
|
50
|
-
const definition = this.moduleDefinitions.find(
|
|
51
|
-
(def) =>
|
|
52
|
-
def.moduleName === entityType ||
|
|
53
|
-
(typeof def.getName === 'function' && def.getName() === entityType)
|
|
54
|
-
);
|
|
55
|
-
if (!definition) {
|
|
56
|
-
throw Boom.badRequest(`Unknown entity type ${entityType}`, {
|
|
57
|
-
code: 'ENTITY_TYPE_NOT_FOUND',
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const ownership = this.authorizeEntityForUser.resolveOwnership({
|
|
62
|
-
user,
|
|
63
|
-
entityType,
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
const credential =
|
|
67
|
-
await this.credentialRepository.findCredentialById(credentialId);
|
|
68
|
-
// A missing credential and someone else's are the same answer, so ids
|
|
69
|
-
// cannot be probed.
|
|
70
|
-
if (!credential || !sameId(credential.userId, ownership.ownerId)) {
|
|
71
|
-
throw Boom.badRequest('Invalid credential ID', {
|
|
72
|
-
code: 'INVALID_CREDENTIAL',
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Built for the owner, with the credential loaded (the repository
|
|
77
|
-
// returns its tokens flat; a Module reads them from `data`), so the
|
|
78
|
-
// API can call the provider and a token refresh during
|
|
79
|
-
// getEntityDetails updates the owner's credential.
|
|
80
|
-
const { id, userId, externalId, authIsValid, ...data } = credential;
|
|
81
|
-
const module = this.createModule({
|
|
82
|
-
userId: ownership.ownerId,
|
|
83
|
-
definition,
|
|
84
|
-
entity: {
|
|
85
|
-
credential: { id, userId, externalId, authIsValid, data },
|
|
86
|
-
},
|
|
87
|
-
});
|
|
88
|
-
const entityDetails =
|
|
89
|
-
await definition.requiredAuthMethods.getEntityDetails(
|
|
90
|
-
module.api,
|
|
91
|
-
null,
|
|
92
|
-
null,
|
|
93
|
-
ownership.ownerId
|
|
94
|
-
);
|
|
95
|
-
Object.assign(
|
|
96
|
-
entityDetails.details,
|
|
97
|
-
module.apiParamsFromEntity(module.api)
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
return this.processAuthorizationCallback.findOrCreateEntity(
|
|
101
|
-
entityDetails,
|
|
102
|
-
definition.moduleName,
|
|
103
|
-
credential.id,
|
|
104
|
-
{
|
|
105
|
-
scope: ownership.scope,
|
|
106
|
-
organizationId: ownership.organizationId,
|
|
107
|
-
legacyOrgId: ownership.legacyOrgId,
|
|
108
|
-
}
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
module.exports = { CreateEntityFromCredential };
|
|
@@ -1,21 +0,0 @@
|
|
|
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 };
|
package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
-- CreateTable
|
|
2
|
-
-- Organization memberships (ADR-056 §3.6). One row per person per organization.
|
|
3
|
-
-- "User"."organizationId" stays as the legacy single-org pointer. It is NOT
|
|
4
|
-
-- backfilled into this table: the domain (user/membership.js) reads it as an
|
|
5
|
-
-- implicit membership when no row exists (see docs/guides/organization-memberships.md).
|
|
6
|
-
CREATE TABLE "OrganizationMembership" (
|
|
7
|
-
"id" SERIAL NOT NULL,
|
|
8
|
-
"individualUserId" INTEGER NOT NULL,
|
|
9
|
-
"organizationUserId" INTEGER NOT NULL,
|
|
10
|
-
"orgRole" TEXT NOT NULL DEFAULT 'MEMBER',
|
|
11
|
-
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
12
|
-
|
|
13
|
-
CONSTRAINT "OrganizationMembership_pkey" PRIMARY KEY ("id")
|
|
14
|
-
);
|
|
15
|
-
|
|
16
|
-
-- CreateIndex
|
|
17
|
-
CREATE INDEX "OrganizationMembership_organizationUserId_idx" ON "OrganizationMembership"("organizationUserId");
|
|
18
|
-
|
|
19
|
-
-- CreateIndex
|
|
20
|
-
CREATE UNIQUE INDEX "OrganizationMembership_individualUserId_organizationUserId_key" ON "OrganizationMembership"("individualUserId", "organizationUserId");
|
|
21
|
-
|
|
22
|
-
-- AddForeignKey
|
|
23
|
-
ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_individualUserId_fkey" FOREIGN KEY ("individualUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
|
24
|
-
|
|
25
|
-
-- AddForeignKey
|
|
26
|
-
ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_organizationUserId_fkey" FOREIGN KEY ("organizationUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
-- Entity and Integration carry the organization they were created in (ADR-056 §3.6).
|
|
2
|
-
-- Nullable, no default and no backfill: a row written before this migration keeps
|
|
3
|
-
-- NULL, and the domain (modules/entity-scope.js) reads NULL as "predates scoping"
|
|
4
|
-
-- (docs/guides/entity-scopes.md). No scope column: the owner's user type says it.
|
|
5
|
-
-- Deliberately no foreign key: a deleted organization must leave its rows
|
|
6
|
-
-- invisible, and ON DELETE SET NULL would turn them into unscoped ones.
|
|
7
|
-
--
|
|
8
|
-
-- Release note: the two CREATE INDEX statements below are plain (not
|
|
9
|
-
-- CONCURRENTLY, which Prisma migrations cannot run inside a transaction). Each
|
|
10
|
-
-- takes a SHARE lock on its table while it builds, so writes to "Entity" and
|
|
11
|
-
-- "Integration" wait for it. Both tables are small in a typical Frigg
|
|
12
|
-
-- deployment; on a very large one, create the indexes by hand first with
|
|
13
|
-
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS under the same names, then deploy.
|
|
14
|
-
|
|
15
|
-
-- AlterTable
|
|
16
|
-
ALTER TABLE "Entity" ADD COLUMN "organizationId" INTEGER;
|
|
17
|
-
|
|
18
|
-
-- AlterTable
|
|
19
|
-
ALTER TABLE "Integration" ADD COLUMN "organizationId" INTEGER;
|
|
20
|
-
|
|
21
|
-
-- CreateIndex
|
|
22
|
-
CREATE INDEX "Entity_userId_organizationId_idx" ON "Entity"("userId", "organizationId");
|
|
23
|
-
|
|
24
|
-
-- CreateIndex
|
|
25
|
-
CREATE INDEX "Integration_userId_organizationId_idx" ON "Integration"("userId", "organizationId");
|
package/types/user/index.d.ts
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
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()`; stored on a new membership. */
|
|
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
|
-
/**
|
|
52
|
-
* With `x-frigg` headers, refuse (400) to make a person a member of an
|
|
53
|
-
* organization that already existed: anyone not already a member, a
|
|
54
|
-
* brand-new `appUserId` included, is rejected. Naming an organization the
|
|
55
|
-
* request itself creates is still allowed. Default false (a non-member is
|
|
56
|
-
* added).
|
|
57
|
-
*/
|
|
58
|
-
strictMembership?: boolean;
|
|
59
|
-
/**
|
|
60
|
-
* @deprecated Alias of `strictMembership`, with the same new meaning (it
|
|
61
|
-
* used to reject a mismatched individual/organization pair). Logs a warning
|
|
62
|
-
* at startup. `strictMembership` wins when both are set.
|
|
63
|
-
*/
|
|
64
|
-
strictUserValidation?: boolean;
|
|
65
|
-
/**
|
|
66
|
-
* Roles that may manage organization-level things (ADR-056 §3). Default
|
|
67
|
-
* `['ADMIN']`. The role is the adopter token's role claim, else the stored
|
|
68
|
-
* membership `orgRole`, else `MEMBER` (never admin). A shared-secret
|
|
69
|
-
* (`x-frigg` header) caller needs no role, nor does a Frigg bearer token
|
|
70
|
-
* unless `orgAdminGateBearerTokens`. Compared case-insensitively.
|
|
71
|
-
* Validated at load.
|
|
72
|
-
*/
|
|
73
|
-
orgAdminRoles?: string[];
|
|
74
|
-
/**
|
|
75
|
-
* Judge Frigg bearer-token callers by role like adopter-token callers.
|
|
76
|
-
* Default false: they keep their behaviour from before organization roles.
|
|
77
|
-
*/
|
|
78
|
-
orgAdminGateBearerTokens?: boolean;
|
|
79
|
-
authModes?: UserAuthModes;
|
|
80
|
-
jwtConfig?: AdopterJwtConfig;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* One person's membership of one organization (ADR-056 §3.6). `implicit` is
|
|
85
|
-
* true when it is only the legacy `IndividualUser.organizationId` pointer.
|
|
86
|
-
*/
|
|
87
|
-
export interface OrganizationMembership {
|
|
88
|
-
id: string | null;
|
|
89
|
-
individualUserId: string;
|
|
90
|
-
organizationUserId: string;
|
|
91
|
-
/** Defaults to `MEMBER`. */
|
|
92
|
-
orgRole: string;
|
|
93
|
-
createdAt?: Date;
|
|
94
|
-
implicit: boolean;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface OrganizationMembershipRepository {
|
|
98
|
-
/** The stored row or null: the legacy `organizationId` pointer is not read here. */
|
|
99
|
-
findMembership(
|
|
100
|
-
individualUserId: string,
|
|
101
|
-
organizationUserId: string
|
|
102
|
-
): Promise<OrganizationMembership | null>;
|
|
103
|
-
/** Idempotent: adds when absent, never changes an existing membership. */
|
|
104
|
-
addMembership(
|
|
105
|
-
individualUserId: string,
|
|
106
|
-
organizationUserId: string,
|
|
107
|
-
options?: { orgRole?: string | null }
|
|
108
|
-
): Promise<OrganizationMembership>;
|
|
109
|
-
/** Stored rows only, oldest first; legacy-pointer people are not here. */
|
|
110
|
-
listMembershipsForOrganization(
|
|
111
|
-
organizationUserId: string
|
|
112
|
-
): Promise<OrganizationMembership[]>;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function createOrganizationMembershipRepository(): OrganizationMembershipRepository;
|
|
116
|
-
|
|
117
|
-
export class User {
|
|
118
|
-
/** The primary user's id; under `primary: 'organization'` the ACTIVE organization. */
|
|
119
|
-
getId(): string | undefined;
|
|
120
|
-
/** The organization this request acts in (token or `x-frigg-apporgid`), or null. */
|
|
121
|
-
getActiveOrganizationId(): string | null;
|
|
122
|
-
getAppUserId(): string | null;
|
|
123
|
-
getAppOrgId(): string | null;
|
|
124
|
-
getIndividualUser(): any;
|
|
125
|
-
getOrganizationUser(): any;
|
|
126
|
-
getPrimaryUser(): any;
|
|
127
|
-
/** The organization role from the adopter token for this request, or null. */
|
|
128
|
-
getOrgRole(): string | null;
|
|
129
|
-
setOrgRole(orgRole: string | null): void;
|
|
130
|
-
/** The stored `orgRole` of the membership of the active organization, or null. Used when the token carries no role. */
|
|
131
|
-
getMembershipRole(): string | null;
|
|
132
|
-
setMembershipRole(membershipRole: string | null): void;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
@@ -1,177 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,59 +0,0 @@
|
|
|
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 };
|
|
@@ -1,122 +0,0 @@
|
|
|
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 };
|