diff --git a/.circleci/config.yml b/.circleci/config.yml index f18e1196..2f031ee6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -130,9 +130,9 @@ workflows: - unit_test: requires: - build - # - integration_test: - # requires: - # - build + - integration_test: + requires: + - build - hmpps/helm_lint: name: helm_lint - hmpps/build_docker: @@ -144,7 +144,7 @@ workflows: requires: - helm_lint - unit_test - # - integration_test + - integration_test - build_docker - hmpps/deploy_env: <<: *feature_branch @@ -182,7 +182,7 @@ workflows: requires: - helm_lint - unit_test - # - integration_test + - integration_test - build_docker - request-staging-approval: diff --git a/cypress.config.ts b/cypress.config.ts index 0bde9dc2..a4665879 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from 'cypress' import { resetStubs } from './integration_tests/mockApis/wiremock' -import auth from './integration_tests/mockApis/auth' +import govukOneLogin from './integration_tests/mockApis/govukOneLogin' import tokenVerification from './integration_tests/mockApis/tokenVerification' export default defineConfig({ @@ -17,7 +17,7 @@ export default defineConfig({ setupNodeEvents(on) { on('task', { reset: resetStubs, - ...auth, + ...govukOneLogin, ...tokenVerification, // Log message to console diff --git a/feature.env b/feature.env index 3517efe9..b5813fff 100644 --- a/feature.env +++ b/feature.env @@ -1,4 +1,5 @@ PORT=3007 +INGRESS_URL=http://localhost:3007 HMPPS_AUTH_URL=http://localhost:9091/auth TOKEN_VERIFICATION_API_URL=http://localhost:9091/verification TOKEN_VERIFICATION_ENABLED=true diff --git a/integration_tests/e2e/govukOneLogin.cy.ts b/integration_tests/e2e/govukOneLogin.cy.ts new file mode 100644 index 00000000..5ccc272d --- /dev/null +++ b/integration_tests/e2e/govukOneLogin.cy.ts @@ -0,0 +1,54 @@ +import HomePage from '../pages/home' +import GovukOneLoginPage from '../pages/govukOneLogin' +import Page from '../pages/page' + +context('Sign in with GOV.UK One Login', () => { + beforeEach(() => { + cy.task('reset') + cy.task('stubSignIn') + }) + + it('Unauthenticated user redirected to GOV.UK One Login - home page', () => { + cy.visit('/') + Page.verifyOnPage(GovukOneLoginPage) + }) + + it('Unauthenticated user redirected to GOV.UK One Login - sign-in URL', () => { + cy.visit('/auth/sign-in') + Page.verifyOnPage(GovukOneLoginPage) + }) + + it('Unauthenticated user redirected to GOV.UK One Login - callback URL', () => { + cy.visit('/auth/callback') + Page.verifyOnPage(GovukOneLoginPage) + }) + + it('Unauthenticated user redirected to GOV.UK One Login - non-existant route', () => { + cy.visit('/NON-EXISTANT-PAGE') + Page.verifyOnPage(GovukOneLoginPage) + }) + + it('User can sign in and view home page', () => { + cy.signIn() + Page.verifyOnPage(HomePage) + + // just testing sample data dumped to the page for now + cy.contains('"sub": "user1"') + cy.contains('"phone_number": "+440123456789"') + cy.contains('"email": "user1@example.com"') + }) + + it('User sent to auth error page if sign in fails', () => { + // setting an invalid nonce value should cause ID token validation to fail + cy.signIn({ failOnStatusCode: false, nonce: 'INVALID_NONCE' }) + cy.get('h1').contains('Authorisation Error') + }) + + it('User can log out', () => { + cy.signIn() + const homePage = Page.verifyOnPage(HomePage) + homePage.signOut().click() + Page.verifyOnPage(GovukOneLoginPage) + cy.contains('You have been logged out.') + }) +}) diff --git a/integration_tests/e2e/health.cy.ts b/integration_tests/e2e/health.cy.ts index b35d7938..fb0da4f2 100644 --- a/integration_tests/e2e/health.cy.ts +++ b/integration_tests/e2e/health.cy.ts @@ -2,7 +2,6 @@ context('Healthcheck', () => { context('All healthy', () => { beforeEach(() => { cy.task('reset') - cy.task('stubAuthPing') cy.task('stubTokenVerificationPing') }) @@ -22,11 +21,9 @@ context('Healthcheck', () => { context('Some unhealthy', () => { it('Reports correctly when token verification down', () => { cy.task('reset') - cy.task('stubAuthPing') cy.task('stubTokenVerificationPing', 500) cy.request({ url: '/health', method: 'GET', failOnStatusCode: false }).then(response => { - expect(response.body.checks.hmppsAuth).to.equal('OK') expect(response.body.checks.tokenVerification).to.contain({ status: 500, retries: 2 }) }) }) diff --git a/integration_tests/e2e/login.cy.ts b/integration_tests/e2e/login.cy.ts deleted file mode 100644 index dabfef48..00000000 --- a/integration_tests/e2e/login.cy.ts +++ /dev/null @@ -1,68 +0,0 @@ -import IndexPage from '../pages/index' -import AuthSignInPage from '../pages/authSignIn' -import Page from '../pages/page' -import AuthManageDetailsPage from '../pages/authManageDetails' - -context('SignIn', () => { - beforeEach(() => { - cy.task('reset') - cy.task('stubSignIn') - cy.task('stubAuthUser') - }) - - it('Unauthenticated user directed to auth', () => { - cy.visit('/') - Page.verifyOnPage(AuthSignInPage) - }) - - it('Unauthenticated user navigating to sign in page directed to auth', () => { - cy.visit('/sign-in') - Page.verifyOnPage(AuthSignInPage) - }) - - it.skip('User name visible in header', () => { - cy.signIn() - const indexPage = Page.verifyOnPage(IndexPage) - indexPage.headerUserName().should('contain.text', 'J. Smith') - }) - - it.skip('User can log out', () => { - cy.signIn() - const indexPage = Page.verifyOnPage(IndexPage) - indexPage.signOut().click() - Page.verifyOnPage(AuthSignInPage) - }) - - it.skip('User can manage their details', () => { - cy.signIn() - const indexPage = Page.verifyOnPage(IndexPage) - - indexPage.manageDetails().get('a').invoke('removeAttr', 'target') - indexPage.manageDetails().click() - Page.verifyOnPage(AuthManageDetailsPage) - }) - - it('Token verification failure takes user to sign in page', () => { - cy.signIn() - Page.verifyOnPage(IndexPage) - cy.task('stubVerifyToken', false) - - // can't do a visit here as cypress requires only one domain - cy.request('/').its('body').should('contain', 'Sign in') - }) - - it.skip('Token verification failure clears user session', () => { - cy.signIn() - const indexPage = Page.verifyOnPage(IndexPage) - cy.task('stubVerifyToken', false) - - // can't do a visit here as cypress requires only one domain - cy.request('/').its('body').should('contain', 'Sign in') - - cy.task('stubVerifyToken', true) - cy.task('stubAuthUser', 'bobby brown') - cy.signIn() - - indexPage.headerUserName().contains('B. Brown') - }) -}) diff --git a/integration_tests/index.d.ts b/integration_tests/index.d.ts index ce64a17b..0bf262de 100644 --- a/integration_tests/index.d.ts +++ b/integration_tests/index.d.ts @@ -2,8 +2,9 @@ declare namespace Cypress { interface Chainable { /** * Custom command to signIn. Set failOnStatusCode to false if you expect and non 200 return code + * Optionally set nonce to override the value used in the ID token * @example cy.signIn({ failOnStatusCode: boolean }) */ - signIn(options?: { failOnStatusCode: boolean }): Chainable + signIn(options?: { failOnStatusCode: boolean; nonce?: string }): Chainable } } diff --git a/integration_tests/mockApis/auth.ts b/integration_tests/mockApis/auth.ts deleted file mode 100644 index 40afa857..00000000 --- a/integration_tests/mockApis/auth.ts +++ /dev/null @@ -1,162 +0,0 @@ -import jwt from 'jsonwebtoken' -import { Response } from 'superagent' - -import { stubFor, getMatchingRequests } from './wiremock' -import tokenVerification from './tokenVerification' - -const createToken = () => { - const payload = { - user_name: 'USER1', - scope: ['read'], - auth_source: 'nomis', - authorities: [], - jti: '83b50a10-cca6-41db-985f-e87efb303ddb', - client_id: 'clientid', - } - - return jwt.sign(payload, 'secret', { expiresIn: '1h' }) -} - -const getSignInUrl = (): Promise => - getMatchingRequests({ - method: 'GET', - urlPath: '/auth/oauth/authorize', - }).then(data => { - const { requests } = data.body - const stateValue = requests[requests.length - 1].queryParams.state.values[0] - return `/sign-in/callback?code=codexxxx&state=${stateValue}` - }) - -const favicon = () => - stubFor({ - request: { - method: 'GET', - urlPattern: '/favicon.ico', - }, - response: { - status: 200, - }, - }) - -const ping = () => - stubFor({ - request: { - method: 'GET', - urlPattern: '/auth/health/ping', - }, - response: { - status: 200, - }, - }) - -const redirect = () => - stubFor({ - request: { - method: 'GET', - urlPattern: '/auth/oauth/authorize\\?response_type=code&redirect_uri=.+?&state=.+?&client_id=clientid', - }, - response: { - status: 200, - headers: { - 'Content-Type': 'text/html', - Location: 'http://localhost:3007/sign-in/callback?code=codexxxx&state=stateyyyy', - }, - body: 'SignIn page

