diff --git a/.devcontainer/README.md b/.devcontainer/README.md
index 223a6b7d9..16954b1b1 100644
--- a/.devcontainer/README.md
+++ b/.devcontainer/README.md
@@ -17,6 +17,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
@@ -72,6 +74,37 @@ _Notes_
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/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/.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/.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 c6d2ea823..46eec3560 100644
--- a/.devcontainer/post-install.sh
+++ b/.devcontainer/post-install.sh
@@ -5,6 +5,9 @@ set -ex
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
npm install
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 95afba61f..c62380825 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -15,7 +15,7 @@
"runtimeExecutable": "npm",
"type": "node",
"env": {
- "NODE_CONFIG_DIR": "${workspaceFolder}/.devcontainer/chefs_local",
+ "NODE_CONFIG_DIR": "${workspaceFolder}/.devcontainer/chefs_local"
}
},
{
@@ -32,7 +32,7 @@
"request": "launch",
"runtimeArgs": ["run", "dev"],
"runtimeExecutable": "npm",
- "type": "node",
+ "type": "node"
},
{
"name": "CHEFS Frontend - chrome",
@@ -41,7 +41,24 @@
"url": "http://localhost:5173/app",
"enableContentValidation": false,
"webRoot": "${workspaceFolder}/app/frontend/src",
- "pathMapping": {"url": "//src/", "path": "${webRoot}/"}
+ "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/.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/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 00143dbae..54fb80c4a 100755
--- a/app/config/custom-environment-variables.json
+++ b/app/config/custom-environment-variables.json
@@ -32,22 +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"
+ "oidc": {
+ "clientId": "SANDBOX_OIDC_CLIENTID",
+ "realm": "SANDBOX_OIDC_REALM",
+ "serverUrl": "SANDBOX_OIDC_SERVERURL",
+ "logoutUrl": "SANDBOX_OIDC_LOGOUTURL"
}
},
"server": {
"apiPath": "SERVER_APIPATH",
"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"
+ "oidc": {
+ "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",
@@ -69,4 +71,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/config/default.json b/app/config/default.json
index 3fadfbf56..31d28ee3f 100644
--- a/app/config/default.json
+++ b/app/config/default.json
@@ -30,21 +30,25 @@
"adminDashboardUrl": "",
"apiPath": "api/v1",
"basePath": "/app",
- "keycloak": {
- "clientId": "chefs-frontend",
- "realm": "chefs",
- "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth"
+ "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": "https://dev.loginproxy.gov.bc.ca/auth"
- },
+ "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": {
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/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/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/components/base/BaseSecure.vue b/app/frontend/src/components/base/BaseSecure.vue
index 0a27df9e3..d75aa7761 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: {
@@ -9,8 +10,8 @@ export default {
type: Boolean,
default: false,
},
- idp: {
- type: Array,
+ permission: {
+ type: String,
default: undefined,
},
},
@@ -19,10 +20,10 @@ export default {
'authenticated',
'identityProvider',
'isAdmin',
- 'isUser',
'ready',
]),
...mapState(useFormStore, ['lang']),
+ ...mapState(useIdpStore, ['hasPermission']),
mailToLink() {
return `mailto:${
import.meta.env.VITE_CONTACT
@@ -34,54 +35,40 @@ export default {
return import.meta.env.VITE_CONTACT;
},
},
- methods: mapActions(useAuthStore, ['login']),
+ methods: {
+ ...mapActions(useAuthStore, ['login']),
+ },
};
-
-
-
- {{ $t('trans.baseSecure.401UnAuthorized') }}
-
-
- {{ $t('trans.baseSecure.401UnAuthorizedErrMsg') }}
-
-
-
-
- {{ $t('trans.baseSecure.403Forbidden') }}
-
-
- {{
- $t('trans.baseSecure.403ErrorMsg', {
- idp: idp,
- })
- }}
-
-
-
-
-
-
+
{{ $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/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.display }}
diff --git a/app/frontend/src/components/designer/settings/FormFunctionalitySettings.vue b/app/frontend/src/components/designer/settings/FormFunctionalitySettings.vue
index d9b6a98d6..f88606501 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?.code);
},
},
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/SubmissionsTable.vue b/app/frontend/src/components/forms/SubmissionsTable.vue
index 1ae673b9a..4ea3bab38 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 0315a74e1..5aca02522 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..908e9a461 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,16 +36,16 @@ export default {
{{ $t('trans.login.authenticateWith') }}
-
-
+
+
- {{ button.label }}
+ {{ button.display }}
diff --git a/app/frontend/src/views/file/Download.vue b/app/frontend/src/views/file/Download.vue
index 9e6a1424c..decd6b4ca 100644
--- a/app/frontend/src/views/file/Download.vue
+++ b/app/frontend/src/views/file/Download.vue
@@ -4,7 +4,7 @@ import { mapActions, mapState } from 'pinia';
import BaseSecure from '~/components/base/BaseSecure.vue';
import { useFormStore } from '~/store/form';
import { useNotificationStore } from '~/store/notification';
-import { IdentityProviders } from '~/utils/constants';
+import { AppPermissions } from '~/utils/constants';
export default {
components: {
@@ -23,7 +23,7 @@ export default {
},
computed: {
...mapState(useFormStore, ['downloadedFile', 'lang', 'isRTL']),
- IDP: () => IdentityProviders,
+ APP_PERMS: () => AppPermissions,
},
async mounted() {
await this.getFile(this.id);
@@ -73,7 +73,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/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 ed57037c8..4b7738788 100644
--- a/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js
+++ b/app/frontend/tests/unit/components/base/BaseAuthButton.spec.js
@@ -3,25 +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, 'replace');
+ 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),
- createLogoutUrl: vi.fn((opts) => opts),
+ clientId: 'clientid'
};
router.currentRoute.value.meta.hasLogin = true;
router.push('/');
@@ -95,12 +104,14 @@ describe('BaseAuthButton.vue', () => {
expect(replace).toHaveBeenCalledTimes(1);
expect(replace).toHaveBeenCalledWith({
name: 'Login',
- query: { idpHint: ['idir', 'bceid-business', 'bceid-basic'] },
+ query: { idpHint: idpStore.loginIdpHints },
});
});
it('logout button redirects to logout url', async () => {
authStore.authenticated = true;
+ authStore.logoutUrl = 'http://redirect.com/logout';
+ authStore.keycloak
authStore.ready = true;
const wrapper = mount(BaseAuthButton, {
global: {
@@ -111,8 +122,7 @@ describe('BaseAuthButton.vue', () => {
wrapper.vm.logout();
expect(wrapper.text()).toMatch('trans.baseAuthButton.logout');
expect(windowReplaceSpy).toHaveBeenCalledTimes(1);
- expect(windowReplaceSpy).toHaveBeenCalledWith({
- redirectUri: 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/components/base/BaseSecure.spec.js b/app/frontend/tests/unit/components/base/BaseSecure.spec.js
index c2ea0e590..585e4f3ca 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,17 +21,20 @@ 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;
authStore.ready = true;
authStore.keycloak = {
tokenParsed: {
- resource_access: {
- chefs: {
- roles: ['user'],
- },
- },
+ client_roles: [],
+ identity_provider: nonPrimaryIdp.code,
},
};
const wrapper = mount(BaseSecure, {
@@ -41,39 +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: [],
- },
- },
- },
- };
- 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'],
- },
+ client_roles: [],
+ identity_provider: nonPrimaryIdp.code,
},
- },
- };
+ };
+
const wrapper = mount(BaseSecure, {
props: {
admin: true,
@@ -91,11 +73,8 @@ describe('BaseSecure.vue', () => {
authStore.ready = true;
authStore.keycloak = {
tokenParsed: {
- resource_access: {
- chefs: {
- roles: ['user'],
- },
- },
+ client_roles: ['admin'],
+ identity_provider: nonPrimaryIdp.code,
},
};
const wrapper = mount(BaseSecure, {
@@ -166,17 +145,14 @@ describe('BaseSecure.vue', () => {
authStore.ready = true;
authStore.keycloak = {
tokenParsed: {
- resource_access: {
- chefs: {
- roles: ['user'],
- },
+ client_roles: [],
+ identity_provider: 'fake', //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/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/frontend/tests/unit/components/bcgov/BCGovNavBar.spec.js b/app/frontend/tests/unit/components/bcgov/BCGovNavBar.spec.js
index 751a0500a..a25ff9687 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,17 +18,17 @@ 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',
- resource_access: {
- chefs: {
- roles: [],
- },
- },
+ identity_provider: primaryIdp.code,
+ client_roles: [],
},
};
authStore.authenticated = true;
@@ -64,12 +65,8 @@ describe('BCGovNavBar.vue', () => {
const authStore = useAuthStore();
authStore.keycloak = {
tokenParsed: {
- identity_provider: 'idir',
- resource_access: {
- chefs: {
- roles: ['admin'],
- },
- },
+ identity_provider: primaryIdp.code,
+ client_roles: ['admin'],
},
};
authStore.authenticated = true;
diff --git a/app/frontend/tests/unit/fixtures/identityProviders.json b/app/frontend/tests/unit/fixtures/identityProviders.json
new file mode 100644
index 000000000..827940877
--- /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": "bceidbasic",
+ "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": "bceidbusiness",
+ "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..f3c87da42 100644
--- a/app/frontend/tests/unit/store/modules/auth.actions.spec.js
+++ b/app/frontend/tests/unit/store/modules/auth.actions.spec.js
@@ -6,14 +6,21 @@ 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();
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();
+ const idpStore = useIdpStore();
+ const appStore = useAppStore();
+
+ idpStore.providers = require('../../fixtures/identityProviders.json');
describe('login', () => {
beforeEach(() => {
@@ -26,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', () => {
@@ -74,7 +82,7 @@ describe('auth actions', () => {
expect(replaceSpy).toHaveBeenCalledTimes(1);
expect(replaceSpy).toHaveBeenCalledWith({
name: 'Login',
- query: { idpHint: ['idir', 'bceid-business', 'bceid-basic'] },
+ query: { idpHint: idpStore.loginIdpHints },
});
});
@@ -97,23 +105,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/constants.spec.js b/app/frontend/tests/unit/utils/constants.spec.js
index 5c0fba6c8..0c2389d02 100644
--- a/app/frontend/tests/unit/utils/constants.spec.js
+++ b/app/frontend/tests/unit/utils/constants.spec.js
@@ -69,11 +69,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..c6e11b268 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.hint] },
});
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..0c2613601 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,8 +69,8 @@ describe('Login.vue', () => {
await nextTick();
- Object.values(IdentityProviders).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..c647f5ef6
--- /dev/null
+++ b/app/src/components/idpService.js
@@ -0,0 +1,212 @@
+const errorToProblem = require('./errorToProblem');
+const { IdentityProvider, User } = require('../forms/common/models');
+
+const SERVICE = 'IdpService';
+
+const IDP_KEY = 'identity_provider';
+
+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})/;
+ const m = s.replace(/-+/g, '').match(regex);
+ 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() === '');
+}
+
+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);
+ }
+
+ 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 {
+ 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) {
+ userInfo[key] = await this.getValue(key, tokenKey, token);
+ }
+ }
+ 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..ead50c6f0
--- /dev/null
+++ b/app/src/components/jwtService.js
@@ -0,0 +1,106 @@
+const jose = require('jose');
+const config = require('config');
+const Problem = require('api-problem');
+
+const errorToProblem = require('./errorToProblem');
+
+const SERVICE = 'JwtService';
+
+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));
+
+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 (JOSEError)
+ const { payload } = await jose.jwtVerify(token, JWKS, {
+ clockTolerance: '15 seconds',
+ 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.JOSEError) {
+ return false;
+ } else {
+ errorToProblem(SERVICE, e);
+ }
+ }
+ }
+
+ protect(spec) {
+ // actual middleware
+ return async (req, res, next) => {
+ try {
+ 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) {
+ next(error);
+ }
+ };
+ }
+}
+
+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,
+ 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/20240401000000_identity_provider_permissions.js b/app/src/db/migrations/20240401000000_identity_provider_permissions.js
new file mode 100644
index 000000000..d68736cce
--- /dev/null
+++ b/app/src/db/migrations/20240401000000_identity_provider_permissions.js
@@ -0,0 +1,146 @@
+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',
+ },
+ },
+ 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) {
+ 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');
+ 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: '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],
+ 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: {},
+ })
+ )
+ .then(() =>
+ knex('identity_provider')
+ .where({ code: 'bceid-business' })
+ .update({
+ idp: 'bceidbusiness',
+ 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],
+ 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,
+ })
+ )
+ .then(() =>
+ 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,
+ })
+ )
+ );
+};
+
+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');
+ 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' }))
+ );
+};
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..6da940057
--- /dev/null
+++ b/app/src/db/migrations/20240401000010_digital_credential_idp.js
@@ -0,0 +1,39 @@
+const { APP_PERMISSIONS, 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: [APP_PERMISSIONS.VIEWS_USER_SUBMISSIONS],
+ 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();
+ });
+};
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 127887a62..d3c22e0fd 100644
--- a/app/src/forms/auth/middleware/userAccess.js
+++ b/app/src/forms/auth/middleware/userAccess.js
@@ -1,7 +1,7 @@
const Problem = require('api-problem');
const uuid = 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');
@@ -24,33 +24,6 @@ const _formHasPermissions = (form, permissions) => {
return intersection.length === permissions.length;
};
-/**
- * Gets the access token, if it exists, from the request.
- *
- * @param {*} req the Express object representing the HTTP request.
- * @returns a string that is the access token, or undefined if it doesn't exist.
- */
-const _getAccessToken = (req) => {
- return req.kauth?.grant?.access_token;
-};
-
-/**
- * Gets the bearer token, if it exists, from the request.
- *
- * @param {*} req the Express object representing the HTTP request.
- * @returns a string that is the bearer token, or undefined if it doesn't exist.
- */
-const _getBearerToken = (req) => {
- const authorization = req.headers?.authorization;
-
- let token;
- if (authorization && authorization.startsWith('Bearer ')) {
- token = authorization.substring(7);
- }
-
- return token;
-};
-
/**
* Gets the form metadata for the given formId from the forms available to the
* current user.
@@ -88,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.
@@ -97,17 +70,17 @@ const _getForm = async (currentUser, formId, includeDeleted) => {
const currentUser = async (req, _res, next) => {
try {
// Validate bearer tokens before anything else - failure means no access.
- const bearerToken = _getBearerToken(req);
+ const bearerToken = jwtService.getBearerToken(req);
if (bearerToken) {
- const ok = await keycloak.grantManager.validateAccessToken(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.' });
}
}
// 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 = _getAccessToken(req);
+ const accessToken = await jwtService.getTokenPayload(req);
req.currentUser = await service.login(accessToken);
next();
@@ -184,7 +157,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
@@ -200,7 +176,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
@@ -216,7 +196,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);
}
@@ -250,7 +234,11 @@ const filterMultipleSubmissions = () => {
//validate all submission ids
const isValidSubmissionId = submissionIds.every((submissionId) => uuid.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) {
@@ -259,11 +247,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);
}
@@ -273,7 +269,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) {
@@ -297,7 +296,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++) {
@@ -317,10 +319,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.',
+ })
+ );
};
};
@@ -331,7 +342,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;
@@ -340,7 +353,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++) {
@@ -350,12 +368,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.",
+ })
+ );
}
}
}
@@ -370,7 +396,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
@@ -383,10 +413,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..9f3caa71a 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,10 +111,9 @@ 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')) {
+ 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;
@@ -169,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;
@@ -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 e547e846f..4a9a83e29 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',
@@ -93,4 +82,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/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: {
diff --git a/app/src/forms/common/models/tables/identityProvider.js b/app/src/forms/common/models/tables/identityProvider.js
index cebd48143..b41870185 100644
--- a/app/src/forms/common/models/tables/identityProvider.js
+++ b/app/src/forms/common/models/tables/identityProvider.js
@@ -18,8 +18,13 @@ class IdentityProvider extends Timestamps(Model) {
query.where('active', value);
}
},
+ filterIdp(query, value) {
+ if (value !== undefined) {
+ query.where('idp', value);
+ }
+ },
orderDefault(builder) {
- builder.orderByRaw('lower("identity_provider"."code")');
+ builder.orderByRaw('"identity_provider"."primary" DESC NULLS LAST, lower("identity_provider"."code")');
},
};
}
@@ -33,6 +38,12 @@ 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' } },
+ tokenmap: { type: 'object' },
+ extra: { type: 'object' },
...stamps,
},
additionalProperties: false,
diff --git a/app/src/forms/common/models/tables/user.js b/app/src/forms/common/models/tables/user.js
index 6124e4f41..9d4d939de 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);
@@ -100,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/src/forms/form/routes.js b/app/src/forms/form/routes.js
index 4da2cbb56..afbb4cd0c 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 validateParameter = require('../common/middleware/validateParameter');
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', validateParameter.validateFormId);
routes.param('formVersionDraftId', validateParameter.validateFormVersionDraftId);
routes.param('formVersionId', validateParameter.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/form/service.js b/app/src/forms/form/service.js
index f262a76a2..44022e69d 100644
--- a/app/src/forms/form/service.js
+++ b/app/src/forms/form/service.js
@@ -237,7 +237,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;
});
},
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 a751f1f1b..57b5e8089 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/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/app/tests/fixtures/form/identity_providers.json b/app/tests/fixtures/form/identity_providers.json
new file mode 100644
index 000000000..726289ccb
--- /dev/null
+++ b/app/tests/fixtures/form/identity_providers.json
@@ -0,0 +1,259 @@
+[
+ {
+ "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": "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",
+ "active": false,
+ "idp": "testonly",
+ "createdBy": "testonly",
+ "createdAt": "2024-03-08T22:02:34.399Z",
+ "updatedBy": null,
+ "login": false,
+ "permissions": [],
+ "roles": 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..724f4fd6a
--- /dev/null
+++ b/app/tests/unit/components/idpService.spec.js
@@ -0,0 +1,240 @@
+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);
+// 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);
+
+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',
+ };
+}
+
+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();
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe('idpService', () => {
+ const assertService = (srv) => {
+ expect(srv).toBeTruthy();
+ expect(srv.providers).toHaveLength(IDP_COUNT);
+ expect(srv.activeProviders).toHaveLength(IDP_ACTIVE_COUNT);
+ };
+
+ it('should return a service', () => {
+ assertService(idpService);
+ });
+
+ it('should return active idps', async () => {
+ const idps = await idpService.getIdentityProviders(true);
+ expect(idps).toHaveLength(IDP_ACTIVE_COUNT);
+ });
+
+ it('should return all idps', async () => {
+ const idps = await idpService.getIdentityProviders(false);
+ expect(idps).toHaveLength(IDP_COUNT);
+ });
+
+ 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 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();
+ });
+
+ 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 return userInfo with good digitalcredential token', async () => {
+ const token = digitalCredentialToken();
+ 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.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');
+ });
+});
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/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 fe16d6313..81e0d8142 100644
--- a/app/tests/unit/forms/auth/middleware/userAccess.spec.js
+++ b/app/tests/unit/forms/auth/middleware/userAccess.spec.js
@@ -4,7 +4,7 @@ const uuid = require('uuid');
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');
@@ -21,6 +21,14 @@ const Roles = {
FORM_SUBMITTER: 'form_submitter',
};
+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' };
+service.login = jest.fn().mockReturnValue(mockUser);
+
const testRes = {
writeHead: jest.fn(),
end: jest.fn(),
@@ -31,195 +39,104 @@ afterEach(() => {
});
// External dependencies used by the implementation are:
-// - keycloak.grantmanager.validateAccessToken: to validate a Bearer token
+// - jwtService.validateAccessToken: to validate a Bearer token
// - service.login: to create the object for req.currentUser
//
describe('currentUser', () => {
- // Default mock of the token validation in the KC lib
- keycloak.grantManager.validateAccessToken = jest.fn().mockReturnValue('yeah ok');
-
- // Default mock of the service login
- const mockUser = { user: 'me' };
- service.login = jest.fn().mockReturnValue(mockUser);
-
- // Keycloak info to be used in request headers
- const kauth = {
- grant: {
- // Static analyzers will complain about hard-coded tokens - randomize.
- access_token: Math.random().toString(36).substring(2),
- },
- };
-
- // Bearer token and its authorization header
- const bearerToken = Math.random().toString(36).substring(2);
- const authorizationHeader = { authorization: 'Bearer ' + bearerToken };
-
- // TODO: Shouldn't this be a 401?
- it('403s if the bearer token is invalid', async () => {
- keycloak.grantManager.validateAccessToken.mockReturnValueOnce(false);
- const req = getMockReq({
- headers: {
- ...authorizationHeader,
+ it('gets the current user with valid request', async () => {
+ const testReq = {
+ params: {
+ formId: 2,
},
- });
- const { res, next } = getMockRes();
-
- await currentUser(req, res, next);
-
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1);
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith(bearerToken);
- expect(service.login).toHaveBeenCalledTimes(0);
- expect(req.currentUser).toEqual(undefined);
- expect(next).toHaveBeenCalledTimes(1);
- expect.objectContaining({ status: 403 });
- });
-
- it('passes on the error if the service login fails unexpectedly', async () => {
- service.login.mockRejectedValueOnce(new Error());
- const req = getMockReq({
headers: {
- ...authorizationHeader,
+ authorization: 'Bearer hjvds0uds',
},
- });
- const { res, next } = getMockRes();
-
- await currentUser(req, res, next);
-
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1);
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith(bearerToken);
- expect(service.login).toHaveBeenCalledTimes(1);
- expect(req.currentUser).toEqual(undefined);
- expect(next).toHaveBeenCalledTimes(1);
- expect(next).toHaveBeenCalledWith(expect.any(Error));
- });
-
- it('gets the current user with no bearer token', async () => {
- const req = getMockReq({});
- const { res, next } = getMockRes();
+ };
- await currentUser(req, res, next);
+ const nxt = jest.fn();
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(0);
+ await currentUser(testReq, testRes, nxt);
+ 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(undefined);
- expect(req.currentUser).toEqual(mockUser);
- expect(next).toHaveBeenCalledTimes(1);
- expect(next).toHaveBeenCalledWith();
+ expect(service.login).toHaveBeenCalledWith({ token: 'payload' });
+ expect(testReq.currentUser).toEqual(mockUser);
+ expect(nxt).toHaveBeenCalledTimes(1);
+ expect(nxt).toHaveBeenCalledWith();
});
- it('does not keycloak validate with basic auth header', async () => {
- const req = getMockReq({
- headers: {
- authorization: 'Basic XYZ',
+ it('prioritizes the url param if both url and query are provided', async () => {
+ const testReq = {
+ params: {
+ formId: 2,
},
- });
- const { res, next } = getMockRes();
-
- await currentUser(req, res, next);
-
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(0);
- expect(req.currentUser).toEqual(mockUser);
- expect(service.login).toHaveBeenCalledTimes(1);
- expect(service.login).toHaveBeenCalledWith(undefined);
- expect(next).toHaveBeenCalledTimes(1);
- expect(next).toHaveBeenCalledWith();
- });
-
- it('does not keycloak validate with unexpected auth header', async () => {
- const req = getMockReq({
- headers: {
- authorization: Math.random().toString(36).substring(2),
+ query: {
+ formId: 99,
},
- });
- const { res, next } = getMockRes();
-
- await currentUser(req, res, next);
-
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(0);
- expect(req.currentUser).toEqual(mockUser);
- expect(service.login).toHaveBeenCalledTimes(1);
- expect(service.login).toHaveBeenCalledWith(undefined);
- expect(next).toHaveBeenCalledTimes(1);
- expect(next).toHaveBeenCalledWith();
- });
-
- it('does handle missing kauth attribute', async () => {
- const req = getMockReq({
headers: {
- ...authorizationHeader,
+ authorization: 'Bearer hjvds0uds',
},
- });
- const { res, next } = getMockRes();
-
- await currentUser(req, res, next);
+ };
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1);
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith(bearerToken);
- expect(service.login).toHaveBeenCalledTimes(1);
- expect(service.login).toHaveBeenCalledWith(undefined);
- expect(req.currentUser).toEqual(mockUser);
- expect(next).toHaveBeenCalledTimes(1);
- expect(next).toHaveBeenCalledWith();
+ await currentUser(testReq, testRes, jest.fn());
+ expect(jwtService.getBearerToken).toHaveBeenCalledTimes(1);
+ expect(jwtService.getTokenPayload).toHaveBeenCalledTimes(1);
+ expect(service.login).toHaveBeenCalledWith({ token: 'payload' });
});
- it('does handle missing kauth.grant attribute', async () => {
- const req = getMockReq({
+ it('uses the query param if both if that is whats provided', async () => {
+ const testReq = {
+ query: {
+ formId: 99,
+ },
headers: {
- ...authorizationHeader,
+ authorization: 'Bearer hjvds0uds',
},
- kauth: {},
- });
- const { res, next } = getMockRes();
-
- await currentUser(req, res, next);
+ };
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1);
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith(bearerToken);
- expect(service.login).toHaveBeenCalledTimes(1);
- expect(service.login).toHaveBeenCalledWith(undefined);
- expect(req.currentUser).toEqual(mockUser);
- expect(next).toHaveBeenCalledTimes(1);
- expect(next).toHaveBeenCalledWith();
+ await currentUser(testReq, testRes, jest.fn());
+ expect(jwtService.getBearerToken).toHaveBeenCalledTimes(1);
+ expect(jwtService.getTokenPayload).toHaveBeenCalledTimes(1);
+ expect(service.login).toHaveBeenCalledWith({ token: 'payload' });
});
- it('does handle missing kauth.grant.access_token attribute', async () => {
- const req = getMockReq({
+ it('401s if the token is invalid', async () => {
+ const testReq = {
headers: {
- ...authorizationHeader,
+ authorization: 'Bearer hjvds0uds',
},
- kauth: { grant: {} },
- });
- const { res, next } = getMockRes();
+ };
- await currentUser(req, res, next);
+ const nxt = jest.fn();
+ jwtService.validateAccessToken = jest.fn().mockReturnValue(false);
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1);
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith(bearerToken);
- expect(service.login).toHaveBeenCalledTimes(1);
- expect(service.login).toHaveBeenCalledWith(undefined);
- expect(req.currentUser).toEqual(mockUser);
- expect(next).toHaveBeenCalledTimes(1);
- expect(next).toHaveBeenCalledWith();
+ await currentUser(testReq, testRes, nxt);
+ 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).toHaveBeenCalledWith(new Problem(401, { detail: 'Authorization token is invalid.' }));
});
+});
- it('does keycloak validate with bearer token', async () => {
- const req = getMockReq({
- headers: {
- ...authorizationHeader,
+describe('getToken', () => {
+ it('returns a null token if no auth bearer in the headers', async () => {
+ const testReq = {
+ params: {
+ formId: 2,
},
- kauth: kauth,
- });
- const { res, next } = getMockRes();
+ };
- await currentUser(req, res, next);
+ jwtService.getBearerToken = jest.fn().mockReturnValue(null);
+ jwtService.getTokenPayload = jest.fn().mockReturnValue(null);
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledTimes(1);
- expect(keycloak.grantManager.validateAccessToken).toHaveBeenCalledWith(bearerToken);
+ 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(kauth.grant.access_token);
- expect(req.currentUser).toEqual(mockUser);
- expect(next).toHaveBeenCalledTimes(1);
- expect(next).toHaveBeenCalledWith();
+ expect(service.login).toHaveBeenCalledWith(null);
});
});
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();
});
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
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 20b88307d..013ff4703 100644
--- a/openshift/app.dc.yaml
+++ b/openshift/app.dc.yaml
@@ -252,6 +252,10 @@ objects:
name: "${APP_NAME}-service-config"
- configMapRef:
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