From c0c81a05743d684011dd41d6343fbef5db5ec77b Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Thu, 25 Jan 2024 08:26:02 -0800
Subject: [PATCH 01/16] Identity Provider from hardcode to config
Signed-off-by: Jason Sherman
---
.devcontainer/README.md | 33 +++++
.devcontainer/devcontainer.json | 25 ++++
.devcontainer/post-install.sh | 2 +
.vscode/launch.json | 4 +-
.vscode/tasks.json | 6 +-
.../src/components/base/BaseSecure.vue | 14 ++-
.../src/components/base/BaseStepper.vue | 9 +-
.../src/components/bcgov/BCGovNavBar.vue | 5 +-
.../src/components/designer/FormsTable.vue | 5 +-
.../designer/settings/FormAccessSettings.vue | 34 ++---
.../settings/FormFunctionalitySettings.vue | 10 +-
.../components/forms/manage/AddTeamMember.vue | 68 +++++-----
.../forms/manage/TeamManagement.vue | 21 +---
.../submission/ManageSubmissionUsers.vue | 49 ++++----
app/frontend/src/main.js | 26 ++++
app/frontend/src/router.js | 28 +++--
app/frontend/src/services/rbacService.js | 8 ++
app/frontend/src/store/auth.js | 4 +-
app/frontend/src/store/identityProviders.js | 114 +++++++++++++++++
app/frontend/src/utils/constants.js | 22 ++--
app/frontend/src/utils/permissionUtils.js | 11 +-
app/frontend/src/views/Admin.vue | 6 +-
app/frontend/src/views/Login.vue | 38 ++----
app/frontend/src/views/file/Download.vue | 6 +-
app/frontend/src/views/form/Create.vue | 3 +-
app/frontend/src/views/form/Emails.vue | 6 +-
app/frontend/src/views/form/Export.vue | 6 +-
app/frontend/src/views/form/Manage.vue | 6 +-
app/frontend/src/views/form/Preview.vue | 6 +-
app/frontend/src/views/form/Submissions.vue | 6 +-
app/frontend/src/views/form/Teams.vue | 6 +-
app/frontend/src/views/form/View.vue | 6 +-
app/frontend/src/views/user/Submissions.vue | 6 +-
...119172630_identity_provider_permissions.js | 118 ++++++++++++++++++
app/src/forms/common/constants.js | 17 +++
.../common/models/tables/identityProvider.js | 7 +-
36 files changed, 534 insertions(+), 207 deletions(-)
create mode 100644 app/frontend/src/store/identityProviders.js
create mode 100644 app/src/db/migrations/20240119172630_identity_provider_permissions.js
diff --git a/.devcontainer/README.md b/.devcontainer/README.md
index fc50bfc97..1bc39036b 100644
--- a/.devcontainer/README.md
+++ b/.devcontainer/README.md
@@ -15,6 +15,8 @@ The `.devcontainer` folder contains the `devcontainer.json` file which defines t
In order to run CHEFS you require Keycloak (configured), Postgresql (seeded) and the CHEFS backend/API and frontend/UX. Previously, this was a series of downloads and configuration updates and numerous commands to run. See `.devcontainer/chefs_local` files.
+**NODE_CONFIG_DIR** to simplify loading a default configuration to the CHEFS infrastructure (Keycloak, Postgresql, etc), we set an environment variable [`NODE_CONFIG_DIR`](https://github.com/node-config/node-config/wiki/Environment-Variables#node_config_dir). This supercedes the files found under `app/config`. Running node apps and commands (ex. knex, launch configurations) will use this environment variable and load configuration from `.devcontainer/chefs_local`.
+
Also included are convenient launch tasks to run and debug CHEFS.
## Open CHEFS in the devcontainer
@@ -65,6 +67,37 @@ When the devcontainer is built, it copies `.devcontainer/chefs_local/local.json.
## Formio Components
If you are developing the formio components, you should build and redeploy them before running your local debug instances of CHEFS. Use tasks `Components build` and `Components Deploy`.
+## KNEX - Database tools
+[knex](https://knexjs.org) is installed globally and should be run from the `/app` directory where the knex configuration is located. Use knex to stub out migrations or to rollback migrations as you are developing.
+
+### create a migration file
+This will create a stub file with a timestamp. You will populate the up and down methods to add/update/delete database objects.
+
+```
+cd app
+knex migrate:make my_new_migration_script
+> Created Migration: /workspaces/common-hosted-form-service/app/src/db/migrations/20240119172630_my_new_migration_script.js
+```
+
+### rollback previous migration
+When developing your migrations, you may find it useful to run the migration and roll it back if it isn't exactly what you expect to happen.
+
+#### run the migration(s)
+```
+cd app
+knex migrate:latest
+> Batch 2 run: 1 migrations
+```
+
+#### rollback the migration(s)
+```
+cd app
+knex migrate:rollback
+> Batch 2 rolled back: 1 migrations
+```
+
+Please review the [knex](https://knexjs.org) for more detail and how to leverage the tool.
+
## Troubleshooting
All development machines are unique and here we will document problems that have been encountered and how to fix them.
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 84375ad6d..7d7b8355f 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -10,10 +10,35 @@
}
},
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "cweijan.vscode-postgresql-client2",
+ "Vue.volar",
+ "esbenp.prettier-vscode"
+ ],
+ "settings": {
+ "database-client.telemetry.usesOnlineServices": false,
+ "editor.defaultFormatter": null,
+ "editor.formatOnSave": false,
+ "[javascript]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true
+ },
+ "prettier.configPath": "${containerWorkspaceFolder}/app/frontend/.prettierrc",
+ "prettier.documentSelectors": ["${containerWorkspaceFolder}/app/frontend/**/*.{js,vue}"]
+ }
+ }
+ },
+
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
+ "containerEnv": {
+ "NODE_CONFIG_DIR": "${containerWorkspaceFolder}/.devcontainer/chefs_local"
+ },
+
// Use this environment variable if you need to bind mount your local source code into a new container.
"remoteEnv": {
"LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}"
diff --git a/.devcontainer/post-install.sh b/.devcontainer/post-install.sh
index c6d2ea823..891641159 100644
--- a/.devcontainer/post-install.sh
+++ b/.devcontainer/post-install.sh
@@ -5,6 +5,8 @@ set -ex
WORKSPACE_DIR=$(pwd)
CHEFS_LOCAL_DIR=${WORKSPACE_DIR}/.devcontainer/chefs_local
+npm install knex -g
+
# install app libraries, prepare for app development and debugging...
cd app
npm install
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 95afba61f..9f576b64a 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -14,9 +14,7 @@
"runtimeArgs": ["run", "serve"],
"runtimeExecutable": "npm",
"type": "node",
- "env": {
- "NODE_CONFIG_DIR": "${workspaceFolder}/.devcontainer/chefs_local",
- }
+ "env": {}
},
{
"cwd": "${workspaceFolder}/app/frontend",
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 85aa0490c..de14b394e 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -62,14 +62,16 @@
{
"label": "chefs_local up",
"type": "shell",
- "command": "docker-compose -f ${workspaceFolder}/.devcontainer/chefs_local/docker-compose.yml up -d",
+ "command": "docker-compose",
+ "args": ["-f", "${workspaceFolder}/.devcontainer/chefs_local/docker-compose.yml", "up", "-d"],
"isBackground": true,
"problemMatcher": [],
},
{
"label": "chefs_local down",
"type": "shell",
- "command": "docker-compose -f ${workspaceFolder}/.devcontainer/chefs_local/docker-compose.yml down",
+ "command": "docker-compose",
+ "args": ["-f", "${workspaceFolder}/.devcontainer/chefs_local/docker-compose.yml", "down"],
"isBackground": true,
"problemMatcher": [],
},
diff --git a/app/frontend/src/components/base/BaseSecure.vue b/app/frontend/src/components/base/BaseSecure.vue
index 0a27df9e3..30da86712 100755
--- a/app/frontend/src/components/base/BaseSecure.vue
+++ b/app/frontend/src/components/base/BaseSecure.vue
@@ -2,6 +2,7 @@
import { mapActions, mapState } from 'pinia';
import { useAuthStore } from '~/store/auth';
import { useFormStore } from '~/store/form';
+import { useIdpStore } from '~/store/identityProviders';
export default {
props: {
@@ -13,6 +14,10 @@ export default {
type: Array,
default: undefined,
},
+ permission: {
+ type: String,
+ default: undefined,
+ },
},
computed: {
...mapState(useAuthStore, [
@@ -23,6 +28,7 @@ export default {
'ready',
]),
...mapState(useFormStore, ['lang']),
+ ...mapState(useIdpStore, ['hasPermission']),
mailToLink() {
return `mailto:${
import.meta.env.VITE_CONTACT
@@ -34,7 +40,9 @@ export default {
return import.meta.env.VITE_CONTACT;
},
},
- methods: mapActions(useAuthStore, ['login']),
+ methods: {
+ ...mapActions(useAuthStore, ['login']),
+ },
};
@@ -50,7 +58,7 @@ export default {
@@ -59,7 +67,7 @@ export default {
{{
$t('trans.baseSecure.403ErrorMsg', {
- idp: idp,
+ idp: permission,
})
}}
diff --git a/app/frontend/src/components/base/BaseStepper.vue b/app/frontend/src/components/base/BaseStepper.vue
index df720f53c..c50f81251 100644
--- a/app/frontend/src/components/base/BaseStepper.vue
+++ b/app/frontend/src/components/base/BaseStepper.vue
@@ -2,7 +2,7 @@
import { mapState } from 'pinia';
import BaseSecure from '~/components/base/BaseSecure.vue';
import { useFormStore } from '~/store/form';
-import { IdentityProviders } from '~/utils/constants';
+import { AppPermissions } from '~/utils/constants';
export default {
name: 'BaseStepper',
@@ -17,7 +17,7 @@ export default {
},
computed: {
...mapState(useFormStore, ['lang', 'isRTL']),
- IDP: () => IdentityProviders,
+ APP_PERMS: () => AppPermissions,
creatorStep() {
return this.step;
},
@@ -26,7 +26,10 @@ export default {
-
+
-
-
- IDIR
-
-
-
-
- Basic BCeID
-
-
-
+
- Business BCeID
+ {{ button.label }}
diff --git a/app/frontend/src/components/designer/settings/FormFunctionalitySettings.vue b/app/frontend/src/components/designer/settings/FormFunctionalitySettings.vue
index d9b6a98d6..83b7e7575 100644
--- a/app/frontend/src/components/designer/settings/FormFunctionalitySettings.vue
+++ b/app/frontend/src/components/designer/settings/FormFunctionalitySettings.vue
@@ -3,7 +3,8 @@ import { mapState, mapWritableState } from 'pinia';
import BasePanel from '~/components/base/BasePanel.vue';
import { useAuthStore } from '~/store/auth';
import { useFormStore } from '~/store/form';
-import { IdentityMode, IdentityProviders } from '~/utils/constants';
+import { useIdpStore } from '~/store/identityProviders';
+import { IdentityMode } from '~/utils/constants';
export default {
components: {
@@ -24,12 +25,13 @@ export default {
computed: {
...mapState(useAuthStore, ['identityProvider']),
...mapState(useFormStore, ['isFormPublished', 'isRTL', 'lang']),
+ ...mapState(useIdpStore, ['isPrimary']),
...mapWritableState(useFormStore, ['form']),
ID_MODE() {
return IdentityMode;
},
- idirUser() {
- return this.identityProvider === IdentityProviders.IDIR;
+ primaryIdpUser() {
+ return this.isPrimary(this.identityProvider);
},
},
methods: {
@@ -225,7 +227,7 @@ export default {
v-model="form.subscribe.enabled"
hide-details="auto"
class="my-0"
- :disabled="idirUser === false || !isFormPublished"
+ :disabled="primaryIdpUser === false || !isFormPublished"
>
diff --git a/app/frontend/src/components/forms/manage/AddTeamMember.vue b/app/frontend/src/components/forms/manage/AddTeamMember.vue
index 0315a74e1..ffdca0759 100644
--- a/app/frontend/src/components/forms/manage/AddTeamMember.vue
+++ b/app/frontend/src/components/forms/manage/AddTeamMember.vue
@@ -1,12 +1,13 @@
-
+
diff --git a/app/frontend/src/views/Login.vue b/app/frontend/src/views/Login.vue
index 701c15737..4f2598a69 100644
--- a/app/frontend/src/views/Login.vue
+++ b/app/frontend/src/views/Login.vue
@@ -3,49 +3,29 @@ import { mapActions, mapState } from 'pinia';
import { useAuthStore } from '~/store/auth';
import { useFormStore } from '~/store/form';
-import { IdentityProviders } from '~/utils/constants';
+import { useIdpStore } from '~/store/identityProviders';
export default {
props: {
idpHint: {
type: Array,
- default: () => [
- IdentityProviders.IDIR,
- IdentityProviders.BCEIDBUSINESS,
- IdentityProviders.BCEIDBASIC,
- ],
+ default: () => [],
},
},
computed: {
...mapState(useAuthStore, ['authenticated', 'createLoginUrl', 'ready']),
...mapState(useFormStore, ['lang']),
- buttons: () => [
- {
- label: 'IDIR',
- type: IdentityProviders.IDIR,
- },
- {
- label: 'Basic BCeID',
- type: IdentityProviders.BCEIDBASIC,
- },
- {
- label: 'Business BCeID',
- type: IdentityProviders.BCEIDBUSINESS,
- },
- ],
- IDPS() {
- return IdentityProviders;
- },
+ ...mapState(useIdpStore, ['loginButtons', 'loginIdpHints']),
},
created() {
// If component gets idpHint, invoke login flow via vuex
- if (this.idpHint && this.idpHint.length === 1) this.login(this.idpHint[0]);
+ if (this.idpHint && this.idpHint.length === 1) {
+ const hint = this.idpHint[0];
+ if (this.loginIdpHints.includes(hint)) this.login(hint);
+ }
},
methods: {
...mapActions(useAuthStore, ['login']),
- buttonEnabled(type) {
- return this.idpHint ? this.idpHint.includes(type) : false;
- },
},
};
@@ -56,8 +36,8 @@ export default {
{{ $t('trans.login.authenticateWith') }}
-
-
+
+
IdentityProviders,
+ APP_PERMS: () => AppPermissions,
},
async mounted() {
await this.getFile(this.id);
@@ -77,7 +77,7 @@ export default {
-
+
{{ $t('trans.download.chefsDataExport') }}
IdentityProviders,
stepper() {
return this.step;
},
diff --git a/app/frontend/src/views/form/Emails.vue b/app/frontend/src/views/form/Emails.vue
index 783b65978..5f8a0eadb 100644
--- a/app/frontend/src/views/form/Emails.vue
+++ b/app/frontend/src/views/form/Emails.vue
@@ -1,7 +1,7 @@
-
+
diff --git a/app/frontend/src/views/form/Export.vue b/app/frontend/src/views/form/Export.vue
index f9111decf..ea7842c23 100644
--- a/app/frontend/src/views/form/Export.vue
+++ b/app/frontend/src/views/form/Export.vue
@@ -1,7 +1,7 @@
-
+
diff --git a/app/frontend/src/views/form/Manage.vue b/app/frontend/src/views/form/Manage.vue
index 3b1562d62..020e8f701 100644
--- a/app/frontend/src/views/form/Manage.vue
+++ b/app/frontend/src/views/form/Manage.vue
@@ -1,7 +1,7 @@
-
+
diff --git a/app/frontend/src/views/form/Preview.vue b/app/frontend/src/views/form/Preview.vue
index bdf04abb2..bbe0fb327 100644
--- a/app/frontend/src/views/form/Preview.vue
+++ b/app/frontend/src/views/form/Preview.vue
@@ -4,7 +4,7 @@ import BaseSecure from '~/components/base/BaseSecure.vue';
import FormViewer from '~/components/designer/FormViewer.vue';
import { useFormStore } from '~/store/form';
-import { IdentityProviders } from '~/utils/constants';
+import { AppPermissions } from '~/utils/constants';
export default {
components: {
@@ -27,13 +27,13 @@ export default {
},
computed: {
...mapState(useFormStore, ['isRTL', 'lang']),
- IDP: () => IdentityProviders,
+ APP_PERMS: () => AppPermissions,
},
};
-
+
import BaseSecure from '~/components/base/BaseSecure.vue';
import SubmissionsTable from '~/components/forms/SubmissionsTable.vue';
-import { IdentityProviders } from '~/utils/constants';
+import { AppPermissions } from '~/utils/constants';
export default {
components: {
@@ -15,13 +15,13 @@ export default {
},
},
computed: {
- IDP: () => IdentityProviders,
+ APP_PERMS: () => AppPermissions,
},
};
-
+
diff --git a/app/frontend/src/views/form/Teams.vue b/app/frontend/src/views/form/Teams.vue
index b35d933f5..9d4bd2bc0 100644
--- a/app/frontend/src/views/form/Teams.vue
+++ b/app/frontend/src/views/form/Teams.vue
@@ -1,7 +1,7 @@
-
+
diff --git a/app/frontend/src/views/form/View.vue b/app/frontend/src/views/form/View.vue
index 90b738cb1..bbfdc2416 100644
--- a/app/frontend/src/views/form/View.vue
+++ b/app/frontend/src/views/form/View.vue
@@ -1,7 +1,7 @@
-
+
diff --git a/app/frontend/src/views/user/Submissions.vue b/app/frontend/src/views/user/Submissions.vue
index bb0177c10..8205d81de 100644
--- a/app/frontend/src/views/user/Submissions.vue
+++ b/app/frontend/src/views/user/Submissions.vue
@@ -1,7 +1,7 @@
-
+
diff --git a/app/src/db/migrations/20240119172630_identity_provider_permissions.js b/app/src/db/migrations/20240119172630_identity_provider_permissions.js
new file mode 100644
index 000000000..f7aef6bd0
--- /dev/null
+++ b/app/src/db/migrations/20240119172630_identity_provider_permissions.js
@@ -0,0 +1,118 @@
+const { APP_PERMISSIONS, Roles } = require('../../forms/common/constants');
+
+const BCEID_EXTRAS = {
+ formAccessSettings: 'idim',
+ addTeamMemberSearch: {
+ text: {
+ minLength: 6,
+ message: 'trans.manageSubmissionUsers.searchInputLength',
+ },
+ email: {
+ exact: true,
+ message: 'trans.manageSubmissionUsers.exactBCEIDSearch',
+ },
+ },
+};
+
+exports.up = function (knex) {
+ return Promise.resolve().then(() =>
+ knex.schema
+ .alterTable('identity_provider', (table) => {
+ table.boolean('primary').notNullable().defaultTo(false);
+ table
+ .boolean('login')
+ .notNullable()
+ .defaultTo(false)
+ .comment('When true, supply buttons to launch login process');
+ table
+ .specificType('permissions', 'text ARRAY')
+ .comment('Map app permissions to the idp');
+ table
+ .specificType('roles', 'text ARRAY')
+ .comment('Map Form role codes to the idp');
+ table
+ .jsonb('extra')
+ .comment(
+ 'Allow customization of the IDP though extra (json) config object.'
+ );
+ })
+ .then(() =>
+ knex('identity_provider')
+ .where({ code: 'public' })
+ .update({ permissions: [], extra: {} })
+ )
+ .then(() =>
+ knex('identity_provider')
+ .where({ code: 'idir' })
+ .update({ primary: true, login: true })
+ )
+ .then(() =>
+ knex('identity_provider')
+ .where({ code: 'idir' })
+ .update({
+ permissions: [
+ APP_PERMISSIONS.VIEWS_FORM_STEPPER,
+ APP_PERMISSIONS.VIEWS_ADMIN,
+ APP_PERMISSIONS.VIEWS_FILE_DOWNLOAD,
+ APP_PERMISSIONS.VIEWS_FORM_EMAILS,
+ APP_PERMISSIONS.VIEWS_FORM_EXPORT,
+ APP_PERMISSIONS.VIEWS_FORM_MANAGE,
+ APP_PERMISSIONS.VIEWS_FORM_PREVIEW,
+ APP_PERMISSIONS.VIEWS_FORM_SUBMISSIONS,
+ APP_PERMISSIONS.VIEWS_FORM_TEAMS,
+ APP_PERMISSIONS.VIEWS_FORM_VIEW,
+ APP_PERMISSIONS.VIEWS_USER_SUBMISSIONS,
+ ],
+ roles: [
+ Roles.OWNER,
+ Roles.TEAM_MANAGER,
+ Roles.FORM_DESIGNER,
+ Roles.SUBMISSION_REVIEWER,
+ Roles.FORM_SUBMITTER,
+ ],
+ extra: {},
+ })
+ )
+ .then(() =>
+ knex('identity_provider')
+ .where({ code: 'bceid-business' })
+ .update({
+ login: true,
+ permissions: [
+ APP_PERMISSIONS.VIEWS_FORM_EXPORT,
+ APP_PERMISSIONS.VIEWS_FORM_MANAGE,
+ APP_PERMISSIONS.VIEWS_FORM_SUBMISSIONS,
+ APP_PERMISSIONS.VIEWS_FORM_TEAMS,
+ APP_PERMISSIONS.VIEWS_FORM_VIEW,
+ APP_PERMISSIONS.VIEWS_USER_SUBMISSIONS,
+ ],
+ roles: [
+ Roles.TEAM_MANAGER,
+ Roles.SUBMISSION_REVIEWER,
+ Roles.FORM_SUBMITTER,
+ ],
+ extra: BCEID_EXTRAS,
+ })
+ )
+ .then(() =>
+ knex('identity_provider')
+ .where({ code: 'bceid-basic' })
+ .update({
+ login: true,
+ permissions: [APP_PERMISSIONS.VIEWS_USER_SUBMISSIONS],
+ roles: [Roles.FORM_SUBMITTER],
+ extra: BCEID_EXTRAS,
+ })
+ )
+ );
+};
+
+exports.down = function (knex) {
+ return Promise.resolve().then(() =>
+ knex.schema.alterTable('identity_provider', (table) => {
+ table.dropColumn('primary');
+ table.dropColumn('permissions');
+ table.dropColumn('extra');
+ })
+ );
+};
diff --git a/app/src/forms/common/constants.js b/app/src/forms/common/constants.js
index 278df008a..4224db129 100644
--- a/app/src/forms/common/constants.js
+++ b/app/src/forms/common/constants.js
@@ -90,4 +90,21 @@ module.exports = Object.freeze({
json: 'json',
default: 'csv',
},
+ // app permissions are not assigned on the form
+ // they are for flow within the UX, what views one can navigate
+ // what buttons one can have.
+ // these will be assigned via the user's IDP.
+ APP_PERMISSIONS: {
+ VIEWS_FORM_STEPPER: 'views_form_stepper',
+ VIEWS_ADMIN: 'views_admin',
+ VIEWS_FILE_DOWNLOAD: 'views_file_download',
+ VIEWS_FORM_EMAILS: 'views_form_emails',
+ VIEWS_FORM_EXPORT: 'views_form_export',
+ VIEWS_FORM_MANAGE: 'views_form_manage',
+ VIEWS_FORM_PREVIEW: 'views_form_preview',
+ VIEWS_FORM_SUBMISSIONS: 'views_form_submissions',
+ VIEWS_FORM_TEAMS: 'views_form_teamS',
+ VIEWS_FORM_VIEW: 'views_form_view',
+ VIEWS_USER_SUBMISSIONS: 'views_user_submissions',
+ },
});
diff --git a/app/src/forms/common/models/tables/identityProvider.js b/app/src/forms/common/models/tables/identityProvider.js
index cebd48143..d340e4c3c 100644
--- a/app/src/forms/common/models/tables/identityProvider.js
+++ b/app/src/forms/common/models/tables/identityProvider.js
@@ -19,7 +19,7 @@ class IdentityProvider extends Timestamps(Model) {
}
},
orderDefault(builder) {
- builder.orderByRaw('lower("identity_provider"."code")');
+ builder.orderByRaw('"identity_provider"."primary" DESC NULLS LAST, lower("identity_provider"."code")');
},
};
}
@@ -33,6 +33,11 @@ class IdentityProvider extends Timestamps(Model) {
display: { type: 'string', minLength: 1, maxLength: 255 },
idp: { type: 'string', minLength: 1, maxLength: 255 },
active: { type: 'boolean' },
+ primary: { type: 'boolean' },
+ login: { type: 'boolean' },
+ permissions: { type: ['array', 'null'], items: { type: 'string' } },
+ roles: { type: ['array', 'null'], items: { type: 'string' } },
+ extra: { type: 'object' },
...stamps,
},
additionalProperties: false,
From 22a4377248c996ec70fcec6279a355ff5030e80b Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Fri, 26 Jan 2024 15:35:57 -0800
Subject: [PATCH 02/16] update frontend unit tests. fixes made to files where
bugs found. changed a param substitution name in resource files.
Signed-off-by: Jason Sherman
---
.../src/components/base/BaseSecure.vue | 4 -
.../trans/chefs/ar/ar.json | 2 +-
.../trans/chefs/de/de.json | 2 +-
.../trans/chefs/en/en.json | 2 +-
.../trans/chefs/es/es.json | 2 +-
.../trans/chefs/fa/fa.json | 2 +-
.../trans/chefs/fr/fr.json | 2 +-
.../trans/chefs/hi/hi.json | 2 +-
.../trans/chefs/it/it.json | 2 +-
.../trans/chefs/ja/ja.json | 2 +-
.../trans/chefs/ko/ko.json | 2 +-
.../trans/chefs/pa/pa.json | 2 +-
.../trans/chefs/pt/pt.json | 2 +-
.../trans/chefs/ru/ru.json | 2 +-
.../trans/chefs/tl/tl.json | 2 +-
.../trans/chefs/uk/uk.json | 2 +-
.../trans/chefs/vi/vi.json | 2 +-
.../trans/chefs/zh/zh.json | 2 +-
.../trans/chefs/zhTW/zh-TW.json | 2 +-
.../components/base/BaseAuthButton.spec.js | 5 +-
.../unit/components/base/BaseSecure.spec.js | 15 ++-
.../unit/components/bcgov/BCGovNavBar.spec.js | 9 +-
.../unit/fixtures/identityProviders.json | 119 ++++++++++++++++++
.../unit/store/modules/auth.actions.spec.js | 6 +-
.../tests/unit/utils/constants.spec.js | 18 ++-
.../tests/unit/utils/permissionUtils.spec.js | 37 +++---
app/frontend/tests/unit/views/Login.spec.js | 7 +-
27 files changed, 205 insertions(+), 51 deletions(-)
create mode 100644 app/frontend/tests/unit/fixtures/identityProviders.json
diff --git a/app/frontend/src/components/base/BaseSecure.vue b/app/frontend/src/components/base/BaseSecure.vue
index 30da86712..f4ee5e424 100755
--- a/app/frontend/src/components/base/BaseSecure.vue
+++ b/app/frontend/src/components/base/BaseSecure.vue
@@ -10,10 +10,6 @@ export default {
type: Boolean,
default: false,
},
- idp: {
- type: Array,
- default: undefined,
- },
permission: {
type: String,
default: undefined,
diff --git a/app/frontend/src/internationalization/trans/chefs/ar/ar.json b/app/frontend/src/internationalization/trans/chefs/ar/ar.json
index d216913f6..3bd8bb3f6 100644
--- a/app/frontend/src/internationalization/trans/chefs/ar/ar.json
+++ b/app/frontend/src/internationalization/trans/chefs/ar/ar.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: غير مصرح به. :",
"401ErrorMsg": "لم يتم إعداد حسابك بشكل صحيح.
الرجاء التواصل",
"403Forbidden": "403 ممنوع. :",
- "403ErrorMsg": "تتطلب هذه الصفحة مصادقة {idp}.",
+ "403ErrorMsg": "تتطلب هذه الصفحة مصادقة {permission}.",
"401UnAuthorized": "401: غير مصرح به. :",
"401UnAuthorizedErrMsg": "ليس لديك الصلاحية لدخول هذه الصفحة."
},
diff --git a/app/frontend/src/internationalization/trans/chefs/de/de.json b/app/frontend/src/internationalization/trans/chefs/de/de.json
index 8561f37eb..331949d4f 100644
--- a/app/frontend/src/internationalization/trans/chefs/de/de.json
+++ b/app/frontend/src/internationalization/trans/chefs/de/de.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: nicht autorisiert. :",
"401ErrorMsg": "Ihr Konto ist nicht richtig eingerichtet.
Kontaktieren Sie bitte",
"403Forbidden": "403 Verboten. :",
- "403ErrorMsg": "Diese Seite erfordert eine {idp}-Authentifizierung.",
+ "403ErrorMsg": "Diese Seite erfordert eine {permission}-Authentifizierung.",
"401UnAuthorized": "401 nicht Autorisiert. :",
"401UnAuthorizedErrMsg": "Sie haben keine Berechtigung, auf diese Seite zuzugreifen."
},
diff --git a/app/frontend/src/internationalization/trans/chefs/en/en.json b/app/frontend/src/internationalization/trans/chefs/en/en.json
index 288b06d88..577e92fe8 100644
--- a/app/frontend/src/internationalization/trans/chefs/en/en.json
+++ b/app/frontend/src/internationalization/trans/chefs/en/en.json
@@ -373,7 +373,7 @@
"401NotAuthorized": "401: not authorized. :(",
"401ErrorMsg": "Your account is not set up correctly.
Please contact",
"403Forbidden": "403: Forbidden. :(",
- "403ErrorMsg": "This page requires {idp} authentication.",
+ "403ErrorMsg": "This page requires {permission} permission.",
"401UnAuthorized": "401: Unauthorized. :(",
"401UnAuthorizedErrMsg": "You do not have permission to access this page."
},
diff --git a/app/frontend/src/internationalization/trans/chefs/es/es.json b/app/frontend/src/internationalization/trans/chefs/es/es.json
index 4f2b44a9e..372873551 100644
--- a/app/frontend/src/internationalization/trans/chefs/es/es.json
+++ b/app/frontend/src/internationalization/trans/chefs/es/es.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: no autorizado. :",
"401ErrorMsg": "Su cuenta no está configurada correctamente.
Por favor contactar",
"403Forbidden": "403: Prohibido. :",
- "403ErrorMsg": "Esta página requiere autenticación {idp}.",
+ "403ErrorMsg": "Esta página requiere autenticación {permission}.",
"401UnAuthorized": "401: No autorizado. :",
"401UnAuthorizedErrMsg": "Usted no tiene permiso para acceder a esta página."
},
diff --git a/app/frontend/src/internationalization/trans/chefs/fa/fa.json b/app/frontend/src/internationalization/trans/chefs/fa/fa.json
index 4e55afbc6..f899dbf92 100644
--- a/app/frontend/src/internationalization/trans/chefs/fa/fa.json
+++ b/app/frontend/src/internationalization/trans/chefs/fa/fa.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: مجاز نیست. :",
"401ErrorMsg": "حساب شما به درستی تنظیم نشده است.
لطفا تماس بگیرید",
"403Forbidden": "403: ممنوع. :",
- "403ErrorMsg": "این صفحه به احراز هویت {idp} نیاز دارد.",
+ "403ErrorMsg": "این صفحه به احراز هویت {permission} نیاز دارد.",
"401UnAuthorized": "401: غیر مجاز. :",
"401UnAuthorizedErrMsg": "شما اجازه دسترسی به این صفحه را ندارید."
},
diff --git a/app/frontend/src/internationalization/trans/chefs/fr/fr.json b/app/frontend/src/internationalization/trans/chefs/fr/fr.json
index 2f7290588..b0013d276 100644
--- a/app/frontend/src/internationalization/trans/chefs/fr/fr.json
+++ b/app/frontend/src/internationalization/trans/chefs/fr/fr.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401 : non autorisé. :",
"401ErrorMsg": "Votre compte n'est pas configuré correctement.
S'il vous plaît contactez",
"403Forbidden": "403 : Interdit. :",
- "403ErrorMsg": "Cette page nécessite une authentification {idp}.",
+ "403ErrorMsg": "Cette page nécessite une authentification {permission}.",
"401UnAuthorized": "401 : non autorisé. :",
"401UnAuthorizedErrMsg": "Vous n'avez pas la permission d'accéder à cette page."
},
diff --git a/app/frontend/src/internationalization/trans/chefs/hi/hi.json b/app/frontend/src/internationalization/trans/chefs/hi/hi.json
index 55858556f..d3333ac31 100644
--- a/app/frontend/src/internationalization/trans/chefs/hi/hi.json
+++ b/app/frontend/src/internationalization/trans/chefs/hi/hi.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: अधिकृत नहीं. :",
"401ErrorMsg": "आपका खाता ठीक से सेट नहीं है.
कृपया संपर्क करें",
"403Forbidden": "403 निषिद्ध। :",
- "403ErrorMsg": "इस पृष्ठ को {idp} प्रमाणीकरण की आवश्यकता है।",
+ "403ErrorMsg": "इस पृष्ठ को {permission} प्रमाणीकरण की आवश्यकता है।",
"401UnAuthorized": "अनधिकृत 401। :",
"401UnAuthorizedErrMsg": "आपको इस पेज को एक्सेस करने की अनुमति नहीं है।"
},
diff --git a/app/frontend/src/internationalization/trans/chefs/it/it.json b/app/frontend/src/internationalization/trans/chefs/it/it.json
index 7f700a239..c0556044d 100644
--- a/app/frontend/src/internationalization/trans/chefs/it/it.json
+++ b/app/frontend/src/internationalization/trans/chefs/it/it.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: non autorizzato. :",
"401ErrorMsg": "Il tuo account non è impostato correttamente.
Si prega di contattare",
"403Forbidden": "403: Proibito. :",
- "403ErrorMsg": "Questa pagina richiede l'autenticazione {idp}.",
+ "403ErrorMsg": "Questa pagina richiede l'autenticazione {permission}.",
"401UnAuthorized": "401: non autorizzato. :",
"401UnAuthorizedErrMsg": "Non sei autorizzato ad accedere a questa pagina."
},
diff --git a/app/frontend/src/internationalization/trans/chefs/ja/ja.json b/app/frontend/src/internationalization/trans/chefs/ja/ja.json
index 2e3031d75..1f7ed1f81 100644
--- a/app/frontend/src/internationalization/trans/chefs/ja/ja.json
+++ b/app/frontend/src/internationalization/trans/chefs/ja/ja.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: 権限がありません。 :",
"401ErrorMsg": "アカウントが正しく設定されていません。
お問い合わせください",
"403Forbidden": "403禁止します。 :",
- "403ErrorMsg": "このページには {idp} 認証が必要です。",
+ "403ErrorMsg": "このページには {permission} 認証が必要です。",
"401UnAuthorized": "401: 不正です。 :",
"401UnAuthorizedErrMsg": "このページにアクセスする権限がありません。"
},
diff --git a/app/frontend/src/internationalization/trans/chefs/ko/ko.json b/app/frontend/src/internationalization/trans/chefs/ko/ko.json
index c95a5b9d9..6e7b08627 100644
--- a/app/frontend/src/internationalization/trans/chefs/ko/ko.json
+++ b/app/frontend/src/internationalization/trans/chefs/ko/ko.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: 권한이 없습니다. :",
"401ErrorMsg": "귀하의 계정이 올바르게 설정되지 않았습니다.
연락주세요",
"403Forbidden": "403 금지. :",
- "403ErrorMsg": "이 페이지는 {idp} 인증이 필요합니다.",
+ "403ErrorMsg": "이 페이지는 {permission} 인증이 필요합니다.",
"401UnAuthorized": "401: 권한이 없습니다. :",
"401UnAuthorizedErrMsg": "이 페이지에 액세스할 수 있는 권한이 없습니다."
},
diff --git a/app/frontend/src/internationalization/trans/chefs/pa/pa.json b/app/frontend/src/internationalization/trans/chefs/pa/pa.json
index 5ec033ee3..f05c92ef0 100644
--- a/app/frontend/src/internationalization/trans/chefs/pa/pa.json
+++ b/app/frontend/src/internationalization/trans/chefs/pa/pa.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: ਅਧਿਕਾਰਤ ਨਹੀਂ। :",
"401ErrorMsg": "ਤੁਹਾਡਾ ਖਾਤਾ ਸਹੀ ਢੰਗ ਨਾਲ ਸੈੱਟਅੱਪ ਨਹੀਂ ਕੀਤਾ ਗਿਆ ਹੈ।
ਕਿਰਪਾ ਕਰਕੇ ਸੰਪਰਕ ਕਰੋ",
"403Forbidden": "403: ਵਰਜਿਤ। :",
- "403ErrorMsg": "ਇਸ ਪੰਨੇ ਨੂੰ {idp} ਪ੍ਰਮਾਣੀਕਰਨ ਦੀ ਲੋੜ ਹੈ।",
+ "403ErrorMsg": "ਇਸ ਪੰਨੇ ਨੂੰ {permission} ਪ੍ਰਮਾਣੀਕਰਨ ਦੀ ਲੋੜ ਹੈ।",
"401UnAuthorized": "401: ਅਣਅਧਿਕਾਰਤ। :",
"401UnAuthorizedErrMsg": "ਤੁਹਾਨੂੰ ਇਸ ਪੰਨੇ ਤੱਕ ਪਹੁੰਚ ਕਰਨ ਦੀ ਇਜਾਜ਼ਤ ਨਹੀਂ ਹੈ।"
},
diff --git a/app/frontend/src/internationalization/trans/chefs/pt/pt.json b/app/frontend/src/internationalization/trans/chefs/pt/pt.json
index 983c5fdb6..6d30dd28d 100644
--- a/app/frontend/src/internationalization/trans/chefs/pt/pt.json
+++ b/app/frontend/src/internationalization/trans/chefs/pt/pt.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: não autorizado. :",
"401ErrorMsg": "Sua conta não está configurada corretamente.
Por favor entre em contato",
"403Forbidden": "403: Proibido. :",
- "403ErrorMsg": "Esta página requer autenticação {idp}.",
+ "403ErrorMsg": "Esta página requer autenticação {permission}.",
"401UnAuthorized": "401 não autorizado. :",
"401UnAuthorizedErrMsg": "Você não tem permissão para acessar esta página."
},
diff --git a/app/frontend/src/internationalization/trans/chefs/ru/ru.json b/app/frontend/src/internationalization/trans/chefs/ru/ru.json
index acae1fd01..5ab21646e 100644
--- a/app/frontend/src/internationalization/trans/chefs/ru/ru.json
+++ b/app/frontend/src/internationalization/trans/chefs/ru/ru.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: не авторизован:",
"401ErrorMsg": "Ваша учетная запись настроена неправильно.
Пожалуйста свяжитесь с нами",
"403Forbidden": "403: Запрещено:",
- "403ErrorMsg": "Для этой страницы требуется аутентификация {idp}.",
+ "403ErrorMsg": "Для этой страницы требуется аутентификация {permission}.",
"401UnAuthorized": "401: Неавторизованный. :",
"401UnAuthorizedErrMsg": "Вы не имеете доступа к этой странице."
},
diff --git a/app/frontend/src/internationalization/trans/chefs/tl/tl.json b/app/frontend/src/internationalization/trans/chefs/tl/tl.json
index 9ab1f3313..647ca319d 100644
--- a/app/frontend/src/internationalization/trans/chefs/tl/tl.json
+++ b/app/frontend/src/internationalization/trans/chefs/tl/tl.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: hindi awtorisado. :",
"401ErrorMsg": "Ang iyong account ay hindi na-set up nang tama.
Mangyaring makipag-ugnayan",
"403Forbidden": "403 Ipinagbabawal. :",
- "403ErrorMsg": "Nangangailangan ang page na ito ng {idp} authentication.",
+ "403ErrorMsg": "Nangangailangan ang page na ito ng {permission} authentication.",
"401UnAuthorized": "401: Hindi awtorisado. :",
"401UnAuthorizedErrMsg": "Wala kang pahintulot na i-access ang pahinang ito."
},
diff --git a/app/frontend/src/internationalization/trans/chefs/uk/uk.json b/app/frontend/src/internationalization/trans/chefs/uk/uk.json
index 41f5122c0..695452f64 100644
--- a/app/frontend/src/internationalization/trans/chefs/uk/uk.json
+++ b/app/frontend/src/internationalization/trans/chefs/uk/uk.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: не авторизовано. :",
"401ErrorMsg": "Ваш обліковий запис налаштовано неправильно.
Будь ласка зв'яжіться",
"403Forbidden": "403 Заборонено. :",
- "403ErrorMsg": "Для цієї сторінки потрібна автентифікація {idp}.",
+ "403ErrorMsg": "Для цієї сторінки потрібна автентифікація {permission}.",
"401UnAuthorized": "401: неавторизовано. :",
"401UnAuthorizedErrMsg": "Ви не маєте дозволу на доступ до цієї сторінки."
},
diff --git a/app/frontend/src/internationalization/trans/chefs/vi/vi.json b/app/frontend/src/internationalization/trans/chefs/vi/vi.json
index 7f440b7d9..73872a2e7 100644
--- a/app/frontend/src/internationalization/trans/chefs/vi/vi.json
+++ b/app/frontend/src/internationalization/trans/chefs/vi/vi.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401: không được ủy quyền. :",
"401ErrorMsg": "Tài khoản của bạn không được thiết lập chính xác.
Vui lòng liên hệ",
"403Forbidden": "403: Cấm. :",
- "403ErrorMsg": "Trang này yêu cầu xác thực {idp}.",
+ "403ErrorMsg": "Trang này yêu cầu xác thực {permission}.",
"401UnAuthorized": "401: Trái phép. :",
"401UnAuthorizedErrMsg": "Bạn không có quyền truy cập trang này."
},
diff --git a/app/frontend/src/internationalization/trans/chefs/zh/zh.json b/app/frontend/src/internationalization/trans/chefs/zh/zh.json
index 4bdb517a1..74aaa5222 100644
--- a/app/frontend/src/internationalization/trans/chefs/zh/zh.json
+++ b/app/frontend/src/internationalization/trans/chefs/zh/zh.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401:未授权。 :",
"401ErrorMsg": "您的帐户设置不正确。
请联系",
"403Forbidden": "403:禁止。 :",
- "403ErrorMsg": "此页面需要 {idp} 身份验证。",
+ "403ErrorMsg": "此页面需要 {permission} 身份验证。",
"401UnAuthorized": "401:未经授权。 :",
"401UnAuthorizedErrMsg": "您没有权限访问此页面。"
},
diff --git a/app/frontend/src/internationalization/trans/chefs/zhTW/zh-TW.json b/app/frontend/src/internationalization/trans/chefs/zhTW/zh-TW.json
index 2a01e8e1d..88d856e10 100644
--- a/app/frontend/src/internationalization/trans/chefs/zhTW/zh-TW.json
+++ b/app/frontend/src/internationalization/trans/chefs/zhTW/zh-TW.json
@@ -377,7 +377,7 @@
"401NotAuthorized": "401:未授權。 :",
"401ErrorMsg": "您的帳戶設置不正確。
請聯繫",
"403Forbidden": "403:禁止。 :",
- "403ErrorMsg": "此頁面需要 {idp} 身份驗證。",
+ "403ErrorMsg": "此頁面需要 {permission} 身份驗證。",
"401UnAuthorized": "401:未經授權。 :",
"401UnAuthorizedErrMsg": "您沒有權限訪問此頁面。"
},
diff --git a/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js b/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js
index ed57037c8..d7f7b60ca 100644
--- a/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js
+++ b/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js
@@ -8,13 +8,16 @@ import { vi } from 'vitest';
import getRouter from '~/router';
import BaseAuthButton from '~/components/base/BaseAuthButton.vue';
import { useAuthStore } from '~/store/auth';
+import { useIdpStore } from '~/store/identityProviders';
describe('BaseAuthButton.vue', () => {
const pinia = createPinia();
setActivePinia(pinia);
const authStore = useAuthStore();
+ const idpStore = useIdpStore();
const router = getRouter();
const windowReplaceSpy = vi.spyOn(window.location, 'replace');
+ idpStore.providers = require('../../fixtures/identityProviders.json');
beforeEach(async () => {
windowReplaceSpy.mockReset();
@@ -95,7 +98,7 @@ describe('BaseAuthButton.vue', () => {
expect(replace).toHaveBeenCalledTimes(1);
expect(replace).toHaveBeenCalledWith({
name: 'Login',
- query: { idpHint: ['idir', 'bceid-business', 'bceid-basic'] },
+ query: { idpHint: idpStore.loginIdpHints },
});
});
diff --git a/app/frontend/tests/unit/components/base/BaseSecure.spec.js b/app/frontend/tests/unit/components/base/BaseSecure.spec.js
index c2ea0e590..d60435ac9 100644
--- a/app/frontend/tests/unit/components/base/BaseSecure.spec.js
+++ b/app/frontend/tests/unit/components/base/BaseSecure.spec.js
@@ -9,6 +9,8 @@ import { expect, vi } from 'vitest';
import getRouter from '~/router';
import BaseSecure from '~/components/base/BaseSecure.vue';
import { useAuthStore } from '~/store/auth';
+import { useIdpStore } from '~/store/identityProviders';
+import { AppPermissions } from '~/utils/constants';
describe('BaseSecure.vue', () => {
const pinia = createPinia();
@@ -19,6 +21,12 @@ describe('BaseSecure.vue', () => {
setActivePinia(pinia);
const authStore = useAuthStore();
+ const idpStore = useIdpStore();
+
+ idpStore.providers = require('../../fixtures/identityProviders.json');
+ const nonPrimaryIdp = idpStore.providers.find(
+ (x) => x.active && x.login && !x.primary
+ );
it('renders nothing if authenticated, user', () => {
authStore.authenticated = true;
@@ -30,6 +38,7 @@ describe('BaseSecure.vue', () => {
roles: ['user'],
},
},
+ identity_provider: nonPrimaryIdp.code,
},
};
const wrapper = mount(BaseSecure, {
@@ -51,6 +60,7 @@ describe('BaseSecure.vue', () => {
roles: [],
},
},
+ identity_provider: nonPrimaryIdp.code,
},
};
const wrapper = mount(BaseSecure, {
@@ -71,6 +81,7 @@ describe('BaseSecure.vue', () => {
chefs: {
roles: ['user'],
},
+ identity_provider: nonPrimaryIdp.code,
},
},
};
@@ -96,6 +107,7 @@ describe('BaseSecure.vue', () => {
roles: ['user'],
},
},
+ identity_provider: nonPrimaryIdp.code,
},
};
const wrapper = mount(BaseSecure, {
@@ -170,13 +182,14 @@ describe('BaseSecure.vue', () => {
chefs: {
roles: ['user'],
},
+ identity_provider: nonPrimaryIdp.code,
},
},
};
const wrapper = mount(BaseSecure, {
props: {
admin: false,
- idp: ['IDIR'],
+ permission: AppPermissions.VIEWS_ADMIN,
},
global: {
plugins: [router, pinia],
diff --git a/app/frontend/tests/unit/components/bcgov/BCGovNavBar.spec.js b/app/frontend/tests/unit/components/bcgov/BCGovNavBar.spec.js
index 751a0500a..2e0b3b502 100644
--- a/app/frontend/tests/unit/components/bcgov/BCGovNavBar.spec.js
+++ b/app/frontend/tests/unit/components/bcgov/BCGovNavBar.spec.js
@@ -9,6 +9,7 @@ import { VApp } from 'vuetify/components';
import BCGovNavBar from '~/components/bcgov/BCGovNavBar.vue';
import getRouter from '~/router';
import { useAuthStore } from '~/store/auth';
+import { useIdpStore } from '~/store/identityProviders';
describe('BCGovNavBar.vue', () => {
const pinia = createPinia();
@@ -17,12 +18,16 @@ describe('BCGovNavBar.vue', () => {
history: createWebHistory(),
routes: getRouter().getRoutes(),
});
+ const idpStore = useIdpStore();
+
+ idpStore.providers = require('../../fixtures/identityProviders');
+ const primaryIdp = idpStore.primaryIdp;
it('renders as non-admin', async () => {
const authStore = useAuthStore();
authStore.keycloak = {
tokenParsed: {
- identity_provider: 'idir',
+ identity_provider: primaryIdp.code,
resource_access: {
chefs: {
roles: [],
@@ -64,7 +69,7 @@ describe('BCGovNavBar.vue', () => {
const authStore = useAuthStore();
authStore.keycloak = {
tokenParsed: {
- identity_provider: 'idir',
+ identity_provider: primaryIdp.code,
resource_access: {
chefs: {
roles: ['admin'],
diff --git a/app/frontend/tests/unit/fixtures/identityProviders.json b/app/frontend/tests/unit/fixtures/identityProviders.json
new file mode 100644
index 000000000..3b757c0d7
--- /dev/null
+++ b/app/frontend/tests/unit/fixtures/identityProviders.json
@@ -0,0 +1,119 @@
+[
+ {
+ "code": "idir",
+ "display": "IDIR",
+ "active": true,
+ "idp": "idir",
+ "createdBy": "migration-002",
+ "createdAt": "2024-01-24T22:35:49.703Z",
+ "updatedBy": null,
+ "updatedAt": "2024-01-24T22:35:49.703Z",
+ "primary": true,
+ "login": true,
+ "permissions": [
+ "views_form_stepper",
+ "views_admin",
+ "views_file_download",
+ "views_form_emails",
+ "views_form_export",
+ "views_form_manage",
+ "views_form_preview",
+ "views_form_submissions",
+ "views_form_teamS",
+ "views_form_view",
+ "views_user_submissions"
+ ],
+ "roles": [
+ "owner",
+ "team_manager",
+ "form_designer",
+ "submission_reviewer",
+ "form_submitter"
+ ],
+ "extra": {}
+ },
+ {
+ "code": "bceid-basic",
+ "display": "Basic BCeID",
+ "active": true,
+ "idp": "bceid-basic",
+ "createdBy": "migration-022",
+ "createdAt": "2024-01-24T22:35:49.703Z",
+ "updatedBy": null,
+ "updatedAt": "2024-01-24T22:35:49.703Z",
+ "primary": false,
+ "login": true,
+ "permissions": [
+ "views_user_submissions"
+ ],
+ "roles": [
+ "form_submitter"
+ ],
+ "extra": {
+ "formAccessSettings": "idim",
+ "addTeamMemberSearch": {
+ "text": {
+ "message": "trans.manageSubmissionUsers.searchInputLength",
+ "minLength": 6
+ },
+ "email": {
+ "exact": true,
+ "message": "trans.manageSubmissionUsers.exactBCEIDSearch"
+ }
+ }
+ }
+ },
+ {
+ "code": "bceid-business",
+ "display": "Business BCeID",
+ "active": true,
+ "idp": "bceid-business",
+ "createdBy": "migration-022",
+ "createdAt": "2024-01-24T22:35:49.703Z",
+ "updatedBy": null,
+ "updatedAt": "2024-01-24T22:35:49.703Z",
+ "primary": false,
+ "login": true,
+ "permissions": [
+ "views_form_export",
+ "views_form_manage",
+ "views_form_submissions",
+ "views_form_teamS",
+ "views_form_view",
+ "views_user_submissions"
+ ],
+ "roles": [
+ "team_manager",
+ "submission_reviewer",
+ "form_submitter"
+ ],
+ "extra": {
+ "formAccessSettings": "idim",
+ "addTeamMemberSearch": {
+ "text": {
+ "message": "trans.manageSubmissionUsers.searchInputLength",
+ "minLength": 6
+ },
+ "email": {
+ "exact": true,
+ "message": "trans.manageSubmissionUsers.exactBCEIDSearch"
+ }
+ }
+ }
+ },
+ {
+ "code": "public",
+ "display": "Public",
+ "active": true,
+ "idp": "public",
+ "createdBy": "migration-002",
+ "createdAt": "2024-01-24T22:35:49.703Z",
+ "updatedBy": null,
+ "updatedAt": "2024-01-24T22:35:49.703Z",
+ "primary": false,
+ "login": false,
+ "permissions": [],
+ "roles": null,
+ "extra": {}
+ }
+]
\ No newline at end of file
diff --git a/app/frontend/tests/unit/store/modules/auth.actions.spec.js b/app/frontend/tests/unit/store/modules/auth.actions.spec.js
index 920aac5f9..1e482b7e4 100644
--- a/app/frontend/tests/unit/store/modules/auth.actions.spec.js
+++ b/app/frontend/tests/unit/store/modules/auth.actions.spec.js
@@ -6,6 +6,7 @@ import getRouter from '~/router';
import { useAuthStore } from '~/store/auth';
import { useFormStore } from '~/store/form';
+import { useIdpStore } from '~/store/identityProviders';
describe('auth actions', () => {
let router = getRouter();
@@ -14,6 +15,9 @@ describe('auth actions', () => {
setActivePinia(createPinia());
const mockStore = useAuthStore();
const formStore = useFormStore();
+ const idpStore = useIdpStore();
+
+ idpStore.providers = require('../../fixtures/identityProviders.json');
describe('login', () => {
beforeEach(() => {
@@ -74,7 +78,7 @@ describe('auth actions', () => {
expect(replaceSpy).toHaveBeenCalledTimes(1);
expect(replaceSpy).toHaveBeenCalledWith({
name: 'Login',
- query: { idpHint: ['idir', 'bceid-business', 'bceid-basic'] },
+ query: { idpHint: idpStore.loginIdpHints },
});
});
diff --git a/app/frontend/tests/unit/utils/constants.spec.js b/app/frontend/tests/unit/utils/constants.spec.js
index 512b9a2c4..7540826cf 100644
--- a/app/frontend/tests/unit/utils/constants.spec.js
+++ b/app/frontend/tests/unit/utils/constants.spec.js
@@ -68,11 +68,19 @@ describe('Constants', () => {
});
});
- it('IdentityProviders has the right values defined', () => {
- expect(constants.IdentityProviders).toEqual({
- BCEIDBASIC: 'bceid-basic',
- BCEIDBUSINESS: 'bceid-business',
- IDIR: 'idir',
+ it('AppPermissions has the right values defined', () => {
+ expect(constants.AppPermissions).toEqual({
+ VIEWS_FORM_STEPPER: 'views_form_stepper',
+ VIEWS_ADMIN: 'views_admin',
+ VIEWS_FILE_DOWNLOAD: 'views_file_download',
+ VIEWS_FORM_EMAILS: 'views_form_emails',
+ VIEWS_FORM_EXPORT: 'views_form_export',
+ VIEWS_FORM_MANAGE: 'views_form_manage',
+ VIEWS_FORM_PREVIEW: 'views_form_preview',
+ VIEWS_FORM_SUBMISSIONS: 'views_form_submissions',
+ VIEWS_FORM_TEAMS: 'views_form_teamS',
+ VIEWS_FORM_VIEW: 'views_form_view',
+ VIEWS_USER_SUBMISSIONS: 'views_user_submissions',
});
});
diff --git a/app/frontend/tests/unit/utils/permissionUtils.spec.js b/app/frontend/tests/unit/utils/permissionUtils.spec.js
index c14bf5be4..031f2e0c8 100644
--- a/app/frontend/tests/unit/utils/permissionUtils.spec.js
+++ b/app/frontend/tests/unit/utils/permissionUtils.spec.js
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { formService } from '~/services';
import { useAuthStore } from '~/store/auth';
+import { useIdpStore } from '~/store/identityProviders';
import { useNotificationStore } from '~/store/notification';
import {
FormPermissions,
@@ -24,12 +25,6 @@ describe('checkFormSubmit', () => {
expect(permissionUtils.checkFormSubmit({ idps: [] })).toBeFalsy();
});
- it('should be true when idps is public', () => {
- expect(
- permissionUtils.checkFormSubmit({ idps: [IdentityProviders.PUBLIC] })
- ).toBeTruthy();
- });
-
it('should be true when permissions is submission creator', () => {
expect(
permissionUtils.checkFormSubmit({
@@ -109,6 +104,7 @@ describe('preFlightAuth', () => {
setActivePinia(createPinia());
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
+ const idpStore = useIdpStore();
const mockNext = vi.fn();
const addNotificationSpy = vi.spyOn(notificationStore, 'addNotification');
const alertNavigateSpy = vi.spyOn(notificationStore, 'alertNavigate');
@@ -116,9 +112,16 @@ describe('preFlightAuth', () => {
const getSubmissionOptionsSpy = vi.spyOn(formService, 'getSubmissionOptions');
const readFormOptionsSpy = vi.spyOn(formService, 'readFormOptions');
+ idpStore.providers = require('../fixtures/identityProviders.json');
+ const primaryIdp = idpStore.primaryIdp;
+ const secondaryIdp = idpStore.providers.find(
+ (x) => x.active && x.login && !x.primary
+ );
+
beforeEach(() => {
authStore.$reset();
notificationStore.$reset();
+ //idpStore.$reset();
mockNext.mockReset();
addNotificationSpy.mockReset();
alertNavigateSpy.mockReset();
@@ -146,7 +149,7 @@ describe('preFlightAuth', () => {
authStore.authenticated = true;
authStore.keycloak = {
tokenParsed: {
- identity_provider: IdentityProviders.PUBLIC,
+ identity_provider: 'public',
},
};
@@ -172,7 +175,7 @@ describe('preFlightAuth', () => {
authStore.authenticated = true;
authStore.keycloak = {
tokenParsed: {
- identity_provider: IdentityProviders.PUBLIC,
+ identity_provider: 'public',
},
};
readFormOptionsSpy.mockImplementation(() => {
@@ -197,7 +200,7 @@ describe('preFlightAuth', () => {
authStore.authenticated = true;
authStore.keycloak = {
tokenParsed: {
- identity_provider: IdentityProviders.PUBLIC,
+ identity_provider: 'public',
},
};
const addNotificationSpy = vi.spyOn(notificationStore, 'addNotification');
@@ -225,7 +228,7 @@ describe('preFlightAuth', () => {
authStore.authenticated = true;
authStore.keycloak = {
tokenParsed: {
- identity_provider: IdentityProviders.PUBLIC,
+ identity_provider: 'unknown',
},
};
const addNotificationSpy = vi.spyOn(notificationStore, 'addNotification');
@@ -253,11 +256,11 @@ describe('preFlightAuth', () => {
authStore.authenticated = true;
authStore.keycloak = {
tokenParsed: {
- identity_provider: IdentityProviders.PUBLIC,
+ identity_provider: 'unknown',
},
};
readFormOptionsSpy.mockResolvedValue({
- data: { idpHints: [IdentityMode.PUBLIC] },
+ data: { idpHints: [] },
});
await permissionUtils.preFlightAuth({ formId: 'f' }, mockNext);
@@ -272,11 +275,11 @@ describe('preFlightAuth', () => {
authStore.authenticated = true;
authStore.keycloak = {
tokenParsed: {
- identity_provider: IdentityProviders.IDIR,
+ identity_provider: primaryIdp.code,
},
};
readFormOptionsSpy.mockResolvedValue({
- data: { idpHints: [IdentityProviders.IDIR] },
+ data: { idpHints: [primaryIdp.code] },
});
await permissionUtils.preFlightAuth({ formId: 'f' }, mockNext);
@@ -291,13 +294,13 @@ describe('preFlightAuth', () => {
authStore.authenticated = true;
authStore.keycloak = {
tokenParsed: {
- identity_provider: IdentityProviders.IDIR,
+ identity_provider: primaryIdp.code,
},
};
const addNotificationSpy = vi.spyOn(notificationStore, 'addNotification');
const errorNavigateSpy = vi.spyOn(notificationStore, 'errorNavigate');
readFormOptionsSpy.mockResolvedValue({
- data: { idpHints: [IdentityProviders.BCEIDBASIC] },
+ data: { idpHints: [secondaryIdp.code] },
});
await permissionUtils.preFlightAuth({ formId: 'f' }, mockNext);
@@ -327,7 +330,7 @@ describe('preFlightAuth', () => {
it('should call getSubmissionOptions and login flow with idpHint', async () => {
authStore.authenticated = false;
getSubmissionOptionsSpy.mockResolvedValue({
- data: { form: { idpHints: [IdentityProviders.IDIR] } },
+ data: { form: { idpHints: ['idir'] } },
});
await permissionUtils.preFlightAuth({ submissionId: 's' }, mockNext);
diff --git a/app/frontend/tests/unit/views/Login.spec.js b/app/frontend/tests/unit/views/Login.spec.js
index 30dbbcc47..4a16c4b1c 100644
--- a/app/frontend/tests/unit/views/Login.spec.js
+++ b/app/frontend/tests/unit/views/Login.spec.js
@@ -6,13 +6,16 @@ import { nextTick } from 'vue';
import { useAuthStore } from '~/store/auth';
import Login from '~/views/Login.vue';
-import { IdentityProviders } from '~/utils/constants';
+import { useIdpStore } from '~/store/identityProviders';
describe('Login.vue', () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const authStore = useAuthStore(pinia);
+ const idpStore = useIdpStore(pinia);
+
+ idpStore.providers = require('../fixtures/identityProviders.json');
beforeEach(() => {
authStore.$reset();
@@ -66,7 +69,7 @@ describe('Login.vue', () => {
await nextTick();
- Object.values(IdentityProviders).forEach((idp) => {
+ Object.values(idpStore.loginIdpHints).forEach((idp) => {
const button = wrapper.find(`[data-test="${idp}"]`);
expect(button.exists()).toBeTruthy();
});
From 879f6b57148a83c264be52d0149f87c8ceba630f Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Mon, 26 Feb 2024 09:57:02 -0800
Subject: [PATCH 03/16] SSO - standard realm updates remove keycloak from API,
replace with jwt verification only. token to user mapping now in
configuration not in keycloak. frontend keycloak configuration changes
slightly (no resource_access). no user role.
Signed-off-by: Jason Sherman
---
app/app.js | 22 ++-
app/config/custom-environment-variables.json | 13 +-
app/config/default.json | 14 +-
.../src/components/base/BaseSecure.vue | 59 ++----
.../src/components/bcgov/BCGovNavBar.vue | 2 +-
.../src/components/designer/FormsTable.vue | 2 +-
.../designer/settings/FormAccessSettings.vue | 11 +-
.../settings/FormFunctionalitySettings.vue | 2 +-
.../src/components/forms/SubmissionsTable.vue | 2 +-
.../components/forms/manage/AddTeamMember.vue | 4 +-
.../forms/manage/TeamManagement.vue | 7 +-
.../submission/ManageSubmissionUsers.vue | 6 +-
app/frontend/src/main.js | 10 +-
app/frontend/src/store/auth.js | 40 ++--
app/frontend/src/store/identityProviders.js | 41 +++-
app/frontend/src/utils/permissionUtils.js | 6 +-
app/frontend/src/views/Login.vue | 8 +-
.../components/base/BaseAuthButton.spec.js | 8 +-
.../unit/components/base/BaseSecure.spec.js | 53 +----
.../unit/components/bcgov/BCGovNavBar.spec.js | 12 +-
.../unit/fixtures/identityProviders.json | 4 +-
.../unit/store/modules/auth.actions.spec.js | 9 +-
.../unit/store/modules/auth.getters.spec.js | 67 ++-----
.../tests/unit/utils/permissionUtils.spec.js | 2 +-
app/frontend/tests/unit/views/Login.spec.js | 4 +-
app/package-lock.json | 187 ++----------------
app/package.json | 2 +-
app/src/components/idpService.js | 169 ++++++++++++++++
app/src/components/jwtService.js | 100 ++++++++++
app/src/components/keycloak.js | 19 --
...119172630_identity_provider_permissions.js | 48 +++++
app/src/forms/admin/routes.js | 5 +-
app/src/forms/auth/middleware/userAccess.js | 144 ++++++++++----
app/src/forms/auth/service.js | 57 ++----
app/src/forms/common/constants.js | 11 --
.../common/models/tables/identityProvider.js | 6 +
app/src/forms/common/models/tables/user.js | 5 +-
app/src/forms/form/routes.js | 5 +-
app/src/forms/permission/routes.js | 5 +-
app/src/forms/rbac/routes.js | 9 +-
app/src/forms/rbac/service.js | 16 +-
app/src/forms/role/routes.js | 11 +-
app/src/forms/user/routes.js | 4 +-
app/src/forms/user/service.js | 28 +--
app/tests/unit/forms/auth/authService.spec.js | 15 +-
.../forms/auth/middleware/userAccess.spec.js | 83 +++++---
app/tests/unit/forms/user/service.spec.js | 3 +
app/tests/unit/routes/v1/admin.spec.js | 4 +-
app/tests/unit/routes/v1/form.spec.js | 4 +-
app/tests/unit/routes/v1/permission.spec.js | 4 +-
app/tests/unit/routes/v1/rbac.spec.js | 4 +-
app/tests/unit/routes/v1/role.spec.js | 4 +-
app/tests/unit/routes/v1/user.spec.js | 4 +-
53 files changed, 749 insertions(+), 615 deletions(-)
create mode 100644 app/src/components/idpService.js
create mode 100644 app/src/components/jwtService.js
delete mode 100755 app/src/components/keycloak.js
diff --git a/app/app.js b/app/app.js
index f960debc5..e2e202789 100644
--- a/app/app.js
+++ b/app/app.js
@@ -5,7 +5,6 @@ const path = require('path');
const Problem = require('api-problem');
const querystring = require('querystring');
-const keycloak = require('./src/components/keycloak');
const log = require('./src/components/log')(module.filename);
const httpLogger = require('./src/components/log').httpLogger;
const middleware = require('./src/forms/common/middleware');
@@ -40,9 +39,6 @@ if (process.env.NODE_ENV !== 'test') {
app.use(httpLogger);
}
-// Use Keycloak OIDC Middleware
-app.use(keycloak.middleware());
-
// Block requests until service is ready
app.use((_req, res, next) => {
if (state.shutdown) {
@@ -178,11 +174,16 @@ function initializeConnections() {
.then((results) => {
state.connections.data = results[0];
- if (state.connections.data) log.info('DataConnection Reachable', { function: 'initializeConnections' });
+ if (state.connections.data)
+ log.info('DataConnection Reachable', {
+ function: 'initializeConnections',
+ });
})
.catch((error) => {
log.error(`Initialization failed: Database OK = ${state.connections.data}`, { function: 'initializeConnections' });
- log.error('Connection initialization failure', error.message, { function: 'initializeConnections' });
+ log.error('Connection initialization failure', error.message, {
+ function: 'initializeConnections',
+ });
if (!state.ready) {
process.exitCode = 1;
shutdown();
@@ -191,7 +192,9 @@ function initializeConnections() {
.finally(() => {
state.ready = Object.values(state.connections).every((x) => x);
if (state.ready) {
- log.info('Service ready to accept traffic', { function: 'initializeConnections' });
+ log.info('Service ready to accept traffic', {
+ function: 'initializeConnections',
+ });
// Start periodic 10 second connection probe check
probeId = setInterval(checkConnections, 10000);
}
@@ -211,7 +214,10 @@ function checkConnections() {
Promise.all(tasks).then((results) => {
state.connections.data = results[0];
state.ready = Object.values(state.connections).every((x) => x);
- if (!wasReady && state.ready) log.info('Service ready to accept traffic', { function: 'checkConnections' });
+ if (!wasReady && state.ready)
+ log.info('Service ready to accept traffic', {
+ function: 'checkConnections',
+ });
log.verbose(state);
if (!state.ready) {
process.exitCode = 1;
diff --git a/app/config/custom-environment-variables.json b/app/config/custom-environment-variables.json
index ca034709e..06d1c82b6 100755
--- a/app/config/custom-environment-variables.json
+++ b/app/config/custom-environment-variables.json
@@ -35,7 +35,8 @@
"keycloak": {
"clientId": "FRONTEND_KC_CLIENTID",
"realm": "FRONTEND_KC_REALM",
- "serverUrl": "FRONTEND_KC_SERVERURL"
+ "serverUrl": "FRONTEND_KC_SERVERURL",
+ "logoutUrl": "FRONTEND_KC_LOGOUTURL"
}
},
"server": {
@@ -43,11 +44,11 @@
"basePath": "SERVER_BASEPATH",
"bodyLimit": "SERVER_BODYLIMIT",
"keycloak": {
- "clientId": "SERVER_KC_CLIENTID",
- "clientSecret": "SERVER_KC_CLIENTSECRET",
- "publicKey": "SERVER_KC_PUBLICKEY",
- "realm": "SERVER_KC_REALM",
- "serverUrl": "SERVER_KC_SERVERURL"
+ "serverUrl": "SERVER_KC_SERVERURL",
+ "jwksUri": "SERVER_KC_JWKSURI",
+ "issuer": "SERVER_KC_ISSUER",
+ "audience": "SERVER_KC_AUDIENCE",
+ "maxTokenAge": "SERVER_KC_MAXTOKENAGE"
},
"logFile": "SERVER_LOGFILE",
"logLevel": "SERVER_LOGLEVEL",
diff --git a/app/config/default.json b/app/config/default.json
index 3fadfbf56..99bb82950 100644
--- a/app/config/default.json
+++ b/app/config/default.json
@@ -32,8 +32,9 @@
"basePath": "/app",
"keycloak": {
"clientId": "chefs-frontend",
- "realm": "chefs",
- "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth"
+ "realm": "standard",
+ "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth",
+ "logoutUrl": "https://logon7.gov.bc.ca/clp-cgi/logoff.cgi?retnow=1&returl=https%3A%2F%2Fdev.loginproxy.gov.bc.ca%2Fauth%2Frealms%2Fstandard%2Fprotocol%2Fopenid-connect%2Flogout%3Fpost_logout_redirect_uri%3Dhttp%3A%2F%2Flocalhost%3A5173%2Fapp%26client_id%3Dchefs-frontend"
}
},
"server": {
@@ -41,9 +42,12 @@
"basePath": "/app",
"bodyLimit": "30mb",
"keycloak": {
- "clientId": "chefs",
- "realm": "chefs",
- "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth"
+ "realm": "standard",
+ "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth",
+ "jwksUri": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard/protocol/openid-connect/certs",
+ "issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard",
+ "audience": "chefs-frontend",
+ "maxTokenAge": "300"
},
"logLevel": "http",
"port": "8080",
diff --git a/app/frontend/src/components/base/BaseSecure.vue b/app/frontend/src/components/base/BaseSecure.vue
index f4ee5e424..d75aa7761 100755
--- a/app/frontend/src/components/base/BaseSecure.vue
+++ b/app/frontend/src/components/base/BaseSecure.vue
@@ -20,7 +20,6 @@ export default {
'authenticated',
'identityProvider',
'isAdmin',
- 'isUser',
'ready',
]),
...mapState(useFormStore, ['lang']),
@@ -44,48 +43,32 @@ export default {
-
-
-
- {{ $t('trans.baseSecure.401UnAuthorized') }}
-
-
- {{ $t('trans.baseSecure.401UnAuthorizedErrMsg') }}
-
-
-
-
- {{ $t('trans.baseSecure.403Forbidden') }}
-
-
- {{
- $t('trans.baseSecure.403ErrorMsg', {
- idp: permission,
- })
- }}
-
-
-
-
-
-
+
{{ $t('trans.baseSecure.401UnAuthorized') }}
-
-
- {{ contactInfo }}
+
+ {{ $t('trans.baseSecure.401UnAuthorizedErrMsg') }}
+
+
+
+
+ {{ $t('trans.baseSecure.403Forbidden') }}
+
+
+ {{
+ $t('trans.baseSecure.403ErrorMsg', {
+ idp: permission,
+ })
+ }}
-
-
-
- {{ $t('trans.baseSecure.about') }}
-
-
+
diff --git a/app/frontend/src/components/bcgov/BCGovNavBar.vue b/app/frontend/src/components/bcgov/BCGovNavBar.vue
index a3a6a30c7..ea88d5322 100755
--- a/app/frontend/src/components/bcgov/BCGovNavBar.vue
+++ b/app/frontend/src/components/bcgov/BCGovNavBar.vue
@@ -19,7 +19,7 @@ export default {
return this.$route && this.$route.meta && this.$route.meta.formSubmitMode;
},
hasPrivileges() {
- return this.isPrimary(this.identityProvider);
+ return this.isPrimary(this.identityProvider?.code);
},
},
};
diff --git a/app/frontend/src/components/designer/FormsTable.vue b/app/frontend/src/components/designer/FormsTable.vue
index c55cf7af8..606e781f8 100644
--- a/app/frontend/src/components/designer/FormsTable.vue
+++ b/app/frontend/src/components/designer/FormsTable.vue
@@ -45,7 +45,7 @@ export default {
];
},
canCreateForm() {
- return this.isPrimary(this.user.idp);
+ return this.isPrimary(this.user.idp?.code);
},
filteredFormList() {
return this.formList.filter(
diff --git a/app/frontend/src/components/designer/settings/FormAccessSettings.vue b/app/frontend/src/components/designer/settings/FormAccessSettings.vue
index f89c163aa..896193b9f 100644
--- a/app/frontend/src/components/designer/settings/FormAccessSettings.vue
+++ b/app/frontend/src/components/designer/settings/FormAccessSettings.vue
@@ -35,6 +35,11 @@ export default {
return IdentityMode;
},
},
+ mounted() {
+ if (this.form?.idps && this.form.idps.length) {
+ this.idpType = this.form.idps[0];
+ }
+ },
methods: {
userTypeChanged() {
// if they checked enable drafts then went back to public, uncheck it
@@ -129,12 +134,12 @@ export default {
>
- {{ button.label }}
+ {{ button.display }}
diff --git a/app/frontend/src/components/designer/settings/FormFunctionalitySettings.vue b/app/frontend/src/components/designer/settings/FormFunctionalitySettings.vue
index 83b7e7575..f88606501 100644
--- a/app/frontend/src/components/designer/settings/FormFunctionalitySettings.vue
+++ b/app/frontend/src/components/designer/settings/FormFunctionalitySettings.vue
@@ -31,7 +31,7 @@ export default {
return IdentityMode;
},
primaryIdpUser() {
- return this.isPrimary(this.identityProvider);
+ return this.isPrimary(this.identityProvider?.code);
},
},
methods: {
diff --git a/app/frontend/src/components/forms/SubmissionsTable.vue b/app/frontend/src/components/forms/SubmissionsTable.vue
index 37ac641dd..726ba7487 100644
--- a/app/frontend/src/components/forms/SubmissionsTable.vue
+++ b/app/frontend/src/components/forms/SubmissionsTable.vue
@@ -427,7 +427,7 @@ export default {
}),
deletedOnly: this.deletedOnly,
createdBy: this.currentUserOnly
- ? `${this.user.username}@${this.user.idp}`
+ ? `${this.user.username}@${this.user.idp?.code}`
: '',
};
await this.fetchSubmissions(criteria);
diff --git a/app/frontend/src/components/forms/manage/AddTeamMember.vue b/app/frontend/src/components/forms/manage/AddTeamMember.vue
index ffdca0759..5aca02522 100644
--- a/app/frontend/src/components/forms/manage/AddTeamMember.vue
+++ b/app/frontend/src/components/forms/manage/AddTeamMember.vue
@@ -174,8 +174,8 @@ export default {
>
diff --git a/app/frontend/src/components/forms/manage/TeamManagement.vue b/app/frontend/src/components/forms/manage/TeamManagement.vue
index 777bfa49b..342ffe613 100644
--- a/app/frontend/src/components/forms/manage/TeamManagement.vue
+++ b/app/frontend/src/components/forms/manage/TeamManagement.vue
@@ -123,6 +123,7 @@ export default {
methods: {
...mapActions(useFormStore, ['fetchForm', 'getFormPermissionsForUser']),
...mapActions(useNotificationStore, ['addNotification']),
+ ...mapActions(useIdpStore, ['findByCode']),
async loadItems() {
this.loading = true;
@@ -162,7 +163,7 @@ export default {
roles: '*',
});
this.formUsers = formUsersResponse?.data?.map((user) => {
- user.idp = user.user_idpCode;
+ user.idp = this.findByCode(user.user_idpCode);
return user;
});
} catch (error) {
@@ -185,7 +186,7 @@ export default {
fullName: user.fullName,
userId: user.userId,
username: user.username,
- identityProvider: user.idp,
+ identityProvider: user.idp?.code,
};
this.roleList
.map((role) => role.code)
@@ -201,7 +202,7 @@ export default {
)
return true;
// if the header isn't in the IDPs roles, then disable
- const idpRoles = this.listRoles(user.identityProvider);
+ const idpRoles = this.listRoles(user.identityProvider?.code);
return idpRoles && !idpRoles.includes(header);
},
diff --git a/app/frontend/src/components/forms/submission/ManageSubmissionUsers.vue b/app/frontend/src/components/forms/submission/ManageSubmissionUsers.vue
index 8f3b699d3..557b8a97a 100644
--- a/app/frontend/src/components/forms/submission/ManageSubmissionUsers.vue
+++ b/app/frontend/src/components/forms/submission/ManageSubmissionUsers.vue
@@ -58,7 +58,7 @@ export default {
if (!input) return;
this.isLoadingDropdown = true;
try {
- // The form's IDP (only support 1 at a time right now), blank is 'team' and should be IDIR
+ // The form's IDP (only support 1 at a time right now), blank is 'team' and should be Primary
let params = {};
params.idpCode = this.selectedIdp;
let teamMembershipConfig = this.teamMembershipSearch(this.selectedIdp);
@@ -242,8 +242,8 @@ export default {
diff --git a/app/frontend/src/main.js b/app/frontend/src/main.js
index bd0d67ca1..cfb257330 100755
--- a/app/frontend/src/main.js
+++ b/app/frontend/src/main.js
@@ -140,7 +140,8 @@ async function loadConfig() {
!config.keycloak ||
!config.keycloak.clientId ||
!config.keycloak.realm ||
- !config.keycloak.serverUrl
+ !config.keycloak.serverUrl ||
+ !config.keycloak.logoutUrl
) {
throw new Error('Keycloak is misconfigured');
}
@@ -165,7 +166,7 @@ function loadKeycloak(config) {
};
const options = Object.assign({}, defaultParams, {
- init: { onLoad: 'check-sso' },
+ init: { pkceMethod: 'S256', checkLoginIframe: false, onLoad: 'check-sso' },
config: {
clientId: config.keycloak.clientId,
realm: config.keycloak.realm,
@@ -188,6 +189,7 @@ function loadKeycloak(config) {
const ctor = sanitizeConfig(cfg);
const authStore = useAuthStore();
+ authStore.logoutUrl = config.keycloak.logoutUrl;
keycloak = new Keycloak(ctor);
keycloak.onReady = (authenticated) => {
@@ -207,9 +209,7 @@ function loadKeycloak(config) {
);
authStore.logoutFn = () => {
clearInterval(updateTokenInterval);
- keycloak.logout(
- options.logout || { redirectUri: config['logoutRedirectUri'] }
- );
+ authStore.updateKeycloak(keycloak, false);
};
};
keycloak.onAuthRefreshSuccess = () => {
diff --git a/app/frontend/src/store/auth.js b/app/frontend/src/store/auth.js
index 374cc7765..56aa4f7ba 100644
--- a/app/frontend/src/store/auth.js
+++ b/app/frontend/src/store/auth.js
@@ -21,12 +21,11 @@ export const useAuthStore = defineStore('auth', {
redirectUri: undefined,
ready: false,
authenticated: false,
+ logoutUrl: undefined,
}),
getters: {
createLoginUrl: (state) => (options) =>
state.keycloak.createLoginUrl(options),
- createLogoutUrl: (state) => (options) =>
- state.keycloak.createLogoutUrl(options),
email: (state) =>
state.keycloak.tokenParsed ? state.keycloak.tokenParsed.email : '',
fullName: (state) => state.keycloak.tokenParsed.name,
@@ -35,38 +34,44 @@ export const useAuthStore = defineStore('auth', {
* @returns (T/F) Whether the state has the required roles
*/
hasResourceRoles: (state) => {
- return (resource, roles) => {
+ return (roles) => {
if (!state.authenticated) return false;
if (!roles.length) return true; // No roles to check against
- if (state.resourceAccess && state.resourceAccess[resource]) {
- return hasRoles(state.resourceAccess[resource].roles, roles);
+ if (state.resourceAccess) {
+ return hasRoles(state.resourceAccess, roles);
}
return false; // There are roles to check, but nothing in token to check against
};
},
- identityProvider: (state) =>
- state.keycloak.tokenParsed
- ? state.keycloak.tokenParsed.identity_provider
- : null,
- isAdmin: (state) => state.hasResourceRoles('chefs', ['admin']),
- isUser: (state) => state.hasResourceRoles('chefs', ['user']),
+ identityProvider: (state) => {
+ const idpStore = useIdpStore();
+ return state.keycloak.tokenParsed
+ ? idpStore.findByHint(state.tokenParsed.identity_provider)
+ : null;
+ },
+ isAdmin: (state) => state.hasResourceRoles(['admin']),
keycloakSubject: (state) => state.keycloak.subject,
identityProviderIdentity: (state) => state.keycloak.tokenParsed.idp_userid,
moduleLoaded: (state) => !!state.keycloak,
realmAccess: (state) => state.keycloak.tokenParsed.realm_access,
- resourceAccess: (state) => state.keycloak.tokenParsed.resource_access,
+ resourceAccess: (state) => state.keycloak.tokenParsed.client_roles,
token: (state) => state.keycloak.token,
tokenParsed: (state) => state.keycloak.tokenParsed,
userName: (state) => state.keycloak.tokenParsed.preferred_username,
user: (state) => {
+ const idpStore = useIdpStore();
const user = {
username: '',
firstName: '',
lastName: '',
fullName: '',
email: '',
- idp: 'public',
+ idp: {
+ code: 'public',
+ display: 'Public',
+ hint: 'public',
+ },
public: !state.authenticated,
};
if (state.authenticated) {
@@ -79,7 +84,8 @@ export const useAuthStore = defineStore('auth', {
user.lastName = state.tokenParsed.family_name;
user.fullName = state.tokenParsed.name;
user.email = state.tokenParsed.email;
- user.idp = state.tokenParsed.identity_provider;
+ const idp = idpStore.findByHint(state.tokenParsed.identity_provider);
+ user.idp = idp;
}
return user;
@@ -117,11 +123,7 @@ export const useAuthStore = defineStore('auth', {
},
logout() {
if (this.ready) {
- window.location.replace(
- this.createLogoutUrl({
- redirectUri: location.origin,
- })
- );
+ window.location.assign(this.logoutUrl);
}
},
},
diff --git a/app/frontend/src/store/identityProviders.js b/app/frontend/src/store/identityProviders.js
index d6b6b5cd0..03301d5ba 100644
--- a/app/frontend/src/store/identityProviders.js
+++ b/app/frontend/src/store/identityProviders.js
@@ -11,8 +11,9 @@ export const useIdpStore = defineStore('idps', {
for (const p of state.providers) {
if (p.login) {
result.push({
- label: p.display,
- type: p.code,
+ code: p.code,
+ display: p.display,
+ hint: p.idp,
});
}
}
@@ -24,7 +25,7 @@ export const useIdpStore = defineStore('idps', {
if (state.providers) {
for (const p of state.providers) {
if (p.login) {
- result.push(p.code);
+ result.push(p.idp);
}
}
}
@@ -47,9 +48,9 @@ export const useIdpStore = defineStore('idps', {
}
return false;
},
- isValidIdp(code) {
- if (code && this.providers) {
- return this.providers.findIndex((x) => x.code === code) > -1;
+ isValidIdpHint(hint) {
+ if (hint && this.providers) {
+ return this.providers.findIndex((x) => x.hint === hint) > -1;
}
return false;
},
@@ -66,7 +67,7 @@ export const useIdpStore = defineStore('idps', {
teamMembershipSearch(code) {
if (code && this.providers) {
const idp = this.providers.find(
- (x) => x.code === code && idp.extra?.addTeamMemberSearch
+ (x) => x.code === code && x.extra?.addTeamMemberSearch
);
return idp ? idp.extra?.addTeamMemberSearch : null;
}
@@ -110,5 +111,31 @@ export const useIdpStore = defineStore('idps', {
}
return undefined;
},
+ findByCode(code) {
+ if (code && this.providers) {
+ const idp = this.providers.find((x) => x.code === code);
+ if (idp) {
+ return {
+ code: idp.code,
+ display: idp.display,
+ hint: idp.idp,
+ };
+ }
+ }
+ return undefined;
+ },
+ findByHint(hint) {
+ if (hint && this.providers) {
+ const idp = this.providers.find((x) => x.idp === hint);
+ if (idp) {
+ return {
+ code: idp.code,
+ display: idp.display,
+ hint: idp.idp,
+ };
+ }
+ }
+ return undefined;
+ },
},
});
diff --git a/app/frontend/src/utils/permissionUtils.js b/app/frontend/src/utils/permissionUtils.js
index e521377c8..16eda3719 100644
--- a/app/frontend/src/utils/permissionUtils.js
+++ b/app/frontend/src/utils/permissionUtils.js
@@ -87,7 +87,7 @@ export async function preFlightAuth(options = {}, next) {
const getIdpHint = (values) => {
return Array.isArray(values) && values.length ? values[0] : undefined;
};
- const isValidIdp = (value) => idpStore.isValidIdp(value);
+ const isValidIdpHint = (value) => idpStore.isValidIdpHint(value);
// Determine current form or submission idpHint if available
let idpHint = undefined;
@@ -136,7 +136,7 @@ export async function preFlightAuth(options = {}, next) {
if (idpHint === IdentityMode.PUBLIC || !idpHint) {
next(); // Permit navigation if public or team form
- } else if (isValidIdp(idpHint) && userIdp === idpHint) {
+ } else if (isValidIdpHint(idpHint) && userIdp?.hint === idpHint) {
next(); // Permit navigation if idps match
} else {
const msg = {
@@ -154,7 +154,7 @@ export async function preFlightAuth(options = {}, next) {
} else {
if (idpHint === IdentityMode.PUBLIC) {
next(); // Permit navigation if public form
- } else if (isValidIdp(idpHint)) {
+ } else if (isValidIdpHint(idpHint)) {
authStore.login(idpHint); // Force login flow with specified idpHint
} else {
authStore.login(); // Force login flow with user choice
diff --git a/app/frontend/src/views/Login.vue b/app/frontend/src/views/Login.vue
index 4f2598a69..908e9a461 100644
--- a/app/frontend/src/views/Login.vue
+++ b/app/frontend/src/views/Login.vue
@@ -36,16 +36,16 @@ export default {
{{ $t('trans.login.authenticateWith') }}
-
+
- {{ button.label }}
+ {{ button.display }}
diff --git a/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js b/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js
index d7f7b60ca..0cb3c64c0 100644
--- a/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js
+++ b/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js
@@ -16,7 +16,7 @@ describe('BaseAuthButton.vue', () => {
const authStore = useAuthStore();
const idpStore = useIdpStore();
const router = getRouter();
- const windowReplaceSpy = vi.spyOn(window.location, 'replace');
+ const windowReplaceSpy = vi.spyOn(window.location, 'assign');
idpStore.providers = require('../../fixtures/identityProviders.json');
beforeEach(async () => {
@@ -24,7 +24,6 @@ describe('BaseAuthButton.vue', () => {
authStore.$reset();
authStore.keycloak = {
createLoginUrl: vi.fn((opts) => opts),
- createLogoutUrl: vi.fn((opts) => opts),
};
router.currentRoute.value.meta.hasLogin = true;
router.push('/');
@@ -104,6 +103,7 @@ describe('BaseAuthButton.vue', () => {
it('logout button redirects to logout url', async () => {
authStore.authenticated = true;
+ authStore.logoutUrl = location.origin;
authStore.ready = true;
const wrapper = mount(BaseAuthButton, {
global: {
@@ -114,8 +114,6 @@ describe('BaseAuthButton.vue', () => {
wrapper.vm.logout();
expect(wrapper.text()).toMatch('trans.baseAuthButton.logout');
expect(windowReplaceSpy).toHaveBeenCalledTimes(1);
- expect(windowReplaceSpy).toHaveBeenCalledWith({
- redirectUri: location.origin,
- });
+ expect(windowReplaceSpy).toHaveBeenCalledWith(location.origin);
});
});
diff --git a/app/frontend/tests/unit/components/base/BaseSecure.spec.js b/app/frontend/tests/unit/components/base/BaseSecure.spec.js
index d60435ac9..585e4f3ca 100644
--- a/app/frontend/tests/unit/components/base/BaseSecure.spec.js
+++ b/app/frontend/tests/unit/components/base/BaseSecure.spec.js
@@ -33,11 +33,7 @@ describe('BaseSecure.vue', () => {
authStore.ready = true;
authStore.keycloak = {
tokenParsed: {
- resource_access: {
- chefs: {
- roles: ['user'],
- },
- },
+ client_roles: [],
identity_provider: nonPrimaryIdp.code,
},
};
@@ -50,41 +46,16 @@ describe('BaseSecure.vue', () => {
expect(wrapper.text()).toEqual('');
});
- it('renders a message if authenticated, not user', () => {
- authStore.authenticated = true;
- authStore.ready = true;
- authStore.keycloak = {
- tokenParsed: {
- resource_access: {
- chefs: {
- roles: [],
- },
- },
- identity_provider: nonPrimaryIdp.code,
- },
- };
- const wrapper = mount(BaseSecure, {
- global: {
- plugins: [router, pinia],
- },
- });
-
- expect(wrapper.text()).toContain('trans.baseSecure.401UnAuthorized');
- });
-
it('renders a message if admin required, not admin', () => {
authStore.authenticated = true;
authStore.ready = true;
authStore.keycloak = {
tokenParsed: {
- resource_access: {
- chefs: {
- roles: ['user'],
- },
- identity_provider: nonPrimaryIdp.code,
+ client_roles: [],
+ identity_provider: nonPrimaryIdp.code,
},
- },
- };
+ };
+
const wrapper = mount(BaseSecure, {
props: {
admin: true,
@@ -102,11 +73,7 @@ describe('BaseSecure.vue', () => {
authStore.ready = true;
authStore.keycloak = {
tokenParsed: {
- resource_access: {
- chefs: {
- roles: ['user'],
- },
- },
+ client_roles: ['admin'],
identity_provider: nonPrimaryIdp.code,
},
};
@@ -178,13 +145,9 @@ describe('BaseSecure.vue', () => {
authStore.ready = true;
authStore.keycloak = {
tokenParsed: {
- resource_access: {
- chefs: {
- roles: ['user'],
- },
- identity_provider: nonPrimaryIdp.code,
+ client_roles: [],
+ identity_provider: 'fake', //nonPrimaryIdp.code,
},
- },
};
const wrapper = mount(BaseSecure, {
props: {
diff --git a/app/frontend/tests/unit/components/bcgov/BCGovNavBar.spec.js b/app/frontend/tests/unit/components/bcgov/BCGovNavBar.spec.js
index 2e0b3b502..a25ff9687 100644
--- a/app/frontend/tests/unit/components/bcgov/BCGovNavBar.spec.js
+++ b/app/frontend/tests/unit/components/bcgov/BCGovNavBar.spec.js
@@ -28,11 +28,7 @@ describe('BCGovNavBar.vue', () => {
authStore.keycloak = {
tokenParsed: {
identity_provider: primaryIdp.code,
- resource_access: {
- chefs: {
- roles: [],
- },
- },
+ client_roles: [],
},
};
authStore.authenticated = true;
@@ -70,11 +66,7 @@ describe('BCGovNavBar.vue', () => {
authStore.keycloak = {
tokenParsed: {
identity_provider: primaryIdp.code,
- resource_access: {
- chefs: {
- roles: ['admin'],
- },
- },
+ client_roles: ['admin'],
},
};
authStore.authenticated = true;
diff --git a/app/frontend/tests/unit/fixtures/identityProviders.json b/app/frontend/tests/unit/fixtures/identityProviders.json
index 3b757c0d7..827940877 100644
--- a/app/frontend/tests/unit/fixtures/identityProviders.json
+++ b/app/frontend/tests/unit/fixtures/identityProviders.json
@@ -36,7 +36,7 @@
"code": "bceid-basic",
"display": "Basic BCeID",
"active": true,
- "idp": "bceid-basic",
+ "idp": "bceidbasic",
"createdBy": "migration-022",
"createdAt": "2024-01-24T22:35:49.703Z",
"updatedBy": null,
@@ -67,7 +67,7 @@
"code": "bceid-business",
"display": "Business BCeID",
"active": true,
- "idp": "bceid-business",
+ "idp": "bceidbusiness",
"createdBy": "migration-022",
"createdAt": "2024-01-24T22:35:49.703Z",
"updatedBy": null,
diff --git a/app/frontend/tests/unit/store/modules/auth.actions.spec.js b/app/frontend/tests/unit/store/modules/auth.actions.spec.js
index 1e482b7e4..c007b993e 100644
--- a/app/frontend/tests/unit/store/modules/auth.actions.spec.js
+++ b/app/frontend/tests/unit/store/modules/auth.actions.spec.js
@@ -12,6 +12,7 @@ describe('auth actions', () => {
let router = getRouter();
const replaceSpy = vi.spyOn(router, 'replace');
const windowReplaceSpy = vi.spyOn(window.location, 'replace');
+ const windowAssignSpy = vi.spyOn(window.location, 'assign');
setActivePinia(createPinia());
const mockStore = useAuthStore();
const formStore = useFormStore();
@@ -101,23 +102,23 @@ describe('auth actions', () => {
mockStore.$reset();
mockStore.keycloak = {
createLoginUrl: vi.fn(() => 'about:blank'),
- createLogoutUrl: vi.fn(() => 'about:blank'),
};
- windowReplaceSpy.mockReset();
+ mockStore.logoutUrl = location.origin;
+ windowAssignSpy.mockReset();
});
it('should do nothing if keycloak is not ready', () => {
mockStore.ready = false;
mockStore.logout();
- expect(windowReplaceSpy).toHaveBeenCalledTimes(0);
+ expect(windowAssignSpy).toHaveBeenCalledTimes(0);
});
it('should trigger navigation action if keycloak is ready', () => {
mockStore.ready = true;
mockStore.logout();
- expect(windowReplaceSpy).toHaveBeenCalledTimes(1);
+ expect(windowAssignSpy).toHaveBeenCalledTimes(1);
});
});
});
diff --git a/app/frontend/tests/unit/store/modules/auth.getters.spec.js b/app/frontend/tests/unit/store/modules/auth.getters.spec.js
index bf44fba17..89ecc6991 100755
--- a/app/frontend/tests/unit/store/modules/auth.getters.spec.js
+++ b/app/frontend/tests/unit/store/modules/auth.getters.spec.js
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
import { createApp } from 'vue';
import { useAuthStore } from '~/store/auth';
+import { useIdpStore } from '~/store/identityProviders';
const zeroUuid = '00000000-0000-0000-0000-000000000000';
const zeroGuid = '00000000000000000000000000000000';
@@ -14,14 +15,15 @@ describe('auth getters', () => {
app.use(pinia);
setActivePinia(pinia);
const store = useAuthStore();
+ const idpStore = useIdpStore();
+ idpStore.providers = require('../../fixtures/identityProviders.json');
beforeEach(() => {
store.$reset();
store.authenticated = true;
store.ready = true;
store.keycloak = {
createLoginUrl: () => 'loginUrl',
- createLogoutUrl: () => 'logoutUrl',
fullName: 'fName',
subject: zeroUuid,
token: 'token',
@@ -34,11 +36,7 @@ describe('auth getters', () => {
idp_userid: zeroGuid,
preferred_username: 'johndoe',
realm_access: {},
- resource_access: {
- chefs: {
- roles: roles,
- },
- },
+ client_roles: roles,
},
userName: 'uName',
};
@@ -54,12 +52,6 @@ describe('auth getters', () => {
expect(store.createLoginUrl()).toMatch('loginUrl');
});
- it('createLogoutUrl should return a string', () => {
- expect(store.createLogoutUrl).toBeTruthy();
- expect(typeof store.createLogoutUrl).toBe('function');
- expect(store.createLogoutUrl()).toMatch('logoutUrl');
- });
-
it('email should return a string', () => {
expect(store.email).toBeTruthy();
expect(store.email).toMatch('e@mail.com');
@@ -80,7 +72,7 @@ describe('auth getters', () => {
store.authenticated = false;
expect(store.authenticated).toBeFalsy();
- expect(store.hasResourceRoles('app', roles)).toBeFalsy();
+ expect(store.hasResourceRoles(roles)).toBeFalsy();
});
it('hasResourceRoles should return true when checking no roles', () => {
@@ -88,7 +80,7 @@ describe('auth getters', () => {
roles = [];
expect(store.authenticated).toBeTruthy();
- expect(store.hasResourceRoles('app', roles)).toBeTruthy();
+ expect(store.hasResourceRoles(roles)).toBeTruthy();
});
it('hasResourceRoles should return true when role exists', () => {
@@ -96,35 +88,31 @@ describe('auth getters', () => {
roles = [];
expect(store.authenticated).toBeTruthy();
- expect(store.hasResourceRoles('app', roles)).toBeTruthy();
+ expect(store.hasResourceRoles(roles)).toBeTruthy();
});
it('hasResourceRoles should return false when resource does not exist', () => {
store.authenticated = true;
store.keycloak.tokenParsed = {
realm_access: {},
- resource_access: {},
+ client_roles: [],
};
roles = ['non-existent-role'];
expect(store.authenticated).toBeTruthy();
- expect(store.hasResourceRoles('app', roles)).toBeFalsy();
+ expect(store.hasResourceRoles(roles)).toBeFalsy();
});
it('identityProvider should return a string', () => {
expect(store.identityProvider).toBeTruthy();
- expect(typeof store.identityProvider).toBe('string');
+ expect(typeof store.identityProvider).toBe('object');
});
it('isAdmin should return false if no admin role', () => {
store.authenticated = true;
roles = [];
store.keycloak.tokenParsed = {
- resource_access: {
- chefs: {
- roles: roles,
- },
- },
+ client_roles: roles,
};
expect(store.authenticated).toBeTruthy();
@@ -135,40 +123,13 @@ describe('auth getters', () => {
store.authenticated = true;
roles = ['admin'];
store.keycloak.tokenParsed = {
- resource_access: {
- chefs: {
- roles: roles,
- },
- },
+ client_roles: roles,
};
expect(store.authenticated).toBeTruthy();
expect(store.isAdmin).toBeTruthy();
});
- it('isUser should return false if no user role', () => {
- store.authenticated = true;
- roles = [];
-
- expect(store.authenticated).toBeTruthy();
- expect(store.isUser).toBeFalsy();
- });
-
- it('isUser should return true if user role', () => {
- store.authenticated = true;
- roles = ['user'];
- store.keycloak.tokenParsed = {
- resource_access: {
- chefs: {
- roles: roles,
- },
- },
- };
-
- expect(store.authenticated).toBeTruthy();
- expect(store.isUser).toBeTruthy();
- });
-
it('ready should return a boolean', () => {
expect(store.ready).toBeTruthy();
});
@@ -229,7 +190,7 @@ describe('auth getters', () => {
lastName: 'Doe',
fullName: 'John Doe',
email: 'e@mail.com',
- idp: 'idir',
+ idp: {code: 'idir', display: 'IDIR', hint: 'idir'},
public: false,
});
});
@@ -245,7 +206,7 @@ describe('auth getters', () => {
lastName: '',
fullName: '',
email: '',
- idp: 'public',
+ idp: { code: 'public', display: 'Public', hint: 'public' },
public: true,
});
});
diff --git a/app/frontend/tests/unit/utils/permissionUtils.spec.js b/app/frontend/tests/unit/utils/permissionUtils.spec.js
index 031f2e0c8..c6e11b268 100644
--- a/app/frontend/tests/unit/utils/permissionUtils.spec.js
+++ b/app/frontend/tests/unit/utils/permissionUtils.spec.js
@@ -279,7 +279,7 @@ describe('preFlightAuth', () => {
},
};
readFormOptionsSpy.mockResolvedValue({
- data: { idpHints: [primaryIdp.code] },
+ data: { idpHints: [primaryIdp.hint] },
});
await permissionUtils.preFlightAuth({ formId: 'f' }, mockNext);
diff --git a/app/frontend/tests/unit/views/Login.spec.js b/app/frontend/tests/unit/views/Login.spec.js
index 4a16c4b1c..0c2613601 100644
--- a/app/frontend/tests/unit/views/Login.spec.js
+++ b/app/frontend/tests/unit/views/Login.spec.js
@@ -69,8 +69,8 @@ describe('Login.vue', () => {
await nextTick();
- Object.values(idpStore.loginIdpHints).forEach((idp) => {
- const button = wrapper.find(`[data-test="${idp}"]`);
+ Object.values(idpStore.loginButtons).forEach((idp) => {
+ const button = wrapper.find(`[data-test="${idp.code}"]`);
expect(button.exists()).toBeTruthy();
});
});
diff --git a/app/package-lock.json b/app/package-lock.json
index 39e45278c..1c9b96fe2 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -30,9 +30,9 @@
"form-data": "^4.0.0",
"fs-extra": "^10.1.0",
"handlebars": "^4.7.8",
+ "jose": "^5.2.2",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.0",
- "keycloak-connect": "^21.1.1",
"knex": "^2.4.2",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
@@ -2106,17 +2106,6 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true
},
- "node_modules/asn1.js": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
- "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
- "dependencies": {
- "bn.js": "^4.0.0",
- "inherits": "^2.0.1",
- "minimalistic-assert": "^1.0.0",
- "safer-buffer": "^2.1.0"
- }
- },
"node_modules/astral-regex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz",
@@ -2385,11 +2374,6 @@
"node": ">=8"
}
},
- "node_modules/bn.js": {
- "version": "4.12.0",
- "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
- "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA=="
- },
"node_modules/body-parser": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz",
@@ -2449,11 +2433,6 @@
"node": ">=8"
}
},
- "node_modules/brorand": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
- "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w=="
- },
"node_modules/browserslist": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz",
@@ -3180,20 +3159,6 @@
"integrity": "sha512-/bKPPcgZVUziECqDc+0HkT87+0zhaWSZHNXqF8FLd2lQcptpmUFwoCSWjCdOng9Gdq+afKArPdEg/0ZW461Eng==",
"dev": true
},
- "node_modules/elliptic": {
- "version": "6.5.4",
- "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz",
- "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==",
- "dependencies": {
- "bn.js": "^4.11.9",
- "brorand": "^1.1.0",
- "hash.js": "^1.0.0",
- "hmac-drbg": "^1.0.1",
- "inherits": "^2.0.4",
- "minimalistic-assert": "^1.0.1",
- "minimalistic-crypto-utils": "^1.0.1"
- }
- },
"node_modules/emittery": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
@@ -6604,15 +6569,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/hash.js": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
- "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
- "dependencies": {
- "inherits": "^2.0.3",
- "minimalistic-assert": "^1.0.1"
- }
- },
"node_modules/hexoid": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
@@ -6622,16 +6578,6 @@
"node": ">=8"
}
},
- "node_modules/hmac-drbg": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
- "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==",
- "dependencies": {
- "hash.js": "^1.0.3",
- "minimalistic-assert": "^1.0.0",
- "minimalistic-crypto-utils": "^1.0.1"
- }
- },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -7853,6 +7799,14 @@
"node": ">= 0.6.0"
}
},
+ "node_modules/jose": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.2.tgz",
+ "integrity": "sha512-/WByRr4jDcsKlvMd1dRJnPfS1GVO3WuKyaurJ/vvXcOaUQO8rnNObCQMlv/5uCceVQIq5Q4WLF44ohsdiTohdg==",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-sdsl": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",
@@ -8005,16 +7959,6 @@
"safe-buffer": "^5.0.1"
}
},
- "node_modules/jwk-to-pem": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/jwk-to-pem/-/jwk-to-pem-2.0.5.tgz",
- "integrity": "sha512-L90jwellhO8jRKYwbssU9ifaMVqajzj3fpRjDKcsDzrslU9syRbFqfkXtT4B89HYAap+xsxNcxgBSB09ig+a7A==",
- "dependencies": {
- "asn1.js": "^5.3.0",
- "elliptic": "^6.5.4",
- "safe-buffer": "^5.0.1"
- }
- },
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
@@ -8024,21 +7968,6 @@
"safe-buffer": "^5.0.1"
}
},
- "node_modules/keycloak-connect": {
- "version": "21.1.1",
- "resolved": "https://registry.npmjs.org/keycloak-connect/-/keycloak-connect-21.1.1.tgz",
- "integrity": "sha512-FFLhsnXjo+OmzMJpFhHcTjLHLwT18aZi1hJj/TLwXjijyHUFDBdVyD+uF7Hspcs4A4s00xwteAqkQGMlFQa6Yw==",
- "deprecated": "This package is deprecated and will be removed in the future. We will shortly provide more details on removal date, and recommended alternatives.",
- "dependencies": {
- "jwk-to-pem": "^2.0.0"
- },
- "engines": {
- "node": ">=14"
- },
- "optionalDependencies": {
- "chromedriver": "latest"
- }
- },
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -8475,16 +8404,6 @@
"node": ">=6"
}
},
- "node_modules/minimalistic-assert": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
- "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
- },
- "node_modules/minimalistic-crypto-utils": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
- "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg=="
- },
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -12783,17 +12702,6 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true
},
- "asn1.js": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
- "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
- "requires": {
- "bn.js": "^4.0.0",
- "inherits": "^2.0.1",
- "minimalistic-assert": "^1.0.0",
- "safer-buffer": "^2.1.0"
- }
- },
"astral-regex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz",
@@ -12996,11 +12904,6 @@
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true
},
- "bn.js": {
- "version": "4.12.0",
- "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
- "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA=="
- },
"body-parser": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz",
@@ -13049,11 +12952,6 @@
"fill-range": "^7.0.1"
}
},
- "brorand": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
- "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w=="
- },
"browserslist": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz",
@@ -13598,20 +13496,6 @@
"integrity": "sha512-/bKPPcgZVUziECqDc+0HkT87+0zhaWSZHNXqF8FLd2lQcptpmUFwoCSWjCdOng9Gdq+afKArPdEg/0ZW461Eng==",
"dev": true
},
- "elliptic": {
- "version": "6.5.4",
- "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz",
- "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==",
- "requires": {
- "bn.js": "^4.11.9",
- "brorand": "^1.1.0",
- "hash.js": "^1.0.0",
- "hmac-drbg": "^1.0.1",
- "inherits": "^2.0.4",
- "minimalistic-assert": "^1.0.1",
- "minimalistic-crypto-utils": "^1.0.1"
- }
- },
"emittery": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
@@ -16183,31 +16067,12 @@
"has-symbols": "^1.0.2"
}
},
- "hash.js": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
- "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
- "requires": {
- "inherits": "^2.0.3",
- "minimalistic-assert": "^1.0.1"
- }
- },
"hexoid": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
"integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==",
"dev": true
},
- "hmac-drbg": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
- "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==",
- "requires": {
- "hash.js": "^1.0.3",
- "minimalistic-assert": "^1.0.0",
- "minimalistic-crypto-utils": "^1.0.1"
- }
- },
"html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -17109,6 +16974,11 @@
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
"integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="
},
+ "jose": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.2.tgz",
+ "integrity": "sha512-/WByRr4jDcsKlvMd1dRJnPfS1GVO3WuKyaurJ/vvXcOaUQO8rnNObCQMlv/5uCceVQIq5Q4WLF44ohsdiTohdg=="
+ },
"js-sdsl": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",
@@ -17226,16 +17096,6 @@
"safe-buffer": "^5.0.1"
}
},
- "jwk-to-pem": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/jwk-to-pem/-/jwk-to-pem-2.0.5.tgz",
- "integrity": "sha512-L90jwellhO8jRKYwbssU9ifaMVqajzj3fpRjDKcsDzrslU9syRbFqfkXtT4B89HYAap+xsxNcxgBSB09ig+a7A==",
- "requires": {
- "asn1.js": "^5.3.0",
- "elliptic": "^6.5.4",
- "safe-buffer": "^5.0.1"
- }
- },
"jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
@@ -17245,15 +17105,6 @@
"safe-buffer": "^5.0.1"
}
},
- "keycloak-connect": {
- "version": "21.1.1",
- "resolved": "https://registry.npmjs.org/keycloak-connect/-/keycloak-connect-21.1.1.tgz",
- "integrity": "sha512-FFLhsnXjo+OmzMJpFhHcTjLHLwT18aZi1hJj/TLwXjijyHUFDBdVyD+uF7Hspcs4A4s00xwteAqkQGMlFQa6Yw==",
- "requires": {
- "chromedriver": "latest",
- "jwk-to-pem": "^2.0.0"
- }
- },
"kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -17570,16 +17421,6 @@
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true
},
- "minimalistic-assert": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
- "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
- },
- "minimalistic-crypto-utils": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
- "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg=="
- },
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
diff --git a/app/package.json b/app/package.json
index 7bbebb39d..7ed116882 100644
--- a/app/package.json
+++ b/app/package.json
@@ -68,9 +68,9 @@
"form-data": "^4.0.0",
"fs-extra": "^10.1.0",
"handlebars": "^4.7.8",
+ "jose": "^5.2.2",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.0",
- "keycloak-connect": "^21.1.1",
"knex": "^2.4.2",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
diff --git a/app/src/components/idpService.js b/app/src/components/idpService.js
new file mode 100644
index 000000000..a002869fa
--- /dev/null
+++ b/app/src/components/idpService.js
@@ -0,0 +1,169 @@
+const errorToProblem = require('./errorToProblem');
+const { IdentityProvider, User } = require('../forms/common/models');
+
+const SERVICE = 'IdpService';
+
+const IDP_KEY = 'identity_provider';
+const KC_ID_KEY = 'keycloakId';
+
+function stringToGUID(s) {
+ const regex = /^([0-f]{8})([0-f]{4})([0-f]{4})([0-f]{4})([0-f]{12})/;
+ const m = s.replace(/-+/g, '').match(regex);
+ return m ? `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}` : null;
+}
+
+function isEmpty(s) {
+ return s === null || (s && s.trim() === '');
+}
+
+function isNotEmpty(s) {
+ return !isEmpty(s);
+}
+
+class IdpService {
+ constructor() {
+ this.providers = null;
+ this.activeProviders = null;
+ }
+
+ // this should be called by the UX on load, so it should be initialized
+ async getIdentityProviders(active) {
+ if (!this.providers) {
+ this.providers = await IdentityProvider.query().modify('orderDefault');
+ this.activeProviders = this.providers.filter((x) => x.active);
+ }
+ return active ? this.activeProviders : this.providers;
+ }
+
+ async findByIdp(idp) {
+ const p = await this.getIdentityProviders(true);
+ return p.find((x) => x.idp === idp);
+ }
+
+ async findByCode(code) {
+ const p = await this.getIdentityProviders(true);
+ return p.find((x) => x.code === code);
+ }
+
+ // given a token, determine idp and transform
+ async parseToken(token) {
+ try {
+ let userInfo = {
+ idpUserId: undefined,
+ keycloakId: undefined,
+ username: 'public',
+ firstName: undefined,
+ lastName: undefined,
+ fullName: 'public',
+ email: undefined,
+ idp: 'public',
+ public: true,
+ };
+ if (token) {
+ // token needs `identity_provider` field
+ if (IDP_KEY in token) {
+ // can we find the idp?
+ const idp = await this.findByIdp(token[IDP_KEY]);
+ if (idp) {
+ // now do the mapping...
+ for (const key of Object.keys(userInfo)) {
+ const tokenKey = idp.tokenmap[key];
+ if (tokenKey) {
+ let tokenValue = token[tokenKey];
+ if (key === KC_ID_KEY) {
+ tokenValue = stringToGUID(token[tokenKey]);
+ if (!tokenValue) {
+ throw new Error(`Value in token for '${tokenKey}' cannot be converted to GUID.`);
+ }
+ }
+ userInfo[key] = tokenValue;
+ }
+ }
+ userInfo.public = false;
+ } else {
+ throw new Error(`Cannot find configuration for Identity Provider: '${token[IDP_KEY]}'.`);
+ }
+ } else {
+ throw new Error(`Token does not have an '${IDP_KEY}' value. Cannot parse token.`);
+ }
+ }
+ return userInfo;
+ } catch (e) {
+ errorToProblem(SERVICE, e);
+ }
+ }
+
+ async userSearch(params) {
+ // check the idpCode, set up User query accordingly.
+ if (params && params.idpCode) {
+ const idp = await this.findByCode(params.idpCode);
+ if (idp && idp.extra?.userSearch) {
+ // ok, this idp has specific requirements of user search...
+ const q = User.query();
+
+ // find all the different groupings for required.
+ // 0 : not required
+ // 1 : required
+ // > 1 : params are grouped by number, one of each group is required.
+
+ const requiredTypes = Array.from(new Set(idp.extra.userSearch.filters.map((x) => x.required)));
+ let valid = false;
+ for (const reqd of requiredTypes) {
+ const filters = idp.extra.userSearch.filters.filter((x) => x.required === reqd);
+ let groupValid = reqd === 1 ? true : false;
+ for (const f of filters) {
+ // add the filter to the query...
+ const filterName = f.name;
+ const paramName = f.param;
+ const value = params[paramName];
+ if ('exact' in f) {
+ const exact = f.exact;
+ q.modify(filterName, value, exact);
+ } else {
+ q.modify(filterName, value);
+ }
+
+ //
+ // ok, check for required...
+ //
+ if (reqd < 1) {
+ // if required < 1, do nothing, always valid
+ groupValid = true;
+ } else if (reqd === 1 && isEmpty(value)) {
+ // if required = 1, all filters in this group are required.
+ groupValid = false;
+ } else {
+ // only one of the filters in this group is required.
+ if (isNotEmpty(value)) {
+ groupValid = true;
+ }
+ }
+ }
+ valid = groupValid ? true : false;
+ }
+ // ok, if not valid then we want to throw an error
+ if (!valid) {
+ throw new Error(idp.extra.userSearch.detail);
+ }
+ // guess we are good, return the customized user search.
+ return q.modify('orderLastFirstAscending');
+ }
+ }
+
+ // ok, no error thown, no specific search requirements...
+ // so here is the default user search.
+ return User.query()
+ .modify('filterIdpUserId', params.idpUserId)
+ .modify('filterIdpCode', params.idpCode)
+ .modify('filterUsername', params.username, false)
+ .modify('filterFullName', params.fullName)
+ .modify('filterFirstName', params.firstName)
+ .modify('filterLastName', params.lastName)
+ .modify('filterEmail', params.email, false)
+ .modify('filterSearch', params.search)
+ .modify('orderLastFirstAscending');
+ }
+}
+
+let idpService = new IdpService();
+module.exports = idpService;
diff --git a/app/src/components/jwtService.js b/app/src/components/jwtService.js
new file mode 100644
index 000000000..53637c0af
--- /dev/null
+++ b/app/src/components/jwtService.js
@@ -0,0 +1,100 @@
+const jose = require('jose');
+const config = require('config');
+const errorToProblem = require('./errorToProblem');
+
+const SERVICE = 'JwtService';
+
+const jwksUri = config.get('server.keycloak.jwksUri');
+
+// Create a remote JWK set that fetches the JWK set from server with caching
+const JWKS = jose.createRemoteJWKSet(new URL(jwksUri));
+
+class JwtService {
+ constructor({ issuer, audience, maxTokenAge }) {
+ if (!issuer || !audience || !maxTokenAge) {
+ throw new Error('JwtService is not configured. Check configuration.');
+ }
+
+ this.audience = audience;
+ this.issuer = issuer;
+ this.maxTokenAge = maxTokenAge;
+ }
+
+ getBearerToken(req) {
+ if (req.headers && req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
+ return req.headers.authorization.substring(7);
+ }
+ // do we want to throw errors?
+ return null;
+ }
+
+ async getTokenPayload(req) {
+ const bear = this.getBearerToken(req);
+ if (bear) {
+ return await this._verify(bear);
+ }
+ return null;
+ }
+
+ async _verify(token) {
+ // could throw JWTClaimValidationFailed
+ const { payload } = await jose.jwtVerify(token, JWKS, {
+ issuer: this.issuer,
+ audience: this.audience,
+ maxTokenAge: parseInt(this.maxTokenAge),
+ });
+ return payload;
+ }
+
+ async validateAccessToken(token) {
+ try {
+ await this._verify(token);
+ // these claims passed, just return true.
+ return true;
+ } catch (e) {
+ if (e instanceof jose.errors.JWTClaimValidationFailed) {
+ return false;
+ } else {
+ errorToProblem(SERVICE, e);
+ }
+ }
+ }
+
+ protect(spec) {
+ // actual middleware
+ return async (req, res, next) => {
+ let authorized = false;
+ try {
+ // get token, check if valid
+ const token = this.getBearerToken(req);
+ if (token) {
+ const payload = await this._verify(token);
+ if (spec) {
+ authorized = payload.client_roles?.includes(spec);
+ } else {
+ authorized = true;
+ }
+ }
+ } catch (error) {
+ authorized = false;
+ }
+ if (!authorized) {
+ res.status(403);
+ res.end('Access denied');
+ } else {
+ return next();
+ }
+ };
+ }
+}
+
+const audience = config.get('server.keycloak.audience');
+const issuer = config.get('server.keycloak.issuer');
+const maxTokenAge = config.get('server.keycloak.maxTokenAge');
+
+let jwtService = new JwtService({
+ issuer: issuer,
+ audience: audience,
+ maxTokenAge: maxTokenAge,
+});
+module.exports = jwtService;
diff --git a/app/src/components/keycloak.js b/app/src/components/keycloak.js
deleted file mode 100755
index 3781b3354..000000000
--- a/app/src/components/keycloak.js
+++ /dev/null
@@ -1,19 +0,0 @@
-const config = require('config');
-const Keycloak = require('keycloak-connect');
-
-module.exports = new Keycloak(
- {},
- {
- bearerOnly: true,
- 'confidential-port': 0,
- clientId: config.get('server.keycloak.clientId'),
- 'policy-enforcer': {},
- realm: config.get('server.keycloak.realm'),
- realmPublicKey: config.has('server.keycloak.publicKey') ? config.get('server.keycloak.publicKey') : undefined,
- secret: config.get('server.keycloak.clientSecret'),
- serverUrl: config.get('server.keycloak.serverUrl'),
- 'ssl-required': 'external',
- 'use-resource-role-mappings': true,
- 'verify-token-audience': false,
- }
-);
diff --git a/app/src/db/migrations/20240119172630_identity_provider_permissions.js b/app/src/db/migrations/20240119172630_identity_provider_permissions.js
index f7aef6bd0..490379cee 100644
--- a/app/src/db/migrations/20240119172630_identity_provider_permissions.js
+++ b/app/src/db/migrations/20240119172630_identity_provider_permissions.js
@@ -12,6 +12,19 @@ const BCEID_EXTRAS = {
message: 'trans.manageSubmissionUsers.exactBCEIDSearch',
},
},
+ userSearch: {
+ filters: [
+ { name: 'filterIdpUserId', param: 'idpUserId', required: 0 },
+ { name: 'filterIdpCode', param: 'idpCode', required: 0 },
+ { name: 'filterUsername', param: 'username', required: 2, exact: true },
+ { name: 'filterFullName', param: 'fullName', required: 0 },
+ { name: 'filterFirstName', param: 'firstName', required: 0 },
+ { name: 'filterLastName', param: 'lastName', required: 0 },
+ { name: 'filterEmail', param: 'email', required: 2, exact: true },
+ { name: 'filterSearch', param: 'search', required: 0 },
+ ],
+ detail: 'Could not retrieve BCeID users. Invalid options provided.'
+ }
};
exports.up = function (knex) {
@@ -30,6 +43,9 @@ exports.up = function (knex) {
table
.specificType('roles', 'text ARRAY')
.comment('Map Form role codes to the idp');
+ table
+ .jsonb('tokenmap')
+ .comment('Map of token fields to CHEFs user fields');
table
.jsonb('extra')
.comment(
@@ -70,6 +86,16 @@ exports.up = function (knex) {
Roles.SUBMISSION_REVIEWER,
Roles.FORM_SUBMITTER,
],
+ tokenmap: {
+ idpUserId: 'idir_user_guid',
+ keycloakId: 'idir_user_guid',
+ username: 'idir_username',
+ firstName: 'given_name',
+ lastName: 'family_name',
+ fullName: 'name',
+ email: 'email',
+ idp: 'identity_provider',
+ },
extra: {},
})
)
@@ -77,6 +103,7 @@ exports.up = function (knex) {
knex('identity_provider')
.where({ code: 'bceid-business' })
.update({
+ idp: 'bceidbusiness',
login: true,
permissions: [
APP_PERMISSIONS.VIEWS_FORM_EXPORT,
@@ -91,6 +118,16 @@ exports.up = function (knex) {
Roles.SUBMISSION_REVIEWER,
Roles.FORM_SUBMITTER,
],
+ tokenmap: {
+ idpUserId: 'bceid_user_guid',
+ keycloakId: 'bceid_user_guid',
+ username: 'bceid_username',
+ firstName: null,
+ lastName: null,
+ fullName: 'name',
+ email: 'email',
+ idp: 'identity_provider',
+ },
extra: BCEID_EXTRAS,
})
)
@@ -98,9 +135,20 @@ exports.up = function (knex) {
knex('identity_provider')
.where({ code: 'bceid-basic' })
.update({
+ idp: 'bceidbasic',
login: true,
permissions: [APP_PERMISSIONS.VIEWS_USER_SUBMISSIONS],
roles: [Roles.FORM_SUBMITTER],
+ tokenmap: {
+ idpUserId: 'bceid_user_guid',
+ keycloakId: 'bceid_user_guid',
+ username: 'bceid_username',
+ firstName: null,
+ lastName: null,
+ fullName: 'name',
+ email: 'email',
+ idp: 'identity_provider',
+ },
extra: BCEID_EXTRAS,
})
)
diff --git a/app/src/forms/admin/routes.js b/app/src/forms/admin/routes.js
index 735a7f5b8..47766a7e8 100644
--- a/app/src/forms/admin/routes.js
+++ b/app/src/forms/admin/routes.js
@@ -1,14 +1,13 @@
-const config = require('config');
const routes = require('express').Router();
const currentUser = require('../auth/middleware/userAccess').currentUser;
const controller = require('./controller');
const userController = require('../user/controller');
-const keycloak = require('../../components/keycloak');
+const jwtService = require('../../components/jwtService');
// Always have this applied to all routes here
-routes.use(keycloak.protect(`${config.get('server.keycloak.clientId')}:admin`));
+routes.use(jwtService.protect('admin'));
routes.use(currentUser);
// Routes under the /admin pathing will fetch data without doing Form permission checks in the database
diff --git a/app/src/forms/auth/middleware/userAccess.js b/app/src/forms/auth/middleware/userAccess.js
index 1f622cdbb..26b7b5f8e 100644
--- a/app/src/forms/auth/middleware/userAccess.js
+++ b/app/src/forms/auth/middleware/userAccess.js
@@ -1,23 +1,15 @@
const Problem = require('api-problem');
const { validate } = require('uuid');
-const keycloak = require('../../../components/keycloak');
+const jwtService = require('../../../components/jwtService');
const Permissions = require('../../common/constants').Permissions;
const Roles = require('../../common/constants').Roles;
const service = require('../service');
const rbacService = require('../../rbac/service');
-const getToken = (req) => {
- try {
- return req.kauth.grant.access_token;
- } catch (err) {
- return null;
- }
-};
-
const setUser = async (req, _res, next) => {
try {
- const token = getToken(req);
+ const token = await jwtService.getTokenPayload(req);
req.currentUser = await service.login(token);
next();
} catch (error) {
@@ -27,12 +19,13 @@ const setUser = async (req, _res, next) => {
const currentUser = async (req, res, next) => {
// Check if authorization header is a bearer token
- if (req.headers && req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
- // need to check keycloak, ensure the bearer token is valid
- const token = req.headers.authorization.substring(7);
- const ok = await keycloak.grantManager.validateAccessToken(token);
+ const token = jwtService.getBearerToken(req);
+ if (token) {
+ const ok = await jwtService.validateAccessToken(token);
if (!ok) {
- return new Problem(403, { detail: 'Authorization token is invalid.' }).send(res);
+ return new Problem(403, {
+ detail: 'Authorization token is invalid.',
+ }).send(res);
}
}
@@ -40,11 +33,17 @@ const currentUser = async (req, res, next) => {
};
const _getForm = async (currentUser, formId) => {
- const forms = await service.getUserForms(currentUser, { active: true, formId: formId });
+ const forms = await service.getUserForms(currentUser, {
+ active: true,
+ formId: formId,
+ });
let form = forms.find((f) => f.formId === formId);
if (!form) {
- const deletedForms = await service.getUserForms(currentUser, { active: false, formId: formId });
+ const deletedForms = await service.getUserForms(currentUser, {
+ active: false,
+ formId: formId,
+ });
form = deletedForms.find((f) => f.formId === formId);
}
@@ -60,7 +59,9 @@ const hasFormPermissions = (permissions) => {
if (!req.currentUser) {
// cannot find the currentUser... guess we don't have access... FAIL!
- return new Problem(401, { detail: 'Current user not found on request.' }).send(res);
+ return new Problem(401, {
+ detail: 'Current user not found on request.',
+ }).send(res);
}
// If we invoke this middleware and the caller is acting on a specific formId, whether in a param or query (precedence to param)
const formId = req.params.formId || req.query.formId;
@@ -71,7 +72,9 @@ const hasFormPermissions = (permissions) => {
let form = await _getForm(req.currentUser, formId);
if (!form) {
// cannot find the form... guess we don't have access... FAIL!
- return new Problem(401, { detail: 'Current user has no access to form.' }).send(res);
+ return new Problem(401, {
+ detail: 'Current user has no access to form.',
+ }).send(res);
}
if (!Array.isArray(permissions)) {
@@ -83,7 +86,9 @@ const hasFormPermissions = (permissions) => {
});
if (intersection.length !== permissions.length) {
- return new Problem(401, { detail: 'Current user does not have required permission(s) on form' }).send(res);
+ return new Problem(401, {
+ detail: 'Current user does not have required permission(s) on form',
+ }).send(res);
} else {
return next();
}
@@ -114,7 +119,10 @@ const hasSubmissionPermissions = (permissions) => {
// Does the user have permissions for this submission due to their FORM permissions
if (req.currentUser) {
- const forms = await service.getUserForms(req.currentUser, { active: true, formId: submissionForm.form.id });
+ const forms = await service.getUserForms(req.currentUser, {
+ active: true,
+ formId: submissionForm.form.id,
+ });
let formFromCurrentUser = forms.find((f) => f.formId === submissionForm.form.id);
if (formFromCurrentUser) {
// Do they have the submission permissions being requested on this FORM
@@ -130,7 +138,11 @@ const hasSubmissionPermissions = (permissions) => {
// Deleted submissions are inaccessible
if (submissionForm.submission.deleted) {
- return next(new Problem(401, { detail: 'You do not have access to this submission.' }));
+ return next(
+ new Problem(401, {
+ detail: 'You do not have access to this submission.',
+ })
+ );
}
// TODO: consider whether DRAFT submissions are restricted as deleted above
@@ -146,7 +158,11 @@ const hasSubmissionPermissions = (permissions) => {
if (submissionPermission) return next();
// no access to this submission...
- return next(new Problem(401, { detail: 'You do not have access to this submission.' }));
+ return next(
+ new Problem(401, {
+ detail: 'You do not have access to this submission.',
+ })
+ );
} catch (error) {
next(error);
}
@@ -180,7 +196,11 @@ const filterMultipleSubmissions = () => {
//validate all submission ids
const isValidSubmissionId = submissionIds.every((submissionId) => validate(submissionId));
if (!isValidSubmissionId) {
- return next(new Problem(401, { detail: 'Invalid submissionId(s) in the submissionIds list.' }));
+ return next(
+ new Problem(401, {
+ detail: 'Invalid submissionId(s) in the submissionIds list.',
+ })
+ );
}
if (formIdWithDeletePermission === formId) {
@@ -189,11 +209,19 @@ const filterMultipleSubmissions = () => {
const isForeignSubmissionId = metaData.every((SubmissionMetadata) => SubmissionMetadata.formId === formId);
if (!isForeignSubmissionId || metaData.length !== submissionIds.length) {
- return next(new Problem(401, { detail: 'Current user does not have required permission(s) for some submissions in the submissionIds list.' }));
+ return next(
+ new Problem(401, {
+ detail: 'Current user does not have required permission(s) for some submissions in the submissionIds list.',
+ })
+ );
}
return next();
}
- return next(new Problem(401, { detail: 'Current user does not have required permission(s) for to delete submissions' }));
+ return next(
+ new Problem(401, {
+ detail: 'Current user does not have required permission(s) for to delete submissions',
+ })
+ );
} catch (error) {
next(error);
}
@@ -203,7 +231,10 @@ const filterMultipleSubmissions = () => {
const hasFormRole = async (formId, user, role) => {
let hasRole = false;
- const forms = await service.getUserForms(user, { active: true, formId: formId });
+ const forms = await service.getUserForms(user, {
+ active: true,
+ formId: formId,
+ });
const form = forms.find((f) => f.formId === formId);
if (form) {
@@ -227,7 +258,10 @@ const hasFormRoles = (formRoles, hasAll = false) => {
return new Problem(401, { detail: 'Form Id not found on request.' }).send(res);
}
- const forms = await service.getUserForms(req.currentUser, { active: true, formId: formId });
+ const forms = await service.getUserForms(req.currentUser, {
+ active: true,
+ formId: formId,
+ });
const form = forms.find((f) => f.formId === formId);
if (form) {
for (let roleIndex = 0; roleIndex < form.roles.length; roleIndex++) {
@@ -247,10 +281,19 @@ const hasFormRoles = (formRoles, hasAll = false) => {
}
if (hasAll) {
- if (formRoles.length > 0) return next(new Problem(401, { detail: 'You do not have permission to update this role.' }));
+ if (formRoles.length > 0)
+ return next(
+ new Problem(401, {
+ detail: 'You do not have permission to update this role.',
+ })
+ );
else return next();
}
- return next(new Problem(401, { detail: 'You do not have permission to update this role.' }));
+ return next(
+ new Problem(401, {
+ detail: 'You do not have permission to update this role.',
+ })
+ );
};
};
@@ -261,7 +304,9 @@ const hasRolePermissions = (removingUsers = false) => {
const formId = req.params.formId || req.query.formId;
if (!formId) {
// No form provided to this route that secures based on form... that's a problem!
- return new Problem(401, { detail: 'Form Id not found on request.' }).send(res);
+ return new Problem(401, {
+ detail: 'Form Id not found on request.',
+ }).send(res);
}
const currentUser = req.currentUser;
@@ -270,7 +315,12 @@ const hasRolePermissions = (removingUsers = false) => {
const isOwner = await hasFormRole(formId, currentUser, Roles.OWNER);
if (removingUsers) {
- if (data.includes(currentUser.id)) return next(new Problem(401, { detail: "You can't remove yourself from this form." }));
+ if (data.includes(currentUser.id))
+ return next(
+ new Problem(401, {
+ detail: "You can't remove yourself from this form.",
+ })
+ );
if (!isOwner) {
for (let i = 0; i < data.length; i++) {
@@ -280,12 +330,20 @@ const hasRolePermissions = (removingUsers = false) => {
// Can't update another user's roles if they are an owner
if (userRoles.some((fru) => fru.role === Roles.OWNER) && userId !== currentUser.id) {
- return next(new Problem(401, { detail: "You can not update an owner's roles." }));
+ return next(
+ new Problem(401, {
+ detail: "You can not update an owner's roles.",
+ })
+ );
}
// If the user is trying to remove the designer role
if (userRoles.some((fru) => fru.role === Roles.FORM_DESIGNER)) {
- return next(new Problem(401, { detail: "You can't remove a form designer role." }));
+ return next(
+ new Problem(401, {
+ detail: "You can't remove a form designer role.",
+ })
+ );
}
}
}
@@ -300,7 +358,11 @@ const hasRolePermissions = (removingUsers = false) => {
// If the user is trying to remove the team manager role for their own userid
if (userRoles.some((fru) => fru.role === Roles.TEAM_MANAGER) && !data.some((role) => role.role === Roles.TEAM_MANAGER) && userId == currentUser.id) {
- return next(new Problem(401, { detail: "You can't remove your own team manager role." }));
+ return next(
+ new Problem(401, {
+ detail: "You can't remove your own team manager role.",
+ })
+ );
}
// Can't update another user's roles if they are an owner
@@ -313,10 +375,18 @@ const hasRolePermissions = (removingUsers = false) => {
// If the user is trying to remove the designer role for another userid
if (userRoles.some((fru) => fru.role === Roles.FORM_DESIGNER) && !data.some((role) => role.role === Roles.FORM_DESIGNER)) {
- return next(new Problem(401, { detail: "You can't remove a form designer role." }));
+ return next(
+ new Problem(401, {
+ detail: "You can't remove a form designer role.",
+ })
+ );
}
if (!userRoles.some((fru) => fru.role === Roles.FORM_DESIGNER) && data.some((role) => role.role === Roles.FORM_DESIGNER)) {
- return next(new Problem(401, { detail: "You can't add a form designer role." }));
+ return next(
+ new Problem(401, {
+ detail: "You can't add a form designer role.",
+ })
+ );
}
}
}
diff --git a/app/src/forms/auth/service.js b/app/src/forms/auth/service.js
index df212e6fe..e7c98ddb4 100644
--- a/app/src/forms/auth/service.js
+++ b/app/src/forms/auth/service.js
@@ -3,6 +3,8 @@ const { v4: uuidv4 } = require('uuid');
const { Form, FormSubmissionUserPermissions, PublicFormAccess, SubmissionMetadata, User, UserFormAccess } = require('../common/models');
const { queryUtils } = require('../common/utils');
+const idpService = require('../../components/idpService');
+
const FORM_SUBMITTER = require('../common/constants').Permissions.FORM_SUBMITTER;
const service = {
@@ -63,48 +65,6 @@ const service = {
}
},
- parseToken: (token) => {
- try {
- // identity_provider_* will be undefined if user login is to local keycloak (userid/password)
- const {
- idp_userid: idpUserId,
- idp_username: identity,
- identity_provider: idp,
- preferred_username: username,
- given_name: firstName,
- family_name: lastName,
- sub: keycloakId,
- name: fullName,
- email,
- } = token.content;
-
- return {
- idpUserId: idpUserId,
- keycloakId: keycloakId,
- username: identity ? identity : username,
- firstName: firstName,
- lastName: lastName,
- fullName: fullName,
- email: email,
- idp: idp ? idp : '',
- public: false,
- };
- } catch (e) {
- // any issues parsing the token, or if token doesn't exist, return a default "public" user
- return {
- idpUserId: undefined,
- keycloakId: undefined,
- username: 'public',
- firstName: undefined,
- lastName: undefined,
- fullName: 'public',
- email: undefined,
- idp: 'public',
- public: true,
- };
- }
- },
-
getUserId: async (userInfo) => {
if (userInfo.public) {
return { id: 'public', ...userInfo };
@@ -124,7 +84,11 @@ const service = {
}
// return with the db id...
- return { id: user.id, usernameIdp: user.idpCode ? `${user.username}@${user.idpCode}` : user.username, ...userInfo };
+ return {
+ id: user.id,
+ usernameIdp: user.idpCode ? `${user.username}@${user.idpCode}` : user.username,
+ ...userInfo,
+ };
},
getUserForms: async (userInfo, params = {}) => {
@@ -147,7 +111,6 @@ const service = {
// we need to filter out the true access level here.
// so we need a role, or a valid idp from login, or form needs to be public.
let forms = [];
-
let filtered = items.filter((x) => {
// include if user has idp, or form is public, or user has an explicit role.
if (x.idps.includes(userInfo.idp) || x.idps.includes('public')) {
@@ -202,10 +165,12 @@ const service = {
},
login: async (token) => {
- const userInfo = service.parseToken(token);
+ const userInfo = await idpService.parseToken(token);
+ const idp = await idpService.findByIdp(userInfo.idp);
+ userInfo.idp = idp.code;
const user = await service.getUserId(userInfo);
- return { ...user };
+ return { ...user, idpHint: idp.idp };
},
// -------------------------------------------------------------------------------------------------------------
diff --git a/app/src/forms/common/constants.js b/app/src/forms/common/constants.js
index 672fc5b44..2535334bd 100644
--- a/app/src/forms/common/constants.js
+++ b/app/src/forms/common/constants.js
@@ -68,22 +68,11 @@ module.exports = Object.freeze({
OBJECT_STORAGE: 'objectStorage',
LOCAL_STORES: ['uploads', 'localStorage', 'exports'],
},
- Restricted: {
- IDP: {
- BCEID_BASIC: 'bceid-basic',
- BCEID_BUSINESS: 'bceid-business',
- },
- },
ScheduleType: {
MANUAL: 'manual',
CLOSINGDATE: 'closingDate',
PERIOD: 'period',
},
- IdentityProviders: {
- BCEIDBASIC: 'bceid-basic', // Basic BCeID
- BCEIDBUSINESS: 'bceid-business', // Business BCeID
- IDIR: 'idir', // IDIR
- },
EXPORT_TYPES: {
submissions: 'submissions',
default: 'submissions',
diff --git a/app/src/forms/common/models/tables/identityProvider.js b/app/src/forms/common/models/tables/identityProvider.js
index d340e4c3c..b41870185 100644
--- a/app/src/forms/common/models/tables/identityProvider.js
+++ b/app/src/forms/common/models/tables/identityProvider.js
@@ -18,6 +18,11 @@ class IdentityProvider extends Timestamps(Model) {
query.where('active', value);
}
},
+ filterIdp(query, value) {
+ if (value !== undefined) {
+ query.where('idp', value);
+ }
+ },
orderDefault(builder) {
builder.orderByRaw('"identity_provider"."primary" DESC NULLS LAST, lower("identity_provider"."code")');
},
@@ -37,6 +42,7 @@ class IdentityProvider extends Timestamps(Model) {
login: { type: 'boolean' },
permissions: { type: ['array', 'null'], items: { type: 'string' } },
roles: { type: ['array', 'null'], items: { type: 'string' } },
+ tokenmap: { type: 'object' },
extra: { type: 'object' },
...stamps,
},
diff --git a/app/src/forms/common/models/tables/user.js b/app/src/forms/common/models/tables/user.js
index 6124e4f41..c609f08e3 100644
--- a/app/src/forms/common/models/tables/user.js
+++ b/app/src/forms/common/models/tables/user.js
@@ -1,6 +1,6 @@
const { Model } = require('objection');
const { Timestamps } = require('../mixins');
-const { Regex, Restricted } = require('../../constants');
+const { Regex } = require('../../constants');
const stamps = require('../jsonSchema').stamps;
class User extends Timestamps(Model) {
@@ -40,9 +40,6 @@ class User extends Timestamps(Model) {
query.where('idpCode', value);
}
},
- filterRestricted(query) {
- query.whereNotIn('idpCode', Object.values(Restricted.IDP));
- },
filterUsername(query, value, exact = false) {
if (value) {
if (exact) query.where('username', value);
diff --git a/app/src/forms/form/routes.js b/app/src/forms/form/routes.js
index cddf0c9e3..5cda8814c 100644
--- a/app/src/forms/form/routes.js
+++ b/app/src/forms/form/routes.js
@@ -1,4 +1,3 @@
-const config = require('config');
const routes = require('express').Router();
const apiAccess = require('../auth/middleware/apiAccess');
const { currentUser, hasFormPermissions } = require('../auth/middleware/userAccess');
@@ -6,7 +5,7 @@ const params = require('../auth/middleware/params');
const P = require('../common/constants').Permissions;
const rateLimiter = require('../common/middleware').apiKeyRateLimiter;
-const keycloak = require('../../components/keycloak');
+const jwtService = require('../../components/jwtService');
const controller = require('./controller');
routes.use(currentUser);
@@ -15,7 +14,7 @@ routes.param('formId', params.validateFormId);
routes.param('formVersionDraftId', params.validateFormVersionDraftId);
routes.param('formVersionId', params.validateFormVersionId);
-routes.get('/', keycloak.protect(`${config.get('server.keycloak.clientId')}:admin`), async (req, res, next) => {
+routes.get('/', jwtService.protect('admin'), async (req, res, next) => {
await controller.listForms(req, res, next);
});
diff --git a/app/src/forms/permission/routes.js b/app/src/forms/permission/routes.js
index bd9ad9b13..83627e4ff 100644
--- a/app/src/forms/permission/routes.js
+++ b/app/src/forms/permission/routes.js
@@ -1,12 +1,11 @@
-const config = require('config');
const routes = require('express').Router();
const currentUser = require('../auth/middleware/userAccess').currentUser;
const controller = require('./controller');
-const keycloak = require('../../components/keycloak');
+const jwtService = require('../../components/jwtService');
-routes.use(keycloak.protect(`${config.get('server.keycloak.clientId')}:admin`));
+routes.use(jwtService.protect('admin'));
routes.use(currentUser);
routes.get('/', async (req, res, next) => {
diff --git a/app/src/forms/rbac/routes.js b/app/src/forms/rbac/routes.js
index 35c463f3b..70002e0b4 100644
--- a/app/src/forms/rbac/routes.js
+++ b/app/src/forms/rbac/routes.js
@@ -1,19 +1,18 @@
-const config = require('config');
const routes = require('express').Router();
const controller = require('./controller');
-const keycloak = require('../../components/keycloak');
+const jwtService = require('../../components/jwtService');
const P = require('../common/constants').Permissions;
const R = require('../common/constants').Roles;
const { currentUser, hasFormPermissions, hasSubmissionPermissions, hasFormRoles, hasRolePermissions } = require('../auth/middleware/userAccess');
routes.use(currentUser);
-routes.get('/current', keycloak.protect(), async (req, res, next) => {
+routes.get('/current', jwtService.protect(), async (req, res, next) => {
await controller.getCurrentUser(req, res, next);
});
-routes.get('/current/submissions', keycloak.protect(), async (req, res, next) => {
+routes.get('/current/submissions', jwtService.protect(), async (req, res, next) => {
await controller.getCurrentUserSubmissions(req, res, next);
});
@@ -37,7 +36,7 @@ routes.put('/submissions', hasSubmissionPermissions(P.SUBMISSION_UPDATE), async
await controller.setSubmissionUserPermissions(req, res, next);
});
-routes.get('/users', keycloak.protect(`${config.get('server.keycloak.clientId')}:admin`), async (req, res, next) => {
+routes.get('/users', jwtService.protect('admin'), async (req, res, next) => {
await controller.getUserForms(req, res, next);
});
diff --git a/app/src/forms/rbac/service.js b/app/src/forms/rbac/service.js
index 838feb9c8..cbee7e3b5 100644
--- a/app/src/forms/rbac/service.js
+++ b/app/src/forms/rbac/service.js
@@ -1,9 +1,11 @@
const Problem = require('api-problem');
const { v4: uuidv4 } = require('uuid');
-const { FormRoleUser, FormSubmissionUser, IdentityProvider, User, UserFormAccess, UserSubmissions } = require('../common/models');
+const { FormRoleUser, FormSubmissionUser, User, UserFormAccess, UserSubmissions } = require('../common/models');
const { Roles } = require('../common/constants');
const { queryUtils } = require('../common/utils');
const authService = require('../auth/service');
+const idpService = require('../../components/idpService');
+
const service = {
list: async () => {
return FormRoleUser.query().allowGraph('[form, userRole, user]').withGraphFetched('[form, userRole, user]').modify('orderCreatedAtDescending');
@@ -75,7 +77,10 @@ const service = {
if (params.team) accessLevels.push('team');
}
- const forms = await authService.getUserForms(user, { ...params, active: true });
+ const forms = await authService.getUserForms(user, {
+ ...params,
+ active: true,
+ });
const filteredForms = authService.filterForms(user, forms, accessLevels);
user.forms = filteredForms;
@@ -271,7 +276,10 @@ const service = {
if (items && items.length) await FormRoleUser.query(trx).insert(items);
await trx.commit();
// return the new mappings
- const result = await service.getUserForms({ userId: userId, formId: formId });
+ const result = await service.getUserForms({
+ userId: userId,
+ formId: formId,
+ });
return result;
} catch (err) {
if (trx) await trx.rollback();
@@ -280,7 +288,7 @@ const service = {
},
getIdentityProviders: (params) => {
- return IdentityProvider.query().modify('filterActive', params.active).modify('orderDefault');
+ return idpService.getIdentityProviders(params.active);
},
};
diff --git a/app/src/forms/role/routes.js b/app/src/forms/role/routes.js
index 4042f8943..4bb8c83cd 100644
--- a/app/src/forms/role/routes.js
+++ b/app/src/forms/role/routes.js
@@ -1,26 +1,25 @@
-const config = require('config');
const routes = require('express').Router();
const currentUser = require('../auth/middleware/userAccess').currentUser;
const controller = require('./controller');
-const keycloak = require('../../components/keycloak');
+const jwtService = require('../../components/jwtService');
routes.use(currentUser);
-routes.get('/', keycloak.protect(), async (req, res, next) => {
+routes.get('/', jwtService.protect(), async (req, res, next) => {
await controller.list(req, res, next);
});
-routes.post('/', keycloak.protect(`${config.get('server.keycloak.clientId')}:admin`), async (req, res, next) => {
+routes.post('/', jwtService.protect('admin'), async (req, res, next) => {
await controller.create(req, res, next);
});
-routes.get('/:code', keycloak.protect(), async (req, res, next) => {
+routes.get('/:code', jwtService.protect(), async (req, res, next) => {
await controller.read(req, res, next);
});
-routes.put('/:code', keycloak.protect(`${config.get('server.keycloak.clientId')}:admin`), async (req, res, next) => {
+routes.put('/:code', jwtService.protect('admin'), async (req, res, next) => {
await controller.update(req, res, next);
});
diff --git a/app/src/forms/user/routes.js b/app/src/forms/user/routes.js
index 2dd90ca4a..032cd6513 100644
--- a/app/src/forms/user/routes.js
+++ b/app/src/forms/user/routes.js
@@ -2,9 +2,9 @@ const routes = require('express').Router();
const controller = require('./controller');
const currentUser = require('../auth/middleware/userAccess').currentUser;
-const keycloak = require('../../components/keycloak');
+const jwtService = require('../../components/jwtService');
-routes.use(keycloak.protect());
+routes.use(jwtService.protect());
routes.use(currentUser);
//
diff --git a/app/src/forms/user/service.js b/app/src/forms/user/service.js
index 31449c45f..1a00c27b3 100644
--- a/app/src/forms/user/service.js
+++ b/app/src/forms/user/service.js
@@ -1,33 +1,21 @@
const Problem = require('api-problem');
const { v4: uuidv4 } = require('uuid');
const { User, UserFormPreferences, Label } = require('../common/models');
-const { IdentityProviders } = require('../common/constants');
+const idpService = require('../../components/idpService');
const service = {
//
// User
//
list: (params) => {
- let exact = false;
- if (params.idpCode && (params.idpCode === IdentityProviders.BCEIDBASIC || params.idpCode === IdentityProviders.BCEIDBUSINESS)) {
- if (!params.email && !params.username) {
- throw new Problem(422, {
- detail: 'Could not retrieve BCeID users. Invalid options provided.',
- });
- }
- exact = true;
+ try {
+ // returns a promise, so caller needs to await.
+ return idpService.userSearch(params);
+ } catch (e) {
+ throw new Problem(422, {
+ detail: e.message,
+ });
}
-
- return User.query()
- .modify('filterIdpUserId', params.idpUserId)
- .modify('filterIdpCode', params.idpCode)
- .modify('filterUsername', params.username, exact)
- .modify('filterFullName', params.fullName)
- .modify('filterFirstName', params.firstName)
- .modify('filterLastName', params.lastName)
- .modify('filterEmail', params.email, exact)
- .modify('filterSearch', params.search)
- .modify('orderLastFirstAscending');
},
read: (userId) => {
diff --git a/app/tests/unit/forms/auth/authService.spec.js b/app/tests/unit/forms/auth/authService.spec.js
index ba5df636c..e9d297f79 100644
--- a/app/tests/unit/forms/auth/authService.spec.js
+++ b/app/tests/unit/forms/auth/authService.spec.js
@@ -1,12 +1,13 @@
const service = require('../../../../src/forms/auth/service');
+const idpService = require('../../../../src/components/idpService');
afterEach(() => {
jest.clearAllMocks();
});
describe('parseToken', () => {
- it('returns a default object when an exception happens', () => {
- const result = service.parseToken(undefined);
+ it('returns a default object when an exception happens', async () => {
+ const result = await idpService.parseToken(undefined);
expect(result).toEqual({
idpUserId: undefined,
username: 'public',
@@ -44,17 +45,19 @@ describe('formAccessToForm', () => {
describe('login', () => {
const resultSample = {
user: 'me',
+ idpHint: 'fake',
};
it('returns a currentUser object', async () => {
- service.parseToken = jest.fn().mockReturnValue('userInf');
+ idpService.parseToken = jest.fn().mockReturnValue({ idp: 'fake' });
+ idpService.findByIdp = jest.fn().mockReturnValue({ idp: 'fake', code: 'fake' });
service.getUserId = jest.fn().mockReturnValue({ user: 'me' });
const token = 'token';
const result = await service.login(token);
- expect(service.parseToken).toHaveBeenCalledTimes(1);
- expect(service.parseToken).toHaveBeenCalledWith(token);
+ expect(idpService.parseToken).toHaveBeenCalledTimes(1);
+ expect(idpService.parseToken).toHaveBeenCalledWith(token);
expect(service.getUserId).toHaveBeenCalledTimes(1);
- expect(service.getUserId).toHaveBeenCalledWith('userInf');
+ expect(service.getUserId).toHaveBeenCalledWith({ idp: 'fake' });
expect(result).toBeTruthy();
expect(result).toEqual(resultSample);
});
diff --git a/app/tests/unit/forms/auth/middleware/userAccess.spec.js b/app/tests/unit/forms/auth/middleware/userAccess.spec.js
index a3bbd5f75..fd81ccc93 100644
--- a/app/tests/unit/forms/auth/middleware/userAccess.spec.js
+++ b/app/tests/unit/forms/auth/middleware/userAccess.spec.js
@@ -3,16 +3,10 @@ const Problem = require('api-problem');
const { currentUser, hasFormPermissions, hasSubmissionPermissions, hasFormRoles, hasRolePermissions } = require('../../../../../src/forms/auth/middleware/userAccess');
-const keycloak = require('../../../../../src/components/keycloak');
+const jwtService = require('../../../../../src/components/jwtService');
const service = require('../../../../../src/forms/auth/service');
const rbacService = require('../../../../../src/forms/rbac/service');
-const kauth = {
- grant: {
- access_token: 'fsdfhsd08f0283hr',
- },
-};
-
const userId = 'c6455376-382c-439d-a811-0381a012d695';
const userId2 = 'c6455376-382c-439d-a811-0381a012d696';
const formId = 'c6455376-382c-439d-a811-0381a012d697';
@@ -26,7 +20,9 @@ const Roles = {
};
// Mock the token validation in the KC lib
-keycloak.grantManager.validateAccessToken = jest.fn().mockReturnValue('yeah ok');
+jwtService.validateAccessToken = jest.fn().mockReturnValue(true);
+jwtService.getBearerToken = jest.fn().mockReturnValue('bearer-token-value');
+jwtService.getTokenPayload = jest.fn().mockReturnValue({ token: 'payload' });
// Mock the service login
const mockUser = { user: 'me' };
@@ -50,16 +46,16 @@ describe('currentUser', () => {
headers: {
authorization: 'Bearer hjvds0uds',
},
- kauth: kauth,
};
const nxt = jest.fn();
await currentUser(testReq, testRes, nxt);
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1);
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith('hjvds0uds');
+ expect(jwtService.validateAccessToken).toHaveBeenCalledTimes(1);
+ expect(jwtService.getBearerToken).toHaveBeenCalledTimes(1);
+ expect(jwtService.validateAccessToken).toHaveBeenCalledWith('bearer-token-value');
expect(service.login).toHaveBeenCalledTimes(1);
- expect(service.login).toHaveBeenCalledWith(kauth.grant.access_token);
+ expect(service.login).toHaveBeenCalledWith({ token: 'payload' });
expect(testReq.currentUser).toEqual(mockUser);
expect(nxt).toHaveBeenCalledTimes(1);
expect(nxt).toHaveBeenCalledWith();
@@ -76,11 +72,12 @@ describe('currentUser', () => {
headers: {
authorization: 'Bearer hjvds0uds',
},
- kauth: kauth,
};
await currentUser(testReq, testRes, jest.fn());
- expect(service.login).toHaveBeenCalledWith(kauth.grant.access_token);
+ expect(jwtService.getBearerToken).toHaveBeenCalledTimes(1);
+ expect(jwtService.getTokenPayload).toHaveBeenCalledTimes(1);
+ expect(service.login).toHaveBeenCalledWith({ token: 'payload' });
});
it('uses the query param if both if that is whats provided', async () => {
@@ -91,11 +88,12 @@ describe('currentUser', () => {
headers: {
authorization: 'Bearer hjvds0uds',
},
- kauth: kauth,
};
await currentUser(testReq, testRes, jest.fn());
- expect(service.login).toHaveBeenCalledWith(kauth.grant.access_token);
+ expect(jwtService.getBearerToken).toHaveBeenCalledTimes(1);
+ expect(jwtService.getTokenPayload).toHaveBeenCalledTimes(1);
+ expect(service.login).toHaveBeenCalledWith({ token: 'payload' });
});
it('403s if the token is invalid', async () => {
@@ -106,27 +104,33 @@ describe('currentUser', () => {
};
const nxt = jest.fn();
- keycloak.grantManager.validateAccessToken = jest.fn().mockReturnValue(undefined);
+ jwtService.validateAccessToken = jest.fn().mockReturnValue(false);
await currentUser(testReq, testRes, nxt);
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1);
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith('hjvds0uds');
+ expect(jwtService.getBearerToken).toHaveBeenCalledTimes(1);
+ expect(jwtService.validateAccessToken).toHaveBeenCalledTimes(1);
+ expect(jwtService.validateAccessToken).toHaveBeenCalledWith('bearer-token-value');
expect(service.login).toHaveBeenCalledTimes(0);
expect(testReq.currentUser).toEqual(undefined);
expect(nxt).toHaveBeenCalledTimes(0);
- // expect(nxt).toHaveBeenCalledWith(new Problem(403, { detail: 'Authorization token is invalid.' }));
+ //expect(nxt).toHaveBeenCalledWith(new Problem(403, { detail: 'Authorization token is invalid.' }));
});
});
describe('getToken', () => {
- it('returns a null token if no kauth in the request', async () => {
+ it('returns a null token if no auth bearer in the headers', async () => {
const testReq = {
params: {
formId: 2,
},
};
+ jwtService.getBearerToken = jest.fn().mockReturnValue(null);
+ jwtService.getTokenPayload = jest.fn().mockReturnValue(null);
+
await currentUser(testReq, testRes, jest.fn());
+ expect(jwtService.getBearerToken).toHaveBeenCalledTimes(1);
+ expect(jwtService.getTokenPayload).toHaveBeenCalledTimes(1);
expect(service.login).toHaveBeenCalledTimes(1);
expect(service.login).toHaveBeenCalledWith(null);
});
@@ -513,7 +517,10 @@ describe('hasSubmissionPermissions', () => {
it('falls through to the query if the current user does not have any FORM access on the current form', async () => {
service.getSubmissionForm = jest.fn().mockReturnValue({
submission: { deleted: false },
- form: { id: '999', identityProviders: [{ code: 'idir' }, { code: 'bceid' }] },
+ form: {
+ id: '999',
+ identityProviders: [{ code: 'idir' }, { code: 'bceid' }],
+ },
});
service.checkSubmissionPermission = jest.fn().mockReturnValue(undefined);
@@ -688,7 +695,11 @@ describe('hasFormRoles', () => {
await hfr(req, testRes, nxt);
expect(nxt).toHaveBeenCalledTimes(1);
- expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: 'You do not have permission to update this role.' }));
+ expect(nxt).toHaveBeenCalledWith(
+ new Problem(401, {
+ detail: 'You do not have permission to update this role.',
+ })
+ );
});
it('falls through if the current user does not have all of the required form roles', async () => {
@@ -706,7 +717,11 @@ describe('hasFormRoles', () => {
await hfr(req, testRes, nxt);
expect(nxt).toHaveBeenCalledTimes(1);
- expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: 'You do not have permission to update this role.' }));
+ expect(nxt).toHaveBeenCalledWith(
+ new Problem(401, {
+ detail: 'You do not have permission to update this role.',
+ })
+ );
});
it('moves on if the user has at least one of the required form roles', async () => {
@@ -975,7 +990,7 @@ describe('hasRolePermissions', () => {
updatedAt: '',
},
{
- id: '5',
+ id: '6',
role: Roles.FORM_SUBMITTER,
formId: formId,
userId: userId2,
@@ -1071,7 +1086,11 @@ describe('hasRolePermissions', () => {
await hrp(req, testRes, nxt);
expect(nxt).toHaveBeenCalledTimes(1);
- expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: "You can't remove your own team manager role." }));
+ expect(nxt).toHaveBeenCalledWith(
+ new Problem(401, {
+ detail: "You can't remove your own team manager role.",
+ })
+ );
});
});
@@ -1332,7 +1351,11 @@ describe('hasRolePermissions', () => {
await hrp(req, testRes, nxt);
expect(nxt).toHaveBeenCalledTimes(1);
- expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: "You can't add a form designer role." }));
+ expect(nxt).toHaveBeenCalledWith(
+ new Problem(401, {
+ detail: "You can't add a form designer role.",
+ })
+ );
});
it('falls through if trying to remove a designer role', async () => {
@@ -1401,7 +1424,11 @@ describe('hasRolePermissions', () => {
await hrp(req, testRes, nxt);
expect(nxt).toHaveBeenCalledTimes(1);
- expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: "You can't remove a form designer role." }));
+ expect(nxt).toHaveBeenCalledWith(
+ new Problem(401, {
+ detail: "You can't remove a form designer role.",
+ })
+ );
});
it('should succeed when adding a manager/reviewer/submitter roles', async () => {
diff --git a/app/tests/unit/forms/user/service.spec.js b/app/tests/unit/forms/user/service.spec.js
index 63fd60bf3..6036ad793 100644
--- a/app/tests/unit/forms/user/service.spec.js
+++ b/app/tests/unit/forms/user/service.spec.js
@@ -5,6 +5,7 @@ jest.mock('../../../../src/forms/common/models/tables/userFormPreferences', () =
jest.mock('../../../../src/forms/common/models/tables/label', () => MockModel);
const service = require('../../../../src/forms/user/service');
+const idpService = require('../../../../src/components/idpService');
const formId = '4d33f4cb-0b72-4c3d-9e41-f2651805fee1';
const userId = 'cc8c64b7-a457-456e-ade0-09ff7ee75a2b';
@@ -15,6 +16,8 @@ beforeEach(() => {
MockTransaction.mockReset();
});
+idpService.findByCode = jest.fn().mockReturnValue(null);
+
describe('list', () => {
it('should query user table by id', async () => {
const params = {
diff --git a/app/tests/unit/routes/v1/admin.spec.js b/app/tests/unit/routes/v1/admin.spec.js
index 7bcfd20d0..476562752 100644
--- a/app/tests/unit/routes/v1/admin.spec.js
+++ b/app/tests/unit/routes/v1/admin.spec.js
@@ -6,12 +6,12 @@ const { expressHelper } = require('../../../common/helper');
//
// mock middleware
//
-const keycloak = require('../../../../src/components/keycloak');
+const jwtService = require('../../../../src/components/jwtService');
//
// test assumes that caller has appropriate token, we are not testing middleware here...
//
-keycloak.protect = jest.fn(() => {
+jwtService.protect = jest.fn(() => {
return jest.fn((_req, _res, next) => {
next();
});
diff --git a/app/tests/unit/routes/v1/form.spec.js b/app/tests/unit/routes/v1/form.spec.js
index b0b90bbf5..40415fa4a 100644
--- a/app/tests/unit/routes/v1/form.spec.js
+++ b/app/tests/unit/routes/v1/form.spec.js
@@ -7,12 +7,12 @@ const { expressHelper } = require('../../../common/helper');
//
// mock middleware
//
-const keycloak = require('../../../../src/components/keycloak');
+const jwtService = require('../../../../src/components/jwtService');
//
// test assumes that caller has appropriate token, we are not testing middleware here...
//
-keycloak.protect = jest.fn(() => {
+jwtService.protect = jest.fn(() => {
return jest.fn((_req, _res, next) => {
next();
});
diff --git a/app/tests/unit/routes/v1/permission.spec.js b/app/tests/unit/routes/v1/permission.spec.js
index b95055757..ebcc1038b 100644
--- a/app/tests/unit/routes/v1/permission.spec.js
+++ b/app/tests/unit/routes/v1/permission.spec.js
@@ -6,12 +6,12 @@ const { expressHelper } = require('../../../common/helper');
//
// mock middleware
//
-const keycloak = require('../../../../src/components/keycloak');
+const jwtService = require('../../../../src/components/jwtService');
//
// test assumes that caller has appropriate token, we are not testing middleware here...
//
-keycloak.protect = jest.fn(() => {
+jwtService.protect = jest.fn(() => {
return jest.fn((_req, _res, next) => {
next();
});
diff --git a/app/tests/unit/routes/v1/rbac.spec.js b/app/tests/unit/routes/v1/rbac.spec.js
index 3cea1e408..4744ff848 100644
--- a/app/tests/unit/routes/v1/rbac.spec.js
+++ b/app/tests/unit/routes/v1/rbac.spec.js
@@ -6,12 +6,12 @@ const { expressHelper } = require('../../../common/helper');
//
// mock middleware
//
-const keycloak = require('../../../../src/components/keycloak');
+const jwtService = require('../../../../src/components/jwtService');
//
// test assumes that caller has appropriate token, we are not testing middleware here...
//
-keycloak.protect = jest.fn(() => {
+jwtService.protect = jest.fn(() => {
return jest.fn((_req, _res, next) => {
next();
});
diff --git a/app/tests/unit/routes/v1/role.spec.js b/app/tests/unit/routes/v1/role.spec.js
index 7a388ef19..83c107f16 100644
--- a/app/tests/unit/routes/v1/role.spec.js
+++ b/app/tests/unit/routes/v1/role.spec.js
@@ -6,12 +6,12 @@ const { expressHelper } = require('../../../common/helper');
//
// mock middleware
//
-const keycloak = require('../../../../src/components/keycloak');
+const jwtService = require('../../../../src/components/jwtService');
//
// test assumes that caller has appropriate token, we are not testing middleware here...
//
-keycloak.protect = jest.fn(() => {
+jwtService.protect = jest.fn(() => {
return jest.fn((_req, _res, next) => {
next();
});
diff --git a/app/tests/unit/routes/v1/user.spec.js b/app/tests/unit/routes/v1/user.spec.js
index 9b882fc3d..1bb9d0f0c 100644
--- a/app/tests/unit/routes/v1/user.spec.js
+++ b/app/tests/unit/routes/v1/user.spec.js
@@ -6,12 +6,12 @@ const { expressHelper } = require('../../../common/helper');
//
// mock middleware
//
-const keycloak = require('../../../../src/components/keycloak');
+const jwtService = require('../../../../src/components/jwtService');
//
// test assumes that caller has appropriate token, we are not testing middleware here...
//
-keycloak.protect = jest.fn(() => {
+jwtService.protect = jest.fn(() => {
return jest.fn((_req, _res, next) => {
next();
});
From d746ebcf7da9d6772fbd77e94d7e2697696499d9 Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Tue, 27 Feb 2024 20:47:32 -0800
Subject: [PATCH 04/16] update keycloak configuration to use another name:
oidc. allow null logoutUrl. add parameters to logoutUrl at runtime
Signed-off-by: Jason Sherman
---
.devcontainer/chefs_local/local.json.sample | 21 +++++++++--------
app/config/custom-environment-variables.json | 23 ++++++++++---------
app/config/default.json | 12 +++++-----
app/config/test.json | 2 +-
.../src/components/admin/AdministerUser.vue | 13 -----------
app/frontend/src/main.js | 17 +++++++-------
app/frontend/src/store/auth.js | 19 ++++++++++++++-
.../components/admin/AdministerUser.spec.js | 5 +---
.../components/base/BaseAuthButton.spec.js | 15 +++++++++---
.../unit/store/modules/auth.actions.spec.js | 3 +++
app/src/components/jwtService.js | 8 +++----
app/src/routes/v1.js | 4 +---
openshift/README.md | 13 +++++++++++
openshift/app.dc.yaml | 2 ++
14 files changed, 93 insertions(+), 64 deletions(-)
diff --git a/.devcontainer/chefs_local/local.json.sample b/.devcontainer/chefs_local/local.json.sample
index d5a02db02..ebbeaf9c7 100644
--- a/.devcontainer/chefs_local/local.json.sample
+++ b/.devcontainer/chefs_local/local.json.sample
@@ -30,21 +30,24 @@
"frontend": {
"apiPath": "api/v1",
"basePath" : "/app",
- "keycloak": {
- "clientId": "chefs-frontend-local",
- "realm": "chefs",
- "serverUrl": "http://localhost:8082"
+ "oidc": {
+ "clientId": "chefs-frontend-localhost-5300",
+ "realm": "standard",
+ "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth",
+ "logoutUrl": "https://logon7.gov.bc.ca/clp-cgi/logoff.cgi?retnow=1&returl=https%3A%2F%2Fdev.loginproxy.gov.bc.ca%2Fauth%2Frealms%2Fstandard%2Fprotocol%2Fopenid-connect%2Flogout"
}
},
"server": {
"apiPath": "/api/v1",
"basePath" : "/app",
"bodyLimit": "30mb",
- "keycloak": {
- "clientId": "chefs",
- "realm": "chefs",
- "serverUrl": "http://localhost:8082",
- "clientSecret": "XXXXXXXXXXXX"
+ "oidc": {
+ "realm": "standard",
+ "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth",
+ "jwksUri": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard/protocol/openid-connect/certs",
+ "issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard",
+ "audience": "chefs-frontend-localhost-5300",
+ "maxTokenAge": "300"
},
"logLevel": "http",
"port": "8080",
diff --git a/app/config/custom-environment-variables.json b/app/config/custom-environment-variables.json
index 06d1c82b6..fd371003a 100755
--- a/app/config/custom-environment-variables.json
+++ b/app/config/custom-environment-variables.json
@@ -32,23 +32,24 @@
"adminDashboardUrl": "VITE_ADMIN_DASHBOARD_URL",
"apiPath": "FRONTEND_APIPATH",
"basePath": "VITE_FRONTEND_BASEPATH",
- "keycloak": {
- "clientId": "FRONTEND_KC_CLIENTID",
- "realm": "FRONTEND_KC_REALM",
- "serverUrl": "FRONTEND_KC_SERVERURL",
- "logoutUrl": "FRONTEND_KC_LOGOUTURL"
+ "oidc": {
+ "clientId": "OIDC_CLIENTID",
+ "realm": "OIDC_REALM",
+ "serverUrl": "OIDC_SERVERURL",
+ "logoutUrl": "OIDC_LOGOUTURL"
}
},
"server": {
"apiPath": "SERVER_APIPATH",
"basePath": "SERVER_BASEPATH",
"bodyLimit": "SERVER_BODYLIMIT",
- "keycloak": {
- "serverUrl": "SERVER_KC_SERVERURL",
- "jwksUri": "SERVER_KC_JWKSURI",
- "issuer": "SERVER_KC_ISSUER",
- "audience": "SERVER_KC_AUDIENCE",
- "maxTokenAge": "SERVER_KC_MAXTOKENAGE"
+ "oidc": {
+ "realm": "OIDC_REALM",
+ "serverUrl": "OIDC_SERVERURL",
+ "jwksUri": "OIDC_JWKSURI",
+ "issuer": "OIDC_ISSUER",
+ "audience": "OIDC_CLIENTID",
+ "maxTokenAge": "OIDC_MAXTOKENAGE"
},
"logFile": "SERVER_LOGFILE",
"logLevel": "SERVER_LOGLEVEL",
diff --git a/app/config/default.json b/app/config/default.json
index 99bb82950..31d28ee3f 100644
--- a/app/config/default.json
+++ b/app/config/default.json
@@ -30,25 +30,25 @@
"adminDashboardUrl": "",
"apiPath": "api/v1",
"basePath": "/app",
- "keycloak": {
- "clientId": "chefs-frontend",
+ "oidc": {
+ "clientId": "chefs-frontend-localhost-5300",
"realm": "standard",
"serverUrl": "https://dev.loginproxy.gov.bc.ca/auth",
- "logoutUrl": "https://logon7.gov.bc.ca/clp-cgi/logoff.cgi?retnow=1&returl=https%3A%2F%2Fdev.loginproxy.gov.bc.ca%2Fauth%2Frealms%2Fstandard%2Fprotocol%2Fopenid-connect%2Flogout%3Fpost_logout_redirect_uri%3Dhttp%3A%2F%2Flocalhost%3A5173%2Fapp%26client_id%3Dchefs-frontend"
+ "logoutUrl": "https://logon7.gov.bc.ca/clp-cgi/logoff.cgi?retnow=1&returl=https%3A%2F%2Fdev.loginproxy.gov.bc.ca%2Fauth%2Frealms%2Fstandard%2Fprotocol%2Fopenid-connect%2Flogout"
}
},
"server": {
"apiPath": "/api/v1",
"basePath": "/app",
"bodyLimit": "30mb",
- "keycloak": {
+ "oidc": {
"realm": "standard",
"serverUrl": "https://dev.loginproxy.gov.bc.ca/auth",
"jwksUri": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard/protocol/openid-connect/certs",
"issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard",
- "audience": "chefs-frontend",
+ "audience": "chefs-frontend-localhost-5300",
"maxTokenAge": "300"
- },
+ },
"logLevel": "http",
"port": "8080",
"rateLimit": {
diff --git a/app/config/test.json b/app/config/test.json
index 23510c2e3..4382649e5 100755
--- a/app/config/test.json
+++ b/app/config/test.json
@@ -26,7 +26,7 @@
},
"server": {
"emailRecipients": "foo@bar.com,baz@boo.com",
- "keycloak": {
+ "oidc": {
"clientSecret": "password"
},
"logLevel": "silent"
diff --git a/app/frontend/src/components/admin/AdministerUser.vue b/app/frontend/src/components/admin/AdministerUser.vue
index f91cf4a2b..cd47ebf15 100644
--- a/app/frontend/src/components/admin/AdministerUser.vue
+++ b/app/frontend/src/components/admin/AdministerUser.vue
@@ -16,9 +16,6 @@ export default {
...mapState(useAppStore, ['config']),
...mapState(useAdminStore, ['user']),
...mapState(useFormStore, ['lang']),
- userUrl() {
- return `${this.config.keycloak.serverUrl}/admin/${this.config.keycloak.realm}/console/#/realms/${this.config.keycloak.realm}/users/${this.user.keycloakId}`;
- },
},
async mounted() {
await this.readUser(this.userId);
@@ -34,15 +31,5 @@ export default {
{{ user.fullName }}
{{ $t('trans.administerUser.userDetails') }}
{{ user }}
-
-
- {{ $t('trans.administerUser.openSSOConsole') }}
-
diff --git a/app/frontend/src/main.js b/app/frontend/src/main.js
index cfb257330..b814dc934 100755
--- a/app/frontend/src/main.js
+++ b/app/frontend/src/main.js
@@ -137,11 +137,10 @@ async function loadConfig() {
if (
!config ||
- !config.keycloak ||
- !config.keycloak.clientId ||
- !config.keycloak.realm ||
- !config.keycloak.serverUrl ||
- !config.keycloak.logoutUrl
+ !config.oidc ||
+ !config.oidc.clientId ||
+ !config.oidc.realm ||
+ !config.oidc.serverUrl
) {
throw new Error('Keycloak is misconfigured');
}
@@ -168,9 +167,9 @@ function loadKeycloak(config) {
const options = Object.assign({}, defaultParams, {
init: { pkceMethod: 'S256', checkLoginIframe: false, onLoad: 'check-sso' },
config: {
- clientId: config.keycloak.clientId,
- realm: config.keycloak.realm,
- url: config.keycloak.serverUrl,
+ clientId: config.oidc.clientId,
+ realm: config.oidc.realm,
+ url: config.oidc.serverUrl,
},
onReady: () => {
initializeApp(true, config.basePath);
@@ -189,7 +188,7 @@ function loadKeycloak(config) {
const ctor = sanitizeConfig(cfg);
const authStore = useAuthStore();
- authStore.logoutUrl = config.keycloak.logoutUrl;
+ authStore.logoutUrl = config.oidc.logoutUrl;
keycloak = new Keycloak(ctor);
keycloak.onReady = (authenticated) => {
diff --git a/app/frontend/src/store/auth.js b/app/frontend/src/store/auth.js
index 56aa4f7ba..0316bca19 100644
--- a/app/frontend/src/store/auth.js
+++ b/app/frontend/src/store/auth.js
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia';
import getRouter from '~/router';
import { useIdpStore } from '~/store/identityProviders';
+import { useAppStore } from '~/store/app';
/**
* @function hasRoles
@@ -26,6 +27,8 @@ export const useAuthStore = defineStore('auth', {
getters: {
createLoginUrl: (state) => (options) =>
state.keycloak.createLoginUrl(options),
+ createLogoutUrl: (state) => (options) =>
+ state.keycloak.createLogoutUrl(options),
email: (state) =>
state.keycloak.tokenParsed ? state.keycloak.tokenParsed.email : '',
fullName: (state) => state.keycloak.tokenParsed.name,
@@ -123,7 +126,21 @@ export const useAuthStore = defineStore('auth', {
},
logout() {
if (this.ready) {
- window.location.assign(this.logoutUrl);
+ // if we have not specified a logoutUrl, then use default
+ if (!this.logoutUrl) {
+ window.location.replace(
+ this.createLogoutUrl({
+ redirectUri: location.origin,
+ })
+ );
+ } else {
+ const appStore = useAppStore();
+ const cli_param = `client_id=${this.keycloak.clientId}`;
+ const redirect_param = `post_logout_redirect_uri=${location.origin}${appStore.config.basePath}`;
+ const logout_param = `${redirect_param}&${cli_param}`;
+ let logout = `${this.logoutUrl}?${encodeURIComponent(logout_param)}`;
+ window.location.assign(logout);
+ }
}
},
},
diff --git a/app/frontend/tests/unit/components/admin/AdministerUser.spec.js b/app/frontend/tests/unit/components/admin/AdministerUser.spec.js
index 65ca02835..ab635c790 100644
--- a/app/frontend/tests/unit/components/admin/AdministerUser.spec.js
+++ b/app/frontend/tests/unit/components/admin/AdministerUser.spec.js
@@ -21,7 +21,7 @@ describe('AdministerUser.vue', () => {
it('renders', async () => {
appStore.config = {
- keycloak: {
+ oidc: {
serverUrl: 'servU',
realm: 'theRealm',
},
@@ -43,8 +43,5 @@ describe('AdministerUser.vue', () => {
await flushPromises();
expect(wrapper.text()).toContain('alice');
- expect(wrapper.html()).toContain(
- 'servU/admin/theRealm/console/#/realms/theRealm/users/1'
- );
});
});
diff --git a/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js b/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js
index 0cb3c64c0..4b7738788 100644
--- a/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js
+++ b/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js
@@ -3,27 +3,34 @@
import { mount } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
-import { vi } from 'vitest';
+import { expect, vi } from 'vitest';
import getRouter from '~/router';
import BaseAuthButton from '~/components/base/BaseAuthButton.vue';
import { useAuthStore } from '~/store/auth';
import { useIdpStore } from '~/store/identityProviders';
+import { useAppStore } from '~/store/app';
describe('BaseAuthButton.vue', () => {
const pinia = createPinia();
setActivePinia(pinia);
const authStore = useAuthStore();
const idpStore = useIdpStore();
+ const appStore = useAppStore();
const router = getRouter();
const windowReplaceSpy = vi.spyOn(window.location, 'assign');
idpStore.providers = require('../../fixtures/identityProviders.json');
beforeEach(async () => {
windowReplaceSpy.mockReset();
+ appStore.$reset();
+ appStore.config = {
+ basePath: '/app'
+ };
authStore.$reset();
authStore.keycloak = {
createLoginUrl: vi.fn((opts) => opts),
+ clientId: 'clientid'
};
router.currentRoute.value.meta.hasLogin = true;
router.push('/');
@@ -103,7 +110,8 @@ describe('BaseAuthButton.vue', () => {
it('logout button redirects to logout url', async () => {
authStore.authenticated = true;
- authStore.logoutUrl = location.origin;
+ authStore.logoutUrl = 'http://redirect.com/logout';
+ authStore.keycloak
authStore.ready = true;
const wrapper = mount(BaseAuthButton, {
global: {
@@ -114,6 +122,7 @@ describe('BaseAuthButton.vue', () => {
wrapper.vm.logout();
expect(wrapper.text()).toMatch('trans.baseAuthButton.logout');
expect(windowReplaceSpy).toHaveBeenCalledTimes(1);
- expect(windowReplaceSpy).toHaveBeenCalledWith(location.origin);
+ const params = encodeURIComponent(`post_logout_redirect_uri=null/app&client_id=clientid`)
+ expect(windowReplaceSpy).toHaveBeenCalledWith(`http://redirect.com/logout?${params}`);
});
});
diff --git a/app/frontend/tests/unit/store/modules/auth.actions.spec.js b/app/frontend/tests/unit/store/modules/auth.actions.spec.js
index c007b993e..f3c87da42 100644
--- a/app/frontend/tests/unit/store/modules/auth.actions.spec.js
+++ b/app/frontend/tests/unit/store/modules/auth.actions.spec.js
@@ -7,6 +7,7 @@ import getRouter from '~/router';
import { useAuthStore } from '~/store/auth';
import { useFormStore } from '~/store/form';
import { useIdpStore } from '~/store/identityProviders';
+import { useAppStore } from '~/store/app';
describe('auth actions', () => {
let router = getRouter();
@@ -17,6 +18,7 @@ describe('auth actions', () => {
const mockStore = useAuthStore();
const formStore = useFormStore();
const idpStore = useIdpStore();
+ const appStore = useAppStore();
idpStore.providers = require('../../fixtures/identityProviders.json');
@@ -31,6 +33,7 @@ describe('auth actions', () => {
replaceSpy.mockReset();
windowReplaceSpy.mockReset();
router.replace.mockReset();
+ appStore.config = { basePath: '/app' };
});
it('should do nothing if keycloak is not ready', () => {
diff --git a/app/src/components/jwtService.js b/app/src/components/jwtService.js
index 53637c0af..39a6fdb36 100644
--- a/app/src/components/jwtService.js
+++ b/app/src/components/jwtService.js
@@ -4,7 +4,7 @@ const errorToProblem = require('./errorToProblem');
const SERVICE = 'JwtService';
-const jwksUri = config.get('server.keycloak.jwksUri');
+const jwksUri = config.get('server.oidc.jwksUri');
// Create a remote JWK set that fetches the JWK set from server with caching
const JWKS = jose.createRemoteJWKSet(new URL(jwksUri));
@@ -88,9 +88,9 @@ class JwtService {
}
}
-const audience = config.get('server.keycloak.audience');
-const issuer = config.get('server.keycloak.issuer');
-const maxTokenAge = config.get('server.keycloak.maxTokenAge');
+const audience = config.get('server.oidc.audience');
+const issuer = config.get('server.oidc.issuer');
+const maxTokenAge = config.get('server.oidc.maxTokenAge');
let jwtService = new JwtService({
issuer: issuer,
diff --git a/app/src/routes/v1.js b/app/src/routes/v1.js
index 097d5456a..fb07884ae 100755
--- a/app/src/routes/v1.js
+++ b/app/src/routes/v1.js
@@ -32,9 +32,7 @@ const getSpec = () => {
const rawSpec = fs.readFileSync(path.join(__dirname, '../docs/v1.api-spec.yaml'), 'utf8');
const spec = yaml.load(rawSpec);
spec.servers[0].url = `${config.get('server.basePath')}/api/v1`;
- spec.components.securitySchemes.OpenID.openIdConnectUrl = `${config.get('server.keycloak.serverUrl')}/realms/${config.get(
- 'server.keycloak.realm'
- )}/.well-known/openid-configuration`;
+ spec.components.securitySchemes.OpenID.openIdConnectUrl = `${config.get('server.oidc.serverUrl')}/realms/${config.get('server.oidc.realm')}/.well-known/openid-configuration`;
return spec;
};
diff --git a/openshift/README.md b/openshift/README.md
index 97d3b0506..854edf140 100644
--- a/openshift/README.md
+++ b/openshift/README.md
@@ -74,6 +74,19 @@ oc create -n $NAMESPACE configmap $APP_NAME-server-config \
--from-literal=SERVER_PORT=8080
```
+_Note:_ OIDC config is for moving from a custom Keycloak realm into the BC Gov standard realm a managed SSO platform. Other KC configuration will be deprecated. Urls and Client IDs will change from environment to environment.
+
+```sh
+oc create -n $NAMESPACE configmap $APP_NAME-oidc-config \
+ --from-literal=OIDC_REALM=standard \
+ --from-literal=OIDC_SERVERURL=https://dev.loginproxy.gov.bc.ca/auth \
+ --from-literal=OIDC_JWKSURI=https://dev.loginproxy.gov.bc.ca/auth/realms/standard/protocol/openid-connect/certs \
+ --from-literal=OIDC_ISSUER=https://dev.loginproxy.gov.bc.ca/auth/realms/standard \
+ --from-literal=OIDC_CLIENTID=chefs-frontend-5299 \
+ --from-literal=OIDC_MAXTOKENAGE=300 \
+ --from-literal=OIDC_LOGOUTURL='https://logon7.gov.bc.ca/clp-cgi/logoff.cgi?retnow=1&returl=https%3A%2F%2Fdev.loginproxy.gov.bc.ca%2Fauth%2Frealms%2Fstandard%2Fprotocol%2Fopenid-connect%2Flogout'
+```
+
_Note:_ We use the Common Services Object Storage for CHEFS. You will need to contact them to have your storage bucket created.
```sh
diff --git a/openshift/app.dc.yaml b/openshift/app.dc.yaml
index 0f77451da..18d8c235b 100644
--- a/openshift/app.dc.yaml
+++ b/openshift/app.dc.yaml
@@ -252,6 +252,8 @@ objects:
name: "${APP_NAME}-service-config"
- configMapRef:
name: "${APP_NAME}-files-config"
+ - configMapRef:
+ name: "${APP_NAME}-oidc-config"
- configMapRef:
name: "${APP_NAME}-custombcaddressformiocomponent-config"
restartPolicy: Always
From 7091e0508b297613352ab611b0319a941435451c Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Wed, 28 Feb 2024 09:21:30 -0800
Subject: [PATCH 05/16] resolve merge conflicts around user access & form
permissions
Signed-off-by: Jason Sherman
---
app/src/forms/auth/middleware/userAccess.js | 144 ++++++----
app/src/forms/form/routes.js | 16 +-
app/src/forms/rbac/routes.js | 8 +-
.../forms/auth/middleware/userAccess.spec.js | 254 ++++++++++--------
4 files changed, 238 insertions(+), 184 deletions(-)
diff --git a/app/src/forms/auth/middleware/userAccess.js b/app/src/forms/auth/middleware/userAccess.js
index 26b7b5f8e..c3c1395d3 100644
--- a/app/src/forms/auth/middleware/userAccess.js
+++ b/app/src/forms/auth/middleware/userAccess.js
@@ -1,5 +1,5 @@
const Problem = require('api-problem');
-const { validate } = require('uuid');
+const uuid = require('uuid');
const jwtService = require('../../../components/jwtService');
const Permissions = require('../../common/constants').Permissions;
@@ -7,6 +7,55 @@ const Roles = require('../../common/constants').Roles;
const service = require('../service');
const rbacService = require('../../rbac/service');
+/**
+ * Checks that every permission is in the user's form permissions.
+ *
+ * @param {*} form the user's form metadata including permissions.
+ * @param {string[]} permissions the permissions needed for access.
+ * @returns true if every permissions value is in the user's form permissions.
+ */
+const _formHasPermissions = (form, permissions) => {
+ // Get the intersection of the two sets of permissions. If it's the same
+ // size as permissions then the user has all the needed permissions.
+ const intersection = permissions.filter((p) => {
+ return form.permissions.includes(p);
+ });
+
+ return intersection.length === permissions.length;
+};
+
+/**
+ * Gets the form metadata for the given formId from the forms available to the
+ * current user.
+ *
+ * @param {*} currentUser the user that is currently logged in; may be public.
+ * @param {uuid} formId the ID of the form to retrieve for the current user.
+ * @param {boolean} includeDeleted if active form not found, look for a deleted
+ * form.
+ * @returns the form metadata.
+ * @throws Problem if the form metadata for the formId cannot be retrieved.
+ */
+const _getForm = async (currentUser, formId, includeDeleted) => {
+ if (!uuid.validate(formId)) {
+ throw new Problem(400, { detail: 'Bad formId' });
+ }
+
+ const forms = await service.getUserForms(currentUser, { active: true, formId: formId });
+ let form = forms.find((f) => f.formId === formId);
+
+ if (!form && includeDeleted) {
+ const deletedForms = await service.getUserForms(currentUser, { active: false, formId: formId });
+ form = deletedForms.find((f) => f.formId === formId);
+ }
+
+ // Cannot find the form: either it doesn't exist or we don't have access.
+ if (!form) {
+ throw new Problem(401, { detail: 'Current user has no access to form' });
+ }
+
+ return form;
+};
+
const setUser = async (req, _res, next) => {
try {
const token = await jwtService.getTokenPayload(req);
@@ -32,65 +81,46 @@ const currentUser = async (req, res, next) => {
return setUser(req, res, next);
};
-const _getForm = async (currentUser, formId) => {
- const forms = await service.getUserForms(currentUser, {
- active: true,
- formId: formId,
- });
- let form = forms.find((f) => f.formId === formId);
-
- if (!form) {
- const deletedForms = await service.getUserForms(currentUser, {
- active: false,
- formId: formId,
- });
- form = deletedForms.find((f) => f.formId === formId);
- }
-
- return form;
-};
-
+/**
+ * Express middleware to check that a user has all the given permissions for a
+ * form. This will fall through if everything is OK, otherwise it will call
+ * next() with a Problem that describes the error.
+ *
+ * @param {string[]} permissions the form permissions that the user must have.
+ * @returns nothing
+ */
const hasFormPermissions = (permissions) => {
- return async (req, res, next) => {
- // Skip permission checks if requesting as API entity
- if (req.apiUser) {
- return next();
- }
+ return async (req, _res, next) => {
+ try {
+ // Skip permission checks if req is already validated using an API key.
+ if (req.apiUser) {
+ next();
- if (!req.currentUser) {
- // cannot find the currentUser... guess we don't have access... FAIL!
- return new Problem(401, {
- detail: 'Current user not found on request.',
- }).send(res);
- }
- // If we invoke this middleware and the caller is acting on a specific formId, whether in a param or query (precedence to param)
- const formId = req.params.formId || req.query.formId;
- if (!formId) {
- // No form provided to this route that secures based on form... that's a problem!
- return new Problem(401, { detail: 'Form Id not found on request.' }).send(res);
- }
- let form = await _getForm(req.currentUser, formId);
- if (!form) {
- // cannot find the form... guess we don't have access... FAIL!
- return new Problem(401, {
- detail: 'Current user has no access to form.',
- }).send(res);
- }
+ return;
+ }
- if (!Array.isArray(permissions)) {
- permissions = [permissions];
- }
+ // If the currentUser does not exist it means that the route is not set up
+ // correctly - the currentUser middleware must be called before this
+ // middleware.
+ if (!req.currentUser) {
+ throw new Problem(500, {
+ detail: 'Current user not found on request',
+ });
+ }
- const intersection = permissions.filter((p) => {
- return form.permissions.includes(p);
- });
+ // The request must include a formId, either in params or query, but give
+ // precedence to params.
+ const form = await _getForm(req.currentUser, req.params.formId || req.query.formId, true);
- if (intersection.length !== permissions.length) {
- return new Problem(401, {
- detail: 'Current user does not have required permission(s) on form',
- }).send(res);
- } else {
- return next();
+ if (!_formHasPermissions(form, permissions)) {
+ throw new Problem(401, {
+ detail: 'Current user does not have required permission(s) on form',
+ });
+ }
+
+ next();
+ } catch (error) {
+ next(error);
}
};
};
@@ -189,12 +219,12 @@ const filterMultipleSubmissions = () => {
}
//validate form id
- if (!validate(formId)) {
+ if (!uuid.validate(formId)) {
return next(new Problem(401, { detail: 'Not a valid form id' }));
}
//validate all submission ids
- const isValidSubmissionId = submissionIds.every((submissionId) => validate(submissionId));
+ const isValidSubmissionId = submissionIds.every((submissionId) => uuid.validate(submissionId));
if (!isValidSubmissionId) {
return next(
new Problem(401, {
diff --git a/app/src/forms/form/routes.js b/app/src/forms/form/routes.js
index 5cda8814c..3be996cf1 100644
--- a/app/src/forms/form/routes.js
+++ b/app/src/forms/form/routes.js
@@ -22,7 +22,7 @@ routes.post('/', async (req, res, next) => {
await controller.createForm(req, res, next);
});
-routes.get('/:formId', rateLimiter, apiAccess, hasFormPermissions(P.FORM_READ), async (req, res, next) => {
+routes.get('/:formId', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ]), async (req, res, next) => {
await controller.readForm(req, res, next);
});
@@ -34,7 +34,7 @@ routes.post('/:formId/export/fields', rateLimiter, apiAccess, hasFormPermissions
await controller.exportWithFields(req, res, next);
});
-routes.get('/:formId/emailTemplates', hasFormPermissions(P.EMAIL_TEMPLATE_READ), async (req, res, next) => {
+routes.get('/:formId/emailTemplates', hasFormPermissions([P.EMAIL_TEMPLATE_READ]), async (req, res, next) => {
await controller.readEmailTemplates(req, res, next);
});
@@ -46,7 +46,7 @@ routes.get('/:formId/options', async (req, res, next) => {
await controller.readFormOptions(req, res, next);
});
-routes.get('/:formId/version', rateLimiter, apiAccess, hasFormPermissions(P.FORM_READ), async (req, res, next) => {
+routes.get('/:formId/version', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ]), async (req, res, next) => {
await controller.readPublishedForm(req, res, next);
});
@@ -118,15 +118,19 @@ routes.get('/:formId/statusCodes', rateLimiter, apiAccess, hasFormPermissions([P
await controller.getStatusCodes(req, res, next);
});
-routes.get('/:formId/apiKey', hasFormPermissions(P.FORM_API_READ), async (req, res, next) => {
+routes.get('/:formId/apiKey', hasFormPermissions([P.FORM_API_READ]), async (req, res, next) => {
await controller.readApiKey(req, res, next);
});
-routes.put('/:formId/apiKey', hasFormPermissions(P.FORM_API_CREATE), async (req, res, next) => {
+routes.put('/:formId/apiKey', hasFormPermissions([P.FORM_API_CREATE]), async (req, res, next) => {
await controller.createOrReplaceApiKey(req, res, next);
});
-routes.delete('/:formId/apiKey', hasFormPermissions(P.FORM_API_DELETE), async (req, res, next) => {
+routes.put('/:formId/apiKey/filesApiAccess', hasFormPermissions([P.FORM_API_CREATE]), async (req, res, next) => {
+ await controller.filesApiKeyAccess(req, res, next);
+});
+
+routes.delete('/:formId/apiKey', hasFormPermissions([P.FORM_API_DELETE]), async (req, res, next) => {
await controller.deleteApiKey(req, res, next);
});
diff --git a/app/src/forms/rbac/routes.js b/app/src/forms/rbac/routes.js
index 70002e0b4..57b5e8089 100644
--- a/app/src/forms/rbac/routes.js
+++ b/app/src/forms/rbac/routes.js
@@ -20,11 +20,11 @@ routes.get('/idps', async (req, res, next) => {
await controller.getIdentityProviders(req, res, next);
});
-routes.get('/forms', hasFormPermissions(P.TEAM_READ), async (req, res, next) => {
+routes.get('/forms', hasFormPermissions([P.TEAM_READ]), async (req, res, next) => {
await controller.getFormUsers(req, res, next);
});
-routes.put('/forms', hasFormPermissions(P.TEAM_UPDATE), async (req, res, next) => {
+routes.put('/forms', hasFormPermissions([P.TEAM_UPDATE]), async (req, res, next) => {
await controller.setFormUsers(req, res, next);
});
@@ -40,11 +40,11 @@ routes.get('/users', jwtService.protect('admin'), async (req, res, next) => {
await controller.getUserForms(req, res, next);
});
-routes.put('/users', hasFormPermissions(P.TEAM_UPDATE), hasFormRoles([R.OWNER, R.TEAM_MANAGER]), hasRolePermissions(false), async (req, res, next) => {
+routes.put('/users', hasFormPermissions([P.TEAM_UPDATE]), hasFormRoles([R.OWNER, R.TEAM_MANAGER]), hasRolePermissions(false), async (req, res, next) => {
await controller.setUserForms(req, res, next);
});
-routes.delete('/users', hasFormPermissions(P.TEAM_UPDATE), hasFormRoles([R.OWNER, R.TEAM_MANAGER]), hasRolePermissions(true), async (req, res, next) => {
+routes.delete('/users', hasFormPermissions([P.TEAM_UPDATE]), hasFormRoles([R.OWNER, R.TEAM_MANAGER]), hasRolePermissions(true), async (req, res, next) => {
await controller.removeMultiUsers(req, res, next);
});
diff --git a/app/tests/unit/forms/auth/middleware/userAccess.spec.js b/app/tests/unit/forms/auth/middleware/userAccess.spec.js
index fd81ccc93..5fd4e1e0e 100644
--- a/app/tests/unit/forms/auth/middleware/userAccess.spec.js
+++ b/app/tests/unit/forms/auth/middleware/userAccess.spec.js
@@ -1,5 +1,6 @@
const { getMockReq, getMockRes } = require('@jest-mock/express');
const Problem = require('api-problem');
+const uuid = require('uuid');
const { currentUser, hasFormPermissions, hasSubmissionPermissions, hasFormRoles, hasRolePermissions } = require('../../../../../src/forms/auth/middleware/userAccess');
@@ -7,9 +8,10 @@ const jwtService = require('../../../../../src/components/jwtService');
const service = require('../../../../../src/forms/auth/service');
const rbacService = require('../../../../../src/forms/rbac/service');
+const formId = uuid.v4();
+const formSubmissionId = uuid.v4();
const userId = 'c6455376-382c-439d-a811-0381a012d695';
const userId2 = 'c6455376-382c-439d-a811-0381a012d696';
-const formId = 'c6455376-382c-439d-a811-0381a012d697';
const Roles = {
OWNER: 'owner',
@@ -19,7 +21,6 @@ const Roles = {
FORM_SUBMITTER: 'form_submitter',
};
-// Mock the token validation in the KC lib
jwtService.validateAccessToken = jest.fn().mockReturnValue(true);
jwtService.getBearerToken = jest.fn().mockReturnValue('bearer-token-value');
jwtService.getTokenPayload = jest.fn().mockReturnValue({ token: 'payload' });
@@ -37,6 +38,10 @@ afterEach(() => {
jest.clearAllMocks();
});
+// External dependencies used by the implementation are:
+// - jwtService.validateAccessToken: to validate a Bearer token
+// - service.login: to create the object for req.currentUser
+//
describe('currentUser', () => {
it('gets the current user with valid request', async () => {
const testReq = {
@@ -136,173 +141,211 @@ describe('getToken', () => {
});
});
+// External dependencies used by the implementation are:
+// - service.getUserForms: gets the forms that the user can access
+//
describe('hasFormPermissions', () => {
- it('returns a middleware function', async () => {
- const mw = hasFormPermissions(['abc']);
- expect(mw).toBeInstanceOf(Function);
- });
+ // Default mock value where the user has no access to forms
+ service.getUserForms = jest.fn().mockReturnValue([]);
- it('401s if the request has no current user', async () => {
- const mw = hasFormPermissions(['abc']);
- const nxt = jest.fn();
- const req = { params: { formId: 1 } };
+ it('returns a middleware function', async () => {
+ const middleware = hasFormPermissions(['FORM_READ']);
- await mw(req, testRes, nxt);
- expect(nxt).toHaveBeenCalledTimes(0);
- // expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: 'Current user not found on request.' }));
+ expect(middleware).toBeInstanceOf(Function);
});
- it('401s if the request has no formId', async () => {
- const mw = hasFormPermissions(['abc']);
- const nxt = jest.fn();
- const req = {
+ it('400s if the request has no formId', async () => {
+ const req = getMockReq({
currentUser: {},
params: {
- submissionId: 123,
+ submissionId: formSubmissionId,
},
query: {
- otherQueryThing: 'abc',
+ otherQueryThing: 'SOMETHING',
},
- };
+ });
+ const { res, next } = getMockRes();
- await mw(req, testRes, nxt);
- expect(nxt).toHaveBeenCalledTimes(0);
- // expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: 'Form Id not found on request.' }));
+ await hasFormPermissions(['FORM_READ'])(req, res, next);
+
+ expect(service.getUserForms).toHaveBeenCalledTimes(0);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 400 }));
});
- it('401s if the user does not have access to the form', async () => {
- service.getUserForms = jest.fn().mockReturnValue([]);
+ it('400s if the formId is not a uuid', async () => {
const req = getMockReq({
currentUser: {},
params: {
- formId: '123',
+ formId: 'undefined',
+ },
+ query: {
+ otherQueryThing: 'SOMETHING',
},
});
const { res, next } = getMockRes();
- const mw = hasFormPermissions(['abc']);
- await mw(req, res, next);
+ await hasFormPermissions(['FORM_READ'])(req, res, next);
- expect(res.end).toHaveBeenCalledWith(expect.stringContaining('401'));
- expect(next).toHaveBeenCalledTimes(0);
+ expect(service.getUserForms).toHaveBeenCalledTimes(0);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 400 }));
});
- it('401s if the user does not have access to the form nor is it in their deleted', async () => {
- const mw = hasFormPermissions(['abc']);
- const nxt = jest.fn();
- const req = {
+ // TODO: This should be a 403, but bundle all breaking changes in a small PR.
+ it('401s if the user does not have access to the form', async () => {
+ const req = getMockReq({
currentUser: {},
params: {
- formId: '123',
+ formId: formId,
},
- };
+ });
+ const { res, next } = getMockRes();
- await mw(req, testRes, nxt);
- expect(nxt).toHaveBeenCalledTimes(0);
- // expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: 'Current user has no access to form.' }));
+ await hasFormPermissions(['FORM_READ'])(req, res, next);
+
+ expect(service.getUserForms).toHaveBeenCalled();
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 401 }));
});
- it('does not 401 if the user has deleted form access', async () => {
- service.getUserForms = jest
- .fn()
- .mockReturnValueOnce([])
- .mockReturnValueOnce([
- {
- formId: '123',
- permissions: ['abc'],
- },
- ]);
+ // TODO: This should be a 403, but bundle all breaking changes in a small PR.
+ it('401s if the expected permissions are not included', async () => {
+ service.getUserForms.mockReturnValueOnce([
+ {
+ formId: formId,
+ permissions: ['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_READ'],
+ },
+ ]);
const req = getMockReq({
currentUser: {},
params: {
- formId: '123',
+ formId: formId,
},
});
const { res, next } = getMockRes();
- const mw = hasFormPermissions(['abc']);
- await mw(req, res, next);
+ await hasFormPermissions(['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE'])(req, res, next);
+ expect(service.getUserForms).toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
- expect(next).toHaveBeenCalledWith();
+ expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 401 }));
});
- it('401s if the expected permissions are not included', async () => {
- service.getUserForms = jest.fn().mockReturnValue([
+ // TODO: This should be a 403, but bundle all breaking changes in a small PR.
+ it('401s if the permissions are a subset but not including everything', async () => {
+ service.getUserForms.mockReturnValueOnce([
{
- formId: '123',
- permissions: ['FORM_READ', 'SUBMISSION_READ', 'DESIGN_CREATE'],
+ formId: formId,
+ permissions: ['DESIGN_CREATE', 'FORM_READ'],
},
]);
const req = getMockReq({
currentUser: {},
params: {
- formId: '123',
+ formId: formId,
},
});
const { res, next } = getMockRes();
- const mw = hasFormPermissions(['FORM_READ', 'SUBMISSION_DELETE', 'DESIGN_CREATE']);
- await mw(req, res, next);
+ await hasFormPermissions(['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE'])(req, res, next);
- expect(res.end).toHaveBeenCalledWith(expect.stringContaining('401'));
- expect(next).toHaveBeenCalledTimes(0);
+ expect(service.getUserForms).toHaveBeenCalled();
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 401 }));
});
- it('401s if the expected permissions are not included (string, not array check)', async () => {
- service.getUserForms = jest.fn().mockReturnValue([
+ it('500s if the request has no current user', async () => {
+ const req = getMockReq({
+ params: { formId: formId },
+ });
+ const { res, next } = getMockRes();
+
+ await hasFormPermissions(['FORM_READ'])(req, res, next);
+
+ expect(service.getUserForms).toHaveBeenCalledTimes(0);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 500 }));
+ });
+
+ it('moves on if a valid API key user has already been set', async () => {
+ const req = getMockReq({
+ apiUser: true,
+ params: { formId: formId },
+ });
+ const { res, next } = getMockRes();
+
+ await hasFormPermissions(['FORM_READ'])(req, res, next);
+
+ expect(service.getUserForms).toHaveBeenCalledTimes(0);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledWith();
+ });
+
+ it('moves on if the expected permissions are included', async () => {
+ service.getUserForms.mockReturnValueOnce([
{
- formId: '123',
- permissions: ['FORM_DELETE'],
+ formId: formId,
+ permissions: ['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE'],
},
]);
const req = getMockReq({
currentUser: {},
params: {
- formId: '123',
+ formId: formId,
},
});
const { res, next } = getMockRes();
- const mw = hasFormPermissions('FORM_READ');
- await mw(req, res, next);
+ await hasFormPermissions(['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE'])(req, res, next);
- expect(res.end).toHaveBeenCalledWith(expect.stringContaining('401'));
- expect(next).toHaveBeenCalledTimes(0);
+ expect(service.getUserForms).toHaveBeenCalled();
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledWith();
});
- it('moves on if the expected permissions are included', async () => {
- service.getUserForms = jest.fn().mockReturnValue([
+ it('moves on if the expected permissions are included with query formId', async () => {
+ service.getUserForms.mockReturnValueOnce([
{
- formId: '123',
+ formId: formId,
permissions: ['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE'],
},
]);
const req = getMockReq({
currentUser: {},
- params: {
- formId: '123',
+ query: {
+ formId: formId,
},
});
const { res, next } = getMockRes();
- const mw = hasFormPermissions(['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE']);
- await mw(req, res, next);
+ await hasFormPermissions(['DESIGN_CREATE', 'FORM_READ', 'SUBMISSION_DELETE'])(req, res, next);
+ expect(service.getUserForms).toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith();
});
- it('moves on if a valid API key user has already been set', async () => {
- const mw = hasFormPermissions(['abc']);
- const nxt = jest.fn();
- const req = {
- apiUser: 1,
- };
+ it('moves on if the user has deleted form access', async () => {
+ service.getUserForms.mockReturnValueOnce([]).mockReturnValueOnce([
+ {
+ formId: formId,
+ permissions: ['FORM_READ'],
+ },
+ ]);
+ const req = getMockReq({
+ currentUser: {},
+ params: {
+ formId: formId,
+ },
+ });
+ const { res, next } = getMockRes();
- await mw(req, testRes, nxt);
- expect(nxt).toHaveBeenCalledTimes(1);
- expect(nxt).toHaveBeenCalledWith();
+ await hasFormPermissions(['FORM_READ'])(req, res, next);
+
+ expect(service.getUserForms).toHaveBeenCalled();
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledWith();
});
});
@@ -517,10 +560,7 @@ describe('hasSubmissionPermissions', () => {
it('falls through to the query if the current user does not have any FORM access on the current form', async () => {
service.getSubmissionForm = jest.fn().mockReturnValue({
submission: { deleted: false },
- form: {
- id: '999',
- identityProviders: [{ code: 'idir' }, { code: 'bceid' }],
- },
+ form: { id: '999', identityProviders: [{ code: 'idir' }, { code: 'bceid' }] },
});
service.checkSubmissionPermission = jest.fn().mockReturnValue(undefined);
@@ -695,11 +735,7 @@ describe('hasFormRoles', () => {
await hfr(req, testRes, nxt);
expect(nxt).toHaveBeenCalledTimes(1);
- expect(nxt).toHaveBeenCalledWith(
- new Problem(401, {
- detail: 'You do not have permission to update this role.',
- })
- );
+ expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: 'You do not have permission to update this role.' }));
});
it('falls through if the current user does not have all of the required form roles', async () => {
@@ -717,11 +753,7 @@ describe('hasFormRoles', () => {
await hfr(req, testRes, nxt);
expect(nxt).toHaveBeenCalledTimes(1);
- expect(nxt).toHaveBeenCalledWith(
- new Problem(401, {
- detail: 'You do not have permission to update this role.',
- })
- );
+ expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: 'You do not have permission to update this role.' }));
});
it('moves on if the user has at least one of the required form roles', async () => {
@@ -990,7 +1022,7 @@ describe('hasRolePermissions', () => {
updatedAt: '',
},
{
- id: '6',
+ id: '5',
role: Roles.FORM_SUBMITTER,
formId: formId,
userId: userId2,
@@ -1086,11 +1118,7 @@ describe('hasRolePermissions', () => {
await hrp(req, testRes, nxt);
expect(nxt).toHaveBeenCalledTimes(1);
- expect(nxt).toHaveBeenCalledWith(
- new Problem(401, {
- detail: "You can't remove your own team manager role.",
- })
- );
+ expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: "You can't remove your own team manager role." }));
});
});
@@ -1351,11 +1379,7 @@ describe('hasRolePermissions', () => {
await hrp(req, testRes, nxt);
expect(nxt).toHaveBeenCalledTimes(1);
- expect(nxt).toHaveBeenCalledWith(
- new Problem(401, {
- detail: "You can't add a form designer role.",
- })
- );
+ expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: "You can't add a form designer role." }));
});
it('falls through if trying to remove a designer role', async () => {
@@ -1424,11 +1448,7 @@ describe('hasRolePermissions', () => {
await hrp(req, testRes, nxt);
expect(nxt).toHaveBeenCalledTimes(1);
- expect(nxt).toHaveBeenCalledWith(
- new Problem(401, {
- detail: "You can't remove a form designer role.",
- })
- );
+ expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: "You can't remove a form designer role." }));
});
it('should succeed when adding a manager/reviewer/submitter roles', async () => {
From 7242d58cee8fdcb373cf20217fe2e65b1a9fd6b0 Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Wed, 28 Feb 2024 09:49:18 -0800
Subject: [PATCH 06/16] fix CodeQL for permissive regex
Signed-off-by: Jason Sherman
---
app/src/components/idpService.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/components/idpService.js b/app/src/components/idpService.js
index a002869fa..eb9e578e5 100644
--- a/app/src/components/idpService.js
+++ b/app/src/components/idpService.js
@@ -7,7 +7,7 @@ const IDP_KEY = 'identity_provider';
const KC_ID_KEY = 'keycloakId';
function stringToGUID(s) {
- const regex = /^([0-f]{8})([0-f]{4})([0-f]{4})([0-f]{4})([0-f]{12})/;
+ const regex = /^([0-9a-fA-F]{8})([0-9a-fA-F]{4})([0-9a-fA-F]{4})([0-9a-fA-F]{4})([0-9a-fA-F]{12})/;
const m = s.replace(/-+/g, '').match(regex);
return m ? `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}` : null;
}
From b70f8de343cbe8d90b8e6799f4bcabbb8c252ad2 Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Wed, 28 Feb 2024 13:44:11 -0800
Subject: [PATCH 07/16] filtering on idp/Hint vs code
Signed-off-by: Jason Sherman
---
.devcontainer/devcontainer.json | 8 ++++++--
.devcontainer/post-install.sh | 1 +
app/src/forms/auth/service.js | 4 ++--
app/src/forms/form/service.js | 2 +-
4 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 6f7cebd58..f0ce7abf1 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -48,8 +48,12 @@
"editor.formatOnSave": true
}
}
- }
+ },
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
- //"remoteUser": "root"
+ //"remoteUser": "root",
+
+ "containerEnv": {
+ "NODE_CONFIG_DIR": "${containerWorkspaceFolder}/.devcontainer/chefs_local"
+ }
}
diff --git a/.devcontainer/post-install.sh b/.devcontainer/post-install.sh
index 891641159..46eec3560 100644
--- a/.devcontainer/post-install.sh
+++ b/.devcontainer/post-install.sh
@@ -6,6 +6,7 @@ WORKSPACE_DIR=$(pwd)
CHEFS_LOCAL_DIR=${WORKSPACE_DIR}/.devcontainer/chefs_local
npm install knex -g
+npm install jest -g
# install app libraries, prepare for app development and debugging...
cd app
diff --git a/app/src/forms/auth/service.js b/app/src/forms/auth/service.js
index e7c98ddb4..9f3caa71a 100644
--- a/app/src/forms/auth/service.js
+++ b/app/src/forms/auth/service.js
@@ -113,7 +113,7 @@ const service = {
let forms = [];
let filtered = items.filter((x) => {
// include if user has idp, or form is public, or user has an explicit role.
- if (x.idps.includes(userInfo.idp) || x.idps.includes('public')) {
+ if (x.idps.includes(userInfo.idpHint) || x.idps.includes('public')) {
// always give submitter permissions to launch by idp and public
x.permissions = Array.from(new Set([...x.permissions, ...FORM_SUBMITTER]));
return true;
@@ -132,7 +132,7 @@ const service = {
hasPublic = item.idps.includes('public');
} else if (accessLevels.includes('idp')) {
// must have user's idp in idps...
- hasIdp = item.idps.includes(userInfo.idp);
+ hasIdp = item.idps.includes(userInfo.idpHint);
} else if (accessLevels.includes('team')) {
// must have a role...
hasTeam = item.roles.length;
diff --git a/app/src/forms/form/service.js b/app/src/forms/form/service.js
index 01ad71249..a255265fb 100644
--- a/app/src/forms/form/service.js
+++ b/app/src/forms/form/service.js
@@ -238,7 +238,7 @@ const service = {
.withGraphFetched('idpHints')
.throwIfNotFound()
.then((form) => {
- form.idpHints = form.idpHints.map((idp) => idp.code);
+ form.idpHints = form.idpHints.map((idp) => idp.idp);
return form;
});
},
From 47e67974c8478722be6aaf72bc72536dec68121e Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Thu, 29 Feb 2024 15:23:48 -0800
Subject: [PATCH 08/16] db rollback fix
Signed-off-by: Jason Sherman
---
...119172630_identity_provider_permissions.js | 88 +++++++------------
1 file changed, 31 insertions(+), 57 deletions(-)
diff --git a/app/src/db/migrations/20240119172630_identity_provider_permissions.js b/app/src/db/migrations/20240119172630_identity_provider_permissions.js
index 490379cee..bb01da893 100644
--- a/app/src/db/migrations/20240119172630_identity_provider_permissions.js
+++ b/app/src/db/migrations/20240119172630_identity_provider_permissions.js
@@ -13,18 +13,18 @@ const BCEID_EXTRAS = {
},
},
userSearch: {
- filters: [
- { name: 'filterIdpUserId', param: 'idpUserId', required: 0 },
- { name: 'filterIdpCode', param: 'idpCode', required: 0 },
- { name: 'filterUsername', param: 'username', required: 2, exact: true },
- { name: 'filterFullName', param: 'fullName', required: 0 },
- { name: 'filterFirstName', param: 'firstName', required: 0 },
- { name: 'filterLastName', param: 'lastName', required: 0 },
- { name: 'filterEmail', param: 'email', required: 2, exact: true },
- { name: 'filterSearch', param: 'search', required: 0 },
+ filters: [
+ { name: 'filterIdpUserId', param: 'idpUserId', required: 0 },
+ { name: 'filterIdpCode', param: 'idpCode', required: 0 },
+ { name: 'filterUsername', param: 'username', required: 2, exact: true },
+ { name: 'filterFullName', param: 'fullName', required: 0 },
+ { name: 'filterFirstName', param: 'firstName', required: 0 },
+ { name: 'filterLastName', param: 'lastName', required: 0 },
+ { name: 'filterEmail', param: 'email', required: 2, exact: true },
+ { name: 'filterSearch', param: 'search', required: 0 },
],
- detail: 'Could not retrieve BCeID users. Invalid options provided.'
- }
+ detail: 'Could not retrieve BCeID users. Invalid options provided.',
+ },
};
exports.up = function (knex) {
@@ -32,36 +32,14 @@ exports.up = function (knex) {
knex.schema
.alterTable('identity_provider', (table) => {
table.boolean('primary').notNullable().defaultTo(false);
- table
- .boolean('login')
- .notNullable()
- .defaultTo(false)
- .comment('When true, supply buttons to launch login process');
- table
- .specificType('permissions', 'text ARRAY')
- .comment('Map app permissions to the idp');
- table
- .specificType('roles', 'text ARRAY')
- .comment('Map Form role codes to the idp');
- table
- .jsonb('tokenmap')
- .comment('Map of token fields to CHEFs user fields');
- table
- .jsonb('extra')
- .comment(
- 'Allow customization of the IDP though extra (json) config object.'
- );
+ table.boolean('login').notNullable().defaultTo(false).comment('When true, supply buttons to launch login process');
+ table.specificType('permissions', 'text ARRAY').comment('Map app permissions to the idp');
+ table.specificType('roles', 'text ARRAY').comment('Map Form role codes to the idp');
+ table.jsonb('tokenmap').comment('Map of token fields to CHEFs user fields');
+ table.jsonb('extra').comment('Allow customization of the IDP though extra (json) config object.');
})
- .then(() =>
- knex('identity_provider')
- .where({ code: 'public' })
- .update({ permissions: [], extra: {} })
- )
- .then(() =>
- knex('identity_provider')
- .where({ code: 'idir' })
- .update({ primary: true, login: true })
- )
+ .then(() => knex('identity_provider').where({ code: 'public' }).update({ permissions: [], extra: {} }))
+ .then(() => knex('identity_provider').where({ code: 'idir' }).update({ primary: true, login: true }))
.then(() =>
knex('identity_provider')
.where({ code: 'idir' })
@@ -79,13 +57,7 @@ exports.up = function (knex) {
APP_PERMISSIONS.VIEWS_FORM_VIEW,
APP_PERMISSIONS.VIEWS_USER_SUBMISSIONS,
],
- roles: [
- Roles.OWNER,
- Roles.TEAM_MANAGER,
- Roles.FORM_DESIGNER,
- Roles.SUBMISSION_REVIEWER,
- Roles.FORM_SUBMITTER,
- ],
+ roles: [Roles.OWNER, Roles.TEAM_MANAGER, Roles.FORM_DESIGNER, Roles.SUBMISSION_REVIEWER, Roles.FORM_SUBMITTER],
tokenmap: {
idpUserId: 'idir_user_guid',
keycloakId: 'idir_user_guid',
@@ -113,11 +85,7 @@ exports.up = function (knex) {
APP_PERMISSIONS.VIEWS_FORM_VIEW,
APP_PERMISSIONS.VIEWS_USER_SUBMISSIONS,
],
- roles: [
- Roles.TEAM_MANAGER,
- Roles.SUBMISSION_REVIEWER,
- Roles.FORM_SUBMITTER,
- ],
+ roles: [Roles.TEAM_MANAGER, Roles.SUBMISSION_REVIEWER, Roles.FORM_SUBMITTER],
tokenmap: {
idpUserId: 'bceid_user_guid',
keycloakId: 'bceid_user_guid',
@@ -157,10 +125,16 @@ exports.up = function (knex) {
exports.down = function (knex) {
return Promise.resolve().then(() =>
- knex.schema.alterTable('identity_provider', (table) => {
- table.dropColumn('primary');
- table.dropColumn('permissions');
- table.dropColumn('extra');
- })
+ knex.schema
+ .alterTable('identity_provider', (table) => {
+ table.dropColumn('primary');
+ table.dropColumn('login');
+ table.dropColumn('permissions');
+ table.dropColumn('roles');
+ table.dropColumn('tokenmap');
+ table.dropColumn('extra');
+ })
+ .then(() => knex('identity_provider').where({ code: 'bceid-business' }).update({ idp: 'bceid-business' }))
+ .then(() => knex('identity_provider').where({ code: 'bceid-basic' }).update({ idp: 'bceid-basic' }))
);
};
From 7598f28fa79d0462523f9d95073508a7bbed92f6 Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Mon, 4 Mar 2024 08:13:48 -0800
Subject: [PATCH 09/16] adding documentation for developers
Signed-off-by: Jason Sherman
---
docs/README.md | 1 +
docs/chefs-identity-provider-changes.md | 134 ++++++++
docs/chefs-sso-changes.md | 232 +++++++++++++
docs/chefs-token-and-userinfo-changes.md | 409 +++++++++++++++++++++++
4 files changed, 776 insertions(+)
create mode 100644 docs/README.md
create mode 100644 docs/chefs-identity-provider-changes.md
create mode 100644 docs/chefs-sso-changes.md
create mode 100644 docs/chefs-token-and-userinfo-changes.md
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 000000000..ff71a0269
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1 @@
+This is a temporary holding area for developer docs and should be removed when we find a proper home for this documentation.
diff --git a/docs/chefs-identity-provider-changes.md b/docs/chefs-identity-provider-changes.md
new file mode 100644
index 000000000..657a4491f
--- /dev/null
+++ b/docs/chefs-identity-provider-changes.md
@@ -0,0 +1,134 @@
+# CHEFS Identity Provider
+
+Within the CHEFs application a user's identity provider determines a lot of their access within CHEFs. Keep in mind, this discussion is not on an individual form, this is what menu items, what navigation they have at the application level.
+
+A User's Identity Provider (IDP) is who vouches for them. In a simplified manner: they provide a username and password (generally) and an Identity Provder verifies them and they end up with a token. Currently for CHEFs we have 3 Identity Providers: `IDIR`, `BCeID Basic` and `BCeID Business`. `IDIR` is for employees/contractors on the BC Government. In CHEFs, the `IDIR` Identity Provider allows for greater power within CHEFs; as far as the CHEFs application is concerned IDIR is the `primary` Identity Provider.
+
+Previously, all IDP logic was hardcoded within the frontend code and was difficult to change and maintain.
+
+**Example pseudocode:**
+
+```
+ if user has idp === 'IDIR' then
+ enable create forms button
+```
+
+By removing the hardcode, we can add in new IDPs and redefine which IDP is the `primary`. This opens up CHEFs for installations in non-BC Government environments.
+
+## Identity Provider Table
+Columns are added to the Identity Provider table to support runtime configuration.
+
+* `primary`: boolean, which IDP is the highest level access (currently IDIR)
+* `login`: boolean, if this IDP should appear as a login option (Public does not)
+* `permissions`: string array, what permissions within CHEFS (not forms) does this IDP have
+* `roles`: string array, what Form Roles does this IDP have (designer, owner, submitter, etc)
+* `tokenmap`: json blob. this contains the mapping of IDP token fields to userInfo fields.
+* `extra`: json blob. this is where non-standard configuration goes. we don't want a column for everything.
+
+### Application Permissions
+
+We have removed this hardcoded dependency and create a set of Application Permissions to replace `if user has idp` logic. We can now use `if user has application permission`. Application Permissions are assigned to one or more IDPs.
+
+```
+ VIEWS_FORM_STEPPER: 'views_form_stepper',
+ VIEWS_ADMIN: 'views_admin',
+ VIEWS_FILE_DOWNLOAD: 'views_file_download',
+ VIEWS_FORM_EMAILS: 'views_form_emails',
+ VIEWS_FORM_EXPORT: 'views_form_export',
+ VIEWS_FORM_MANAGE: 'views_form_manage',
+ VIEWS_FORM_PREVIEW: 'views_form_preview',
+ VIEWS_FORM_SUBMISSIONS: 'views_form_submissions',
+ VIEWS_FORM_TEAMS: 'views_form_teamS',
+ VIEWS_FORM_VIEW: 'views_form_view',
+ VIEWS_USER_SUBMISSIONS: 'views_user_submissions',
+```
+
+The application permissions will enable/restrict different sections of the CHEFs application.
+
+### Form Roles
+
+Identity Provider also sets the scope of what roles a user can be assigned to an individual form. This was hardcoded and is now part of the Identity Provider configuration. These roles can be assigned to one or more IDPs.
+
+```
+ OWNER: 'owner',
+ TEAM_MANAGER: 'team_manager',
+ FORM_DESIGNER: 'form_designer',
+ SUBMISSION_REVIEWER: 'submission_reviewer',
+ FORM_SUBMITTER: 'form_submitter',
+```
+
+### Extra
+This is a `json` field with no predetermined structure. For BC Gov, we use it for extra functionality for the BCeID IDPs.
+
+There are UX "enhancements" (frontend) and user search restrictions (server side) that were hardcoded, so now moved into this `json`. Any use of `extra` should assume that data fields may not exist or have null values.
+
+Currently, `IDIR` has no data in `extra`.
+
+```
+{
+ formAccessSettings: 'idim',
+ addTeamMemberSearch: {
+ text: {
+ minLength: 6,
+ message: 'trans.manageSubmissionUsers.searchInputLength',
+ },
+ email: {
+ exact: true,
+ message: 'trans.manageSubmissionUsers.exactBCEIDSearch',
+ },
+ },
+ userSearch: {
+ filters: [
+ { name: 'filterIdpUserId', param: 'idpUserId', required: 0 },
+ { name: 'filterIdpCode', param: 'idpCode', required: 0 },
+ { name: 'filterUsername', param: 'username', required: 2, exact: true },
+ { name: 'filterFullName', param: 'fullName', required: 0 },
+ { name: 'filterFirstName', param: 'firstName', required: 0 },
+ { name: 'filterLastName', param: 'lastName', required: 0 },
+ { name: 'filterEmail', param: 'email', required: 2, exact: true },
+ { name: 'filterSearch', param: 'search', required: 0 },
+ ],
+ detail: 'Could not retrieve BCeID users. Invalid options provided.'
+ }
+}
+```
+
+### Tokenmap
+As part of the transistion to a new managed Keycloak realm, we lose the ability to do mapping of Identity Provider attributes to tokens. We do expect our User Information to be standardized and independent of the IDP, so we need to to the mapping ourselves.
+
+The `tokenmap` is a `json` blob that is effectively a `userInfo` property name mapped to a `token` attribute. Each Identity Provider must provide a mapping so we can build out our `userInfo` object (our current user).
+
+```
+// userInfo.property: token attribute
+{
+ idpUserId: 'bceid_user_guid',
+ keycloakId: 'bceid_user_guid',
+ username: 'bceid_username',
+ firstName: null,
+ lastName: null,
+ fullName: 'name',
+ email: 'email',
+ idp: 'identity_provider',
+}
+```
+
+Note that the `keycloakId` is a GUID and the standard realm does not provide the data as a true GUID, so we need to format it as we build out our `userInfo` object.
+
+### code and idp
+
+Each Identity Provider has a `code` and an `idp`. The `code` never changes and is the `id` and used for referential integrity. Previously, `code` and `idp` were exactly the same. Now that we no longer control the keycloak realm, the actual `idp` values have changed (for `bceid`).
+
+The `idp` fields represents the name if the Identity Provider as found in Keycloak and as returned in the tokens. Within the frontend code, this value is used for idp `hint` - let Keycloak know which IDP the user wished to use for sign in.
+
+The code (both server and frontend) is confusing since `code` and `idp` fields were used interchangeably as the values always matched. `IDIR` still does. In the userInfo/currentUser object `idp` property is actually `code`. Sigh. Added an `idpHint` property but this should be changed to frontend and backend are consistent as are the property/fields names. In the frontend Identity Provider `idp` is `hint` or `idpHint`.
+
+Basically, be aware and cautious with `code`, `idp`, `hint` and `idpHint` until this is addressed.
+
+## Frontend - idpStore
+When the application is loaded, we query and store the Identity Providers. This can be found in `frontend/store/identityProviders.js`.
+
+This has helper methods for building the login buttons, getting login hints, the primary IDP and getting data from `extra`. All access to the cached IDP data should come through this store.
+
+## Backend - IdpService
+Logic for new Identity Provider fields encapsulated in `components/idpService.js`. The queries and logic for parsing the token (use `tokenmap` field to transform token to userInfo). Also, `userSearch` is here as BCeID has specific requirements that are contained in the `extra` field.
+
diff --git a/docs/chefs-sso-changes.md b/docs/chefs-sso-changes.md
new file mode 100644
index 000000000..0fa71ac34
--- /dev/null
+++ b/docs/chefs-sso-changes.md
@@ -0,0 +1,232 @@
+# CHEFS Single Sign-On (Keycloak Standard Realm)
+
+## History
+Current state of OIDC sign in is using a custom Keycloak realm, managed by the CHEFs team. This realm uses Identity Providers for: IDIR, BCeID Basic and BCeID Business.
+
+The custom Keycloak realm allows the CHEFs team complete control over the shape of tokens using Client Scopes and custom mappers.
+
+Both the server/backend and the frontend have their own service clients: `chefs` and `chefs-frontend` respectively. User sign in through the UX/frontend using the `chefs-frontend` client. This client uses the a `chefs` scope to include security (roles) from the `chefs` client. Basically, the `chefs` client is responsible for security and the `chefs-frontend` allows getting a token through the browser.
+
+The server based client (`chefs`) requires a `clientId` and `clientSecret` to connect and perform its security duties. Obviously a frontend client cannot be configured with a secret so that's where the two clients came in.
+
+
+```
+ "frontend": {
+...
+ "keycloak": {
+ "clientId": "chefs-frontend",
+ "realm": "chefs",
+ "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth"
+ }
+ },
+ "server": {
+...
+ "keycloak": {
+ "clientId": "chefs",
+ "clientSecret": "...",
+ "publicKey": "...",
+ "realm": "chefs",
+ "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth"
+ },
+...
+ },
+```
+
+When a user would sign in, they would get a token like:
+
+```
+{
+ "exp": 1709164869,
+ "iat": 1709164569,
+ "auth_time": 1709164569,
+ "jti": "4c2fbf8c-518c-484e-8b99-6fc36c9ba12f",
+ "iss": "https://dev.loginproxy.gov.bc.ca/auth/realms/chefs",
+ "aud": "chefs",
+ "sub": "5c3e4a62-974b-4c81-ade5-3f2587d5363c",
+ "typ": "Bearer",
+ "azp": "chefs-frontend",
+ "nonce": "ba7da2cb-fcdf-4146-88b3-cae8e775a891",
+ "session_state": "6209cc93-9f99-466b-8d15-72f7f6bbc266",
+ "resource_access": {
+ "chefs": {
+ "roles": [
+ "user"
+ ]
+ }
+ },
+ "scope": "openid chefs",
+ "sid": "6209cc93-9f99-466b-8d15-72f7f6bbc266",
+ "identity_provider": "bceid-basic",
+ "idp_username": "jason.sherman",
+ "name": "Jason Sherman",
+ "idp_userid": "22D34CC4510D4943A53362BDECD676C6",
+ "preferred_username": "22d34cc4510d4943a53362bdecd676c6@bceidbasic",
+ "given_name": "Jason Sherman",
+ "email": "jason.sherman@gmail.com"
+}
+```
+
+Note: the `aud`/`audience` is `chefs` even though the client is `chefs-frontend`. And that the `scope` includes `chefs` and also the `resource_access` is qualified by `chefs`.
+
+The ability for CHEFs to manage our own Keycloak realm allows us to add the scope `chefs` to our `chefs-frontend` client and get data from the `chefs` client included in that token. This also allows the `chefs` client to verify and validate this token.
+
+### User role
+
+The user role is added to each user that signs in to the realm. No matter which Identity Provider is used, Keycloak will add a `chefs` user role to that user. This ends up in `resources_access:chefs:roles`.
+
+## Standard realm limitations
+
+Moving to the BC Government standard realm will allow CHEFs to use Single Sign-on but will take control over the shape of the token and they types of service clients we can create. This removes our ability to add custom token mappers for each Identity Provider, use custom scopes and removes auto-assignment of roles.
+
+
+## Standard realm changes
+
+Most significantly, we only use a single client: `chefs-frontend`. The type of client is changed to `Public` and is for browser logins only. This requires no client secret data to be stored or passed through to the frontend.
+
+There is no need for a backend/server client, but we need to verify the token on each request. And this can be done by asking the OIDC server to verify using JSON Web Key Set (JWKS). So we need configuration to set up the verification.
+
+### SSO Integration Requests
+
+To make requests, and to manage the clients: [Common Hosted Single Sign-On (CSS) Console](https://bcgov.github.io/sso-requests)
+
+
+**Example SSO Integration Request**
+
+```
+Associated Team:
+ Coco Team
+Client Protocol:
+ OpenID Connect
+Client Type:
+ Public
+Usecase:
+ Browser Login
+Project Name:
+ chefs-frontend
+Primary End Users:
+ People living in BC, People doing business/travel in BC, BC Gov Employees, Other: public - unauthenticated
+Identity Providers Required:
+ IDIR, Basic BCeID, Business BCeID
+Dev Redirect URIs:
+ https://chefs-dev.apps.silver.devops.gov.bc.ca/*
+ https://chefs-fider.apps.silver.devops.gov.bc.ca/*
+ https://dev.loginproxy.gov.bc.ca/*
+Test Redirect URIs:
+ https://chefs-fider.apps.silver.devops.gov.bc.ca/*
+ https://chefs-test.apps.silver.devops.gov.bc.ca/*
+ https://test.loginproxy.gov.bc.ca/*
+Prod Redirect URIs:
+ https://chefs-fider.apps.silver.devops.gov.bc.ca/*
+ https://submit.digital.gov.bc.ca/app
+```
+
+** IMPORTANT** the client id will not be `chefs-frontend`, but will have some numerical suffix for each environment is deployed. Ex. `chefs-frontend-5299` for development.
+
+#### Admin role
+This console will allow us to create `admin` role and then assign that role to users who have signed in using our client. Fairly similar process to what we have now (except we cannot assign by adding a user to a group).
+
+### Identity Providers
+Although we have the same identity providers: `IDIR`, `BCeID Basic` and `BCeID Business`, they are named differently. This means the values in tokens for `identity_provider` attribute and used as `idpHints` are different.
+
+In our custom realm: `idir`, `bceid-basic` and `bceid-business`.
+
+In standard realm: `idir`, `bceidbasic` and `bceidbusiness`.
+
+We address this in our IdentityProvider table via `code` and `idp` where `idp` is the Keycloak Identity provider name.
+
+
+### Token Changes
+
+Since we lose the ability to add custom mappers and the tokens are different for each Identity Provider.
+
+For instance, in each IDP we would map an attribute (`idir_username`, `bceid_username`) that would end up in the token as `idp_username`. So the token would be consistent. So, in the frontend and token parsing is inconsistent as we lose our `idp_XXX` fields. We handle this in the server as we build our user objects by reading a configuration that maps token attributes to user attribues.
+
+**NOTE** maybe we should place similar logic in the frontend. We do have the IDP configuration cached so we can use that to write a parsing function.
+
+Summary:
+1. `identity_provider` attribute values have changed
+2. `resource_access` no longer supplied, replace with a similar list of roles: `client_roles`
+3. `idp_XXX` attributes no longer exist, each IDP has a unique set of attributes. There is overlap on some attributes.
+
+
+### CHEFs Configuration
+
+Configuration for the frontend does not change signifcantly (nor does the actual javascript/Vue code to interact with the library). We do need to add in a `logoutUrl`.
+
+However the server configuration changes significantly; as does the code base.
+
+**Example configuration**
+
+```
+ "frontend": {
+...
+ "oidc": {
+ "clientId": "chefs-frontend-localhost-5300",
+ "realm": "standard",
+ "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth",
+ "logoutUrl": "https://logon7.gov.bc.ca/clp-cgi/logoff.cgi?retnow=1&returl=https%3A%2F%2Fdev.loginproxy.gov.bc.ca%2Fauth%2Frealms%2Fstandard%2Fprotocol%2Fopenid-connect%2Flogout"
+ }
+ },
+ "server": {
+...
+ "oidc": {
+ "realm": "standard",
+ "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth",
+ "jwksUri": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard/protocol/openid-connect/certs",
+ "issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard",
+ "audience": "chefs-frontend-localhost-5300",
+ "maxTokenAge": "300"
+ },
+...
+ },
+```
+
+Note that the configuration block key has changed from `keycloak` to `oidc`. This is mainly to allow two completely different CHEFs instances running side by side in our development namespace. As all instances share the same config maps/secrets, we need to deploy a new config map for this transition.
+
+The server configuration now uses the frontend `clientId` as the `audience`. We expect the token to come from a particular issuer for a particular client.
+
+**IMPORTANT** unclear if verifying the `audience/clientId` will allow true single sign-on. Will have to consult with the SSO team and maybe loosen our verify call to only check token age and issuer.
+
+#### Logout URL
+
+The addition of the logout url is to support logging out from Siteminder and Keycloak. Note that the configuration contains only part of the complete logout url as we need to build the redirect url at runtime and add in a `client_id`.
+
+See note [here](https://github.com/bcgov/keycloak-example-apps/blob/4fdf10494dea8b14d460c2d4a8648f0fdccb965c/examples/oidc/public/vue/src/services/keycloak.js#L36).
+
+
+### OIDC Config Map
+Add a new OIDC Config map (no differentition for frontend/server as it is the same client).
+
+```sh
+oc create -n $NAMESPACE configmap $APP_NAME-oidc-config \
+ --from-literal=OIDC_REALM=standard \
+ --from-literal=OIDC_SERVERURL=https://dev.loginproxy.gov.bc.ca/auth \
+ --from-literal=OIDC_JWKSURI=https://dev.loginproxy.gov.bc.ca/auth/realms/standard/protocol/openid-connect/certs \
+ --from-literal=OIDC_ISSUER=https://dev.loginproxy.gov.bc.ca/auth/realms/standard \
+ --from-literal=OIDC_CLIENTID=chefs-frontend-5299 \
+ --from-literal=OIDC_MAXTOKENAGE=300 \
+ --from-literal=OIDC_LOGOUTURL='https://logon7.gov.bc.ca/clp-cgi/logoff.cgi?retnow=1&returl=https%3A%2F%2Fdev.loginproxy.gov.bc.ca%2Fauth%2Frealms%2Fstandard%2Fprotocol%2Fopenid-connect%2Flogout'
+```
+
+### Backend code changes
+
+Significant changes to server/backend code. Most notably we remove `keycloak-connect` library. Keycloak keeps threatening to deprecate this library, so good to get rid of it. However, it did provide a lot of useful middleware that we've had to replicate.
+
+Most logic is found in `components/jwtService.js` including the `protect` middleware. Changes to the token and how we map to a user are found in `components/idpService.js`.
+
+
+### Frontend code changes
+
+Basically the frontend remains the same as we continue to use the same library: `keycloak-js`.
+
+The `init` is slightly different as we move to a `public` client, we need to specify that we want to use `pkceMethod`:
+
+```
+ init: { pkceMethod: 'S256', checkLoginIframe: false, onLoad: 'check-sso' },
+```
+
+Changes to the token mean we change how we determine roles. We no longer qualify by resource (`chefs`). and we get the data from `client_roles`.
+
+Since we added the `logoutUrl`, the logout method has changed too. `logoutUrl` is optional, which will make it easier for non-BC installations. See the auth store (`store/auth.js`).
+
+
diff --git a/docs/chefs-token-and-userinfo-changes.md b/docs/chefs-token-and-userinfo-changes.md
new file mode 100644
index 000000000..a17d66e01
--- /dev/null
+++ b/docs/chefs-token-and-userinfo-changes.md
@@ -0,0 +1,409 @@
+# CHEFs User and Standard Realm Tokens
+
+
+ Custom Realm | Standard Realm (SSO) |
+
+
+
+{
+ "exp": 1709324197,
+ "iat": 1709323897,
+ "auth_time": 1709323896,
+ "jti": "32353e01-3ebf-402f-9ef0-1d56c595aa55",
+ "iss": "https://dev.loginproxy.gov.bc.ca/auth/realms/chefs",
+ "aud": "chefs",
+ "sub": "bdd91117-55ed-47fd-ae23-365a25fae566",
+ "typ": "Bearer",
+ "azp": "chefs-frontend",
+ "nonce": "2bfa957e-b8bf-4072-8720-94adee440c4d",
+ "session_state": "8799a22f-5f93-4c04-813b-c637b1b81687",
+ "resource_access": {
+ "chefs": {
+ "roles": [
+ "admin",
+ "user"
+ ]
+ }
+ },
+ "scope": "openid chefs",
+ "sid": "8799a22f-5f93-4c04-813b-c637b1b81687",
+ "identity_provider": "idir",
+ "idp_username": "JPERRY",
+ "name": "Joe Perry",
+ "idp_userid": "584861AA34E546F8BDA6A7004DC9C6C9",
+ "preferred_username": "584861aa34e546f8bda6a7004dc9c6c9@idir",
+ "given_name": "Joe",
+ "family_name": "Perry",
+ "email": "joe.perry@gov.bc.ca"
+}
+ |
+
+{
+ "exp": 1709322907,
+ "iat": 1709322607,
+ "auth_time": 1709322607,
+ "jti": "5f4088e8-8e55-49fa-8df5-9ebfa6f585b5",
+ "iss": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard",
+ "aud": "chefs-frontend-5299",
+ "sub": "584861aa34e546f8bda6a7004dc9c6c9@idir",
+ "typ": "Bearer",
+ "azp": "chefs-frontend-5299",
+ "nonce": "33974cb4-7607-4f1f-80e3-c129e40436cf",
+ "session_state": "1893ab3b-410f-48c8-8274-cad94f4812ab",
+ "scope": "openid idir bceidbusiness email profile bceidbasic",
+ "sid": "1893ab3b-410f-48c8-8274-cad94f4812ab",
+ "idir_user_guid": "584861AA34E546F8BDA6A7004DC9C6C9",
+ "client_roles": [
+ "admin"
+ ],
+ "identity_provider": "idir",
+ "idir_username": "JPERRY",
+ "email_verified": false,
+ "name": "Perry, Joe CITZ:EX",
+ "preferred_username": "584861aa34e546f8bda6a7004dc9c6c9@idir",
+ "display_name": "Perry, Joe CITZ:EX",
+ "given_name": "Joe",
+ "family_name": "Perry",
+ "email": "joe.perry@gov.bc.ca"
+}
+ |
+
+
+
+{
+ "id": "c6042253-da3f-49d3-bb7d-595ec68fd780",
+ "usernameIdp": "JPERRY@idir",
+ "idpUserId": "584861AA34E546F8BDA6A7004DC9C6C9",
+ "keycloakId": "bdd91117-55ed-47fd-ae23-365a25fae566",
+ "username": "JPERRY",
+ "firstName": "Joe",
+ "lastName": "Perry",
+ "fullName": "Joe Perry",
+ "email": "joe.perry@gov.bc.ca",
+ "idp": "idir",
+ "public": false,
+ "forms": []
+}
+ |
+
+{
+ "id": "a0c195aa-57d9-4a70-8169-588876917765",
+ "usernameIdp": "JPERRY@idir",
+ "idpUserId": "584861AA34E546F8BDA6A7004DC9C6C9",
+ "keycloakId": "584861AA-34E5-46F8-BDA6-A7004DC9C6C9",
+ "username": "JPERRY",
+ "firstName": "Joe",
+ "lastName": "Perry",
+ "fullName": "Perry, Joe CITZ:EX",
+ "email": "joe.perry@gov.bc.ca",
+ "idp": "idir",
+ "public": false,
+ "idpHint": "idir",
+ "forms": []
+} |
+
+
+
+# BCeID Basic
+
+
+ Custom Realm | Standard Realm (SSO) |
+
+
+
+{
+ "exp": 1709324355,
+ "iat": 1709324055,
+ "auth_time": 1709324042,
+ "jti": "ac68f321-4e42-4b5c-907f-34c0485410af",
+ "iss": "https://dev.loginproxy.gov.bc.ca/auth/realms/chefs",
+ "aud": "chefs",
+ "sub": "5b3d4a62-974b-4c81-adf5-3e2587d5363c",
+ "typ": "Bearer",
+ "azp": "chefs-frontend",
+ "nonce": "86984a12-77de-4910-8ae5-88d3e204038c",
+ "session_state": "28cafa4e-0ef8-4ab3-8a14-9d125fbbb8ad",
+ "resource_access": {
+ "chefs": {
+ "roles": [
+ "user"
+ ]
+ }
+ },
+ "scope": "openid chefs",
+ "sid": "28cafa4e-0ef8-4ab3-8a14-9d125fbbb8ad",
+ "identity_provider": "bceid-basic",
+ "idp_username": "joe.perry",
+ "name": "Joe Perry",
+ "idp_userid": "11D34CC4510D4943A53362BDECD676C6",
+ "preferred_username": "11d34cc4510d4943a53362bdecd676c6@bceidbasic",
+ "given_name": "Joe Perry",
+ "email": "joe.perry@gmail.com"
+} |
+
+{
+ "exp": 1709323834,
+ "iat": 1709323534,
+ "auth_time": 1709323533,
+ "jti": "889f9919-fcc3-4f4b-b6ac-7d2be0a51ca0",
+ "iss": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard",
+ "aud": "chefs-frontend-5299",
+ "sub": "11d34cc4510d4943a53362bdecd676c6@bceidbasic",
+ "typ": "Bearer",
+ "azp": "chefs-frontend-5299",
+ "nonce": "cba89d3f-dd38-44f6-81cb-d412df5c0570",
+ "session_state": "00fad75b-25c5-42d7-af05-8e992e283a01",
+ "scope": "openid idir bceidbusiness email profile bceidbasic",
+ "sid": "00fad75b-25c5-42d7-af05-8e992e283a01",
+ "client_roles": [
+ "admin"
+ ],
+ "bceid_user_guid": "11D34CC4510D4943A53362BDECD676C6",
+ "identity_provider": "bceidbasic",
+ "bceid_username": "joe.perry",
+ "email_verified": false,
+ "name": "Joe Perry",
+ "preferred_username": "11d34cc4510d4943a53362bdecd676c6@bceidbasic",
+ "display_name": "Joe Perry",
+ "given_name": "Joe Perry",
+ "family_name": "",
+ "email": "joe.perry@gmail.com"
+} |
+
+
+
+{
+ "id": "cdaeea76-eadb-4eb5-b8e6-bde57f1d65c8",
+ "usernameIdp": "joe.perry@bceid-basic",
+ "idpUserId": "11D34CC4510D4943A53362BDECD676C6",
+ "keycloakId": "5b3d4a62-974b-4c81-adf5-3e2587d5363c",
+ "username": "joe.perry",
+ "firstName": "Joe Perry",
+ "fullName": "Joe Perry",
+ "email": "joe.perry@gmail.com",
+ "idp": "bceid-basic",
+ "public": false,
+ "forms": []
+} |
+
+{
+ "id": "6a6a8134-5dcb-4e77-8ac8-44d59391690c",
+ "usernameIdp": "joe.perry@bceid-basic",
+ "idpUserId": "11D34CC4510D4943A53362BDECD676C6",
+ "keycloakId": "11D34CC4-510D-4943-A533-62BDECD676C6",
+ "username": "joe.perry",
+ "fullName": "Joe Perry",
+ "email": "joe.perry@gmail.com",
+ "idp": "bceid-basic",
+ "public": false,
+ "idpHint": "bceidbasic",
+ "forms": []
+} |
+
+
+
+
+# BCeID Business
+
+
+ Custom Realm | Standard Realm (SSO) |
+
+
+
+{
+ "exp": 1709324544,
+ "iat": 1709324244,
+ "auth_time": 1709324232,
+ "jti": "60918b9c-82b5-4fa6-aec7-64aa54ec031a",
+ "iss": "https://dev.loginproxy.gov.bc.ca/auth/realms/chefs",
+ "aud": "chefs",
+ "sub": "429b39bc-fa98-4169-a25e-0139f0ae689d",
+ "typ": "Bearer",
+ "azp": "chefs-frontend",
+ "nonce": "e43e0a35-5d4e-4a56-82d7-b258444f4ac6",
+ "session_state": "7bb75437-8fc7-44a5-b96b-5f885d9e534f",
+ "resource_access": {
+ "chefs": {
+ "roles": [
+ "user"
+ ]
+ }
+ },
+ "scope": "openid chefs",
+ "sid": "7bb75437-8fc7-44a5-b96b-5f885d9e534f",
+ "identity_provider": "bceid-business",
+ "idp_username": "stevieray",
+ "name": "Stevie Ray-Vaughan",
+ "idp_userid": "F8F0E333E79C4AD183D19C9377498785",
+ "preferred_username": "f8f0e333e79c4ad183d19c9377498785@bceidbusiness",
+ "given_name": "Stevie Ray-Vaughan",
+ "email": "stevie.ray@gov.bc.ca"
+} |
+
+{
+ "exp": 1709323929,
+ "iat": 1709323629,
+ "auth_time": 1709323628,
+ "jti": "64064578-67b4-4248-a267-125a5a87848e",
+ "iss": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard",
+ "aud": "chefs-frontend-5299",
+ "sub": "f8f0e333e79c4ad183d19c9377498785@bceidbusiness",
+ "typ": "Bearer",
+ "azp": "chefs-frontend-5299",
+ "nonce": "54e10ed1-c0a7-4fce-9c4a-21d8e85ac076",
+ "session_state": "f684d70a-c894-47d1-af38-f746f038b176",
+ "scope": "openid idir bceidbusiness email profile bceidbasic",
+ "sid": "f684d70a-c894-47d1-af38-f746f038b176",
+ "bceid_business_guid": "B50E1574C1A944189BC661DED01345FB",
+ "bceid_business_name": "texasflood",
+ "bceid_user_guid": "F8F0E333E79C4AD183D19C9377498785",
+ "bceid_username": "stevieray",
+ "email_verified": false,
+ "preferred_username": "f8f0e333e79c4ad183d19c9377498785@bceidbusiness",
+ "display_name": "Stevie Ray-Vaughan",
+ "given_name": "Stevie Ray-Vaughan",
+ "client_roles": [
+ "admin"
+ ],
+ "identity_provider": "bceidbusiness",
+ "name": "Stevie Ray-Vaughan",
+ "family_name": "",
+ "email": "stevie.ray@gov.bc.ca"
+} |
+
+
+
+{
+ "id": "ed1dfcbb-d2e0-448e-b4f2-7b5808d7f4a3",
+ "usernameIdp": "stevieray@bceid-business",
+ "idpUserId": "F8F0E333E79C4AD183D19C9377498785",
+ "keycloakId": "429b39bc-fa98-4169-a25e-0139f0ae689d",
+ "username": "stevieray",
+ "firstName": "Stevie Ray-Vaughan",
+ "fullName": "Stevie Ray-Vaughan",
+ "email": "stevie.ray@gov.bc.ca",
+ "idp": "bceid-business",
+ "public": false,
+ "forms": []
+} |
+
+{
+ "id": "8a2e1c04-ace2-414a-a5f0-9627e2f8b3ba",
+ "usernameIdp": "stevieray@bceid-business",
+ "idpUserId": "F8F0E333E79C4AD183D19C9377498785",
+ "keycloakId": "F8F0E333-E79C-4AD1-83D1-9C9377498785",
+ "username": "stevieray",
+ "fullName": "Stevie Ray-Vaughan",
+ "email": "stevie.ray@gov.bc.ca",
+ "idp": "bceid-business",
+ "public": false,
+ "idpHint": "bceidbusiness",
+ "forms": []
+} |
+
+
+
+
+## Token Key Differences
+
+### idp\_userid / idir\_user\_guid / bceid\_user\_guid
+
+In the custom realm, we mapped `idp_userid` to the `idpUserId`.
+
+There is no `idp_userid` attribute in standard realm tokens. But `idp_userid` was mapped from `idir_user_guid` and `bceid_user_guid` (depending on the IDP).
+
+As we parse tokens we will be setting the `idpUserId` correctly and it since that value comes from the IDP and not Keycloak, it matches in both realms.
+
+### idp\_username
+
+`idp_username` is a custom mapped field so it doesn't exist in standard realm tokens, it is used to populate the userInfo field: `username`. In the standard realm we pull from `idir_username` or `bceid_username`.
+
+### display\_name
+
+Standard realm returns a `display_name` attribute, but it appears to be the same as `name`.
+
+### IDIR name / display\_name
+
+The standard realm IDIR provider returns a name with Ministry information:
+
+`Perry, Joe CITZ:EX`
+
+this maps to userInfo `fullName` which is very different that our custom realm mapping (`Joe Perry`).
+
+### sub / keycloakId
+In custom realm the subject is a GUID. We use this as a `keycloakId`.
+
+`"sub": "bdd91117-55ed-47fd-ae23-365a25fae566",`
+
+In the standard realm the subject matches the `preferred_username` and is not a GUID. However, `idir_user_guid` and `bceid_user_guid` are *almost* GUIDs and can be transformed easily. So we can use this as the `keycloakId`.
+
+`keycloakId` is no longer a useful field, it was only used to jump into the custom realm Keycloak Admin console which is no longer allowed for us in the standard realm.
+
+
+**MIGRATION NOTE** update `keycloakId` to match `idpUserId` as `idpUserId` will match between realms.
+
+### identity\_provider
+
+In custom realm, `BCeID Basic` = `bceid-basic` and `BCeID Business` = `bceid-business`.
+
+In standard realm they are `bceidbasic` and `bceidbusiness` respectively. Hints passed to Keycloak match the `identity_provider` and need updating.
+
+
+### resource\_access / client\_roles
+
+```
+ "resource_access": {
+ "chefs": {
+ "roles": [
+ "admin",
+ "user"
+ ]
+ }
+ },
+
+ ...
+
+ "client_roles": [
+ "admin"
+ ],
+```
+
+`resource_access` no longer exists, but we have `client_roles`. There is no `user` role, and roles are not qualified by a specific resource (ie. `chefs`) they are just a list of role names.
+
+
+## User table and UserInfo/CurrentUser
+
+In our custom realm, no matter what Identity Provider was used, the token contained the same attributes. Mapping a token to a userInfo object (ie. currentUser) is straightforward.
+
+In the standard realm, we need dynamic mapping. To achieve this, we now store a map in our IdentityProvider table: `tokenmap`. This is how we determine which token attribute value becomes the userInfo attribute value.
+
+One key point is the `keycloakId` field requires a GUID, in the mapping (mapped to `idpUserId`) we take a field that is GUID-like and format it to be a GUID.
+
+### idpUserId
+The `idpUserId` field is our non-key unique field and is used to actually identify the user from the token. Since it comes from the Identity Provider it is consistent across realms.
+
+### userInfo idp and idpHint
+
+In the custom realm, as we parse out the token to make the userInfo object we add in a field: `idpHint` that contains the actual token `identity_provider`. The now poorly named `idp` attribute contains the `code` from the IdentityProvider table.
+
+A work item should be created to make the userInfo object consistent with the token and frontend code where we have IDPs as `code`, `display` and `hint`. It may be more trouble than it is worth to rename the column and the views, but the transformation codes (token -> userInfo) should return an object consistent with naming conventions used in frontend logic so we always know if we are using our CHEFs IdentityProvider table `code` value or `hint` value.
+
+## Data Migration
+
+Since `keycloakId` is no longer a useful field and that is the only realm specific data, we are good for data migration. `keycloakId` will be updated as `idpUserId` (`idir_user_guid` or `bceid_user_guid` in GUID format). Any other data (`name`) will also be updated during the normal course of login.
+
+**API call flow **
+
+* get current user
+ * get bearer token
+ * validate token
+* set request user
+ * get token payload
+ * login
+ * parse token (use the IDP `tokenmap`)
+ * get user id (find by `idpUserId`
+ * create user if not found
+ * update user fields if found
+
+
+`idpUserId` remains the same across realms since it comes from the Identity Provider, all user fields will be updated if the user exists, otherwise we create a new one.
\ No newline at end of file
From cafc83884e6bb113ed4bc0ac6828259662e66c92 Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Sat, 9 Mar 2024 16:03:19 -0800
Subject: [PATCH 10/16] add tests for new service classes. fix middleware
calls. fix issues raised in PR.
Signed-off-by: Jason Sherman
---
.devcontainer/chefs_local/test.json | 92 +++++++
.vscode/launch.json | 8 +-
app/frontend/src/utils/constants.js | 2 +-
.../tests/unit/utils/constants.spec.js | 2 +-
app/src/components/idpService.js | 2 +-
app/src/components/jwtService.js | 37 +--
app/src/forms/auth/middleware/userAccess.js | 44 ++--
app/src/forms/common/constants.js | 2 +-
.../fixtures/form/identity_providers.json | 239 ++++++++++++++++++
app/tests/unit/components/idpService.spec.js | 193 ++++++++++++++
app/tests/unit/components/jwtService.spec.js | 186 ++++++++++++++
.../forms/auth/middleware/userAccess.spec.js | 3 +-
12 files changed, 767 insertions(+), 43 deletions(-)
create mode 100644 .devcontainer/chefs_local/test.json
create mode 100644 app/tests/fixtures/form/identity_providers.json
create mode 100644 app/tests/unit/components/idpService.spec.js
create mode 100644 app/tests/unit/components/jwtService.spec.js
diff --git a/.devcontainer/chefs_local/test.json b/.devcontainer/chefs_local/test.json
new file mode 100644
index 000000000..6aa8fd1da
--- /dev/null
+++ b/.devcontainer/chefs_local/test.json
@@ -0,0 +1,92 @@
+{
+ "db": {
+ "database": "chefs",
+ "host": "localhost",
+ "port": "5432",
+ "username": "app",
+ "password": "admin"
+ },
+ "files": {
+ "uploads": {
+ "enabled": "true",
+ "fileCount": "1",
+ "fileKey": "files",
+ "fileMaxSize": "25MB",
+ "fileMinSize": "0KB",
+ "path": "files"
+ },
+ "permanent": "localStorage",
+ "localStorage": {
+ "path": "myfiles"
+ },
+ "objectStorage": {
+ "accessKeyId": "bcgov-citz-ccft",
+ "bucket": "chefs",
+ "endpoint": "https://commonservices.objectstore.gov.bc.ca",
+ "key": "chefs/dev/",
+ "secretAccessKey": "anything"
+ }
+ },
+ "frontend": {
+ "apiPath": "api/v1",
+ "basePath": "/app",
+ "oidc": {
+ "clientId": "chefs-frontend-localhost-5300",
+ "realm": "standard",
+ "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth",
+ "logoutUrl": "https://logon7.gov.bc.ca/clp-cgi/logoff.cgi?retnow=1&returl=https%3A%2F%2Fdev.loginproxy.gov.bc.ca%2Fauth%2Frealms%2Fstandard%2Fprotocol%2Fopenid-connect%2Flogout"
+ }
+ },
+ "server": {
+ "apiPath": "/api/v1",
+ "basePath": "/app",
+ "bodyLimit": "30mb",
+ "oidc": {
+ "realm": "standard",
+ "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth",
+ "jwksUri": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard/protocol/openid-connect/certs",
+ "issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard",
+ "audience": "chefs-frontend-localhost-5300",
+ "maxTokenAge": "300"
+ },
+ "logLevel": "http",
+ "port": "8080",
+ "rateLimit": {
+ "public": {
+ "windowMs": "900000",
+ "max": "100"
+ }
+ }
+ },
+ "serviceClient": {
+ "commonServices": {
+ "ches": {
+ "endpoint": "https://ches-dev.api.gov.bc.ca/api",
+ "tokenEndpoint": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token",
+ "clientId": "CHES_CLIENT_ID",
+ "clientSecret": "CHES_CLIENT_SECRET"
+ },
+ "cdogs": {
+ "endpoint": "https://cdogs-dev.api.gov.bc.ca/api",
+ "tokenEndpoint": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token",
+ "clientId": "CDOGS_CLIENT_ID",
+ "clientSecret": "CDOGS_CLIENT_SECRET"
+ }
+ }
+ },
+ "customBcAddressFormioComponent": {
+ "apikey": "xxxxxxxxxxxxxxx",
+ "bcAddressURL": "https://geocoder.api.gov.bc.ca/addresses.json",
+ "queryParameters": {
+ "echo": false,
+ "brief": true,
+ "minScore": 55,
+ "onlyCivic": true,
+ "maxResults": 15,
+ "autocomplete": true,
+ "matchAccuracy": 100,
+ "matchPrecision": "occupant, unit, site, civic_number, intersection, block, street, locality, province",
+ "precisionPoints": 100
+ }
+ }
+}
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 9f576b64a..6d5d4d795 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -14,7 +14,9 @@
"runtimeArgs": ["run", "serve"],
"runtimeExecutable": "npm",
"type": "node",
- "env": {}
+ "env": {
+ "NODE_CONFIG_DIR": "${workspaceFolder}/.devcontainer/chefs_local"
+ }
},
{
"cwd": "${workspaceFolder}/app/frontend",
@@ -30,7 +32,7 @@
"request": "launch",
"runtimeArgs": ["run", "dev"],
"runtimeExecutable": "npm",
- "type": "node",
+ "type": "node"
},
{
"name": "CHEFS Frontend - chrome",
@@ -39,7 +41,7 @@
"url": "http://localhost:5173/app",
"enableContentValidation": false,
"webRoot": "${workspaceFolder}/app/frontend/src",
- "pathMapping": {"url": "//src/", "path": "${webRoot}/"}
+ "pathMapping": { "url": "//src/", "path": "${webRoot}/" }
}
],
"version": "0.2.0"
diff --git a/app/frontend/src/utils/constants.js b/app/frontend/src/utils/constants.js
index c026c9d4f..b63bd2a66 100755
--- a/app/frontend/src/utils/constants.js
+++ b/app/frontend/src/utils/constants.js
@@ -73,7 +73,7 @@ export const AppPermissions = Object.freeze({
VIEWS_FORM_MANAGE: 'views_form_manage',
VIEWS_FORM_PREVIEW: 'views_form_preview',
VIEWS_FORM_SUBMISSIONS: 'views_form_submissions',
- VIEWS_FORM_TEAMS: 'views_form_teamS',
+ VIEWS_FORM_TEAMS: 'views_form_teams',
VIEWS_FORM_VIEW: 'views_form_view',
VIEWS_USER_SUBMISSIONS: 'views_user_submissions',
});
diff --git a/app/frontend/tests/unit/utils/constants.spec.js b/app/frontend/tests/unit/utils/constants.spec.js
index eb4df08e6..0c2389d02 100644
--- a/app/frontend/tests/unit/utils/constants.spec.js
+++ b/app/frontend/tests/unit/utils/constants.spec.js
@@ -79,7 +79,7 @@ describe('Constants', () => {
VIEWS_FORM_MANAGE: 'views_form_manage',
VIEWS_FORM_PREVIEW: 'views_form_preview',
VIEWS_FORM_SUBMISSIONS: 'views_form_submissions',
- VIEWS_FORM_TEAMS: 'views_form_teamS',
+ VIEWS_FORM_TEAMS: 'views_form_teams',
VIEWS_FORM_VIEW: 'views_form_view',
VIEWS_USER_SUBMISSIONS: 'views_user_submissions',
});
diff --git a/app/src/components/idpService.js b/app/src/components/idpService.js
index eb9e578e5..7548bdec3 100644
--- a/app/src/components/idpService.js
+++ b/app/src/components/idpService.js
@@ -13,7 +13,7 @@ function stringToGUID(s) {
}
function isEmpty(s) {
- return s === null || (s && s.trim() === '');
+ return s === undefined || s === null || (s && s.trim() === '');
}
function isNotEmpty(s) {
diff --git a/app/src/components/jwtService.js b/app/src/components/jwtService.js
index 39a6fdb36..9aa999d7c 100644
--- a/app/src/components/jwtService.js
+++ b/app/src/components/jwtService.js
@@ -1,5 +1,7 @@
const jose = require('jose');
const config = require('config');
+const Problem = require('api-problem');
+
const errorToProblem = require('./errorToProblem');
const SERVICE = 'JwtService';
@@ -63,26 +65,29 @@ class JwtService {
protect(spec) {
// actual middleware
return async (req, res, next) => {
- let authorized = false;
try {
- // get token, check if valid
- const token = this.getBearerToken(req);
- if (token) {
- const payload = await this._verify(token);
- if (spec) {
- authorized = payload.client_roles?.includes(spec);
- } else {
- authorized = true;
+ let authorized = false;
+ try {
+ // get token, check if valid
+ const token = this.getBearerToken(req);
+ if (token) {
+ const payload = await this._verify(token);
+ if (spec) {
+ authorized = payload.client_roles?.includes(spec);
+ } else {
+ authorized = true;
+ }
}
+ } catch (error) {
+ authorized = false;
+ }
+ if (!authorized) {
+ throw new Problem(401, { detail: 'Access denied' });
+ } else {
+ return next();
}
} catch (error) {
- authorized = false;
- }
- if (!authorized) {
- res.status(403);
- res.end('Access denied');
- } else {
- return next();
+ next(error);
}
};
}
diff --git a/app/src/forms/auth/middleware/userAccess.js b/app/src/forms/auth/middleware/userAccess.js
index c3c1395d3..e7a0ace06 100644
--- a/app/src/forms/auth/middleware/userAccess.js
+++ b/app/src/forms/auth/middleware/userAccess.js
@@ -56,31 +56,39 @@ const _getForm = async (currentUser, formId, includeDeleted) => {
return form;
};
-const setUser = async (req, _res, next) => {
+/**
+ * Express middleware that adds the user information as the res.currentUser
+ * attribute so that all downstream middleware and business logic can use it.
+ *
+ * This will fall through if everything is OK. If the Bearer auth is not valid,
+ * this will produce a 403 error.
+ *
+ * @param {*} req the Express object representing the HTTP request.
+ * @param {*} _res the Express object representing the HTTP response - unused.
+ * @param {*} next the Express chaining function.
+ */
+const currentUser = async (req, _res, next) => {
try {
- const token = await jwtService.getTokenPayload(req);
- req.currentUser = await service.login(token);
+ // Validate bearer tokens before anything else - failure means no access.
+ const bearerToken = jwtService.getBearerToken(req);
+ if (bearerToken) {
+ const ok = await jwtService.validateAccessToken(bearerToken);
+ if (!ok) {
+ throw new Problem(403, { detail: 'Authorization token is invalid.' });
+ }
+ }
+
+ // Add the request element that contains the current user's parsed info. It
+ // is ok if the access token isn't defined: then we'll have a public user.
+ const accessToken = await jwtService.getTokenPayload(req);
+ req.currentUser = await service.login(accessToken);
+
next();
} catch (error) {
next(error);
}
};
-const currentUser = async (req, res, next) => {
- // Check if authorization header is a bearer token
- const token = jwtService.getBearerToken(req);
- if (token) {
- const ok = await jwtService.validateAccessToken(token);
- if (!ok) {
- return new Problem(403, {
- detail: 'Authorization token is invalid.',
- }).send(res);
- }
- }
-
- return setUser(req, res, next);
-};
-
/**
* Express middleware to check that a user has all the given permissions for a
* form. This will fall through if everything is OK, otherwise it will call
diff --git a/app/src/forms/common/constants.js b/app/src/forms/common/constants.js
index 2535334bd..4a9a83e29 100644
--- a/app/src/forms/common/constants.js
+++ b/app/src/forms/common/constants.js
@@ -95,7 +95,7 @@ module.exports = Object.freeze({
VIEWS_FORM_MANAGE: 'views_form_manage',
VIEWS_FORM_PREVIEW: 'views_form_preview',
VIEWS_FORM_SUBMISSIONS: 'views_form_submissions',
- VIEWS_FORM_TEAMS: 'views_form_teamS',
+ VIEWS_FORM_TEAMS: 'views_form_teams',
VIEWS_FORM_VIEW: 'views_form_view',
VIEWS_USER_SUBMISSIONS: 'views_user_submissions',
},
diff --git a/app/tests/fixtures/form/identity_providers.json b/app/tests/fixtures/form/identity_providers.json
new file mode 100644
index 000000000..c60d60d34
--- /dev/null
+++ b/app/tests/fixtures/form/identity_providers.json
@@ -0,0 +1,239 @@
+[
+ {
+ "code": "idir",
+ "display": "IDIR",
+ "active": true,
+ "idp": "idir",
+ "createdBy": "migration-002",
+ "createdAt": "2024-03-08T22:02:34.399Z",
+ "updatedBy": null,
+ "updatedAt": "2024-03-08T22:02:34.399Z",
+ "primary": true,
+ "login": true,
+ "permissions": [
+ "views_form_stepper",
+ "views_admin",
+ "views_file_download",
+ "views_form_emails",
+ "views_form_export",
+ "views_form_manage",
+ "views_form_preview",
+ "views_form_submissions",
+ "views_form_teams",
+ "views_form_view",
+ "views_user_submissions"
+ ],
+ "roles": ["owner", "team_manager", "form_designer", "submission_reviewer", "form_submitter"],
+ "tokenmap": {
+ "idp": "identity_provider",
+ "email": "email",
+ "fullName": "name",
+ "lastName": "family_name",
+ "username": "idir_username",
+ "firstName": "given_name",
+ "idpUserId": "idir_user_guid",
+ "keycloakId": "idir_user_guid"
+ },
+ "extra": {}
+ },
+ {
+ "code": "bceid-basic",
+ "display": "Basic BCeID",
+ "active": true,
+ "idp": "bceidbasic",
+ "createdBy": "migration-022",
+ "createdAt": "2024-03-08T22:02:34.399Z",
+ "updatedBy": null,
+ "updatedAt": "2024-03-08T22:02:34.399Z",
+ "primary": false,
+ "login": true,
+ "permissions": ["views_user_submissions"],
+ "roles": ["form_submitter"],
+ "tokenmap": {
+ "idp": "identity_provider",
+ "email": "email",
+ "fullName": "name",
+ "lastName": null,
+ "username": "bceid_username",
+ "firstName": null,
+ "idpUserId": "bceid_user_guid",
+ "keycloakId": "bceid_user_guid"
+ },
+ "extra": {
+ "userSearch": {
+ "detail": "Could not retrieve BCeID users. Invalid options provided.",
+ "filters": [
+ {
+ "name": "filterIdpUserId",
+ "param": "idpUserId",
+ "required": 0
+ },
+ {
+ "name": "filterIdpCode",
+ "param": "idpCode",
+ "required": 0
+ },
+ {
+ "name": "filterUsername",
+ "exact": true,
+ "param": "username",
+ "required": 2
+ },
+ {
+ "name": "filterFullName",
+ "param": "fullName",
+ "required": 0
+ },
+ {
+ "name": "filterFirstName",
+ "param": "firstName",
+ "required": 0
+ },
+ {
+ "name": "filterLastName",
+ "param": "lastName",
+ "required": 0
+ },
+ {
+ "name": "filterEmail",
+ "exact": true,
+ "param": "email",
+ "required": 2
+ },
+ {
+ "name": "filterSearch",
+ "param": "search",
+ "required": 0
+ }
+ ]
+ },
+ "formAccessSettings": "idim",
+ "addTeamMemberSearch": {
+ "text": {
+ "message": "trans.manageSubmissionUsers.searchInputLength",
+ "minLength": 6
+ },
+ "email": {
+ "exact": true,
+ "message": "trans.manageSubmissionUsers.exactBCEIDSearch"
+ }
+ }
+ }
+ },
+ {
+ "code": "bceid-business",
+ "display": "Business BCeID",
+ "active": true,
+ "idp": "bceidbusiness",
+ "createdBy": "migration-022",
+ "createdAt": "2024-03-08T22:02:34.399Z",
+ "updatedBy": null,
+ "updatedAt": "2024-03-08T22:02:34.399Z",
+ "primary": false,
+ "login": true,
+ "permissions": ["views_form_export", "views_form_manage", "views_form_submissions", "views_form_teams", "views_form_view", "views_user_submissions"],
+ "roles": ["team_manager", "submission_reviewer", "form_submitter"],
+ "tokenmap": {
+ "idp": "identity_provider",
+ "email": "email",
+ "fullName": "name",
+ "lastName": null,
+ "username": "bceid_username",
+ "firstName": null,
+ "idpUserId": "bceid_user_guid",
+ "keycloakId": "bceid_user_guid"
+ },
+ "extra": {
+ "userSearch": {
+ "detail": "Could not retrieve BCeID users. Invalid options provided.",
+ "filters": [
+ {
+ "name": "filterIdpUserId",
+ "param": "idpUserId",
+ "required": 0
+ },
+ {
+ "name": "filterIdpCode",
+ "param": "idpCode",
+ "required": 0
+ },
+ {
+ "name": "filterUsername",
+ "exact": true,
+ "param": "username",
+ "required": 2
+ },
+ {
+ "name": "filterFullName",
+ "param": "fullName",
+ "required": 0
+ },
+ {
+ "name": "filterFirstName",
+ "param": "firstName",
+ "required": 0
+ },
+ {
+ "name": "filterLastName",
+ "param": "lastName",
+ "required": 0
+ },
+ {
+ "name": "filterEmail",
+ "exact": true,
+ "param": "email",
+ "required": 2
+ },
+ {
+ "name": "filterSearch",
+ "param": "search",
+ "required": 0
+ }
+ ]
+ },
+ "formAccessSettings": "idim",
+ "addTeamMemberSearch": {
+ "text": {
+ "message": "trans.manageSubmissionUsers.searchInputLength",
+ "minLength": 6
+ },
+ "email": {
+ "exact": true,
+ "message": "trans.manageSubmissionUsers.exactBCEIDSearch"
+ }
+ }
+ }
+ },
+ {
+ "code": "public",
+ "display": "Public",
+ "active": true,
+ "idp": "public",
+ "createdBy": "migration-002",
+ "createdAt": "2024-03-08T22:02:34.399Z",
+ "updatedBy": null,
+ "updatedAt": "2024-03-08T22:02:34.399Z",
+ "primary": false,
+ "login": false,
+ "permissions": [],
+ "roles": null,
+ "tokenmap": null,
+ "extra": {}
+ },
+ {
+ "code": "testonly",
+ "display": "N/A",
+ "active": false,
+ "idp": "testonly",
+ "createdBy": "testonly",
+ "createdAt": "2024-03-08T22:02:34.399Z",
+ "updatedBy": null,
+ "updatedAt": "2024-03-08T22:02:34.399Z",
+ "primary": false,
+ "login": false,
+ "permissions": [],
+ "roles": null,
+ "tokenmap": null,
+ "extra": {}
+ }
+]
diff --git a/app/tests/unit/components/idpService.spec.js b/app/tests/unit/components/idpService.spec.js
new file mode 100644
index 000000000..ec8d1fccc
--- /dev/null
+++ b/app/tests/unit/components/idpService.spec.js
@@ -0,0 +1,193 @@
+const Problem = require('api-problem');
+const { MockModel } = require('../../common/dbHelper');
+const idpService = require('../../../src/components/idpService');
+const idpData = require('../../fixtures/form/identity_providers.json');
+
+// let's just load data once..
+idpService.providers = idpData;
+idpService.activeProviders = idpData.filter((x) => x.active);
+
+jest.mock('../../../src/forms/common/models/tables/user', () => MockModel);
+
+function idirToken() {
+ return {
+ exp: 1709942517,
+ iat: 1709942217,
+ auth_time: 1709942210,
+ jti: '3b1a0e84-4612-4804-99ca-5d3383c27ab1',
+ iss: 'https://dev.loginproxy.gov.bc.ca/auth/realms/standard',
+ aud: 'chefs-frontend-localhost-5300',
+ sub: '674861aa34e546f8bda6a7004dc9c6c9@idir',
+ typ: 'Bearer',
+ azp: 'chefs-frontend-localhost-5300',
+ nonce: 'ffb100a7-1afc-488a-8755-7ff436a11ad2',
+ session_state: '48d6429c-5d41-481e-81f7-9aaa9d70ddd1',
+ scope: 'openid idir bceidbusiness email profile bceidbasic',
+ sid: '48d6429c-5d41-481e-81f7-9aaa9d70ddd1',
+ idir_user_guid: '674861AA34E546F8BDA6A7004DC9C6C9',
+ client_roles: ['admin'],
+ identity_provider: 'idir',
+ idir_username: 'PASWAYZE',
+ email_verified: false,
+ name: 'Swayze, Patrick CITZ:EX',
+ preferred_username: '674861aa34e546f8bda6a7004dc9c6c9@idir',
+ display_name: 'Swayze, Patrick CITZ:EX',
+ given_name: 'Patrick',
+ family_name: 'Swayze',
+ email: 'patrick.swayze@gov.bc.ca',
+ };
+}
+
+beforeEach(() => {
+ MockModel.mockReset();
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe('idpService', () => {
+ const assertService = (srv) => {
+ expect(srv).toBeTruthy();
+ expect(srv.providers).toHaveLength(5);
+ expect(srv.activeProviders).toHaveLength(4);
+ };
+
+ it('should return a service', () => {
+ assertService(idpService);
+ });
+
+ it('should return active idps', async () => {
+ const idps = await idpService.getIdentityProviders(true);
+ expect(idps).toHaveLength(4);
+ });
+
+ it('should return all idps', async () => {
+ const idps = await idpService.getIdentityProviders(false);
+ expect(idps).toHaveLength(5);
+ });
+
+ it('should return bceid-business by idp', async () => {
+ const idp = await idpService.findByIdp('bceidbusiness');
+ expect(idp).toBeTruthy();
+ expect(idp.code).toBe('bceid-business');
+ expect(idp.idp).toBe('bceidbusiness');
+ });
+
+ it('should return bceid-business by code', async () => {
+ const idp = await idpService.findByCode('bceid-business');
+ expect(idp).toBeTruthy();
+ expect(idp.code).toBe('bceid-business');
+ expect(idp.idp).toBe('bceidbusiness');
+ });
+
+ it('should return nothing by bad idp', async () => {
+ const idp = await idpService.findByIdp('doesnotexist');
+ expect(idp).toBeFalsy();
+ });
+
+ it('should return nothing by bad code', async () => {
+ const idp = await idpService.findByCode('doesnotexist');
+ expect(idp).toBeFalsy();
+ });
+
+ it('should return a user search', async () => {
+ const s = await idpService.userSearch({ idpCode: 'idir', email: 'em@il.com' });
+ expect(s).toBeFalsy();
+ expect(MockModel.query).toHaveBeenCalledTimes(1);
+ expect(MockModel.modify).toHaveBeenCalledTimes(9);
+ expect(MockModel.modify).toHaveBeenCalledWith('filterIdpCode', 'idir');
+ expect(MockModel.modify).toHaveBeenCalledWith('filterEmail', 'em@il.com', false);
+ });
+
+ it('should return a customized user search', async () => {
+ const s = await idpService.userSearch({ idpCode: 'bceid-business', email: 'em@il.com' });
+ expect(s).toBeFalsy();
+ expect(MockModel.query).toHaveBeenCalledTimes(1);
+ expect(MockModel.modify).toHaveBeenCalledWith('filterIdpCode', 'bceid-business');
+ expect(MockModel.modify).toHaveBeenCalledWith('filterEmail', 'em@il.com', true);
+ expect(MockModel.modify).toHaveBeenCalledTimes(9);
+ });
+
+ it('should throw error when customized user search fails validation', async () => {
+ let e = undefined;
+ try {
+ // needs one of email or username
+ await idpService.userSearch({ idpCode: 'bceid-business' });
+ } catch (error) {
+ e = error;
+ }
+ expect(e).toBeTruthy();
+ expect(e).toBeInstanceOf(Error);
+ expect(e.message).toBe('Could not retrieve BCeID users. Invalid options provided.');
+ });
+
+ it('should parse null token into public userInfo', async () => {
+ const token = null;
+ const userInfo = await idpService.parseToken(token);
+ expect(userInfo).toBeTruthy();
+ expect(userInfo.idp).toBe('public');
+ expect(userInfo.public).toBeTruthy();
+ });
+
+ it('should return userInfo with known provider', async () => {
+ const token = idirToken();
+ let r = undefined;
+ let e = undefined;
+ try {
+ r = await idpService.parseToken(token);
+ } catch (error) {
+ e = error;
+ }
+
+ expect(e).toBeFalsy();
+ expect(r).toBeTruthy();
+ expect(r.keycloakId).toBeTruthy();
+ expect(r.idpUserId).toBe(token.idir_user_guid);
+ });
+
+ it('should throw Problem parsing token without a provider', async () => {
+ const token = {};
+ let r = undefined;
+ let e = undefined;
+ try {
+ r = await idpService.parseToken(token);
+ } catch (error) {
+ e = error;
+ }
+
+ expect(e).toBeInstanceOf(Problem);
+ expect(r).toBe(undefined);
+ });
+
+ it('should throw Problem parsing token with an unknown provider', async () => {
+ const token = { identity_provider: 'doesnotexist' };
+ let r = undefined;
+ let e = undefined;
+ try {
+ r = await idpService.parseToken(token);
+ } catch (error) {
+ e = error;
+ }
+
+ expect(e).toBeInstanceOf(Problem);
+ expect(r).toBe(undefined);
+ });
+
+ it('should throw a Problem when token has no keycloakId cannot parse into GUID', async () => {
+ let token = idirToken();
+ token.idir_user_guid = 123; //will not parse into a GUID...
+ let r = undefined;
+ let e = undefined;
+ try {
+ r = await idpService.parseToken(token);
+ } catch (error) {
+ e = error;
+ }
+
+ expect(e).toBeTruthy();
+ expect(r).toBeFalsy();
+ expect(e).toBeInstanceOf(Problem);
+ expect(r).toBe(undefined);
+ });
+});
diff --git a/app/tests/unit/components/jwtService.spec.js b/app/tests/unit/components/jwtService.spec.js
new file mode 100644
index 000000000..33b3d9d57
--- /dev/null
+++ b/app/tests/unit/components/jwtService.spec.js
@@ -0,0 +1,186 @@
+const { getMockReq, getMockRes } = require('@jest-mock/express');
+const jose = require('jose');
+const Problem = require('api-problem');
+
+const config = require('config');
+const jwtService = require('../../../src/components/jwtService');
+
+describe('jwtService', () => {
+ const assertService = (srv) => {
+ expect(srv).toBeTruthy();
+ expect(srv.audience).toBe(config.get('server.oidc.audience'));
+ expect(srv.issuer).toBe(config.get('server.oidc.issuer'));
+ expect(srv.maxTokenAge).toBe(config.get('server.oidc.maxTokenAge'));
+ };
+
+ it('should return a service', () => {
+ assertService(jwtService);
+ });
+
+ it('should get token if bearer', () => {
+ const req = getMockReq({ headers: { authorization: 'Bearer JWT' } });
+ const bearerToken = jwtService.getBearerToken(req);
+ expect(bearerToken).toBe('JWT');
+ });
+
+ it('should not get token if basic', () => {
+ const req = getMockReq({ headers: { authorization: 'Basic username/password' } });
+ const bearerToken = jwtService.getBearerToken(req);
+ expect(bearerToken).toBe(null);
+ });
+
+ it('should get payload if token valid', async () => {
+ const jwt = {};
+ const payload = {};
+ jwtService.getBearerToken = jest.fn().mockReturnValue(jwt);
+ // need to mock out this whole function, very difficult to mock jose...
+ jwtService._verify = jest.fn().mockReturnValue(payload);
+
+ const req = getMockReq({ headers: { authorization: 'Bearer JWT' } });
+ const r = await jwtService.getTokenPayload(req);
+ expect(r).toBe(payload);
+ expect(jwtService.getBearerToken).toHaveBeenCalledTimes(1);
+ expect(jwtService._verify).toHaveBeenCalledTimes(1);
+ });
+
+ it('should error if token not valid', async () => {
+ const jwt = {};
+ jwtService.getBearerToken = jest.fn().mockReturnValue(jwt);
+ // need to mock out this whole function, very difficult to mock jose...
+ jwtService._verify = jest.fn().mockImplementation(() => {
+ throw new jose.errors.JWTClaimValidationFailed('bad');
+ });
+
+ const req = getMockReq({ headers: { authorization: 'Bearer JWT' } });
+ let payload = undefined;
+ try {
+ payload = await jwtService.getTokenPayload(req);
+ } catch (e) {
+ expect(e).toBeInstanceOf(jose.errors.JWTClaimValidationFailed);
+ expect(payload).toBe(undefined);
+ }
+ expect(jwtService.getBearerToken).toHaveBeenCalledTimes(1);
+ expect(jwtService._verify).toHaveBeenCalledTimes(1);
+ });
+
+ it('should validate access token on good jwt', async () => {
+ const payload = {};
+ // need to mock out this whole function, very difficult to mock jose...
+ jwtService._verify = jest.fn().mockReturnValue(payload);
+
+ const req = getMockReq({ headers: { authorization: 'Bearer JWT' } });
+ const r = await jwtService.validateAccessToken(req);
+ expect(r).toBeTruthy();
+ expect(jwtService._verify).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not validate access token on jwt error', async () => {
+ // need to mock out this whole function, very difficult to mock jose...
+ jwtService._verify = jest.fn().mockImplementation(() => {
+ throw new jose.errors.JWTClaimValidationFailed('bad');
+ });
+
+ const req = getMockReq({ headers: { authorization: 'Bearer JWT' } });
+ const r = await jwtService.validateAccessToken(req);
+ expect(r).toBeFalsy();
+
+ expect(jwtService._verify).toHaveBeenCalledTimes(1);
+ });
+
+ it('should throw problem when validate access token catches (non-jwt) error)', async () => {
+ // need to mock out this whole function, very difficult to mock jose...
+ jwtService._verify = jest.fn().mockImplementation(() => {
+ throw new Error('bad');
+ });
+
+ const req = getMockReq({ headers: { authorization: 'Bearer JWT' } });
+ let r = undefined;
+ let e = undefined;
+ try {
+ r = await jwtService.validateAccessToken(req);
+ } catch (error) {
+ e = error;
+ }
+
+ expect(e).toBeInstanceOf(Problem);
+ expect(r).toBe(undefined);
+ expect(jwtService._verify).toHaveBeenCalledTimes(1);
+ });
+
+ it('should pass middleware protect with valid jwt)', async () => {
+ const jwt = {};
+ const payload = { client_roles: ['admin'] };
+ jwtService.getBearerToken = jest.fn().mockReturnValue(jwt);
+ // need to mock out this whole function, very difficult to mock jose...
+ jwtService._verify = jest.fn().mockReturnValue(payload);
+
+ const req = getMockReq({
+ headers: { authorization: 'Bearer JWT' },
+ });
+ const { res, next } = getMockRes();
+
+ const middleware = jwtService.protect();
+
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledWith();
+ });
+
+ it('should fail middleware protect with invalid jwt', async () => {
+ const jwt = {};
+ jwtService.getBearerToken = jest.fn().mockReturnValue(jwt);
+ // need to mock out this whole function, very difficult to mock jose...
+ jwtService._verify = jest.fn().mockImplementation(() => {
+ throw new jose.errors.JWTClaimValidationFailed('bad');
+ });
+
+ const req = getMockReq({
+ headers: { authorization: 'Bearer JWT' },
+ });
+ const { res, next } = getMockRes();
+
+ const middleware = jwtService.protect();
+
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 401 }));
+ });
+
+ it('should pass middleware protect with valid jwt and role', async () => {
+ const jwt = {};
+ const payload = { client_roles: ['admin'] };
+ jwtService.getBearerToken = jest.fn().mockReturnValue(jwt);
+ // need to mock out this whole function, very difficult to mock jose...
+ jwtService._verify = jest.fn().mockReturnValue(payload);
+
+ const req = getMockReq({
+ headers: { authorization: 'Bearer JWT' },
+ });
+ const { res, next } = getMockRes();
+
+ const middleware = jwtService.protect('admin');
+
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledWith();
+ });
+
+ it('should fail middleware protect with valid jwt and but no role', async () => {
+ const jwt = {};
+ const payload = { client_roles: [] };
+ jwtService.getBearerToken = jest.fn().mockReturnValue(jwt);
+ // need to mock out this whole function, very difficult to mock jose...
+ jwtService._verify = jest.fn().mockReturnValue(payload);
+
+ const req = getMockReq({
+ headers: { authorization: 'Bearer JWT' },
+ });
+ const { res, next } = getMockRes();
+
+ const middleware = jwtService.protect('admin');
+
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalledTimes(1);
+ expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 401 }));
+ });
+});
diff --git a/app/tests/unit/forms/auth/middleware/userAccess.spec.js b/app/tests/unit/forms/auth/middleware/userAccess.spec.js
index 5fd4e1e0e..3bb51b516 100644
--- a/app/tests/unit/forms/auth/middleware/userAccess.spec.js
+++ b/app/tests/unit/forms/auth/middleware/userAccess.spec.js
@@ -117,8 +117,7 @@ describe('currentUser', () => {
expect(jwtService.validateAccessToken).toHaveBeenCalledWith('bearer-token-value');
expect(service.login).toHaveBeenCalledTimes(0);
expect(testReq.currentUser).toEqual(undefined);
- expect(nxt).toHaveBeenCalledTimes(0);
- //expect(nxt).toHaveBeenCalledWith(new Problem(403, { detail: 'Authorization token is invalid.' }));
+ expect(nxt).toHaveBeenCalledWith(new Problem(403, { detail: 'Authorization token is invalid.' }));
});
});
From 0a39ed18565ed88cafb145f3bf1f64ef07113ae3 Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Mon, 11 Mar 2024 11:03:38 -0700
Subject: [PATCH 11/16] tweak middleware - catch more errors, throw 401 on
invalid token/unauthorized
Signed-off-by: Jason Sherman
---
app/frontend/tests/unit/components/bcgov/BCGovHeader.spec.js | 2 +-
app/src/components/jwtService.js | 4 ++--
app/src/forms/auth/middleware/userAccess.js | 4 ++--
app/tests/unit/forms/auth/middleware/userAccess.spec.js | 4 ++--
4 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/app/frontend/tests/unit/components/bcgov/BCGovHeader.spec.js b/app/frontend/tests/unit/components/bcgov/BCGovHeader.spec.js
index 8dd1de1fc..871400376 100644
--- a/app/frontend/tests/unit/components/bcgov/BCGovHeader.spec.js
+++ b/app/frontend/tests/unit/components/bcgov/BCGovHeader.spec.js
@@ -1,5 +1,5 @@
import { setActivePinia, createPinia } from 'pinia';
-import { flushPromises, mount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { createRouter, createWebHistory } from 'vue-router';
diff --git a/app/src/components/jwtService.js b/app/src/components/jwtService.js
index 9aa999d7c..a25ddc0f7 100644
--- a/app/src/components/jwtService.js
+++ b/app/src/components/jwtService.js
@@ -39,7 +39,7 @@ class JwtService {
}
async _verify(token) {
- // could throw JWTClaimValidationFailed
+ // could throw JWTClaimValidationFailed (JOSEError)
const { payload } = await jose.jwtVerify(token, JWKS, {
issuer: this.issuer,
audience: this.audience,
@@ -54,7 +54,7 @@ class JwtService {
// these claims passed, just return true.
return true;
} catch (e) {
- if (e instanceof jose.errors.JWTClaimValidationFailed) {
+ if (e instanceof jose.errors.JOSEError) {
return false;
} else {
errorToProblem(SERVICE, e);
diff --git a/app/src/forms/auth/middleware/userAccess.js b/app/src/forms/auth/middleware/userAccess.js
index e7a0ace06..d3c22e0fd 100644
--- a/app/src/forms/auth/middleware/userAccess.js
+++ b/app/src/forms/auth/middleware/userAccess.js
@@ -61,7 +61,7 @@ const _getForm = async (currentUser, formId, includeDeleted) => {
* attribute so that all downstream middleware and business logic can use it.
*
* This will fall through if everything is OK. If the Bearer auth is not valid,
- * this will produce a 403 error.
+ * this will produce a 401 error.
*
* @param {*} req the Express object representing the HTTP request.
* @param {*} _res the Express object representing the HTTP response - unused.
@@ -74,7 +74,7 @@ const currentUser = async (req, _res, next) => {
if (bearerToken) {
const ok = await jwtService.validateAccessToken(bearerToken);
if (!ok) {
- throw new Problem(403, { detail: 'Authorization token is invalid.' });
+ throw new Problem(401, { detail: 'Authorization token is invalid.' });
}
}
diff --git a/app/tests/unit/forms/auth/middleware/userAccess.spec.js b/app/tests/unit/forms/auth/middleware/userAccess.spec.js
index 3bb51b516..81e0d8142 100644
--- a/app/tests/unit/forms/auth/middleware/userAccess.spec.js
+++ b/app/tests/unit/forms/auth/middleware/userAccess.spec.js
@@ -101,7 +101,7 @@ describe('currentUser', () => {
expect(service.login).toHaveBeenCalledWith({ token: 'payload' });
});
- it('403s if the token is invalid', async () => {
+ it('401s if the token is invalid', async () => {
const testReq = {
headers: {
authorization: 'Bearer hjvds0uds',
@@ -117,7 +117,7 @@ describe('currentUser', () => {
expect(jwtService.validateAccessToken).toHaveBeenCalledWith('bearer-token-value');
expect(service.login).toHaveBeenCalledTimes(0);
expect(testReq.currentUser).toEqual(undefined);
- expect(nxt).toHaveBeenCalledWith(new Problem(403, { detail: 'Authorization token is invalid.' }));
+ expect(nxt).toHaveBeenCalledWith(new Problem(401, { detail: 'Authorization token is invalid.' }));
});
});
From 85ce000d3e6d59a6619b1db5c4b957f854d5f407 Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Wed, 13 Mar 2024 15:19:40 -0700
Subject: [PATCH 12/16] rename migration to provide space for emergency merges
before we release. remove index on keycloakId, remove restriction that is is
a GUID change token map value parsing spec, allow json and stringToGUID.
add/enhance tests around token parsing
Signed-off-by: Jason Sherman
---
.vscode/launch.json | 17 +++++
app/src/components/idpService.js | 61 +++++++++++++---
...01000000_identity_provider_permissions.js} | 6 ++
app/src/forms/common/models/tables/user.js | 2 +-
.../fixtures/form/identity_providers.json | 26 ++++++-
app/tests/unit/components/idpService.spec.js | 69 ++++++++++++++++---
6 files changed, 157 insertions(+), 24 deletions(-)
rename app/src/db/migrations/{20240119172630_identity_provider_permissions.js => 20240401000000_identity_provider_permissions.js} (96%)
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 6d5d4d795..c62380825 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -42,6 +42,23 @@
"enableContentValidation": false,
"webRoot": "${workspaceFolder}/app/frontend/src",
"pathMapping": { "url": "//src/", "path": "${webRoot}/" }
+ },
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Jest: current file",
+ //"env": { "NODE_ENV": "test" },
+ "program": "${workspaceFolder}/app/node_modules/.bin/jest",
+ "args": [
+ "${fileBasenameNoExtension}",
+ "--config",
+ "${workspaceFolder}/app/jest.config.js",
+ "--coverage=false"
+ ],
+ "console": "integratedTerminal",
+ "windows": {
+ "program": "${workspaceFolder}/app/node_modules/jest/bin/jest"
+ }
}
],
"version": "0.2.0"
diff --git a/app/src/components/idpService.js b/app/src/components/idpService.js
index 7548bdec3..c647f5ef6 100644
--- a/app/src/components/idpService.js
+++ b/app/src/components/idpService.js
@@ -4,7 +4,6 @@ const { IdentityProvider, User } = require('../forms/common/models');
const SERVICE = 'IdpService';
const IDP_KEY = 'identity_provider';
-const KC_ID_KEY = 'keycloakId';
function stringToGUID(s) {
const regex = /^([0-9a-fA-F]{8})([0-9a-fA-F]{4})([0-9a-fA-F]{4})([0-9a-fA-F]{4})([0-9a-fA-F]{12})/;
@@ -12,6 +11,23 @@ function stringToGUID(s) {
return m ? `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}` : null;
}
+function getNestedObject(obj, key) {
+ return key.split('.').reduce(function (o, x) {
+ return typeof o == 'undefined' || o === null ? o : o[x];
+ }, obj);
+}
+
+function parseJsonField(attributeName, searchpath, token) {
+ let value = null;
+ for (const k of Object.keys(token)) {
+ if (k === attributeName) {
+ const obj = JSON.parse(token[k]);
+ value = getNestedObject(obj, searchpath);
+ }
+ }
+ return value;
+}
+
function isEmpty(s) {
return s === undefined || s === null || (s && s.trim() === '');
}
@@ -45,6 +61,40 @@ class IdpService {
return p.find((x) => x.code === code);
}
+ async getValue(key, tokenKey, token) {
+ let tokenValue = null;
+ // examine the key, it may contain parsing information
+ // if key contains `::`, then we have parsing method to call.
+ if (tokenKey.includes('::')) {
+ // determine which convert method...
+ const k_fn = tokenKey.split('::'); //split to key and function
+ const tv = token[k_fn[0]]; //token value
+ const fn = k_fn[1]; //function name
+ switch (fn) {
+ case 'stringToGUID':
+ tokenValue = stringToGUID(tv);
+ if (!tokenValue) {
+ throw new Error(`Value in token for '${tv}' cannot be converted to GUID.`);
+ }
+ break;
+ case 'parseJsonField':
+ try {
+ // k_fn[0] is [attribute name , json search path]
+ tokenValue = parseJsonField(k_fn[0].split(',')[0], k_fn[0].split(',')[1], token);
+ } catch (error) {
+ throw new Error(`Value in token mapped to '${key}' cannot be converted from JSON.`);
+ }
+ break;
+ default:
+ throw new Error(`Value in token mapped to '${key}' specified unknown parsing routine: ${fn}.`);
+ }
+ } else {
+ tokenValue = token[tokenKey];
+ }
+ // errors if no value???
+ return tokenValue;
+ }
+
// given a token, determine idp and transform
async parseToken(token) {
try {
@@ -69,14 +119,7 @@ class IdpService {
for (const key of Object.keys(userInfo)) {
const tokenKey = idp.tokenmap[key];
if (tokenKey) {
- let tokenValue = token[tokenKey];
- if (key === KC_ID_KEY) {
- tokenValue = stringToGUID(token[tokenKey]);
- if (!tokenValue) {
- throw new Error(`Value in token for '${tokenKey}' cannot be converted to GUID.`);
- }
- }
- userInfo[key] = tokenValue;
+ userInfo[key] = await this.getValue(key, tokenKey, token);
}
}
userInfo.public = false;
diff --git a/app/src/db/migrations/20240119172630_identity_provider_permissions.js b/app/src/db/migrations/20240401000000_identity_provider_permissions.js
similarity index 96%
rename from app/src/db/migrations/20240119172630_identity_provider_permissions.js
rename to app/src/db/migrations/20240401000000_identity_provider_permissions.js
index bb01da893..d68736cce 100644
--- a/app/src/db/migrations/20240119172630_identity_provider_permissions.js
+++ b/app/src/db/migrations/20240401000000_identity_provider_permissions.js
@@ -30,6 +30,9 @@ const BCEID_EXTRAS = {
exports.up = function (knex) {
return Promise.resolve().then(() =>
knex.schema
+ .alterTable('user', function (table) {
+ table.dropIndex('keycloakId');
+ })
.alterTable('identity_provider', (table) => {
table.boolean('primary').notNullable().defaultTo(false);
table.boolean('login').notNullable().defaultTo(false).comment('When true, supply buttons to launch login process');
@@ -126,6 +129,9 @@ exports.up = function (knex) {
exports.down = function (knex) {
return Promise.resolve().then(() =>
knex.schema
+ .alterTable('user', function (table) {
+ table.index('keycloakId');
+ })
.alterTable('identity_provider', (table) => {
table.dropColumn('primary');
table.dropColumn('login');
diff --git a/app/src/forms/common/models/tables/user.js b/app/src/forms/common/models/tables/user.js
index c609f08e3..9d4d939de 100644
--- a/app/src/forms/common/models/tables/user.js
+++ b/app/src/forms/common/models/tables/user.js
@@ -97,7 +97,7 @@ class User extends Timestamps(Model) {
properties: {
id: { type: 'string', pattern: Regex.UUID },
idpUserId: { type: 'string', maxLength: 255 },
- keycloakId: { type: 'string', pattern: Regex.UUID },
+ keycloakId: { type: 'string', maxLength: 255 },
username: { type: ['string', 'null'], maxLength: 255 },
firstName: { type: ['string', 'null'], maxLength: 255 },
lastName: { type: ['string', 'null'], maxLength: 255 },
diff --git a/app/tests/fixtures/form/identity_providers.json b/app/tests/fixtures/form/identity_providers.json
index c60d60d34..726289ccb 100644
--- a/app/tests/fixtures/form/identity_providers.json
+++ b/app/tests/fixtures/form/identity_providers.json
@@ -220,6 +220,29 @@
"tokenmap": null,
"extra": {}
},
+ {
+ "code": "digital-credential",
+ "display": "Digital Credential",
+ "active": true,
+ "idp": "digitalcredential",
+ "createdBy": "testonly",
+ "createdAt": "2024-03-08T22:02:34.399Z",
+ "updatedBy": null,
+ "updatedAt": "2024-03-08T22:02:34.399Z",
+ "primary": false,
+ "login": false,
+ "permissions": [],
+ "roles": null,
+ "tokenmap": {
+ "idpUserId": "vc_user_guid::stringToGUID",
+ "keycloakId": "preferred_username",
+ "email": "vc_presented_attributes,email::parseJsonField",
+ "firstName": "vc_presented_attributes,name.first::parseJsonField",
+ "lastName": "vc_presented_attributes,name.last::parseJsonField",
+ "idp": "identity_provider"
+ },
+ "extra": {}
+ },
{
"code": "testonly",
"display": "N/A",
@@ -228,12 +251,9 @@
"createdBy": "testonly",
"createdAt": "2024-03-08T22:02:34.399Z",
"updatedBy": null,
- "updatedAt": "2024-03-08T22:02:34.399Z",
- "primary": false,
"login": false,
"permissions": [],
"roles": null,
- "tokenmap": null,
"extra": {}
}
]
diff --git a/app/tests/unit/components/idpService.spec.js b/app/tests/unit/components/idpService.spec.js
index ec8d1fccc..724f4fd6a 100644
--- a/app/tests/unit/components/idpService.spec.js
+++ b/app/tests/unit/components/idpService.spec.js
@@ -6,6 +6,9 @@ const idpData = require('../../fixtures/form/identity_providers.json');
// let's just load data once..
idpService.providers = idpData;
idpService.activeProviders = idpData.filter((x) => x.active);
+// change these as appropriate if adding test case idps...
+const IDP_COUNT = 6;
+const IDP_ACTIVE_COUNT = 5;
jest.mock('../../../src/forms/common/models/tables/user', () => MockModel);
@@ -38,6 +41,31 @@ function idirToken() {
};
}
+function digitalCredentialToken() {
+ return {
+ exp: 1709853624,
+ iat: 1709853324,
+ auth_time: 1709853313,
+ jti: '7d85f2db-d4a5-4ce8-bcf0-4ecc1ab009d2',
+ iss: 'https://dev.sandbox.loginproxy.gov.bc.ca/auth/realms/standard',
+ aud: 'chefs-frontend-localhost-12200',
+ sub: '5bc63f3b8d93f6fa259f2ca8fa5e79a4175567c63871b5bad13e3e846ded4b19@digitalcredential',
+ typ: 'Bearer',
+ azp: 'chefs-frontend-localhost-12200',
+ nonce: '47652a72-83cf-46b2-8872-2ddbe6e32bd3',
+ session_state: '2682bdcd-2778-4709-a9d0-bf6f7d0f153d',
+ scope: 'openid email idir profile digitalcredential bceidbusiness bceidbasic',
+ sid: '2682bdcd-2778-4709-a9d0-bf6f7d0f153d',
+ identity_provider: 'digitalcredential',
+ email_verified: false,
+ pres_req_conf_id: 'verified-email',
+ vc_presented_attributes: '{"email": "patrick.swayze@gmail.com", "name": {"first": "patrick", "last": "swayze"}}',
+ preferred_username: '5bc63f3b8d93f6fa259f2ca8fa5e79a4175567c63871b5bad13e3e846ded4b19@digitalcredential',
+ vc_user_guid: '674861AA34E546F8BDA6A7004DC9C6C9',
+ vc_user_guid_converted: '674861AA-34E5-46F8-BDA6-A7004DC9C6C9',
+ };
+}
+
beforeEach(() => {
MockModel.mockReset();
});
@@ -49,8 +77,8 @@ afterEach(() => {
describe('idpService', () => {
const assertService = (srv) => {
expect(srv).toBeTruthy();
- expect(srv.providers).toHaveLength(5);
- expect(srv.activeProviders).toHaveLength(4);
+ expect(srv.providers).toHaveLength(IDP_COUNT);
+ expect(srv.activeProviders).toHaveLength(IDP_ACTIVE_COUNT);
};
it('should return a service', () => {
@@ -59,12 +87,12 @@ describe('idpService', () => {
it('should return active idps', async () => {
const idps = await idpService.getIdentityProviders(true);
- expect(idps).toHaveLength(4);
+ expect(idps).toHaveLength(IDP_ACTIVE_COUNT);
});
it('should return all idps', async () => {
const idps = await idpService.getIdentityProviders(false);
- expect(idps).toHaveLength(5);
+ expect(idps).toHaveLength(IDP_COUNT);
});
it('should return bceid-business by idp', async () => {
@@ -81,6 +109,20 @@ describe('idpService', () => {
expect(idp.idp).toBe('bceidbusiness');
});
+ it('should return digital-credential by idp', async () => {
+ const idp = await idpService.findByIdp('digitalcredential');
+ expect(idp).toBeTruthy();
+ expect(idp.code).toBe('digital-credential');
+ expect(idp.idp).toBe('digitalcredential');
+ });
+
+ it('should return digital-credential by code', async () => {
+ const idp = await idpService.findByCode('digital-credential');
+ expect(idp).toBeTruthy();
+ expect(idp.code).toBe('digital-credential');
+ expect(idp.idp).toBe('digitalcredential');
+ });
+
it('should return nothing by bad idp', async () => {
const idp = await idpService.findByIdp('doesnotexist');
expect(idp).toBeFalsy();
@@ -174,9 +216,8 @@ describe('idpService', () => {
expect(r).toBe(undefined);
});
- it('should throw a Problem when token has no keycloakId cannot parse into GUID', async () => {
- let token = idirToken();
- token.idir_user_guid = 123; //will not parse into a GUID...
+ it('should return userInfo with good digitalcredential token', async () => {
+ const token = digitalCredentialToken();
let r = undefined;
let e = undefined;
try {
@@ -185,9 +226,15 @@ describe('idpService', () => {
e = error;
}
- expect(e).toBeTruthy();
- expect(r).toBeFalsy();
- expect(e).toBeInstanceOf(Problem);
- expect(r).toBe(undefined);
+ expect(e).toBeFalsy();
+ expect(r).toBeTruthy();
+ expect(r.keycloakId).toBeTruthy();
+ expect(r.keycloakId).toBe(token.preferred_username); // not a GUID!
+ // test the stringToGUID parsing (valid answer in token...)
+ expect(r.idpUserId).toEqual(token.vc_user_guid_converted);
+ // test JSON parsing
+ expect(r.email).toBe('patrick.swayze@gmail.com');
+ expect(r.firstName).toBe('patrick');
+ expect(r.lastName).toBe('swayze');
});
});
From 18b0673e0a49386191db2b7c7a9f9856dd036ce2 Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Thu, 14 Mar 2024 18:49:30 -0700
Subject: [PATCH 13/16] Fix for form.idpHints and FE idp store IDP check
Signed-off-by: Jason Sherman
---
app/frontend/src/store/identityProviders.js | 2 +-
app/src/forms/common/models/tables/form.js | 13 ++++++++-----
2 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/app/frontend/src/store/identityProviders.js b/app/frontend/src/store/identityProviders.js
index 03301d5ba..9bfffaf9a 100644
--- a/app/frontend/src/store/identityProviders.js
+++ b/app/frontend/src/store/identityProviders.js
@@ -50,7 +50,7 @@ export const useIdpStore = defineStore('idps', {
},
isValidIdpHint(hint) {
if (hint && this.providers) {
- return this.providers.findIndex((x) => x.hint === hint) > -1;
+ return this.providers.findIndex((x) => x.idp === hint) > -1;
}
return false;
},
diff --git a/app/src/forms/common/models/tables/form.js b/app/src/forms/common/models/tables/form.js
index 0b0b4139c..d947bf9b6 100644
--- a/app/src/forms/common/models/tables/form.js
+++ b/app/src/forms/common/models/tables/form.js
@@ -31,7 +31,6 @@ class Form extends Timestamps(Model) {
}
static get relationMappings() {
- const FormIdentityProvider = require('./formIdentityProvider');
const FormVersion = require('./formVersion');
const FormVersionDraft = require('./formVersionDraft');
const IdentityProvider = require('./identityProvider');
@@ -45,12 +44,16 @@ class Form extends Timestamps(Model) {
},
},
idpHints: {
- relation: Model.HasManyRelation,
- modelClass: FormIdentityProvider,
- filter: (query) => query.select('code'),
+ relation: Model.ManyToManyRelation,
+ modelClass: IdentityProvider,
+ filter: (query) => query.select('idp'),
join: {
from: 'form.id',
- to: 'form_identity_provider.formId',
+ through: {
+ from: 'form_identity_provider.formId',
+ to: 'form_identity_provider.code',
+ },
+ to: 'identity_provider.code',
},
},
identityProviders: {
From a58e66b6f3f42dea145f5c4a319524fb3a00f22d Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Thu, 14 Mar 2024 18:45:42 -0700
Subject: [PATCH 14/16] Add support for digital credential IDPs. Need
supplementary login parameters to OIDC.
Signed-off-by: Jason Sherman
---
app/frontend/src/store/auth.js | 5 ++-
app/frontend/src/store/identityProviders.js | 12 ++++++
.../20240401000010_digital_credential_idp.js | 39 +++++++++++++++++++
3 files changed, 54 insertions(+), 2 deletions(-)
create mode 100644 app/src/db/migrations/20240401000010_digital_credential_idp.js
diff --git a/app/frontend/src/store/auth.js b/app/frontend/src/store/auth.js
index 0316bca19..afbf48763 100644
--- a/app/frontend/src/store/auth.js
+++ b/app/frontend/src/store/auth.js
@@ -110,13 +110,14 @@ export const useAuthStore = defineStore('auth', {
// Determine idpHint based on input or form
if (idpHint && typeof idpHint === 'string') options.idpHint = idpHint;
+ const idpStore = useIdpStore();
if (options.idpHint) {
// Redirect to Keycloak if idpHint is available
- window.location.replace(this.createLoginUrl(options));
+ const loginOptions = idpStore.getLoginOptions(options.idpHint);
+ window.location.replace(this.createLoginUrl(options) + loginOptions);
} else {
// Navigate to internal login page if no idpHint specified
const router = getRouter();
- const idpStore = useIdpStore();
router.replace({
name: 'Login',
query: { idpHint: idpStore.loginIdpHints },
diff --git a/app/frontend/src/store/identityProviders.js b/app/frontend/src/store/identityProviders.js
index 9bfffaf9a..c421a46f2 100644
--- a/app/frontend/src/store/identityProviders.js
+++ b/app/frontend/src/store/identityProviders.js
@@ -54,6 +54,18 @@ export const useIdpStore = defineStore('idps', {
}
return false;
},
+ getLoginOptions(hint) {
+ let result = ''; // return empty string so no check needed to add to existing string.
+ if (hint && this.providers) {
+ const idp = this.providers.find((x) => x.idp === hint);
+ if (idp) {
+ if (idp.extra?.loginOptions) {
+ result = idp.extra?.loginOptions;
+ }
+ }
+ }
+ return result;
+ },
hasFormAccessSettings(code, accessSettingsType) {
let result = false;
if (code && accessSettingsType && this.providers) {
diff --git a/app/src/db/migrations/20240401000010_digital_credential_idp.js b/app/src/db/migrations/20240401000010_digital_credential_idp.js
new file mode 100644
index 000000000..c78673bb4
--- /dev/null
+++ b/app/src/db/migrations/20240401000010_digital_credential_idp.js
@@ -0,0 +1,39 @@
+const { Roles } = require('../../forms/common/constants');
+
+const CREATED_BY = 'migration-dc-idp';
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+exports.up = function (knex) {
+ return Promise.resolve().then(() => {
+ return knex('identity_provider').insert([
+ {
+ createdBy: CREATED_BY,
+ code: 'verified-email',
+ display: 'Verified Email',
+ active: true,
+ idp: 'digitalcredential',
+ primary: false,
+ login: true,
+ permissions: [],
+ roles: [Roles.FORM_SUBMITTER],
+ tokenmap: {
+ idpUserId: 'preferred_username',
+ keycloakId: 'preferred_username',
+ email: 'vc_presented_attributes,email::parseJsonField',
+ idp: 'identity_provider',
+ },
+ extra: {
+ loginOptions: '&pres_req_conf_id=verified-email',
+ },
+ },
+ ]);
+ });
+};
+
+exports.down = function (knex) {
+ return Promise.resolve().then(() => {
+ return knex('identity_provider').where('createdBy', CREATED_BY).del();
+ });
+};
From 92842c1a7c02848070b64b454a37997552545231 Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Fri, 15 Mar 2024 16:39:53 -0700
Subject: [PATCH 15/16] little fudge room on token time for different systems.
add default CHEFs permission for digital cred IDP
Signed-off-by: Jason Sherman
---
app/src/components/jwtService.js | 1 +
.../db/migrations/20240401000010_digital_credential_idp.js | 4 ++--
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/app/src/components/jwtService.js b/app/src/components/jwtService.js
index a25ddc0f7..ead50c6f0 100644
--- a/app/src/components/jwtService.js
+++ b/app/src/components/jwtService.js
@@ -41,6 +41,7 @@ class JwtService {
async _verify(token) {
// could throw JWTClaimValidationFailed (JOSEError)
const { payload } = await jose.jwtVerify(token, JWKS, {
+ clockTolerance: '15 seconds',
issuer: this.issuer,
audience: this.audience,
maxTokenAge: parseInt(this.maxTokenAge),
diff --git a/app/src/db/migrations/20240401000010_digital_credential_idp.js b/app/src/db/migrations/20240401000010_digital_credential_idp.js
index c78673bb4..6da940057 100644
--- a/app/src/db/migrations/20240401000010_digital_credential_idp.js
+++ b/app/src/db/migrations/20240401000010_digital_credential_idp.js
@@ -1,4 +1,4 @@
-const { Roles } = require('../../forms/common/constants');
+const { APP_PERMISSIONS, Roles } = require('../../forms/common/constants');
const CREATED_BY = 'migration-dc-idp';
/**
@@ -16,7 +16,7 @@ exports.up = function (knex) {
idp: 'digitalcredential',
primary: false,
login: true,
- permissions: [],
+ permissions: [APP_PERMISSIONS.VIEWS_USER_SUBMISSIONS],
roles: [Roles.FORM_SUBMITTER],
tokenmap: {
idpUserId: 'preferred_username',
From cd67f934ff130cbbe526d40a08df413be4d45be9 Mon Sep 17 00:00:00 2001
From: Jason Sherman
Date: Mon, 18 Mar 2024 11:17:54 -0700
Subject: [PATCH 16/16] change from main new config map and env vars for demo
against sandbox sso
Signed-off-by: Jason Sherman
---
app/config/custom-environment-variables.json | 22 ++++++++---------
app/frontend/src/App.vue | 25 +-------------------
openshift/app.dc.yaml | 2 ++
3 files changed, 14 insertions(+), 35 deletions(-)
diff --git a/app/config/custom-environment-variables.json b/app/config/custom-environment-variables.json
index 00ba319a0..54fb80c4a 100755
--- a/app/config/custom-environment-variables.json
+++ b/app/config/custom-environment-variables.json
@@ -33,10 +33,10 @@
"apiPath": "FRONTEND_APIPATH",
"basePath": "VITE_FRONTEND_BASEPATH",
"oidc": {
- "clientId": "OIDC_CLIENTID",
- "realm": "OIDC_REALM",
- "serverUrl": "OIDC_SERVERURL",
- "logoutUrl": "OIDC_LOGOUTURL"
+ "clientId": "SANDBOX_OIDC_CLIENTID",
+ "realm": "SANDBOX_OIDC_REALM",
+ "serverUrl": "SANDBOX_OIDC_SERVERURL",
+ "logoutUrl": "SANDBOX_OIDC_LOGOUTURL"
}
},
"server": {
@@ -44,12 +44,12 @@
"basePath": "SERVER_BASEPATH",
"bodyLimit": "SERVER_BODYLIMIT",
"oidc": {
- "realm": "OIDC_REALM",
- "serverUrl": "OIDC_SERVERURL",
- "jwksUri": "OIDC_JWKSURI",
- "issuer": "OIDC_ISSUER",
- "audience": "OIDC_CLIENTID",
- "maxTokenAge": "OIDC_MAXTOKENAGE"
+ "realm": "SANDBOX_OIDC_REALM",
+ "serverUrl": "SANDBOX_OIDC_SERVERURL",
+ "jwksUri": "SANDBOX_OIDC_JWKSURI",
+ "issuer": "SANDBOX_OIDC_ISSUER",
+ "audience": "SANDBOX_OIDC_CLIENTID",
+ "maxTokenAge": "SANDBOX_OIDC_MAXTOKENAGE"
},
"logFile": "SERVER_LOGFILE",
"logLevel": "SERVER_LOGLEVEL",
@@ -71,4 +71,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/frontend/src/App.vue b/app/frontend/src/App.vue
index 047263f84..d5c3e8749 100755
--- a/app/frontend/src/App.vue
+++ b/app/frontend/src/App.vue
@@ -11,14 +11,6 @@ export default {
BCGovNavBar,
BCGovFooter,
},
- computed: {
- isSubmitPage() {
- // return this.$route.name === 'FormSubmit' or FormView
- return (
- this.$route.name === 'FormSubmit' || this.$route.name === 'FormView'
- );
- },
- },
};
@@ -30,10 +22,7 @@ export default {
-
+
@@ -52,16 +41,4 @@ export default {
.main {
flex: 1 0 auto;
}
-
-.main-wide {
- flex: 1 0 auto;
- max-width: 100%;
-}
-
-@media (min-width: 1024px) {
- .main-wide {
- padding-left: 50px;
- padding-right: 50px;
- }
-}
diff --git a/openshift/app.dc.yaml b/openshift/app.dc.yaml
index 14120d516..013ff4703 100644
--- a/openshift/app.dc.yaml
+++ b/openshift/app.dc.yaml
@@ -254,6 +254,8 @@ objects:
name: "${APP_NAME}-files-config"
- configMapRef:
name: "${APP_NAME}-oidc-config"
+ - configMapRef:
+ name: "${APP_NAME}-sandbox-oidc-config"
- configMapRef:
name: "${APP_NAME}-custombcaddressformiocomponent-config"
restartPolicy: Always