diff --git a/.env b/.env index 4fa1a7c3..4affa2d7 100644 --- a/.env +++ b/.env @@ -1,4 +1,11 @@ IMAGE=treetracker-admin-api VERSION=latest CONTAINER_NAME=admin-api -JWT_SECRET = FORTESTFORTESTFORTESTFORTESTFORTESTFORTESTFORTESTFORTESTFORTESTF \ No newline at end of file +JWT_SECRET = FORTESTFORTESTFORTESTFORTESTFORTESTFORTESTFORTESTFORTESTFORTESTF +DATABASE_URL=get it from admin panel slack channel +KEYCLOAK_URL=get it from admin panel slack channel +KEYCLOAK_REALM=get it from admin panel slack channel +KEYCLOAK_CLIENT_ID=get it from admin panel slack channel +KEYCLOAK_ADMIN_ID=tget it from admin panel slack channel +KEYCLOAK_CLIENT_EXPECTED_CLIENT_ID=get it from admin panel slack channel +KEYCLOAK_ADMIN_CLIENT_SECRET=get it from admin panel slack channel diff --git a/README.md b/README.md index 184ab857..4ea9de66 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,204 @@ npm start ### Step 7: Start developing! +## Keycloak Setup For Organization Onboarding + +The organization onboarding flow now relies on Keycloak for: + +- authenticating `/api` requests with bearer tokens +- assigning the `org` realm role after organization creation +- exposing the created organization id in the token as `organization_id` + +This setup uses two different Keycloak clients: + +1. Frontend browser client + - `treetracker-admin-client-fe` + - used by `treetracker-admin-client` + - issues the access token the frontend sends to the API +2. Backend admin/service client + - `treetracker-admin-client-be` + - used by `treetracker-admin-api` + - uses service-account credentials to update user attributes and assign roles + +### Keycloak Role + +Create or confirm the existence of this realm role in the `treetracker` realm: + +- `org` + +This role is granted to a user after they successfully create an organization. + +### Frontend Keycloak Client + +Client name: + +- `treetracker-admin-client-fe` + +Recommended settings used by the current implementation: + +- `Client authentication`: Off +- `Standard flow`: On +- `Direct access grants`: Off +- `Implicit flow`: Off +- `Service accounts roles`: Off + +For local development, valid redirect URIs should include: + +- `http://localhost:3001/*` +- `http://localhost:3001/auth/callback` + +Web origins should include: + +- `http://localhost:3001` + +The frontend environment values currently used are: + +```env +REACT_APP_KEYCLOAK_URL=https://dev-k8s.treetracker.org/keycloak +REACT_APP_KEYCLOAK_REALM=treetracker +REACT_APP_KEYCLOAK_CLIENT_ID=treetracker-admin-client-fe +``` + +### Backend Keycloak Admin Client + +Client name: + +- `treetracker-admin-client-be` + +Recommended settings used by the current implementation: + +- `Client authentication`: On +- `Service accounts roles`: On +- `Standard flow`: Off +- `Direct access grants`: Off + +The backend uses this client to call the Keycloak Admin API. + +The backend environment values currently used are: + +```env +KEYCLOAK_URL=https://dev-k8s.treetracker.org/keycloak +KEYCLOAK_REALM=treetracker +KEYCLOAK_CLIENT_ID=treetracker-admin-client-be +KEYCLOAK_ADMIN_ID=treetracker-admin-client-be +KEYCLOAK_CLIENT_EXPECTED_CLIENT_ID=treetracker-admin-client-fe +KEYCLOAK_ADMIN_CLIENT_SECRET= +``` + +Notes: + +- `KEYCLOAK_CLIENT_EXPECTED_CLIENT_ID` is the frontend client id expected in the token `azp` claim. +- `KEYCLOAK_ADMIN_ID` is the service-account client used by the API for admin calls. +- Do not use the frontend client for backend admin role assignment. + +### Backend Service Account Permissions + +For `treetracker-admin-client-be`, assign the required `realm-management` roles to the service account so the API can: + +- read realm roles +- update users +- assign realm roles to users + +Admin Console steps: + +1. Open the `treetracker` realm in the Keycloak Admin Console. +2. Go to `Clients`. +3. Select `treetracker-admin-client-be`. +4. Confirm these settings are enabled on the client: + - `Client authentication`: On + - `Service accounts roles`: On +5. Open the `Service account roles` tab for `treetracker-admin-client-be`. +6. In the client-role selector, choose `realm-management`. +7. Add the required roles to the service account. + +At minimum, review and grant the needed permissions for: + +- `manage-users` +- `view-realm` + +If role fetch/assignment still fails with `403`, also review: + +- `query-users` +- `query-roles` + +After saving the roles, the backend service account should be able to: + +- fetch the `org` realm role +- update a user’s `organization_id` attribute +- assign the `org` realm role to the user + +### Add The `organization_id` Claim To Frontend Tokens + +The backend stores the created organization id as a Keycloak user attribute: + +- `organization_id` + +To expose that value in the frontend access token, add a mapper on the frontend client's dedicated scope. + +Client: + +- `treetracker-admin-client-fe` + +Dedicated scope: + +- `treetracker-admin-client-fe-dedicated` + +Admin Console steps: + +1. Open the `treetracker` realm in the Keycloak Admin Console. +2. Go to `Clients`. +3. Select `treetracker-admin-client-fe`. +4. Open `Dedicated scopes`. +5. Select `treetracker-admin-client-fe-dedicated`. +6. Open the `Mappers` tab. +7. Click `Add mapper`. +8. Choose `By configuration`. +9. Choose `User Attribute`. + +Create a mapper with these exact values: + +- `Mapper Type`: `User Attribute` +- `Name`: `organization_id` +- `User Attribute`: `organization_id` +- `Token Claim Name`: `organization_id` +- `Claim JSON Type`: `String` +- `Add to access token`: On +- `Add to ID token`: Optional +- `Multivalued`: Off + +Notes: + +- The user attribute name must match exactly: `organization_id` +- The token claim name must match exactly: `organization_id` +- The frontend currently reads the claim from the refreshed access token, so `Add to access token` must be enabled + +``` + +To verify the mapper is working: + +1. Confirm the Keycloak user has the `organization_id` attribute set in the user profile. +2. Log out of the frontend application. +3. Log back in so a new token is issued. +4. Decode the refreshed access token and confirm it contains: + - `realm_access.roles` including `org` + - `organization_id` + +### Backend Organization Creation Flow + +When a user creates an organization through the API, the backend now does the following: + +1. creates the organization row in the database +2. sets the Keycloak user attribute: + - `organization_id` +3. assigns the `org` realm role to the user + + +### Troubleshooting + +- If the frontend token contains `org` but not `organization_id`, verify the frontend client mapper. +- If organization creation succeeds but claim/role assignment fails with `403`, verify the backend service-account permissions in Keycloak. +- The API currently verifies Keycloak bearer tokens using `jose`, not `keycloak-connect`. + ## Commit Message and PR Title Format We use automatic semantic versioning, which looks at commit messages to determine how to increment the version number for deployment. @@ -85,7 +283,9 @@ We use automatic semantic versioning, which looks at commit messages to determin Your commit messages will need to follow the [Conventional Commits](https://www.conventionalcommits.org/) format, for example: ``` + feat: add new button + ``` Since we squash commits on merging PRs into `master`, this applies to PR titles as well. @@ -95,16 +295,20 @@ Since we squash commits on merging PRs into `master`, this applies to PR titles Your forked repo won't automatically stay in sync with Greenstand, so you'll need to occassionally sync manually (typically before starting work on a new feature). ``` + git pull upstream master --rebase git push origin master + ``` You might also need to sync and merge `master` into your feature branch before submitting a PR to resolve any conflicts. ``` + git checkout git merge master -``` + +```` ## Code style guide @@ -126,7 +330,7 @@ You can also manually run `npm run prettier`. Configuration files are already in ```js const foo = 'bar'; -``` +```` **Braces** Opening braces go on the same line as the statement diff --git a/jest.config.js b/jest.config.js index 51b558b3..695d069c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,7 +6,7 @@ module.exports = { transformIgnorePatterns: ['/node_modules/'], globals: { 'ts-jest': { - tsConfig: { + tsconfig: { allowJs: true, }, }, diff --git a/package-lock.json b/package-lock.json index 45d4d99c..03e4eb3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,9 +24,11 @@ "expect-runtime": "^0.7.0", "express-list-endpoints": "^5.0.0", "generate-password": "^1.5.1", + "jose": "^6.2.2", "jsonwebtoken": "^9.0.0", "loglevel": "^1.6.8", "loopback-connector-postgresql": "^5.5.1", + "node-fetch": "^3.3.2", "pg": "^8.7.1", "rascal": "^12.0.1" }, @@ -6891,6 +6893,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/call-me-maybe": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", @@ -7464,6 +7479,14 @@ "node": ">=8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-urls": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", @@ -7759,6 +7782,20 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -7835,6 +7872,36 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -7869,15 +7936,15 @@ } }, "node_modules/escodegen": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", - "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1" + "esutils": "^2.0.2" }, "bin": { "escodegen": "bin/escodegen.js", @@ -8883,6 +8950,28 @@ "bser": "2.1.1" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -9147,6 +9236,17 @@ "node": ">= 0.12" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", @@ -9242,9 +9342,13 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/functional-red-black-tree": { "version": "1.0.1", @@ -9284,13 +9388,24 @@ } }, "node_modules/get-intrinsic": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", - "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9305,6 +9420,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stdin": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz", @@ -9422,6 +9550,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", @@ -9472,9 +9612,10 @@ } }, "node_modules/has-symbols": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", - "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -9582,6 +9723,18 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -13403,6 +13556,14 @@ "node": ">=6" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -13706,19 +13867,6 @@ "node": ">=6" } }, - "node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/lines-and-columns": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", @@ -14139,6 +14287,15 @@ "node": ">=0.10.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -14811,6 +14968,42 @@ "tslib": "^2.0.3" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-fetch-h2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz", @@ -15531,23 +15724,6 @@ "opencollective-postinstall": "index.js" } }, - "node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/os-locale": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-5.0.0.tgz", @@ -16130,15 +16306,6 @@ "node": ">=0.10.0" } }, - "node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/prettier": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz", @@ -18916,18 +19083,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" }, - "node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -19281,6 +19436,14 @@ "makeerror": "1.0.x" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", @@ -23682,8 +23845,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz", "integrity": "sha512-BgZuLUSeKzvlL/VUjx/Yb787VQ26RU3gGjA3iiFvdsp/2bMfVIWUVP7tjxtjS0e+HP409cPlPvNkQloz8C91ew==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-eslint-plugin": { "version": "3.6.1", @@ -23898,8 +24060,7 @@ "ajv-errors": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", - "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", - "requires": {} + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==" }, "ajv-keywords": { "version": "5.0.0", @@ -24751,8 +24912,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} + "dev": true }, "acorn-walk": { "version": "7.2.0", @@ -25447,6 +25607,15 @@ "get-intrinsic": "^1.0.2" } }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, "call-me-maybe": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", @@ -25911,6 +26080,11 @@ "integrity": "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==", "dev": true }, + "data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" + }, "data-urls": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", @@ -26131,6 +26305,16 @@ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, "ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -26195,6 +26379,24 @@ "is-arrayish": "^0.2.1" } }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "requires": { + "es-errors": "^1.3.0" + } + }, "es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -26223,15 +26425,14 @@ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "escodegen": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", - "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", "dev": true, "requires": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2", - "optionator": "^0.8.1", "source-map": "~0.6.1" }, "dependencies": { @@ -26515,8 +26716,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/eslint-plugin-eslint-plugin/-/eslint-plugin-eslint-plugin-2.1.0.tgz", "integrity": "sha512-kT3A/ZJftt28gbl/Cv04qezb/NQ1dwYIbi8lyf806XMxkus7DvOVCLIfTXMrorp322Pnoez7+zabXH29tADIDg==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-mocha": { "version": "5.3.0", @@ -26993,6 +27193,15 @@ "bser": "2.1.1" } }, + "fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "requires": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + } + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -27197,6 +27406,14 @@ "mime-types": "^2.1.12" } }, + "formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "requires": { + "fetch-blob": "^3.1.2" + } + }, "formidable": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", @@ -27256,9 +27473,9 @@ "optional": true }, "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, "functional-red-black-tree": { "version": "1.0.1", @@ -27289,13 +27506,20 @@ "dev": true }, "get-intrinsic": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", - "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" } }, "get-package-type": { @@ -27304,6 +27528,15 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, "get-stdin": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz", @@ -27388,6 +27621,11 @@ "slash": "^3.0.0" } }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, "graceful-fs": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", @@ -27426,9 +27664,9 @@ "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, "has-symbols": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", - "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" }, "has-value": { "version": "1.0.0", @@ -27506,6 +27744,14 @@ } } }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "requires": { + "function-bind": "^1.1.2" + } + }, "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -29463,8 +29709,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", - "dev": true, - "requires": {} + "dev": true }, "jest-regex-util": { "version": "26.0.0", @@ -30375,6 +30620,11 @@ } } }, + "jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -30613,16 +30863,6 @@ } } }, - "levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - } - }, "lines-and-columns": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", @@ -30947,6 +31187,11 @@ "object-visit": "^1.0.0" } }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, "md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -31466,6 +31711,21 @@ "tslib": "^2.0.3" } }, + "node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==" + }, + "node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "requires": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + } + }, "node-fetch-h2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz", @@ -32034,20 +32294,6 @@ "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", "dev": true }, - "optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "requires": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - } - }, "os-locale": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-5.0.0.tgz", @@ -32345,8 +32591,7 @@ "pg-pool": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.4.1.tgz", - "integrity": "sha512-TVHxR/gf3MeJRvchgNHxsYsTCHQ+4wm3VIHSS19z8NC0+gioEhq1okDY1sm/TYbfoP6JLFx01s0ShvZ3puP/iQ==", - "requires": {} + "integrity": "sha512-TVHxR/gf3MeJRvchgNHxsYsTCHQ+4wm3VIHSS19z8NC0+gioEhq1okDY1sm/TYbfoP6JLFx01s0ShvZ3puP/iQ==" }, "pg-protocol": { "version": "1.5.0", @@ -32470,12 +32715,6 @@ "xtend": "^4.0.0" } }, - "prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "dev": true - }, "prettier": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz", @@ -34682,15 +34921,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" }, - "type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2" - } - }, "type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -34975,6 +35205,11 @@ "makeerror": "1.0.x" } }, + "web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" + }, "webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", @@ -35163,8 +35398,7 @@ "version": "7.5.5", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==", - "dev": true, - "requires": {} + "dev": true }, "xml-name-validator": { "version": "3.0.0", diff --git a/package.json b/package.json index c9ddc2df..3fbf0263 100644 --- a/package.json +++ b/package.json @@ -65,9 +65,11 @@ "expect-runtime": "^0.7.0", "express-list-endpoints": "^5.0.0", "generate-password": "^1.5.1", + "jose": "^6.2.2", "jsonwebtoken": "^9.0.0", "loglevel": "^1.6.8", "loopback-connector-postgresql": "^5.5.1", + "node-fetch": "^3.3.2", "pg": "^8.7.1", "rascal": "^12.0.1" }, diff --git a/src/__tests__/integration/integration.ts b/src/__tests__/integration/integration.ts index aec52803..12e66150 100644 --- a/src/__tests__/integration/integration.ts +++ b/src/__tests__/integration/integration.ts @@ -1,6 +1,11 @@ /* * To test organizational user, like login, permission, and so on */ +jest.mock('jose', () => ({ + createRemoteJWKSet: jest.fn(() => jest.fn()), + jwtVerify: jest.fn(), +})); + import { ExpressServer } from '../../server'; import request from 'supertest'; diff --git a/src/application.ts b/src/application.ts index 8e6d77a0..b3efa3d5 100644 --- a/src/application.ts +++ b/src/application.ts @@ -1,7 +1,7 @@ import { BootMixin } from '@loopback/boot'; import { ApplicationConfig } from '@loopback/core'; import { RepositoryMixin } from '@loopback/repository'; -import { RestApplication } from '@loopback/rest'; +import { RestApplication, RestBindings } from '@loopback/rest'; import { RestExplorerComponent } from '@loopback/rest-explorer'; import { ServiceMixin } from '@loopback/service-proxy'; import * as path from 'path'; @@ -18,6 +18,15 @@ export class TreetrackerAdminApiApplication extends BootMixin( // Set up the custom sequence this.sequence(MySequence); + // use for showing custom erros + this.bind(RestBindings.REQUEST_BODY_PARSER_OPTIONS).to({ + validation: { + ajvErrors: {}, + ajvErrorTransformer: (errors) => + errors.map(({ params, ...error }) => error) as typeof errors, + }, + }); + // Set up default home page this.static('/', path.join(__dirname, '../public')); diff --git a/src/controllers/organization.controller.test.ts b/src/controllers/organization.controller.test.ts new file mode 100644 index 00000000..69f82d80 --- /dev/null +++ b/src/controllers/organization.controller.test.ts @@ -0,0 +1,409 @@ +import { validateValueAgainstSchema } from '@loopback/rest'; + +import { ORGANIZATION_REQUEST_SCHEMA } from '../dto/organization-dto'; +import { OrganizationController } from './organization.controller'; +import { ErrorCode } from '../types/error-codes'; +import { Role } from '../types/roles'; +import { + assignOrganizationRole, + setOrganizationClaim, +} from '../services/keycloakAdminService'; + +jest.mock('../services/keycloakAdminService', () => ({ + assignOrganizationRole: jest.fn(), + setOrganizationClaim: jest.fn(), +})); + +describe('OrganizationController', () => { + const originalEnv = process.env; + const validateOrganization = (value: object) => + validateValueAgainstSchema( + value, + ORGANIZATION_REQUEST_SCHEMA, + {}, + { source: 'body', ajvErrors: {} }, + ); + + beforeEach(() => { + process.env = { ...originalEnv }; + jest.clearAllMocks(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('delegates organization creation to the repository', async () => { + const createOrganization = jest.fn().mockResolvedValue({ + id: 178, + type: 'o', + name: 'FCC', + email: 'fcc@example.com', + phone: '+232 123 4567', + pwdResetRequired: false, + website: 'https://fcc.example.com', + logoUrl: 'https://fcc.example.com/logo.png', + mapName: 'freetown', + }); + const transaction = { + commit: jest.fn().mockResolvedValue(undefined), + rollback: jest.fn().mockResolvedValue(undefined), + }; + const controller = new OrganizationController({ + createOrganization, + dataSource: { + beginTransaction: jest.fn().mockResolvedValue(transaction), + }, + } as never); + + const result = await controller.create({ + name: 'FCC', + email: 'fcc@example.com', + phone: '+232 123 4567', + website: 'https://fcc.example.com', + logoUrl: 'https://fcc.example.com/logo.png', + mapName: 'freetown', + }); + + expect(createOrganization).toHaveBeenCalledWith( + { + name: 'FCC', + email: 'fcc@example.com', + phone: '+232 123 4567', + website: 'https://fcc.example.com', + logoUrl: 'https://fcc.example.com/logo.png', + mapName: 'freetown', + }, + { + transaction, + }, + ); + expect(result).toMatchObject({ + id: 178, + type: 'o', + name: 'FCC', + email: 'fcc@example.com', + phone: '+232 123 4567', + pwdResetRequired: false, + website: 'https://fcc.example.com', + logoUrl: 'https://fcc.example.com/logo.png', + mapName: 'freetown', + }); + expect(transaction.commit).toHaveBeenCalledTimes(1); + expect(transaction.rollback).not.toHaveBeenCalled(); + }); + + it('rejects users who already have the organization role', async () => { + const createOrganization = jest.fn(); + const controller = new OrganizationController( + { + createOrganization, + dataSource: { + beginTransaction: jest.fn(), + }, + } as never, + { + user: { + id: 'user-1', + policy: { + policies: [{ name: Role.ORGANIZATION }], + }, + }, + } as never, + ); + + await expect( + controller.create({ + name: 'FCC', + email: 'fcc@example.com', + }), + ).rejects.toMatchObject({ + code: ErrorCode.ORGANIZATION_ROLE_ALREADY_ASSIGNED, + }); + + expect(createOrganization).not.toHaveBeenCalled(); + }); + + it('assigns organization role and commits when keycloak env is configured', async () => { + process.env.KEYCLOAK_URL = 'https://dev-k8s.treetracker.org/keycloak'; + process.env.KEYCLOAK_ADMIN_ID = 'treetracker-admin-client-be'; + process.env.KEYCLOAK_ADMIN_CLIENT_SECRET = 'secret'; + + const createOrganization = jest.fn().mockResolvedValue({ + id: 178, + name: 'FCC', + }); + const transaction = { + commit: jest.fn().mockResolvedValue(undefined), + rollback: jest.fn().mockResolvedValue(undefined), + }; + + (assignOrganizationRole as jest.Mock).mockResolvedValue(undefined); + (setOrganizationClaim as jest.Mock).mockResolvedValue(undefined); + + const controller = new OrganizationController( + { + createOrganization, + dataSource: { + beginTransaction: jest.fn().mockResolvedValue(transaction), + }, + } as never, + { + user: { + id: 'user-123', + policy: { + policies: [], + }, + }, + } as never, + ); + + await controller.create({ + name: 'FCC', + email: 'fcc@example.com', + }); + + expect(setOrganizationClaim).toHaveBeenCalledWith('user-123', 178); + expect(assignOrganizationRole).toHaveBeenCalledWith('user-123'); + expect(transaction.commit).toHaveBeenCalledTimes(1); + expect(transaction.rollback).not.toHaveBeenCalled(); + }); + + it('rolls back and returns error code when claim update fails', async () => { + process.env.KEYCLOAK_URL = 'https://dev-k8s.treetracker.org/keycloak'; + process.env.KEYCLOAK_ADMIN_ID = 'treetracker-admin-client-be'; + process.env.KEYCLOAK_ADMIN_CLIENT_SECRET = 'secret'; + + const createOrganization = jest.fn().mockResolvedValue({ + id: 178, + name: 'FCC', + }); + const transaction = { + commit: jest.fn().mockResolvedValue(undefined), + rollback: jest.fn().mockResolvedValue(undefined), + }; + + (setOrganizationClaim as jest.Mock).mockRejectedValue( + new Error('claim failure'), + ); + + const controller = new OrganizationController( + { + createOrganization, + dataSource: { + beginTransaction: jest.fn().mockResolvedValue(transaction), + }, + } as never, + { + user: { + id: 'user-123', + policy: { + policies: [], + }, + }, + } as never, + ); + + await expect( + controller.create({ + name: 'FCC', + email: 'fcc@example.com', + }), + ).rejects.toMatchObject({ + code: ErrorCode.ORGANIZATION_CLAIM_UPDATE_FAILED, + }); + + expect(assignOrganizationRole).not.toHaveBeenCalled(); + expect(transaction.rollback).toHaveBeenCalledTimes(1); + expect(transaction.commit).not.toHaveBeenCalled(); + }); + + it('rolls back and returns error code when role assignment fails', async () => { + process.env.KEYCLOAK_URL = 'https://dev-k8s.treetracker.org/keycloak'; + process.env.KEYCLOAK_ADMIN_ID = 'treetracker-admin-client-be'; + process.env.KEYCLOAK_ADMIN_CLIENT_SECRET = 'secret'; + + const createOrganization = jest.fn().mockResolvedValue({ + id: 178, + name: 'FCC', + }); + const transaction = { + commit: jest.fn().mockResolvedValue(undefined), + rollback: jest.fn().mockResolvedValue(undefined), + }; + + (assignOrganizationRole as jest.Mock).mockRejectedValue( + new Error('role failure'), + ); + (setOrganizationClaim as jest.Mock).mockResolvedValue(undefined); + + const controller = new OrganizationController( + { + createOrganization, + dataSource: { + beginTransaction: jest.fn().mockResolvedValue(transaction), + }, + } as never, + { + user: { + id: 'user-123', + policy: { + policies: [], + }, + }, + } as never, + ); + + await expect( + controller.create({ + name: 'FCC', + email: 'fcc@example.com', + }), + ).rejects.toMatchObject({ + code: ErrorCode.ORGANIZATION_ROLE_ASSIGNMENT_FAILED, + }); + + expect(transaction.rollback).toHaveBeenCalledTimes(1); + expect(transaction.commit).not.toHaveBeenCalled(); + }); + + describe('request schema', () => { + it('accepts valid organization payloads', async () => { + await expect( + validateOrganization({ + name: 'FCC', + email: 'fcc@example.com', + phone: '+232 123 4567', + website: 'https://fcc.example.com', + logoUrl: '', + mapName: 'freetown', + }), + ).resolves.toMatchObject({ + name: 'FCC', + phone: '+232 123 4567', + website: 'https://fcc.example.com', + }); + }); + + it('accepts blank optional fields', async () => { + await expect( + validateOrganization({ + name: 'FCC', + email: 'fcc@example.com', + phone: '', + website: '', + logoUrl: '', + mapName: '', + }), + ).resolves.toMatchObject({ + name: 'FCC', + phone: '', + website: '', + logoUrl: '', + mapName: '', + }); + }); + + it('rejects missing required fields', async () => { + await expect( + validateOrganization({ + phone: '+232 123 4567', + }), + ).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + details: expect.arrayContaining([ + expect.objectContaining({ message: 'Name is required' }), + expect.objectContaining({ message: 'Email is required' }), + ]), + }); + }); + + it('rejects firstName and lastName fields', async () => { + await expect( + validateOrganization({ + name: 'FCC', + email: 'fcc@example.com', + firstName: 'checking', + lastName: 'person', + }), + ).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + details: expect.arrayContaining([ + expect.objectContaining({ + message: 'Only supported organization fields are allowed', + }), + ]), + }); + }); + + it('rejects unsupported fields', async () => { + await expect( + validateOrganization({ + name: 'FCC', + email: 'fcc@example.com', + extra: 'nope', + }), + ).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + details: expect.arrayContaining([ + expect.objectContaining({ + message: 'Only supported organization fields are allowed', + }), + ]), + }); + }); + + it('rejects invalid website values', async () => { + await expect( + validateOrganization({ + name: 'FCC', + email: 'fcc@example.com', + website: 'not-a-url', + }), + ).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + details: expect.arrayContaining([ + expect.objectContaining({ + message: 'Website must be empty or a valid URL', + }), + ]), + }); + }); + + it('rejects phone numbers with fewer than 10 digits with one clear error', async () => { + await expect( + validateOrganization({ + name: 'FCC', + email: 'fcc@example.com', + phone: '0311350', + }), + ).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + details: [ + expect.objectContaining({ + path: '/phone', + code: 'errorMessage', + message: 'Phone must be empty or a valid phone number', + }), + ], + }); + }); + + it('rejects phone numbers with invalid characters', async () => { + await expect( + validateOrganization({ + name: 'FCC', + email: 'fcc@example.com', + phone: '12345abcde', + }), + ).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + details: expect.arrayContaining([ + expect.objectContaining({ + message: 'Phone must be empty or a valid phone number', + }), + ]), + }); + }); + }); +}); diff --git a/src/controllers/organization.controller.ts b/src/controllers/organization.controller.ts index f9fa82a4..07422964 100644 --- a/src/controllers/organization.controller.ts +++ b/src/controllers/organization.controller.ts @@ -8,21 +8,41 @@ import { import { param, get, + post, + HttpErrors, + requestBody, getFilterSchemaFor, getWhereSchemaFor, + RestBindings, } from '@loopback/rest'; +import { inject } from '@loopback/context'; import { Organization } from '../models'; -import { OrganizationRepository } from '../repositories'; +import { + CreateOrganizationData, + OrganizationRepository, +} from '../repositories'; +import { ORGANIZATION_REQUEST_SCHEMA } from '../dto/organization-dto'; +import { + assignOrganizationRole, + setOrganizationClaim, +} from '../services/keycloakAdminService'; +import { KeycloakRequest } from '../middleware/keycloakMiddleware'; +import { ErrorCode } from '../types/error-codes'; +import { Role } from '../types/roles'; +import { Transaction } from 'loopback-connector'; // Extend the LoopBack filter types for the Planter model to include type type OrganizationWhere = (Where & { type?: string }) | undefined; export type OrganizationFilter = Filter & { where: OrganizationWhere; }; + export class OrganizationController { constructor( @repository(OrganizationRepository) public organizationRepository: OrganizationRepository, + @inject(RestBindings.Http.REQUEST, { optional: true }) + private request?: KeycloakRequest, ) {} @get('/organizations/count', { @@ -59,6 +79,102 @@ export class OrganizationController { return await this.organizationRepository.find(filter); } + @post('/organizations', { + responses: { + '200': { + description: 'Organization POST success', + content: { + 'application/json': { + schema: { type: 'object', additionalProperties: true }, + }, + }, + }, + }, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: ORGANIZATION_REQUEST_SCHEMA, + }, + }, + }) + organization: CreateOrganizationData, + ): Promise { + if (this.requestHasRole(Role.ORGANIZATION)) { + const error = new HttpErrors.Forbidden( + 'User already belongs to an organization', + ) as HttpErrors.HttpError & { code?: string }; + error.code = ErrorCode.ORGANIZATION_ROLE_ALREADY_ASSIGNED; + throw error; + } + + const tx = await this.organizationRepository.dataSource.beginTransaction({ + isolationLevel: Transaction.READ_COMMITTED, + }); + + try { + const created = await this.organizationRepository.createOrganization( + organization, + { transaction: tx }, + ); + + if ( + process.env.KEYCLOAK_URL && + process.env.KEYCLOAK_ADMIN_ID && + process.env.KEYCLOAK_ADMIN_CLIENT_SECRET + ) { + const userId = this.request?.user?.id; + const organizationId = Number(created.id); + + if (userId) { + try { + await setOrganizationClaim(userId, organizationId); + } catch (error) { + console.error( + 'Failed to update organization claim in Keycloak:', + error, + ); + + const claimError = new HttpErrors.InternalServerError( + 'Organization claim update failed', + ) as HttpErrors.HttpError & { code?: string }; + claimError.code = ErrorCode.ORGANIZATION_CLAIM_UPDATE_FAILED; + throw claimError; + } + + try { + await assignOrganizationRole(userId); + } catch (error) { + console.error( + 'Failed to assign organization role in Keycloak:', + error, + ); + + const roleError = new HttpErrors.InternalServerError( + 'Organization role assignment failed', + ) as HttpErrors.HttpError & { code?: string }; + roleError.code = ErrorCode.ORGANIZATION_ROLE_ASSIGNMENT_FAILED; + throw roleError; + } + } + } + + await tx.commit(); + return created; + } catch (error) { + await tx.rollback(); + throw error; + } + } + + // inpired from fe + private requestHasRole(roleName: string): boolean { + const policies = this.request?.user?.policy?.policies ?? []; + + return policies.some((policy) => policy.name === roleName); + } + @get('/organization/{organizationId}/organizations', { responses: { '200': { diff --git a/src/controllers/trees.controller.ts b/src/controllers/trees.controller.ts index 888bef75..3aa803f9 100644 --- a/src/controllers/trees.controller.ts +++ b/src/controllers/trees.controller.ts @@ -22,6 +22,8 @@ import { publishMessage } from '../messaging/RabbitMQMessaging.js'; import { config } from '../config.js'; import { v4 as uuid } from 'uuid'; import { Transaction } from 'loopback-connector'; +import { Role } from '../types/roles'; +import { requireRole } from '../interceptors/requireRole.interceptor'; // Extend the LoopBack filter types for the Trees model to include tagId // This is a workaround for the lack of proper join support in LoopBack @@ -159,6 +161,7 @@ export class TreesController { }, }, }) + @requireRole(Role.ORGANIZATION) async updateById( @param.path.number('id') id: number, @requestBody() trees: Trees, diff --git a/src/controllers/treesOrganization.controller.ts b/src/controllers/treesOrganization.controller.ts index 5a0575fd..e3db5441 100644 --- a/src/controllers/treesOrganization.controller.ts +++ b/src/controllers/treesOrganization.controller.ts @@ -19,6 +19,8 @@ import { } from '@loopback/rest'; import { Trees } from '../models'; import { TreesRepository } from '../repositories'; +import { requireRole } from '../interceptors/requireRole.interceptor'; +import { Role } from '../types/roles'; // Extend the LoopBack filter types for the Trees model to include tagId // This is a workaround for the lack of proper join support in LoopBack @@ -189,6 +191,7 @@ export class TreesOrganizationController { }, }, }) + @requireRole(Role.ORGANIZATION) async updateById( @param.path.number('organizationId') organizationId: number, @param.path.number('id') id: number, diff --git a/src/datasources/config.ts b/src/datasources/config.ts index 2a0424b1..1a191a8b 100644 --- a/src/datasources/config.ts +++ b/src/datasources/config.ts @@ -1,15 +1,19 @@ -import pg from 'pg'; -pg.defaults.ssl = { rejectUnauthorized: false }; export interface DatasourceConfig { name: string; connector: string; url: string; + ssl: { + rejectUnauthorized: boolean; + }; } const config: DatasourceConfig = { name: 'treetracker_dev', connector: 'postgresql', url: process.env.DATABASE_URL || '', + ssl: { + rejectUnauthorized: false, + }, }; if (!config.url) { diff --git a/src/dto/organization-dto.ts b/src/dto/organization-dto.ts new file mode 100644 index 00000000..207e5b85 --- /dev/null +++ b/src/dto/organization-dto.ts @@ -0,0 +1,72 @@ +import { SchemaObject } from '@loopback/openapi-v3'; + +const PHONE_REGEX = /^(?:$|(?=(?:.*\d){10,})[+()\-.\s\d]{10,20})$/; + +function optionalStringProperty(fieldLabel: string): SchemaObject { + return { + type: 'string', + errorMessage: { + type: `${fieldLabel} must be a string`, + }, + } as SchemaObject; +} + +function optionalUrlProperty(fieldLabel: string): SchemaObject { + return { + anyOf: [ + { type: 'string', maxLength: 0 }, + { type: 'string', format: 'uri' }, + ], + errorMessage: { + anyOf: `${fieldLabel} must be empty or a valid URL`, + }, + } as SchemaObject; +} + +function optionalPhoneProperty(fieldLabel: string): SchemaObject { + return { + type: 'string', + pattern: PHONE_REGEX.source, + errorMessage: { + type: `${fieldLabel} must be a string`, + pattern: `${fieldLabel} must be empty or a valid phone number`, + }, + } as SchemaObject; +} + +export const ORGANIZATION_REQUEST_SCHEMA: SchemaObject = { + type: 'object', + required: ['name', 'email'], + additionalProperties: false, + properties: { + name: { + type: 'string', + minLength: 1, + errorMessage: { + type: 'Name must be a string', + minLength: 'Name is required', + }, + } as SchemaObject, + email: { + type: 'string', + minLength: 1, + format: 'email', + errorMessage: { + type: 'Email must be a string', + minLength: 'Email is required', + format: 'Email must be a valid email address', + }, + } as SchemaObject, + phone: optionalPhoneProperty('Phone'), + website: optionalUrlProperty('Website'), + logoUrl: optionalUrlProperty('Logo URL'), + mapName: optionalStringProperty('Map name'), + }, + errorMessage: { + required: { + name: 'Name is required', + email: 'Email is required', + }, + additionalProperties: 'Only supported organization fields are allowed', + }, +}; diff --git a/src/interceptors/requireRole.interceptor.ts b/src/interceptors/requireRole.interceptor.ts new file mode 100644 index 00000000..af0092dd --- /dev/null +++ b/src/interceptors/requireRole.interceptor.ts @@ -0,0 +1,31 @@ +import { intercept, InvocationContext, Next } from '@loopback/context'; +import { HttpErrors, RestBindings } from '@loopback/rest'; +import { KeycloakRequest, PolicyEntry } from '../middleware/keycloakMiddleware'; + +function hasRequiredRole( + request: KeycloakRequest | undefined, + roles: string[], +): boolean { + const policies: PolicyEntry[] = request?.user?.policy?.policies ?? []; + + return roles.some((role) => policies.some((policy) => policy.name === role)); +} + +export function requireRole(...roles: string[]): ReturnType { + return intercept(async (invocationCtx: InvocationContext, next: Next) => { + const request = await invocationCtx.get( + RestBindings.Http.REQUEST, + { optional: true }, + ); + + if (!request?.user) { + throw new HttpErrors.Unauthorized('Missing authenticated user'); + } + + if (!hasRequiredRole(request, roles)) { + throw new HttpErrors.Forbidden('Insufficient permissions'); + } + + return next(); + }); +} diff --git a/src/js/Audit.js b/src/js/Audit.js index ef47ef69..8fabced1 100644 --- a/src/js/Audit.js +++ b/src/js/Audit.js @@ -7,10 +7,21 @@ import { Pool } from 'pg'; // import log from 'loglevel'; // import {strict as assert} from 'assert'; import getDatasource from '../datasources/config'; -import jwt from 'jsonwebtoken'; -import { config } from '../config'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +// Lazily initialized — only created when KEYCLOAK_URL is set +let JWKS; +function getJWKS() { + if (!JWKS) { + JWKS = createRemoteJWKSet( + new URL( + `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/certs`, + ), + ); + } + return JWKS; +} -const jwtSecret = config.jwtSecret; const operations = { login: { type: 'login', @@ -31,8 +42,15 @@ export const auditMiddleware = (request, response, next) => { //assert(response.statusCode); const token = request.headers.authorization || ''; if (token) { - const user = jwt.verify(token, jwtSecret); - request.user = user; + const rawToken = token.startsWith('Bearer ') ? token.slice(7) : token; + const issuer = `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`; + const { payload } = await jwtVerify(rawToken, getJWKS(), { + issuer, + }); + request.user = { + id: payload.sub, + userName: payload.preferred_username, + }; } if (/2\d\d/.test(response.statusCode)) { diff --git a/src/js/Audit.test.js b/src/js/Audit.test.js index 6e547982..582d1bf2 100644 --- a/src/js/Audit.test.js +++ b/src/js/Audit.test.js @@ -3,6 +3,11 @@ import request from 'supertest'; import express from 'express'; +jest.mock('jose', () => ({ + createRemoteJWKSet: jest.fn(() => jest.fn()), + jwtVerify: jest.fn(), +})); + import { Pool } from 'pg'; jest.mock('pg'); diff --git a/src/js/auth.js b/src/js/auth.js index 7542ae05..35cf738e 100644 --- a/src/js/auth.js +++ b/src/js/auth.js @@ -484,10 +484,16 @@ const isAuth = async (req, res, next) => { } try { - const token = req.headers.authorization; - const decodedToken = jwt.verify(token, jwtSecret); - const userSession = decodedToken; - req.user = userSession; + let userSession; + if (req.user) { + // Already authenticated by Keycloak middleware — skip JWT verification + userSession = req.user; + } else { + const token = req.headers.authorization; + const decodedToken = jwt.verify(token, jwtSecret); + userSession = decodedToken; + req.user = userSession; + } // console.log('userSession', userSession); console.log('url', url); @@ -663,7 +669,7 @@ const isAuth = async (req, res, next) => { }); //res.status(200).json([user]); } catch (e) { - console.warn(e); + console.warn('error verify', e); res.status(401).json({ error: new Error('Invalid request!'), }); diff --git a/src/middleware/keycloakMiddleware.ts b/src/middleware/keycloakMiddleware.ts new file mode 100644 index 00000000..c096cfe1 --- /dev/null +++ b/src/middleware/keycloakMiddleware.ts @@ -0,0 +1,129 @@ +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { Request, Response, NextFunction } from 'express'; + +export interface PolicyEntry { + name: string; +} + +export interface KeycloakOrganization { + id: number; +} + +export interface NormalizedUser { + id: string; + userName: string; + email: string; + policy: { + policies: PolicyEntry[]; + organization: KeycloakOrganization | undefined; + }; +} + +export interface KeycloakRequest extends Request { + user?: NormalizedUser; +} + +interface KeycloakTokenPayload { + sub: string; + preferred_username?: string; + email?: string; + azp?: string; + realm_access?: { roles?: string[] }; + resource_access?: Record; + organization_id?: string; +} + +function getOrganizationId(payload: KeycloakTokenPayload): number | undefined { + if (payload.organization_id === undefined) { + return undefined; + } + + const organizationId = Number(payload.organization_id); + + return Number.isNaN(organizationId) ? undefined : organizationId; +} + +// Lazily initialized — createRemoteJWKSet is only called on the first request, +// so missing env vars at import time do not throw. +let JWKS: ReturnType | undefined; + +function getJWKS(): ReturnType { + if (!JWKS) { + JWKS = createRemoteJWKSet( + new URL( + `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/certs`, + ), + ); + } + return JWKS; +} + +/** + * Express middleware that validates a Keycloak bearer token using jose. + * + * On success, sets req.user with the shape the rest of the app expects: + * req.user.policy.policies – [{ name: string }, ...] (realm + client roles) + * req.user.policy.organization – { id: number, name: string } | undefined + * + * Keycloak role names must match the app's policy names + * (super_permission, list_tree, approve_tree, list_planter, etc.) + * so that the existing permission checks in auth.js continue to work. + * + * Returns 401 for missing, invalid, or expired tokens. + */ +async function keycloakAuth( + req: KeycloakRequest, + res: Response, + next: NextFunction, +): Promise { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + res.status(401).json({ error: 'Missing or invalid Authorization header' }); + return; + } + + const token = authHeader.slice(7); + const issuer = `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`; + const expectedAuthorizedParty = + process.env.KEYCLOAK_CLIENT_EXPECTED_CLIENT_ID; + + try { + const { payload } = await jwtVerify( + token, + getJWKS(), + { issuer }, + ); + + if (expectedAuthorizedParty && payload.azp !== expectedAuthorizedParty) { + res.status(401).json({ error: 'Invalid token client' }); + return; + } + + const realmRoles: string[] = payload.realm_access?.roles ?? []; + const clientRoles: string[] = + payload.resource_access?.[process.env.KEYCLOAK_CLIENT_ID ?? '']?.roles ?? + []; + const policies: PolicyEntry[] = [ + ...new Set([...realmRoles, ...clientRoles]), + ].map((role) => ({ name: role })); + const organizationId = getOrganizationId(payload); + + req.user = { + id: payload.sub, + userName: payload.preferred_username || payload.email || '', + email: payload.email || '', + policy: { + policies, + organization: + organizationId !== undefined ? { id: organizationId } : undefined, + }, + }; + + next(); + } catch { + res.status(401).json({ error: 'Invalid or expired token' }); + } +} + +export { keycloakAuth }; diff --git a/src/repositories/organization.repository.test.ts b/src/repositories/organization.repository.test.ts new file mode 100644 index 00000000..3dece5bd --- /dev/null +++ b/src/repositories/organization.repository.test.ts @@ -0,0 +1,60 @@ +import { OrganizationRepository } from './organization.repository'; + +describe('OrganizationRepository', () => { + it('creates an organization row with normalized payload values', async () => { + const execute = jest.fn().mockResolvedValue([ + { + id: 178, + type: 'o', + name: 'FCC', + email: 'fcc@example.com', + phone: '+232 123 4567', + pwd_reset_required: false, + website: 'https://fcc.example.com', + logo_url: 'https://fcc.example.com/logo.png', + map_name: 'freetown', + }, + ]); + const repository = Object.create( + OrganizationRepository.prototype, + ) as OrganizationRepository & { + execute: jest.Mock; + }; + repository.execute = execute; + + const result = await repository.createOrganization({ + name: 'FCC', + email: 'fcc@example.com', + phone: '+232 123 4567', + website: 'https://fcc.example.com', + logoUrl: 'https://fcc.example.com/logo.png', + mapName: 'freetown', + }); + + expect(execute).toHaveBeenCalledWith( + 'insert into entity (type, name, email, phone, pwd_reset_required, website, logo_url, map_name) values ($1, $2, $3, $4, $5, $6, $7, $8) returning *', + [ + 'o', + 'FCC', + 'fcc@example.com', + '+232 123 4567', + false, + 'https://fcc.example.com', + 'https://fcc.example.com/logo.png', + 'freetown', + ], + undefined, + ); + expect(result).toMatchObject({ + id: 178, + type: 'o', + name: 'FCC', + email: 'fcc@example.com', + phone: '+232 123 4567', + pwdResetRequired: false, + website: 'https://fcc.example.com', + logoUrl: 'https://fcc.example.com/logo.png', + mapName: 'freetown', + }); + }); +}); diff --git a/src/repositories/organization.repository.ts b/src/repositories/organization.repository.ts index 423cc87e..ec4dd346 100644 --- a/src/repositories/organization.repository.ts +++ b/src/repositories/organization.repository.ts @@ -1,8 +1,27 @@ -import { DefaultCrudRepository } from '@loopback/repository'; +import { DefaultCrudRepository, Options } from '@loopback/repository'; import { Organization, OrganizationRelations } from '../models'; import { TreetrackerDataSource } from '../datasources'; import { inject } from '@loopback/core'; import expect from 'expect-runtime'; +import { utils } from '../js/utils'; + +export type CreateOrganizationData = { + name: string; + email: string; + phone?: string; + website?: string; + logoUrl?: string; + mapName?: string; +}; + +function normalizeRequiredValue(value: string): string { + return value.trim(); +} + +function normalizeOptionalValue(value?: string): string | null { + const normalizedValue = value?.trim(); + return normalizedValue ? normalizedValue : null; +} export class OrganizationRepository extends DefaultCrudRepository< Organization, @@ -39,4 +58,37 @@ export class OrganizationRepository extends DefaultCrudRepository< and: [where, { id: { inq: entityIds } }], }; } + + async createOrganization( + organization: CreateOrganizationData, + options?: Options, + ): Promise { + const dbOrganization = utils.convertDB({ + type: 'o', + name: normalizeRequiredValue(organization.name), + email: normalizeRequiredValue(organization.email), + phone: normalizeOptionalValue(organization.phone), + pwdResetRequired: false, + website: normalizeOptionalValue(organization.website), + logoUrl: normalizeOptionalValue(organization.logoUrl), + mapName: normalizeOptionalValue(organization.mapName), + }); + + const dbEntries = Object.entries(dbOrganization); + const columns = dbEntries.map(([key]) => key); + const values = dbEntries.map(([, value]) => value); + const placeholders = values.map((_, index) => `$${index + 1}`); + const query = `insert into entity (${columns.join( + ', ', + )}) values (${placeholders.join(', ')}) returning *`; + const result = (await this.execute(query, values, options)) as + | Array> + | undefined; + + if (!result?.length) { + throw new Error('Organization was not created'); + } + + return utils.convertCamel(result[0]) as Organization; + } } diff --git a/src/server.ts b/src/server.ts index 76fc939f..75ef26e5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,7 @@ import { TreetrackerAdminApiApplication, } from './application'; import auth from './js/auth.js'; +import { keycloakAuth } from './middleware/keycloakMiddleware'; import { auditMiddleware } from './js/Audit'; import listEndpoints from 'express-list-endpoints'; @@ -28,9 +29,13 @@ export class ExpressServer { this.app.use(express.json()); this.lbApp = new TreetrackerAdminApiApplication(options); - // Expose the front-end assets via Express, not as LB4 route - this.app.use('/api', auth.isAuth); - this.app.use('/auth', auth.isAuth); + // Authenticate API requests with Keycloak bearer tokens. + if (process.env.KEYCLOAK_URL) { + this.app.use('/api', keycloakAuth); + } + + // Keep legacy auth middleware for legacy /auth routes only. + // this.app.use('/auth', auth.isAuth); //audit this.app.use(auditMiddleware); diff --git a/src/services/keycloakAdminService.ts b/src/services/keycloakAdminService.ts new file mode 100644 index 00000000..d69a5711 --- /dev/null +++ b/src/services/keycloakAdminService.ts @@ -0,0 +1,206 @@ +import fetch from 'node-fetch'; +import { Role } from '../types/roles'; + +interface KeycloakRole { + id: string; + name: string; +} + +interface KeycloakUserRepresentation { + id?: string; + username?: string; + attributes?: Record; + [key: string]: unknown; +} + +// Cached after first fetch — role IDs are stable for the lifetime of the realm +let cachedOrganizationRole: KeycloakRole | undefined; + +/** + * Obtains a short-lived admin access token using the backend client's + * service account (client_credentials grant). + * + * Requires the Keycloak client to have "Service Accounts Enabled" and + * its service account must hold the `manage-users` role from realm-management. + */ + +async function getAdminToken(): Promise { + // u need to enable it first and this is how i enabled it + // Step 1 — Go to the Settings tab (you should already be on it) + // Scroll down until you see a section called Capability config. You'll see a set of toggles. Enable this one: + + // Service account roles [ OFF → ON ] + + // Click Save at the bottom. + + // --- + // Step 2 — Assign the role to the service account + + // After saving, a new tab called Service account roles will appear at the top. Click it. + + // Then: + // 1. Click Assign role + // 2. Change the filter from Filter by realm roles → Filter by clients + // 3. Search for realm-management + // 4. Find manage-users in the list → tick it → click Assign + + // --- + // Step 3 — Get the client secret + + // Go to the Credentials tab (next to Service account roles). + + // Copy the value under Client secret and paste it into your .env: + + // KEYCLOAK_ADMIN_CLIENT_SECRET= + + // --- + // That's it. Once those three steps are done, getAdminToken() in keycloakAdminService.ts will be able to fetch an admin token automatically + // whenever a user creates an organization. + + const url = `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`; + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: process.env.KEYCLOAK_ADMIN_ID ?? '', + client_secret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET ?? '', + }).toString(); + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (res.status !== 200) { + throw new Error( + `Failed to obtain Keycloak admin token: HTTP ${ + res.status + } — ${await res.text()}`, + ); + } + + const parsed = (await res.json()) as { access_token: string }; + return parsed.access_token; +} + +async function getRealmRole( + adminToken: string, + roleName: string, +): Promise { + const url = `${process.env.KEYCLOAK_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/roles/${roleName}`; + + const res = await fetch(url, { + method: 'GET', + headers: { Authorization: `Bearer ${adminToken}` }, + }); + + if (res.status !== 200) { + throw new Error( + `Failed to fetch Keycloak role "${roleName}": HTTP ${ + res.status + } — ${await res.text()}`, + ); + } + + const role = (await res.json()) as KeycloakRole; + return { id: role.id, name: role.name }; +} + +async function assignRoleToUser( + adminToken: string, + userId: string, + role: KeycloakRole, +): Promise { + const url = `${process.env.KEYCLOAK_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${userId}/role-mappings/realm`; + + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${adminToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify([{ id: role.id, name: role.name }]), + }); + + // 204 No Content is the success response for role assignment + if (res.status !== 204) { + throw new Error( + `Failed to assign role "${role.name}" to user "${userId}": HTTP ${ + res.status + } — ${await res.text()}`, + ); + } +} + +async function getUserRepresentation( + adminToken: string, + userId: string, +): Promise { + const url = `${process.env.KEYCLOAK_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${userId}`; + + const res = await fetch(url, { + method: 'GET', + headers: { Authorization: `Bearer ${adminToken}` }, + }); + + if (res.status !== 200) { + throw new Error( + `Failed to fetch Keycloak user "${userId}": HTTP ${ + res.status + } — ${await res.text()}`, + ); + } + + return res.json() as Promise; +} + +async function updateUserRepresentation( + adminToken: string, + userId: string, + user: KeycloakUserRepresentation, +): Promise { + const url = `${process.env.KEYCLOAK_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${userId}`; + + const res = await fetch(url, { + method: 'PUT', + headers: { + Authorization: `Bearer ${adminToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(user), + }); + + if (res.status !== 204) { + throw new Error( + `Failed to update Keycloak user "${userId}": HTTP ${ + res.status + } — ${await res.text()}`, + ); + } +} + +export async function setOrganizationClaim( + userId: string, + organizationId: number, +): Promise { + const adminToken = await getAdminToken(); + const user = await getUserRepresentation(adminToken, userId); + const currentAttributes = user.attributes || {}; + + await updateUserRepresentation(adminToken, userId, { + ...user, + attributes: { + ...currentAttributes, + organization_id: [String(organizationId)], + }, + }); +} + +export async function assignOrganizationRole(userId: string): Promise { + const adminToken = await getAdminToken(); + + if (!cachedOrganizationRole) { + cachedOrganizationRole = await getRealmRole(adminToken, Role.ORGANIZATION); + } + + await assignRoleToUser(adminToken, userId, cachedOrganizationRole); +} diff --git a/src/types/error-codes.ts b/src/types/error-codes.ts new file mode 100644 index 00000000..663ce04f --- /dev/null +++ b/src/types/error-codes.ts @@ -0,0 +1,5 @@ +export enum ErrorCode { + ORGANIZATION_ROLE_ALREADY_ASSIGNED = 'ORGANIZATION_ROLE_ALREADY_ASSIGNED', + ORGANIZATION_ROLE_ASSIGNMENT_FAILED = 'ORGANIZATION_ROLE_ASSIGNMENT_FAILED', + ORGANIZATION_CLAIM_UPDATE_FAILED = 'ORGANIZATION_CLAIM_UPDATE_FAILED', +} diff --git a/src/types/roles.ts b/src/types/roles.ts new file mode 100644 index 00000000..5506bdb3 --- /dev/null +++ b/src/types/roles.ts @@ -0,0 +1,13 @@ +/** + * Keycloak realm role names used across the application. + * + * These must match the role names defined in your Keycloak realm exactly. + * The same names are also used in the legacy JWT permission system (auth.js) + * as the POLICIES constants. + */ +export enum Role { + ORGANIZATION = 'org', + APPROVE_TREE = 'approve_tree', + SUPER_PERMISSION = 'super_permission', + MANAGER_USER = 'manager_user', +} diff --git a/tsconfig.json b/tsconfig.json index 19e9172e..04c2a562 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,9 +6,8 @@ "outDir": "dist", "rootDir": "src", "esModuleInterop": true, - "noImplicitAny": false + "noImplicitAny": false, + "types": ["node", "jest"] }, - "include": [ - "src" - ] -} \ No newline at end of file + "include": ["src"] +}