Sign in

', - }, - }) - -const signOut = () => - stubFor({ - request: { - method: 'GET', - urlPattern: '/auth/sign-out.*', - }, - response: { - status: 200, - headers: { - 'Content-Type': 'text/html', - }, - body: 'SignIn page

Sign in

', - }, - }) - -const manageDetails = () => - stubFor({ - request: { - method: 'GET', - urlPattern: '/auth/account-details.*', - }, - response: { - status: 200, - headers: { - 'Content-Type': 'text/html', - }, - body: '

Your account details

', - }, - }) - -const token = () => - stubFor({ - request: { - method: 'POST', - urlPattern: '/auth/oauth/token', - }, - response: { - status: 200, - headers: { - 'Content-Type': 'application/json;charset=UTF-8', - Location: 'http://localhost:3007/sign-in/callback?code=codexxxx&state=stateyyyy', - }, - jsonBody: { - access_token: createToken(), - token_type: 'bearer', - user_name: 'USER1', - expires_in: 599, - scope: 'read', - internalUser: true, - }, - }, - }) - -const stubUser = (name: string) => - stubFor({ - request: { - method: 'GET', - urlPattern: '/auth/api/user/me', - }, - response: { - status: 200, - headers: { - 'Content-Type': 'application/json;charset=UTF-8', - }, - jsonBody: { - staffId: 231232, - username: 'USER1', - active: true, - name, - }, - }, - }) - -const stubUserRoles = () => - stubFor({ - request: { - method: 'GET', - urlPattern: '/auth/api/user/me/roles', - }, - response: { - status: 200, - headers: { - 'Content-Type': 'application/json;charset=UTF-8', - }, - jsonBody: [{ roleCode: 'SOME_USER_ROLE' }], - }, - }) - -export default { - getSignInUrl, - stubAuthPing: ping, - stubSignIn: (): Promise<[Response, Response, Response, Response, Response, Response]> => - Promise.all([favicon(), redirect(), signOut(), manageDetails(), token(), tokenVerification.stubVerifyToken()]), - stubAuthUser: (name = 'john smith'): Promise<[Response, Response]> => Promise.all([stubUser(name), stubUserRoles()]), -} diff --git a/integration_tests/mockApis/govukOneLogin.ts b/integration_tests/mockApis/govukOneLogin.ts new file mode 100644 index 00000000..b0695348 --- /dev/null +++ b/integration_tests/mockApis/govukOneLogin.ts @@ -0,0 +1,164 @@ +import fs from 'fs' +import path from 'path' +import jwt from 'jsonwebtoken' +import { Response } from 'superagent' +import { createPublicKey } from 'crypto' +import { getMatchingRequests, stubFor } from './wiremock' + +const oidcConfigPath = path.join(__dirname, 'mappings/openid-configuration.json') +const oidcConfig = JSON.parse(fs.readFileSync(oidcConfigPath).toString()) + +const stubOidcDiscovery = () => stubFor(oidcConfig) + +const stubJwks = () => { + const publicKey = fs.readFileSync(path.join(__dirname, '../testKeys/server_public_key.pem')) + const publicKeyJwk = createPublicKey({ key: publicKey }).export({ format: 'jwk' }) + + return stubFor({ + request: { + method: 'GET', + url: '/govukOneLogin/.well-known/jwks.json', + }, + response: { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + jsonBody: { keys: [publicKeyJwk] }, + }, + }) +} + +const redirect = () => + stubFor({ + request: { + method: 'GET', + urlPath: '/govukOneLogin/authorize', + queryParameters: { + response_type: { equalTo: 'code' }, + scope: { equalTo: 'openid email phone' }, + client_id: { equalTo: 'clientId' }, + state: { matches: '.*' }, + redirect_uri: { equalTo: 'http://localhost:3007/auth/callback' }, + nonce: { matches: '.*' }, + vtr: { equalTo: '["Cl.Cm"]' }, + ui_locales: { equalTo: 'en' }, + }, + }, + response: { + status: 200, + headers: { + 'Content-Type': 'text/html', + }, + body: '

