diff --git a/.devcontainer/chefs_local/test.json b/.devcontainer/chefs_local/test.json new file mode 100644 index 000000000..6aa8fd1da --- /dev/null +++ b/.devcontainer/chefs_local/test.json @@ -0,0 +1,92 @@ +{ + "db": { + "database": "chefs", + "host": "localhost", + "port": "5432", + "username": "app", + "password": "admin" + }, + "files": { + "uploads": { + "enabled": "true", + "fileCount": "1", + "fileKey": "files", + "fileMaxSize": "25MB", + "fileMinSize": "0KB", + "path": "files" + }, + "permanent": "localStorage", + "localStorage": { + "path": "myfiles" + }, + "objectStorage": { + "accessKeyId": "bcgov-citz-ccft", + "bucket": "chefs", + "endpoint": "https://commonservices.objectstore.gov.bc.ca", + "key": "chefs/dev/", + "secretAccessKey": "anything" + } + }, + "frontend": { + "apiPath": "api/v1", + "basePath": "/app", + "oidc": { + "clientId": "chefs-frontend-localhost-5300", + "realm": "standard", + "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth", + "logoutUrl": "https://logon7.gov.bc.ca/clp-cgi/logoff.cgi?retnow=1&returl=https%3A%2F%2Fdev.loginproxy.gov.bc.ca%2Fauth%2Frealms%2Fstandard%2Fprotocol%2Fopenid-connect%2Flogout" + } + }, + "server": { + "apiPath": "/api/v1", + "basePath": "/app", + "bodyLimit": "30mb", + "oidc": { + "realm": "standard", + "serverUrl": "https://dev.loginproxy.gov.bc.ca/auth", + "jwksUri": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard/protocol/openid-connect/certs", + "issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard", + "audience": "chefs-frontend-localhost-5300", + "maxTokenAge": "300" + }, + "logLevel": "http", + "port": "8080", + "rateLimit": { + "public": { + "windowMs": "900000", + "max": "100" + } + } + }, + "serviceClient": { + "commonServices": { + "ches": { + "endpoint": "https://ches-dev.api.gov.bc.ca/api", + "tokenEndpoint": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token", + "clientId": "CHES_CLIENT_ID", + "clientSecret": "CHES_CLIENT_SECRET" + }, + "cdogs": { + "endpoint": "https://cdogs-dev.api.gov.bc.ca/api", + "tokenEndpoint": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token", + "clientId": "CDOGS_CLIENT_ID", + "clientSecret": "CDOGS_CLIENT_SECRET" + } + } + }, + "customBcAddressFormioComponent": { + "apikey": "xxxxxxxxxxxxxxx", + "bcAddressURL": "https://geocoder.api.gov.bc.ca/addresses.json", + "queryParameters": { + "echo": false, + "brief": true, + "minScore": 55, + "onlyCivic": true, + "maxResults": 15, + "autocomplete": true, + "matchAccuracy": 100, + "matchPrecision": "occupant, unit, site, civic_number, intersection, block, street, locality, province", + "precisionPoints": 100 + } + } +} diff --git a/.vscode/launch.json b/.vscode/launch.json index 9f576b64a..6d5d4d795 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,9 @@ "runtimeArgs": ["run", "serve"], "runtimeExecutable": "npm", "type": "node", - "env": {} + "env": { + "NODE_CONFIG_DIR": "${workspaceFolder}/.devcontainer/chefs_local" + } }, { "cwd": "${workspaceFolder}/app/frontend", @@ -30,7 +32,7 @@ "request": "launch", "runtimeArgs": ["run", "dev"], "runtimeExecutable": "npm", - "type": "node", + "type": "node" }, { "name": "CHEFS Frontend - chrome", @@ -39,7 +41,7 @@ "url": "http://localhost:5173/app", "enableContentValidation": false, "webRoot": "${workspaceFolder}/app/frontend/src", - "pathMapping": {"url": "//src/", "path": "${webRoot}/"} + "pathMapping": { "url": "//src/", "path": "${webRoot}/" } } ], "version": "0.2.0" diff --git a/app/frontend/src/utils/constants.js b/app/frontend/src/utils/constants.js index c026c9d4f..b63bd2a66 100755 --- a/app/frontend/src/utils/constants.js +++ b/app/frontend/src/utils/constants.js @@ -73,7 +73,7 @@ export const AppPermissions = Object.freeze({ VIEWS_FORM_MANAGE: 'views_form_manage', VIEWS_FORM_PREVIEW: 'views_form_preview', VIEWS_FORM_SUBMISSIONS: 'views_form_submissions', - VIEWS_FORM_TEAMS: 'views_form_teamS', + VIEWS_FORM_TEAMS: 'views_form_teams', VIEWS_FORM_VIEW: 'views_form_view', VIEWS_USER_SUBMISSIONS: 'views_user_submissions', }); diff --git a/app/frontend/tests/unit/utils/constants.spec.js b/app/frontend/tests/unit/utils/constants.spec.js index eb4df08e6..0c2389d02 100644 --- a/app/frontend/tests/unit/utils/constants.spec.js +++ b/app/frontend/tests/unit/utils/constants.spec.js @@ -79,7 +79,7 @@ describe('Constants', () => { VIEWS_FORM_MANAGE: 'views_form_manage', VIEWS_FORM_PREVIEW: 'views_form_preview', VIEWS_FORM_SUBMISSIONS: 'views_form_submissions', - VIEWS_FORM_TEAMS: 'views_form_teamS', + VIEWS_FORM_TEAMS: 'views_form_teams', VIEWS_FORM_VIEW: 'views_form_view', VIEWS_USER_SUBMISSIONS: 'views_user_submissions', }); diff --git a/app/src/components/idpService.js b/app/src/components/idpService.js index eb9e578e5..7548bdec3 100644 --- a/app/src/components/idpService.js +++ b/app/src/components/idpService.js @@ -13,7 +13,7 @@ function stringToGUID(s) { } function isEmpty(s) { - return s === null || (s && s.trim() === ''); + return s === undefined || s === null || (s && s.trim() === ''); } function isNotEmpty(s) { diff --git a/app/src/components/jwtService.js b/app/src/components/jwtService.js index 39a6fdb36..9aa999d7c 100644 --- a/app/src/components/jwtService.js +++ b/app/src/components/jwtService.js @@ -1,5 +1,7 @@ const jose = require('jose'); const config = require('config'); +const Problem = require('api-problem'); + const errorToProblem = require('./errorToProblem'); const SERVICE = 'JwtService'; @@ -63,26 +65,29 @@ class JwtService { protect(spec) { // actual middleware return async (req, res, next) => { - let authorized = false; try { - // get token, check if valid - const token = this.getBearerToken(req); - if (token) { - const payload = await this._verify(token); - if (spec) { - authorized = payload.client_roles?.includes(spec); - } else { - authorized = true; + let authorized = false; + try { + // get token, check if valid + const token = this.getBearerToken(req); + if (token) { + const payload = await this._verify(token); + if (spec) { + authorized = payload.client_roles?.includes(spec); + } else { + authorized = true; + } } + } catch (error) { + authorized = false; + } + if (!authorized) { + throw new Problem(401, { detail: 'Access denied' }); + } else { + return next(); } } catch (error) { - authorized = false; - } - if (!authorized) { - res.status(403); - res.end('Access denied'); - } else { - return next(); + next(error); } }; } diff --git a/app/src/forms/auth/middleware/userAccess.js b/app/src/forms/auth/middleware/userAccess.js index c3c1395d3..e7a0ace06 100644 --- a/app/src/forms/auth/middleware/userAccess.js +++ b/app/src/forms/auth/middleware/userAccess.js @@ -56,31 +56,39 @@ const _getForm = async (currentUser, formId, includeDeleted) => { return form; }; -const setUser = async (req, _res, next) => { +/** + * Express middleware that adds the user information as the res.currentUser + * attribute so that all downstream middleware and business logic can use it. + * + * This will fall through if everything is OK. If the Bearer auth is not valid, + * this will produce a 403 error. + * + * @param {*} req the Express object representing the HTTP request. + * @param {*} _res the Express object representing the HTTP response - unused. + * @param {*} next the Express chaining function. + */ +const currentUser = async (req, _res, next) => { try { - const token = await jwtService.getTokenPayload(req); - req.currentUser = await service.login(token); + // Validate bearer tokens before anything else - failure means no access. + const bearerToken = jwtService.getBearerToken(req); + if (bearerToken) { + const ok = await jwtService.validateAccessToken(bearerToken); + if (!ok) { + throw new Problem(403, { detail: 'Authorization token is invalid.' }); + } + } + + // Add the request element that contains the current user's parsed info. It + // is ok if the access token isn't defined: then we'll have a public user. + const accessToken = await jwtService.getTokenPayload(req); + req.currentUser = await service.login(accessToken); + next(); } catch (error) { next(error); } }; -const currentUser = async (req, res, next) => { - // Check if authorization header is a bearer token - const token = jwtService.getBearerToken(req); - if (token) { - const ok = await jwtService.validateAccessToken(token); - if (!ok) { - return new Problem(403, { - detail: 'Authorization token is invalid.', - }).send(res); - } - } - - return setUser(req, res, next); -}; - /** * Express middleware to check that a user has all the given permissions for a * form. This will fall through if everything is OK, otherwise it will call diff --git a/app/src/forms/common/constants.js b/app/src/forms/common/constants.js index 2535334bd..4a9a83e29 100644 --- a/app/src/forms/common/constants.js +++ b/app/src/forms/common/constants.js @@ -95,7 +95,7 @@ module.exports = Object.freeze({ VIEWS_FORM_MANAGE: 'views_form_manage', VIEWS_FORM_PREVIEW: 'views_form_preview', VIEWS_FORM_SUBMISSIONS: 'views_form_submissions', - VIEWS_FORM_TEAMS: 'views_form_teamS', + VIEWS_FORM_TEAMS: 'views_form_teams', VIEWS_FORM_VIEW: 'views_form_view', VIEWS_USER_SUBMISSIONS: 'views_user_submissions', }, diff --git a/app/tests/fixtures/form/identity_providers.json b/app/tests/fixtures/form/identity_providers.json new file mode 100644 index 000000000..c60d60d34 --- /dev/null +++ b/app/tests/fixtures/form/identity_providers.json @@ -0,0 +1,239 @@ +[ + { + "code": "idir", + "display": "IDIR", + "active": true, + "idp": "idir", + "createdBy": "migration-002", + "createdAt": "2024-03-08T22:02:34.399Z", + "updatedBy": null, + "updatedAt": "2024-03-08T22:02:34.399Z", + "primary": true, + "login": true, + "permissions": [ + "views_form_stepper", + "views_admin", + "views_file_download", + "views_form_emails", + "views_form_export", + "views_form_manage", + "views_form_preview", + "views_form_submissions", + "views_form_teams", + "views_form_view", + "views_user_submissions" + ], + "roles": ["owner", "team_manager", "form_designer", "submission_reviewer", "form_submitter"], + "tokenmap": { + "idp": "identity_provider", + "email": "email", + "fullName": "name", + "lastName": "family_name", + "username": "idir_username", + "firstName": "given_name", + "idpUserId": "idir_user_guid", + "keycloakId": "idir_user_guid" + }, + "extra": {} + }, + { + "code": "bceid-basic", + "display": "Basic BCeID", + "active": true, + "idp": "bceidbasic", + "createdBy": "migration-022", + "createdAt": "2024-03-08T22:02:34.399Z", + "updatedBy": null, + "updatedAt": "2024-03-08T22:02:34.399Z", + "primary": false, + "login": true, + "permissions": ["views_user_submissions"], + "roles": ["form_submitter"], + "tokenmap": { + "idp": "identity_provider", + "email": "email", + "fullName": "name", + "lastName": null, + "username": "bceid_username", + "firstName": null, + "idpUserId": "bceid_user_guid", + "keycloakId": "bceid_user_guid" + }, + "extra": { + "userSearch": { + "detail": "Could not retrieve BCeID users. Invalid options provided.", + "filters": [ + { + "name": "filterIdpUserId", + "param": "idpUserId", + "required": 0 + }, + { + "name": "filterIdpCode", + "param": "idpCode", + "required": 0 + }, + { + "name": "filterUsername", + "exact": true, + "param": "username", + "required": 2 + }, + { + "name": "filterFullName", + "param": "fullName", + "required": 0 + }, + { + "name": "filterFirstName", + "param": "firstName", + "required": 0 + }, + { + "name": "filterLastName", + "param": "lastName", + "required": 0 + }, + { + "name": "filterEmail", + "exact": true, + "param": "email", + "required": 2 + }, + { + "name": "filterSearch", + "param": "search", + "required": 0 + } + ] + }, + "formAccessSettings": "idim", + "addTeamMemberSearch": { + "text": { + "message": "trans.manageSubmissionUsers.searchInputLength", + "minLength": 6 + }, + "email": { + "exact": true, + "message": "trans.manageSubmissionUsers.exactBCEIDSearch" + } + } + } + }, + { + "code": "bceid-business", + "display": "Business BCeID", + "active": true, + "idp": "bceidbusiness", + "createdBy": "migration-022", + "createdAt": "2024-03-08T22:02:34.399Z", + "updatedBy": null, + "updatedAt": "2024-03-08T22:02:34.399Z", + "primary": false, + "login": true, + "permissions": ["views_form_export", "views_form_manage", "views_form_submissions", "views_form_teams", "views_form_view", "views_user_submissions"], + "roles": ["team_manager", "submission_reviewer", "form_submitter"], + "tokenmap": { + "idp": "identity_provider", + "email": "email", + "fullName": "name", + "lastName": null, + "username": "bceid_username", + "firstName": null, + "idpUserId": "bceid_user_guid", + "keycloakId": "bceid_user_guid" + }, + "extra": { + "userSearch": { + "detail": "Could not retrieve BCeID users. Invalid options provided.", + "filters": [ + { + "name": "filterIdpUserId", + "param": "idpUserId", + "required": 0 + }, + { + "name": "filterIdpCode", + "param": "idpCode", + "required": 0 + }, + { + "name": "filterUsername", + "exact": true, + "param": "username", + "required": 2 + }, + { + "name": "filterFullName", + "param": "fullName", + "required": 0 + }, + { + "name": "filterFirstName", + "param": "firstName", + "required": 0 + }, + { + "name": "filterLastName", + "param": "lastName", + "required": 0 + }, + { + "name": "filterEmail", + "exact": true, + "param": "email", + "required": 2 + }, + { + "name": "filterSearch", + "param": "search", + "required": 0 + } + ] + }, + "formAccessSettings": "idim", + "addTeamMemberSearch": { + "text": { + "message": "trans.manageSubmissionUsers.searchInputLength", + "minLength": 6 + }, + "email": { + "exact": true, + "message": "trans.manageSubmissionUsers.exactBCEIDSearch" + } + } + } + }, + { + "code": "public", + "display": "Public", + "active": true, + "idp": "public", + "createdBy": "migration-002", + "createdAt": "2024-03-08T22:02:34.399Z", + "updatedBy": null, + "updatedAt": "2024-03-08T22:02:34.399Z", + "primary": false, + "login": false, + "permissions": [], + "roles": null, + "tokenmap": null, + "extra": {} + }, + { + "code": "testonly", + "display": "N/A", + "active": false, + "idp": "testonly", + "createdBy": "testonly", + "createdAt": "2024-03-08T22:02:34.399Z", + "updatedBy": null, + "updatedAt": "2024-03-08T22:02:34.399Z", + "primary": false, + "login": false, + "permissions": [], + "roles": null, + "tokenmap": null, + "extra": {} + } +] diff --git a/app/tests/unit/components/idpService.spec.js b/app/tests/unit/components/idpService.spec.js new file mode 100644 index 000000000..ec8d1fccc --- /dev/null +++ b/app/tests/unit/components/idpService.spec.js @@ -0,0 +1,193 @@ +const Problem = require('api-problem'); +const { MockModel } = require('../../common/dbHelper'); +const idpService = require('../../../src/components/idpService'); +const idpData = require('../../fixtures/form/identity_providers.json'); + +// let's just load data once.. +idpService.providers = idpData; +idpService.activeProviders = idpData.filter((x) => x.active); + +jest.mock('../../../src/forms/common/models/tables/user', () => MockModel); + +function idirToken() { + return { + exp: 1709942517, + iat: 1709942217, + auth_time: 1709942210, + jti: '3b1a0e84-4612-4804-99ca-5d3383c27ab1', + iss: 'https://dev.loginproxy.gov.bc.ca/auth/realms/standard', + aud: 'chefs-frontend-localhost-5300', + sub: '674861aa34e546f8bda6a7004dc9c6c9@idir', + typ: 'Bearer', + azp: 'chefs-frontend-localhost-5300', + nonce: 'ffb100a7-1afc-488a-8755-7ff436a11ad2', + session_state: '48d6429c-5d41-481e-81f7-9aaa9d70ddd1', + scope: 'openid idir bceidbusiness email profile bceidbasic', + sid: '48d6429c-5d41-481e-81f7-9aaa9d70ddd1', + idir_user_guid: '674861AA34E546F8BDA6A7004DC9C6C9', + client_roles: ['admin'], + identity_provider: 'idir', + idir_username: 'PASWAYZE', + email_verified: false, + name: 'Swayze, Patrick CITZ:EX', + preferred_username: '674861aa34e546f8bda6a7004dc9c6c9@idir', + display_name: 'Swayze, Patrick CITZ:EX', + given_name: 'Patrick', + family_name: 'Swayze', + email: 'patrick.swayze@gov.bc.ca', + }; +} + +beforeEach(() => { + MockModel.mockReset(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('idpService', () => { + const assertService = (srv) => { + expect(srv).toBeTruthy(); + expect(srv.providers).toHaveLength(5); + expect(srv.activeProviders).toHaveLength(4); + }; + + it('should return a service', () => { + assertService(idpService); + }); + + it('should return active idps', async () => { + const idps = await idpService.getIdentityProviders(true); + expect(idps).toHaveLength(4); + }); + + it('should return all idps', async () => { + const idps = await idpService.getIdentityProviders(false); + expect(idps).toHaveLength(5); + }); + + it('should return bceid-business by idp', async () => { + const idp = await idpService.findByIdp('bceidbusiness'); + expect(idp).toBeTruthy(); + expect(idp.code).toBe('bceid-business'); + expect(idp.idp).toBe('bceidbusiness'); + }); + + it('should return bceid-business by code', async () => { + const idp = await idpService.findByCode('bceid-business'); + expect(idp).toBeTruthy(); + expect(idp.code).toBe('bceid-business'); + expect(idp.idp).toBe('bceidbusiness'); + }); + + it('should return nothing by bad idp', async () => { + const idp = await idpService.findByIdp('doesnotexist'); + expect(idp).toBeFalsy(); + }); + + it('should return nothing by bad code', async () => { + const idp = await idpService.findByCode('doesnotexist'); + expect(idp).toBeFalsy(); + }); + + it('should return a user search', async () => { + const s = await idpService.userSearch({ idpCode: 'idir', email: 'em@il.com' }); + expect(s).toBeFalsy(); + expect(MockModel.query).toHaveBeenCalledTimes(1); + expect(MockModel.modify).toHaveBeenCalledTimes(9); + expect(MockModel.modify).toHaveBeenCalledWith('filterIdpCode', 'idir'); + expect(MockModel.modify).toHaveBeenCalledWith('filterEmail', 'em@il.com', false); + }); + + it('should return a customized user search', async () => { + const s = await idpService.userSearch({ idpCode: 'bceid-business', email: 'em@il.com' }); + expect(s).toBeFalsy(); + expect(MockModel.query).toHaveBeenCalledTimes(1); + expect(MockModel.modify).toHaveBeenCalledWith('filterIdpCode', 'bceid-business'); + expect(MockModel.modify).toHaveBeenCalledWith('filterEmail', 'em@il.com', true); + expect(MockModel.modify).toHaveBeenCalledTimes(9); + }); + + it('should throw error when customized user search fails validation', async () => { + let e = undefined; + try { + // needs one of email or username + await idpService.userSearch({ idpCode: 'bceid-business' }); + } catch (error) { + e = error; + } + expect(e).toBeTruthy(); + expect(e).toBeInstanceOf(Error); + expect(e.message).toBe('Could not retrieve BCeID users. Invalid options provided.'); + }); + + it('should parse null token into public userInfo', async () => { + const token = null; + const userInfo = await idpService.parseToken(token); + expect(userInfo).toBeTruthy(); + expect(userInfo.idp).toBe('public'); + expect(userInfo.public).toBeTruthy(); + }); + + it('should return userInfo with known provider', async () => { + const token = idirToken(); + let r = undefined; + let e = undefined; + try { + r = await idpService.parseToken(token); + } catch (error) { + e = error; + } + + expect(e).toBeFalsy(); + expect(r).toBeTruthy(); + expect(r.keycloakId).toBeTruthy(); + expect(r.idpUserId).toBe(token.idir_user_guid); + }); + + it('should throw Problem parsing token without a provider', async () => { + const token = {}; + let r = undefined; + let e = undefined; + try { + r = await idpService.parseToken(token); + } catch (error) { + e = error; + } + + expect(e).toBeInstanceOf(Problem); + expect(r).toBe(undefined); + }); + + it('should throw Problem parsing token with an unknown provider', async () => { + const token = { identity_provider: 'doesnotexist' }; + let r = undefined; + let e = undefined; + try { + r = await idpService.parseToken(token); + } catch (error) { + e = error; + } + + expect(e).toBeInstanceOf(Problem); + expect(r).toBe(undefined); + }); + + it('should throw a Problem when token has no keycloakId cannot parse into GUID', async () => { + let token = idirToken(); + token.idir_user_guid = 123; //will not parse into a GUID... + let r = undefined; + let e = undefined; + try { + r = await idpService.parseToken(token); + } catch (error) { + e = error; + } + + expect(e).toBeTruthy(); + expect(r).toBeFalsy(); + expect(e).toBeInstanceOf(Problem); + expect(r).toBe(undefined); + }); +}); diff --git a/app/tests/unit/components/jwtService.spec.js b/app/tests/unit/components/jwtService.spec.js new file mode 100644 index 000000000..33b3d9d57 --- /dev/null +++ b/app/tests/unit/components/jwtService.spec.js @@ -0,0 +1,186 @@ +const { getMockReq, getMockRes } = require('@jest-mock/express'); +const jose = require('jose'); +const Problem = require('api-problem'); + +const config = require('config'); +const jwtService = require('../../../src/components/jwtService'); + +describe('jwtService', () => { + const assertService = (srv) => { + expect(srv).toBeTruthy(); + expect(srv.audience).toBe(config.get('server.oidc.audience')); + expect(srv.issuer).toBe(config.get('server.oidc.issuer')); + expect(srv.maxTokenAge).toBe(config.get('server.oidc.maxTokenAge')); + }; + + it('should return a service', () => { + assertService(jwtService); + }); + + it('should get token if bearer', () => { + const req = getMockReq({ headers: { authorization: 'Bearer JWT' } }); + const bearerToken = jwtService.getBearerToken(req); + expect(bearerToken).toBe('JWT'); + }); + + it('should not get token if basic', () => { + const req = getMockReq({ headers: { authorization: 'Basic username/password' } }); + const bearerToken = jwtService.getBearerToken(req); + expect(bearerToken).toBe(null); + }); + + it('should get payload if token valid', async () => { + const jwt = {}; + const payload = {}; + jwtService.getBearerToken = jest.fn().mockReturnValue(jwt); + // need to mock out this whole function, very difficult to mock jose... + jwtService._verify = jest.fn().mockReturnValue(payload); + + const req = getMockReq({ headers: { authorization: 'Bearer JWT' } }); + const r = await jwtService.getTokenPayload(req); + expect(r).toBe(payload); + expect(jwtService.getBearerToken).toHaveBeenCalledTimes(1); + expect(jwtService._verify).toHaveBeenCalledTimes(1); + }); + + it('should error if token not valid', async () => { + const jwt = {}; + jwtService.getBearerToken = jest.fn().mockReturnValue(jwt); + // need to mock out this whole function, very difficult to mock jose... + jwtService._verify = jest.fn().mockImplementation(() => { + throw new jose.errors.JWTClaimValidationFailed('bad'); + }); + + const req = getMockReq({ headers: { authorization: 'Bearer JWT' } }); + let payload = undefined; + try { + payload = await jwtService.getTokenPayload(req); + } catch (e) { + expect(e).toBeInstanceOf(jose.errors.JWTClaimValidationFailed); + expect(payload).toBe(undefined); + } + expect(jwtService.getBearerToken).toHaveBeenCalledTimes(1); + expect(jwtService._verify).toHaveBeenCalledTimes(1); + }); + + it('should validate access token on good jwt', async () => { + const payload = {}; + // need to mock out this whole function, very difficult to mock jose... + jwtService._verify = jest.fn().mockReturnValue(payload); + + const req = getMockReq({ headers: { authorization: 'Bearer JWT' } }); + const r = await jwtService.validateAccessToken(req); + expect(r).toBeTruthy(); + expect(jwtService._verify).toHaveBeenCalledTimes(1); + }); + + it('should not validate access token on jwt error', async () => { + // need to mock out this whole function, very difficult to mock jose... + jwtService._verify = jest.fn().mockImplementation(() => { + throw new jose.errors.JWTClaimValidationFailed('bad'); + }); + + const req = getMockReq({ headers: { authorization: 'Bearer JWT' } }); + const r = await jwtService.validateAccessToken(req); + expect(r).toBeFalsy(); + + expect(jwtService._verify).toHaveBeenCalledTimes(1); + }); + + it('should throw problem when validate access token catches (non-jwt) error)', async () => { + // need to mock out this whole function, very difficult to mock jose... + jwtService._verify = jest.fn().mockImplementation(() => { + throw new Error('bad'); + }); + + const req = getMockReq({ headers: { authorization: 'Bearer JWT' } }); + let r = undefined; + let e = undefined; + try { + r = await jwtService.validateAccessToken(req); + } catch (error) { + e = error; + } + + expect(e).toBeInstanceOf(Problem); + expect(r).toBe(undefined); + expect(jwtService._verify).toHaveBeenCalledTimes(1); + }); + + it('should pass middleware protect with valid jwt)', async () => { + const jwt = {}; + const payload = { client_roles: ['admin'] }; + jwtService.getBearerToken = jest.fn().mockReturnValue(jwt); + // need to mock out this whole function, very difficult to mock jose... + jwtService._verify = jest.fn().mockReturnValue(payload); + + const req = getMockReq({ + headers: { authorization: 'Bearer JWT' }, + }); + const { res, next } = getMockRes(); + + const middleware = jwtService.protect(); + + await middleware(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + + it('should fail middleware protect with invalid jwt', async () => { + const jwt = {}; + jwtService.getBearerToken = jest.fn().mockReturnValue(jwt); + // need to mock out this whole function, very difficult to mock jose... + jwtService._verify = jest.fn().mockImplementation(() => { + throw new jose.errors.JWTClaimValidationFailed('bad'); + }); + + const req = getMockReq({ + headers: { authorization: 'Bearer JWT' }, + }); + const { res, next } = getMockRes(); + + const middleware = jwtService.protect(); + + await middleware(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 401 })); + }); + + it('should pass middleware protect with valid jwt and role', async () => { + const jwt = {}; + const payload = { client_roles: ['admin'] }; + jwtService.getBearerToken = jest.fn().mockReturnValue(jwt); + // need to mock out this whole function, very difficult to mock jose... + jwtService._verify = jest.fn().mockReturnValue(payload); + + const req = getMockReq({ + headers: { authorization: 'Bearer JWT' }, + }); + const { res, next } = getMockRes(); + + const middleware = jwtService.protect('admin'); + + await middleware(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + + it('should fail middleware protect with valid jwt and but no role', async () => { + const jwt = {}; + const payload = { client_roles: [] }; + jwtService.getBearerToken = jest.fn().mockReturnValue(jwt); + // need to mock out this whole function, very difficult to mock jose... + jwtService._verify = jest.fn().mockReturnValue(payload); + + const req = getMockReq({ + headers: { authorization: 'Bearer JWT' }, + }); + const { res, next } = getMockRes(); + + const middleware = jwtService.protect('admin'); + + await middleware(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ status: 401 })); + }); +}); diff --git a/app/tests/unit/forms/auth/middleware/userAccess.spec.js b/app/tests/unit/forms/auth/middleware/userAccess.spec.js index 5fd4e1e0e..3bb51b516 100644 --- a/app/tests/unit/forms/auth/middleware/userAccess.spec.js +++ b/app/tests/unit/forms/auth/middleware/userAccess.spec.js @@ -117,8 +117,7 @@ describe('currentUser', () => { expect(jwtService.validateAccessToken).toHaveBeenCalledWith('bearer-token-value'); expect(service.login).toHaveBeenCalledTimes(0); expect(testReq.currentUser).toEqual(undefined); - expect(nxt).toHaveBeenCalledTimes(0); - //expect(nxt).toHaveBeenCalledWith(new Problem(403, { detail: 'Authorization token is invalid.' })); + expect(nxt).toHaveBeenCalledWith(new Problem(403, { detail: 'Authorization token is invalid.' })); }); });