GOV.UK One Login

', + }, + }) + +const getSignInUrl = (nonce?: string): Promise => + getMatchingRequests({ + method: 'GET', + urlPath: '/govukOneLogin/authorize', + }).then(data => { + const { requests } = data.body + const stateValue = requests[requests.length - 1].queryParams.state.values[0] + const nonceForToken = nonce || requests[requests.length - 1].queryParams.nonce.values[0] + // set up /token response while we have access to the nonce + Promise.resolve(token(nonceForToken)) + return `/auth/callback?code=AUTHORIZATION_CODE&state=${stateValue}` + }) + +const createIdToken = (nonce: string) => { + const nowTimestamp = new Date().getTime() + + const payload = { + sub: 'user1', + iss: 'http://localhost:9091/govukOneLogin/', + nonce, + aud: 'clientId', + exp: nowTimestamp + 180, + iat: nowTimestamp, + sid: 'SESSION_IDENTIFIER', + } + + const privateKey = fs.readFileSync(path.join(__dirname, '../testKeys/server_private_key.pem')) + const idToken = jwt.sign(payload, privateKey, { algorithm: 'ES256' }) + return idToken +} + +const token = (nonce: string) => + stubFor({ + request: { + method: 'POST', + url: '/govukOneLogin/token', + headers: { + 'Content-Type': { equalTo: 'application/x-www-form-urlencoded' }, + }, + formParameters: { + grant_type: { equalTo: 'authorization_code' }, + code: { equalTo: 'AUTHORIZATION_CODE' }, + redirect_uri: { equalTo: 'http://localhost:3007/auth/callback' }, + client_assertion_type: { equalTo: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' }, + client_assertion: { matches: '.*' }, + }, + }, + response: { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + jsonBody: { + access_token: 'ACCESS_TOKEN', + token_type: 'Bearer', + expiresIn: 180, + id_token: createIdToken(nonce), + }, + }, + }) + +const stubUserInfo = () => + stubFor({ + request: { + method: 'GET', + url: '/govukOneLogin/userinfo', + headers: { + Authorization: { equalTo: 'Bearer ACCESS_TOKEN' }, + }, + }, + response: { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + jsonBody: { + sub: 'user1', + phone_number_verified: true, + phone_number: '+440123456789', + email_verified: true, + email: 'user1@example.com', + }, + }, + }) + +const signOut = () => + stubFor({ + request: { + method: 'GET', + urlPath: '/govukOneLogin/logout', + queryParameters: { + client_id: { equalTo: 'clientId' }, + }, + }, + response: { + status: 200, + headers: { + 'Content-Type': 'text/html', + }, + body: '

GOV.UK One Login

You have been logged out.

', + }, + }) + +export default { + getSignInUrl, + stubSignIn: (): Promise<[Response, Response, Response, Response, Response]> => + Promise.all([stubOidcDiscovery(), stubJwks(), redirect(), stubUserInfo(), signOut()]), +} diff --git a/integration_tests/pages/govukOneLogin.ts b/integration_tests/pages/govukOneLogin.ts new file mode 100644 index 00000000..79f7ca57 --- /dev/null +++ b/integration_tests/pages/govukOneLogin.ts @@ -0,0 +1,7 @@ +import Page from './page' + +export default class GovukOneLoginPage extends Page { + constructor() { + super('GOV.UK One Login', { axeTest: false }) + } +} diff --git a/integration_tests/pages/home.ts b/integration_tests/pages/home.ts new file mode 100644 index 00000000..91ed6876 --- /dev/null +++ b/integration_tests/pages/home.ts @@ -0,0 +1,7 @@ +import Page from './page' + +export default class HomePage extends Page { + constructor() { + super('This site is under construction...') + } +} diff --git a/integration_tests/pages/index.ts b/integration_tests/pages/index.ts deleted file mode 100644 index d20d2615..00000000 --- a/integration_tests/pages/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import Page, { PageElement } from './page' - -export default class IndexPage extends Page { - constructor() { - super('This site is under construction...') - } - - headerUserName = (): PageElement => cy.get('[data-qa=header-user-name]') -} diff --git a/integration_tests/pages/page.ts b/integration_tests/pages/page.ts index d6f3d3a6..457ed451 100644 --- a/integration_tests/pages/page.ts +++ b/integration_tests/pages/page.ts @@ -34,7 +34,5 @@ export default abstract class Page { ) } - signOut = (): PageElement => cy.get('[data-qa=signOut]') - - manageDetails = (): PageElement => cy.get('[data-qa=manageDetails]') + signOut = (): PageElement => cy.get('[data-test=signOut]') } diff --git a/integration_tests/support/commands.ts b/integration_tests/support/commands.ts index e8d0a000..db8f08f9 100644 --- a/integration_tests/support/commands.ts +++ b/integration_tests/support/commands.ts @@ -1,4 +1,4 @@ Cypress.Commands.add('signIn', (options = { failOnStatusCode: true }) => { cy.request('/') - return cy.task('getSignInUrl').then((url: string) => cy.visit(url, options)) + return cy.task('getSignInUrl', options.nonce).then((url: string) => cy.visit(url, options)) }) diff --git a/integration_tests/testKeys/server_private_key.pem b/integration_tests/testKeys/server_private_key.pem new file mode 100644 index 00000000..6b3d52ea --- /dev/null +++ b/integration_tests/testKeys/server_private_key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgOL/am/xf96xiNATJ +UU9ZycZdHNFOB/tEYL5Q82B4PAWhRANCAARn4i8wn7cUd4ElVWWDhh172kjZMLk4 +xIdK2p+1W7JrjbF8aREw7xVM9I44d/Qa9/vu9sFbwxJgheZsEHEu6XZW +-----END PRIVATE KEY----- diff --git a/integration_tests/testKeys/server_public_key.pem b/integration_tests/testKeys/server_public_key.pem new file mode 100644 index 00000000..26e77566 --- /dev/null +++ b/integration_tests/testKeys/server_public_key.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZ+IvMJ+3FHeBJVVlg4Yde9pI2TC5 +OMSHStqftVuya42xfGkRMO8VTPSOOHf0Gvf77vbBW8MSYIXmbBBxLul2Vg== +-----END PUBLIC KEY----- diff --git a/server/authentication/govukOneLogin.ts b/server/authentication/govukOneLogin.ts index cd2c83b3..eb48f3ad 100644 --- a/server/authentication/govukOneLogin.ts +++ b/server/authentication/govukOneLogin.ts @@ -60,6 +60,7 @@ async function init(): Promise { params: { scope: 'openid email phone', vtr: '["Cl.Cm"]', + ui_locales: 'en', }, usePKCE: false, }, diff --git a/server/errorHandler.ts b/server/errorHandler.ts index 9ab1ee09..5c040d84 100644 --- a/server/errorHandler.ts +++ b/server/errorHandler.ts @@ -4,7 +4,7 @@ import logger from '../logger' export default function createErrorHandler(production: boolean) { return (error: HTTPError, req: Request, res: Response, next: NextFunction): void => { - logger.error(`Error handling request for '${req.originalUrl}', user '${req.user?.sub.slice(0, 30)}...'`, error) + logger.error(`Error handling request for '${req.originalUrl}', user '${req.user?.sub}...'`, error) if (error.status === 401 || error.status === 403) { logger.info('Logging user out') diff --git a/server/services/healthCheck.ts b/server/services/healthCheck.ts index a47d1c0d..e2073693 100644 --- a/server/services/healthCheck.ts +++ b/server/services/healthCheck.ts @@ -50,7 +50,6 @@ function gatherCheckInfo(aggregateStatus: Record, currentStatus } const apiChecks = [ - service('hmppsAuth', `${config.apis.hmppsAuth.url}/health/ping`, config.apis.hmppsAuth.agent), ...(config.apis.tokenVerification.enabled ? [ service( diff --git a/server/views/pages/index.njk b/server/views/pages/index.njk index eccb2710..43cd07e5 100644 --- a/server/views/pages/index.njk +++ b/server/views/pages/index.njk @@ -22,5 +22,5 @@

-

Sign out

+

Sign out

{% endblock %}