diff --git a/cypress/integration/ete/registration_1.2.cy.ts b/cypress/integration/ete/registration_1.2.cy.ts index 5725f54e7..4fa92a467 100644 --- a/cypress/integration/ete/registration_1.2.cy.ts +++ b/cypress/integration/ete/registration_1.2.cy.ts @@ -11,6 +11,119 @@ const breachCheck = () => { }).as('breachCheck'); }; +const existingUserSendEmailAndValidatePasscode = ({ + emailAddress, + expectedReturnUrl = 'https://m.code.dev-theguardian.com/', + params = '', + expectedEmailBody = 'Your one-time passcode', + additionalTests, +}: { + emailAddress: string; + expectedReturnUrl?: string; + params?: string; + expectedEmailBody?: 'Your one-time passcode' | 'Your verification code'; + additionalTests?: 'passcode-incorrect' | 'resend-email' | 'change-email'; +}) => { + cy.visit(`/register/email?${params}`); + cy.get('input[name=email]').clear().type(emailAddress); + + const timeRequestWasMade = new Date(); + cy.get('[data-cy="main-form-submit-button"]').click(); + + cy.checkForEmailAndGetDetails(emailAddress, timeRequestWasMade).then( + ({ body, codes }) => { + // email + expect(body).to.have.string(expectedEmailBody); + expect(codes?.length).to.eq(1); + const code = codes?.[0].value; + expect(code).to.match(/^\d{6}$/); + + // passcode page + cy.url().should('include', '/register/email-sent'); + cy.contains('Enter your code'); + + switch (additionalTests) { + case 'resend-email': + { + const timeRequestWasMade2 = new Date(); + cy.contains('send again').click(); + + cy.checkForEmailAndGetDetails( + emailAddress, + timeRequestWasMade2, + ).then(({ body, codes }) => { + // email + expect(body).to.have.string(expectedEmailBody); + expect(codes?.length).to.eq(1); + const code = codes?.[0].value; + expect(code).to.match(/^\d{6}$/); + + cy.get('input[name=code]').type(code!); + cy.contains('Submit verification code').click(); + + cy.contains('Return to the Guardian') + .should('have.attr', 'href') + .and('include', expectedReturnUrl); + + cy.getTestOktaUser(emailAddress).then((user) => { + expect(user.status).to.eq('ACTIVE'); + expect(user.profile.emailValidated).to.eq(true); + }); + }); + } + break; + case 'change-email': { + cy.contains('try another address').click(); + cy.url().should('include', '/register/email'); + break; + } + case 'passcode-incorrect': + { + cy.get('input[name=code]').type(`123456`); + + cy.contains('Submit verification code').click(); + + cy.url().should('include', '/register/code'); + + cy.contains('Incorrect code'); + cy.get('input[name=code]').clear().type(code!); + + cy.contains('Submit verification code').click(); + + cy.url().should('include', '/welcome/existing'); + cy.contains('Return to the Guardian') + .should('have.attr', 'href') + .and('include', expectedReturnUrl); + + cy.getTestOktaUser(emailAddress).then((user) => { + expect(user.status).to.eq('ACTIVE'); + expect(user.profile.emailValidated).to.eq(true); + }); + } + break; + default: { + cy.get('input[name=code]').type(code!); + cy.contains('Submit verification code').click(); + + if (params?.includes('fromURI')) { + cy.url().should('include', expectedReturnUrl); + } else { + cy.url().should('include', '/welcome/existing'); + cy.contains('Return to the Guardian') + .should('have.attr', 'href') + .and('include', expectedReturnUrl); + + cy.getTestOktaUser(emailAddress).then((user) => { + expect(user.status).to.eq('ACTIVE'); + expect(user.profile.emailValidated).to.eq(true); + }); + } + } + } + }, + ); +}; + describe('Registration flow - Split 1/2', () => { context('Registering with Okta', () => { it('successfully registers using an email with no existing account using a passcode', () => { @@ -396,409 +509,222 @@ describe('Registration flow - Split 1/2', () => { }); }); - context('Existing users attempting to register with Okta', () => { - it('should send a STAGED user a set password email with an Okta activation token', () => { - // Test users created via IDAPI-with-Okta do not have the activation - // lifecycle run at creation, so they don't transition immediately from - // STAGED to PROVISIONED (c.f. - // https://developer.okta.com/docs/reference/api/users/#create-user) . - // This is useful for us as we can test STAGED users first, then test - // PROVISIONED users in the next test by activating a STAGED user. Users - // created through Gateway-with-Okta do have this lifecycle run, so if we - // rebuild these tests to not use IDAPI at all, we need to figure out a - // way to test STAGED and PROVISIONED users (probably by just passing an - // optional `activate` prop to a createUser function). - cy - .createTestUser({ - isGuestUser: true, - isUserEmailValidated: true, - }) - ?.then(({ emailAddress }) => { - cy.getTestOktaUser(emailAddress).then((oktaUser) => { - expect(oktaUser.status).to.eq(Status.STAGED); + context('existing user going through registration flow', () => { + // set up useful variables + const returnUrl = + 'https://www.theguardian.com/world/2013/jun/09/edward-snowden-nsa-whistleblower-surveillance'; + const encodedReturnUrl = encodeURIComponent(returnUrl); + const appClientId = 'appClientId1'; + const fromURI = '/oauth2/v1/authorize'; + + context('ACTIVE user - with email authenticator', () => { + it('Should sign in with passcode', () => { + cy + .createTestUser({ + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + existingUserSendEmailAndValidatePasscode({ + emailAddress, + }); + }); + }); - cy.visit('/register/email'); - const timeRequestWasMade = new Date(); + it('should sign in with passocde - preserve returnUrl', () => { + cy + .createTestUser({ + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + existingUserSendEmailAndValidatePasscode({ + emailAddress, + expectedReturnUrl: returnUrl, + params: `returnUrl=${encodedReturnUrl}`, + }); + }); + }); - cy.get('input[name=email]').type(emailAddress); - cy.get('[data-cy="main-form-submit-button"]').click(); + it('should sign in with passcode - preserve fromURI', () => { + cy + .createTestUser({ + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + existingUserSendEmailAndValidatePasscode({ + emailAddress, + expectedReturnUrl: fromURI, + params: `fromURI=${fromURI}&appClientId=${appClientId}`, + }); + }); + }); - cy.contains('Check your inbox'); - cy.contains(emailAddress); - cy.contains('send again'); - cy.contains('try another address'); + it('should sign in with passcode - resend email', () => { + cy + .createTestUser({ + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + existingUserSendEmailAndValidatePasscode({ + emailAddress, + additionalTests: 'resend-email', + }); + }); + }); - cy.checkForEmailAndGetDetails( + it('should sign in with passcode - change email', () => { + cy + .createTestUser({ + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + existingUserSendEmailAndValidatePasscode({ emailAddress, - timeRequestWasMade, - /\/set-password\/([^"]*)/, - ).then(({ links, body }) => { - expect(body).to.have.string('This account already exists'); + additionalTests: 'change-email', + }); + }); + }); - expect(body).to.have.string('Create password'); - expect(links.length).to.eq(2); - const setPasswordLink = links.find((s) => - s.text?.includes('Create password'), - ); - expect(setPasswordLink?.href).not.to.have.string('useOkta=true'); - cy.visit(setPasswordLink?.href as string); - cy.contains('Create password'); - cy.contains(emailAddress); + it('should sign in with passcode - passcode incorrect', () => { + cy + .createTestUser({ + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + existingUserSendEmailAndValidatePasscode({ + emailAddress, + additionalTests: 'passcode-incorrect', }); }); - }); + }); }); - it('should send a STAGED user a set password email with an Okta activation token, and has a prefixed activation token when using a native app', () => { - // Test users created via IDAPI-with-Okta do not have the activation - // lifecycle run at creation, so they don't transition immediately from - // STAGED to PROVISIONED (c.f. - // https://developer.okta.com/docs/reference/api/users/#create-user) . - // This is useful for us as we can test STAGED users first, then test - // PROVISIONED users in the next test by activating a STAGED user. Users - // created through Gateway-with-Okta do have this lifecycle run, so if we - // rebuild these tests to not use IDAPI at all, we need to figure out a - // way to test STAGED and PROVISIONED users (probably by just passing an - // optional `activate` prop to a createUser function). - cy - .createTestUser({ - isGuestUser: true, - isUserEmailValidated: true, - }) - ?.then(({ emailAddress }) => { - cy.getTestOktaUser(emailAddress).then((oktaUser) => { - expect(oktaUser.status).to.eq(Status.STAGED); + context('ACTIVE user - with only password authenticator', () => { + it('should sign in with passcode', () => { + /** + * START - SETUP USER WITH ONLY PASSWORD AUTHENTICATOR + */ + const emailAddress = randomMailosaurEmail(); + cy.visit(`/register/email`); - const appClientId = Cypress.env('OKTA_ANDROID_CLIENT_ID'); - const fromURI = 'fromURI1'; + const timeRequestWasMade = new Date(); + cy.get('input[name=email]').type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); - cy.visit( - `/register/email?appClientId=${appClientId}&fromURI=${fromURI}`, - ); - const timeRequestWasMade = new Date(); + cy.contains('Enter your code'); + cy.contains(emailAddress); - cy.get('input[name=email]').type(emailAddress); - cy.get('[data-cy="main-form-submit-button"]').click(); + cy.checkForEmailAndGetDetails(emailAddress, timeRequestWasMade).then( + ({ body, codes }) => { + // email + expect(body).to.have.string('Your verification code'); + expect(codes?.length).to.eq(1); + const code = codes?.[0].value; + expect(code).to.match(/^\d{6}$/); - cy.contains('Check your inbox'); - cy.contains(emailAddress); - cy.contains('send again'); - cy.contains('try another address'); + // passcode page + cy.url().should('include', '/register/email-sent'); + + // make sure we don't use a passcode + // we instead reset their password using classic flow to set a password + cy.visit('/reset-password?useOktaClassic=true'); + + const timeRequestWasMade = new Date(); + cy.get('input[name=email]').clear().type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); cy.checkForEmailAndGetDetails( emailAddress, timeRequestWasMade, /\/set-password\/([^"]*)/, ).then(({ links, body }) => { - expect(body).to.have.string('This account already exists'); + expect(body).to.have.string('Welcome back'); expect(body).to.have.string('Create password'); expect(links.length).to.eq(2); const setPasswordLink = links.find((s) => s.text?.includes('Create password'), ); - expect(setPasswordLink?.href ?? '') - .to.have.string('al_') - .and.not.to.have.string('useOkta=true'); - cy.visit(setPasswordLink?.href as string); - cy.contains('Create password'); - cy.contains(emailAddress); - }); - }); - }); - }); - - it('should send a PROVISIONED user a set password email with an Okta activation token', () => { - cy - .createTestUser({ - isGuestUser: true, - isUserEmailValidated: true, - }) - ?.then(({ emailAddress }) => { - cy.activateTestOktaUser(emailAddress).then(() => { - cy.getTestOktaUser(emailAddress).then((oktaUser) => { - expect(oktaUser.status).to.eq(Status.PROVISIONED); - cy.visit('/register/email'); - const timeRequestWasMade = new Date(); + cy.visit(setPasswordLink?.href as string); - cy.get('input[name=email]').type(emailAddress); - cy.get('[data-cy="main-form-submit-button"]').click(); + const password = randomPassword(); + cy.get('input[name=password]').type(password); - cy.contains('Check your inbox'); - cy.contains(emailAddress); - cy.contains('send again'); - cy.contains('try another address'); + cy.get('[data-cy="main-form-submit-button"]') + .click() + .should('be.disabled'); + cy.contains('Password created'); + cy.contains(emailAddress.toLowerCase()); - cy.checkForEmailAndGetDetails( + /** + * END - SETUP USER WITH ONLY PASSWORD AUTHENTICATOR + */ + cy.visit('/signin?usePasscodeSignIn=true'); + cy.contains('Sign in with a different email').click(); + existingUserSendEmailAndValidatePasscode({ emailAddress, - timeRequestWasMade, - /\/set-password\/([^"]*)/, - ).then(({ links, body }) => { - expect(body).to.have.string('This account already exists'); - expect(body).to.have.string('Create password'); - expect(links.length).to.eq(2); - const setPasswordLink = links.find((s) => - s.text?.includes('Create password'), - ); - expect(setPasswordLink?.href).not.to.have.string( - 'useOkta=true', - ); - cy.visit(setPasswordLink?.href as string); - cy.contains('Create password'); - cy.contains(emailAddress); + expectedEmailBody: 'Your verification code', }); }); - }); - }); + }, + ); + }); }); - it('should send an ACTIVE UNvalidated user with a password a security email with activation token', () => { - cy - .createTestUser({ - isGuestUser: false, - isUserEmailValidated: false, - }) - ?.then(({ emailAddress }) => { - cy.getTestOktaUser(emailAddress).then((oktaUser) => { - expect(oktaUser.status).to.eq(Status.ACTIVE); - - cy.visit('/register/email'); - const timeRequestWasMade = new Date(); - cy.get('input[name=email]').type(emailAddress); - cy.get('[data-cy="main-form-submit-button"]').click(); - - // Make sure that we don't get sent to the 'security reasons' page - cy.url().should('include', '/register/email-sent'); - cy.contains( - 'For security reasons we need you to change your password.', - ).should('not.exist'); - cy.contains(emailAddress); - cy.contains('send again'); - cy.contains('try another address'); - - cy.checkForEmailAndGetDetails( - emailAddress, - timeRequestWasMade, - /reset-password\/([^"]*)/, - ).then(({ links, body }) => { - expect(body).to.have.string( - 'Because your security is extremely important to us, we have changed our password policy.', - ); - expect(body).to.have.string('Reset password'); - expect(links.length).to.eq(2); - const resetPasswordLink = links.find((s) => - s.text?.includes('Reset password'), - ); - cy.visit(resetPasswordLink?.href as string); - cy.contains(emailAddress); - cy.contains('Create new password'); - }); - }); - }); - }); - it('should send an ACTIVE validated user WITH a password a reset password email with an activation token', () => { - cy - .createTestUser({ - isGuestUser: false, - isUserEmailValidated: true, - }) - ?.then(({ emailAddress }) => { + context('non-ACTIVE user', () => { + it('STAGED user - should sign in with passcode', () => { + cy.createTestUser({ isGuestUser: true })?.then(({ emailAddress }) => { cy.getTestOktaUser(emailAddress).then((oktaUser) => { - expect(oktaUser.status).to.eq(Status.ACTIVE); - - cy.visit('/register/email'); - const timeRequestWasMade = new Date(); - - cy.get('input[name=email]').type(emailAddress); - cy.get('[data-cy="main-form-submit-button"]').click(); - - cy.contains('Check your inbox'); - cy.contains(emailAddress); - cy.contains('send again'); - cy.contains('try another address'); + expect(oktaUser.status).to.eq(Status.STAGED); - cy.checkForEmailAndGetDetails( + existingUserSendEmailAndValidatePasscode({ emailAddress, - timeRequestWasMade, - /reset-password\/([^"]*)/, - ).then(({ links, body }) => { - expect(body).to.have.string('This account already exists'); - expect(body).to.have.string('Sign in'); - expect(body).to.have.string('Reset password'); - expect(links.length).to.eq(3); - const resetPasswordLink = links.find((s) => - s.text?.includes('Reset password'), - ); - expect(resetPasswordLink?.href ?? '').not.to.have.string( - 'useOkta=true', - ); - cy.visit(resetPasswordLink?.href as string); - cy.contains(emailAddress); - cy.contains('Create new password'); }); }); }); - }); - it('should send an ACTIVE validated user WITH a password a reset password email with an activation token, and prefixed activation token if using native app', () => { - cy - .createTestUser({ - isGuestUser: false, - isUserEmailValidated: true, - }) - ?.then(({ emailAddress }) => { - cy.getTestOktaUser(emailAddress).then((oktaUser) => { - expect(oktaUser.status).to.eq(Status.ACTIVE); - - const appClientId = Cypress.env('OKTA_ANDROID_CLIENT_ID'); - const fromURI = 'fromURI1'; - - cy.visit( - `/register/email?appClientId=${appClientId}&fromURI=${fromURI}`, - ); - const timeRequestWasMade = new Date(); - - cy.get('input[name=email]').type(emailAddress); - cy.get('[data-cy="main-form-submit-button"]').click(); + }); - cy.contains('Check your inbox'); - cy.contains(emailAddress); - cy.contains('send again'); - cy.contains('try another address'); + it('PROVISIONED user - should sign in with passcode', () => { + cy.createTestUser({ isGuestUser: true })?.then(({ emailAddress }) => { + cy.activateTestOktaUser(emailAddress).then(() => { + cy.getTestOktaUser(emailAddress).then((oktaUser) => { + expect(oktaUser.status).to.eq(Status.PROVISIONED); - cy.checkForEmailAndGetDetails( - emailAddress, - timeRequestWasMade, - /reset-password\/([^"]*)/, - ).then(({ links, body }) => { - expect(body).to.have.string('This account already exists'); - expect(body).to.have.string('Sign in'); - expect(body).to.have.string('Reset password'); - expect(links.length).to.eq(3); - const resetPasswordLink = links.find((s) => - s.text?.includes('Reset password'), - ); - expect(resetPasswordLink?.href ?? '') - .to.have.string('al_') - .and.not.to.have.string('useOkta=true'); - cy.visit(resetPasswordLink?.href as string); - cy.contains(emailAddress); - cy.contains('Create new password'); + existingUserSendEmailAndValidatePasscode({ emailAddress }); }); }); }); - }); - it('should send a RECOVERY user a reset password email with an Okta activation token', () => { - cy - .createTestUser({ - isGuestUser: false, - isUserEmailValidated: true, - }) - ?.then(({ emailAddress }) => { + }); + + it('RECOVERY user - should sign in with passcode', () => { + cy.createTestUser({ isGuestUser: false })?.then(({ emailAddress }) => { cy.resetOktaUserPassword(emailAddress).then(() => { cy.getTestOktaUser(emailAddress).then((oktaUser) => { expect(oktaUser.status).to.eq(Status.RECOVERY); - cy.visit('/register/email'); - const timeRequestWasMade = new Date(); - - cy.get('input[name=email]').type(emailAddress); - cy.get('[data-cy="main-form-submit-button"]').click(); - - cy.contains('Check your inbox'); - cy.contains(emailAddress); - cy.contains('send again'); - cy.contains('try another address'); - - cy.checkForEmailAndGetDetails( - emailAddress, - timeRequestWasMade, - /reset-password\/([^"]*)/, - ).then(({ links, body }) => { - expect(body).to.have.string('Password reset'); - expect(body).to.have.string('Reset password'); - expect(links.length).to.eq(3); - const resetPasswordLink = links.find((s) => - s.text?.includes('Reset password'), - ); - expect(resetPasswordLink?.href ?? '').not.to.have.string( - 'useOkta=true', - ); - cy.visit(resetPasswordLink?.href as string); - cy.contains('Create new password'); - cy.contains(emailAddress); - }); + existingUserSendEmailAndValidatePasscode({ emailAddress }); }); }); }); - }); - it('should send a PASSWORD_EXPIRED user a reset password email with an Okta activation token', () => { - cy - .createTestUser({ - isGuestUser: false, - isUserEmailValidated: true, - }) - ?.then(({ emailAddress }) => { + }); + + it('PASSWORD_EXPIRED user - should sign in with passcode', () => { + cy.createTestUser({ isGuestUser: false })?.then(({ emailAddress }) => { cy.expireOktaUserPassword(emailAddress).then(() => { cy.getTestOktaUser(emailAddress).then((oktaUser) => { expect(oktaUser.status).to.eq(Status.PASSWORD_EXPIRED); - cy.visit('/register/email'); - const timeRequestWasMade = new Date(); - - cy.get('input[name=email]').type(emailAddress); - cy.get('[data-cy="main-form-submit-button"]').click(); - - cy.contains('Check your inbox'); - cy.contains(emailAddress); - cy.contains('send again'); - cy.contains('try another address'); - - cy.checkForEmailAndGetDetails( - emailAddress, - timeRequestWasMade, - /reset-password\/([^"]*)/, - ).then(({ links, body }) => { - expect(body).to.have.string('Password reset'); - expect(body).to.have.string('Reset password'); - expect(links.length).to.eq(3); - const resetPasswordLink = links.find((s) => - s.text?.includes('Reset password'), - ); - expect(resetPasswordLink?.href ?? '').not.to.have.string( - 'useOkta=true', - ); - cy.visit(resetPasswordLink?.href as string); - cy.contains('Create new password'); - cy.contains(emailAddress); - }); - }); - }); - }); - }); - it('should display an error if a SUSPENDED user attempts to register', () => { - cy - .createTestUser({ - isGuestUser: false, - isUserEmailValidated: true, - }) - ?.then(({ emailAddress }) => { - cy.suspendOktaUser(emailAddress).then(() => { - cy.getTestOktaUser(emailAddress).then((oktaUser) => { - expect(oktaUser.status).to.eq(Status.SUSPENDED); - - cy.visit('/register/email'); - - cy.get('input[name=email]').type(emailAddress); - cy.get('[data-cy="main-form-submit-button"]').click(); - - cy.contains('There was a problem registering, please try again.'); + existingUserSendEmailAndValidatePasscode({ emailAddress }); }); }); }); + }); }); }); + // after launching passcodes for all users, these users should no longer be generated, as using passcodes + // will automatically transition them to ACTIVE + // this test is kept for posterity context( 'Passcode limbo state - user does not set password after using passcode', () => { @@ -833,8 +759,8 @@ describe('Registration flow - Split 1/2', () => { cy.url().should('include', '/welcome/password'); // user now in limbo state where they have not set a password - // recover by going through reset password flow - cy.visit('/register/email'); + // recover by going through classic flow + cy.visit('/register/email?useOktaClassic=true'); const timeRequestWasMade = new Date(); cy.get('input[name=email]').clear().type(emailAddress); @@ -906,8 +832,8 @@ describe('Registration flow - Split 1/2', () => { // transition user to PROVISIONED state cy.activateTestOktaUser(emailAddress).then(() => { // user now in limbo state where they have not set a password - // recover by going through reset password flow - cy.visit('/register/email'); + // recover by going through classic flow + cy.visit('/register/email?useOktaClassic=true'); const timeRequestWasMade = new Date(); cy.get('input[name=email]').clear().type(emailAddress); diff --git a/cypress/integration/ete/registration_2.6.cy.ts b/cypress/integration/ete/registration_2.6.cy.ts index 532a8efe7..8da07d3f3 100644 --- a/cypress/integration/ete/registration_2.6.cy.ts +++ b/cypress/integration/ete/registration_2.6.cy.ts @@ -6,7 +6,7 @@ import { Status } from '../../../src/server/models/okta/User'; describe('Registration flow - Split 2/2', () => { context( - 'Existing users asking for an email to be resent after attempting to register with Okta', + 'Existing users asking for an email to be resent after attempting to register with Okta - useOktaClassic', () => { it('should resend a STAGED user a set password email with an Okta activation token', () => { cy @@ -18,7 +18,7 @@ describe('Registration flow - Split 2/2', () => { cy.getTestOktaUser(emailAddress).then((oktaUser) => { expect(oktaUser.status).to.eq(Status.STAGED); - cy.visit('/register/email'); + cy.visit('/register/email?useOktaClassic=true'); const timeRequestWasMade = new Date(); @@ -75,7 +75,7 @@ describe('Registration flow - Split 2/2', () => { cy.getTestOktaUser(emailAddress).then((oktaUser) => { expect(oktaUser.status).to.eq(Status.PROVISIONED); - cy.visit('/register/email'); + cy.visit('/register/email?useOktaClassic=true'); const timeRequestWasMade = new Date(); @@ -128,7 +128,7 @@ describe('Registration flow - Split 2/2', () => { cy.getTestOktaUser(emailAddress).then((oktaUser) => { expect(oktaUser.status).to.eq(Status.ACTIVE); - cy.visit('/register/email'); + cy.visit('/register/email?useOktaClassic=true'); const timeRequestWasMade = new Date(); cy.get('input[name=email]').type(emailAddress); @@ -180,7 +180,7 @@ describe('Registration flow - Split 2/2', () => { cy.getTestOktaUser(emailAddress).then((oktaUser) => { expect(oktaUser.status).to.eq(Status.RECOVERY); - cy.visit('/register/email'); + cy.visit('/register/email?useOktaClassic=true'); const timeRequestWasMade = new Date(); cy.get('input[name=email]').type(emailAddress); @@ -233,7 +233,7 @@ describe('Registration flow - Split 2/2', () => { cy.getTestOktaUser(emailAddress).then((oktaUser) => { expect(oktaUser.status).to.eq(Status.PASSWORD_EXPIRED); - cy.visit('/register/email'); + cy.visit('/register/email?useOktaClassic=true'); const timeRequestWasMade = new Date(); cy.get('input[name=email]').type(emailAddress); @@ -335,7 +335,7 @@ describe('Registration flow - Split 2/2', () => { cy.getTestOktaUser(emailAddress).then((oktaUser) => { expect(oktaUser.status).to.eq(Status.STAGED); - cy.visit('/welcome/resend'); + cy.visit('/welcome/resend?useOktaClassic=true'); const timeRequestWasMade = new Date(); @@ -391,7 +391,7 @@ describe('Registration flow - Split 2/2', () => { cy.getTestOktaUser(emailAddress).then((oktaUser) => { expect(oktaUser.status).to.eq(Status.PROVISIONED); - cy.visit('/welcome/resend'); + cy.visit('/welcome/resend?useOktaClassic=true'); const timeRequestWasMade = new Date(); @@ -444,7 +444,7 @@ describe('Registration flow - Split 2/2', () => { cy.getTestOktaUser(emailAddress).then((oktaUser) => { expect(oktaUser.status).to.eq(Status.ACTIVE); - cy.visit('/welcome/resend'); + cy.visit('/welcome/resend?useOktaClassic=true'); const timeRequestWasMade = new Date(); cy.get('input[name=email]').type(emailAddress); @@ -496,7 +496,7 @@ describe('Registration flow - Split 2/2', () => { cy.getTestOktaUser(emailAddress).then((oktaUser) => { expect(oktaUser.status).to.eq(Status.RECOVERY); - cy.visit('/welcome/resend'); + cy.visit('/welcome/resend?useOktaClassic=true'); const timeRequestWasMade = new Date(); cy.get('input[name=email]').type(emailAddress); @@ -549,7 +549,7 @@ describe('Registration flow - Split 2/2', () => { cy.getTestOktaUser(emailAddress).then((oktaUser) => { expect(oktaUser.status).to.eq(Status.PASSWORD_EXPIRED); - cy.visit('/welcome/resend'); + cy.visit('/welcome/resend?useOktaClassic=true'); const timeRequestWasMade = new Date(); cy.get('input[name=email]').type(emailAddress); @@ -651,7 +651,7 @@ describe('Registration flow - Split 2/2', () => { }); // a few tests to check if the Okta Classic flow is still working using the useOktaClassic flag - context('Okta Classic Flow', () => { + context('Okta Classic Flow - new user', () => { it('create account - successfully registers using an email with no existing account', () => { const encodedReturnUrl = 'https%3A%2F%2Fm.code.dev-theguardian.com%2Ftravel%2F2019%2Fdec%2F18%2Ffood-culture-tour-bethlehem-palestine-east-jerusalem-photo-essay'; @@ -839,6 +839,416 @@ describe('Registration flow - Split 2/2', () => { }); }); + context( + 'Existing users attempting to register with Okta - useOktaClassic', + () => { + it('should send a STAGED user a set password email with an Okta activation token', () => { + // Test users created via IDAPI-with-Okta do not have the activation + // lifecycle run at creation, so they don't transition immediately from + // STAGED to PROVISIONED (c.f. + // https://developer.okta.com/docs/reference/api/users/#create-user) . + // This is useful for us as we can test STAGED users first, then test + // PROVISIONED users in the next test by activating a STAGED user. Users + // created through Gateway-with-Okta do have this lifecycle run, so if we + // rebuild these tests to not use IDAPI at all, we need to figure out a + // way to test STAGED and PROVISIONED users (probably by just passing an + // optional `activate` prop to a createUser function). + cy + .createTestUser({ + isGuestUser: true, + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + cy.getTestOktaUser(emailAddress).then((oktaUser) => { + expect(oktaUser.status).to.eq(Status.STAGED); + + cy.visit('/register/email?useOktaClassic=true'); + const timeRequestWasMade = new Date(); + + cy.get('input[name=email]').type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); + + cy.contains('Check your inbox'); + cy.contains(emailAddress); + cy.contains('send again'); + cy.contains('try another address'); + + cy.checkForEmailAndGetDetails( + emailAddress, + timeRequestWasMade, + /\/set-password\/([^"]*)/, + ).then(({ links, body }) => { + expect(body).to.have.string('This account already exists'); + + expect(body).to.have.string('Create password'); + expect(links.length).to.eq(2); + const setPasswordLink = links.find((s) => + s.text?.includes('Create password'), + ); + expect(setPasswordLink?.href).not.to.have.string( + 'useOkta=true', + ); + cy.visit(setPasswordLink?.href as string); + cy.contains('Create password'); + cy.contains(emailAddress); + }); + }); + }); + }); + + it('should send a STAGED user a set password email with an Okta activation token, and has a prefixed activation token when using a native app', () => { + // Test users created via IDAPI-with-Okta do not have the activation + // lifecycle run at creation, so they don't transition immediately from + // STAGED to PROVISIONED (c.f. + // https://developer.okta.com/docs/reference/api/users/#create-user) . + // This is useful for us as we can test STAGED users first, then test + // PROVISIONED users in the next test by activating a STAGED user. Users + // created through Gateway-with-Okta do have this lifecycle run, so if we + // rebuild these tests to not use IDAPI at all, we need to figure out a + // way to test STAGED and PROVISIONED users (probably by just passing an + // optional `activate` prop to a createUser function). + cy + .createTestUser({ + isGuestUser: true, + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + cy.getTestOktaUser(emailAddress).then((oktaUser) => { + expect(oktaUser.status).to.eq(Status.STAGED); + + const appClientId = Cypress.env('OKTA_ANDROID_CLIENT_ID'); + const fromURI = 'fromURI1'; + + cy.visit( + `/register/email?appClientId=${appClientId}&fromURI=${fromURI}&useOktaClassic=true`, + ); + const timeRequestWasMade = new Date(); + + cy.get('input[name=email]').type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); + + cy.contains('Check your inbox'); + cy.contains(emailAddress); + cy.contains('send again'); + cy.contains('try another address'); + + cy.checkForEmailAndGetDetails( + emailAddress, + timeRequestWasMade, + /\/set-password\/([^"]*)/, + ).then(({ links, body }) => { + expect(body).to.have.string('This account already exists'); + + expect(body).to.have.string('Create password'); + expect(links.length).to.eq(2); + const setPasswordLink = links.find((s) => + s.text?.includes('Create password'), + ); + expect(setPasswordLink?.href ?? '') + .to.have.string('al_') + .and.not.to.have.string('useOkta=true'); + cy.visit(setPasswordLink?.href as string); + cy.contains('Create password'); + cy.contains(emailAddress); + }); + }); + }); + }); + + it('should send a PROVISIONED user a set password email with an Okta activation token', () => { + cy + .createTestUser({ + isGuestUser: true, + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + cy.activateTestOktaUser(emailAddress).then(() => { + cy.getTestOktaUser(emailAddress).then((oktaUser) => { + expect(oktaUser.status).to.eq(Status.PROVISIONED); + + cy.visit('/register/email?useOktaClassic=true'); + const timeRequestWasMade = new Date(); + + cy.get('input[name=email]').type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); + + cy.contains('Check your inbox'); + cy.contains(emailAddress); + cy.contains('send again'); + cy.contains('try another address'); + + cy.checkForEmailAndGetDetails( + emailAddress, + timeRequestWasMade, + /\/set-password\/([^"]*)/, + ).then(({ links, body }) => { + expect(body).to.have.string('This account already exists'); + expect(body).to.have.string('Create password'); + expect(links.length).to.eq(2); + const setPasswordLink = links.find((s) => + s.text?.includes('Create password'), + ); + expect(setPasswordLink?.href).not.to.have.string( + 'useOkta=true', + ); + cy.visit(setPasswordLink?.href as string); + cy.contains('Create password'); + cy.contains(emailAddress); + }); + }); + }); + }); + }); + it('should send an ACTIVE UNvalidated user with a password a security email with activation token', () => { + cy + .createTestUser({ + isGuestUser: false, + isUserEmailValidated: false, + }) + ?.then(({ emailAddress }) => { + cy.getTestOktaUser(emailAddress).then((oktaUser) => { + expect(oktaUser.status).to.eq(Status.ACTIVE); + + cy.visit('/register/email?useOktaClassic=true'); + const timeRequestWasMade = new Date(); + + cy.get('input[name=email]').type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); + + // Make sure that we don't get sent to the 'security reasons' page + cy.url().should('include', '/register/email-sent'); + cy.contains( + 'For security reasons we need you to change your password.', + ).should('not.exist'); + cy.contains(emailAddress); + cy.contains('send again'); + cy.contains('try another address'); + + cy.checkForEmailAndGetDetails( + emailAddress, + timeRequestWasMade, + /reset-password\/([^"]*)/, + ).then(({ links, body }) => { + expect(body).to.have.string( + 'Because your security is extremely important to us, we have changed our password policy.', + ); + expect(body).to.have.string('Reset password'); + expect(links.length).to.eq(2); + const resetPasswordLink = links.find((s) => + s.text?.includes('Reset password'), + ); + cy.visit(resetPasswordLink?.href as string); + cy.contains(emailAddress); + cy.contains('Create new password'); + }); + }); + }); + }); + it('should send an ACTIVE validated user WITH a password a reset password email with an activation token', () => { + cy + .createTestUser({ + isGuestUser: false, + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + cy.getTestOktaUser(emailAddress).then((oktaUser) => { + expect(oktaUser.status).to.eq(Status.ACTIVE); + + cy.visit('/register/email?useOktaClassic=true'); + const timeRequestWasMade = new Date(); + + cy.get('input[name=email]').type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); + + cy.contains('Check your inbox'); + cy.contains(emailAddress); + cy.contains('send again'); + cy.contains('try another address'); + + cy.checkForEmailAndGetDetails( + emailAddress, + timeRequestWasMade, + /reset-password\/([^"]*)/, + ).then(({ links, body }) => { + expect(body).to.have.string('This account already exists'); + expect(body).to.have.string('Sign in'); + expect(body).to.have.string('Reset password'); + expect(links.length).to.eq(3); + const resetPasswordLink = links.find((s) => + s.text?.includes('Reset password'), + ); + expect(resetPasswordLink?.href ?? '').not.to.have.string( + 'useOkta=true', + ); + cy.visit(resetPasswordLink?.href as string); + cy.contains(emailAddress); + cy.contains('Create new password'); + }); + }); + }); + }); + it('should send an ACTIVE validated user WITH a password a reset password email with an activation token, and prefixed activation token if using native app', () => { + cy + .createTestUser({ + isGuestUser: false, + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + cy.getTestOktaUser(emailAddress).then((oktaUser) => { + expect(oktaUser.status).to.eq(Status.ACTIVE); + + const appClientId = Cypress.env('OKTA_ANDROID_CLIENT_ID'); + const fromURI = 'fromURI1'; + + cy.visit( + `/register/email?appClientId=${appClientId}&fromURI=${fromURI}&useOktaClassic=true`, + ); + const timeRequestWasMade = new Date(); + + cy.get('input[name=email]').type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); + + cy.contains('Check your inbox'); + cy.contains(emailAddress); + cy.contains('send again'); + cy.contains('try another address'); + + cy.checkForEmailAndGetDetails( + emailAddress, + timeRequestWasMade, + /reset-password\/([^"]*)/, + ).then(({ links, body }) => { + expect(body).to.have.string('This account already exists'); + expect(body).to.have.string('Sign in'); + expect(body).to.have.string('Reset password'); + expect(links.length).to.eq(3); + const resetPasswordLink = links.find((s) => + s.text?.includes('Reset password'), + ); + expect(resetPasswordLink?.href ?? '') + .to.have.string('al_') + .and.not.to.have.string('useOkta=true'); + cy.visit(resetPasswordLink?.href as string); + cy.contains(emailAddress); + cy.contains('Create new password'); + }); + }); + }); + }); + it('should send a RECOVERY user a reset password email with an Okta activation token', () => { + cy + .createTestUser({ + isGuestUser: false, + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + cy.resetOktaUserPassword(emailAddress).then(() => { + cy.getTestOktaUser(emailAddress).then((oktaUser) => { + expect(oktaUser.status).to.eq(Status.RECOVERY); + + cy.visit('/register/email?useOktaClassic=true'); + const timeRequestWasMade = new Date(); + + cy.get('input[name=email]').type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); + + cy.contains('Check your inbox'); + cy.contains(emailAddress); + cy.contains('send again'); + cy.contains('try another address'); + + cy.checkForEmailAndGetDetails( + emailAddress, + timeRequestWasMade, + /reset-password\/([^"]*)/, + ).then(({ links, body }) => { + expect(body).to.have.string('Password reset'); + expect(body).to.have.string('Reset password'); + expect(links.length).to.eq(3); + const resetPasswordLink = links.find((s) => + s.text?.includes('Reset password'), + ); + expect(resetPasswordLink?.href ?? '').not.to.have.string( + 'useOkta=true', + ); + cy.visit(resetPasswordLink?.href as string); + cy.contains('Create new password'); + cy.contains(emailAddress); + }); + }); + }); + }); + }); + it('should send a PASSWORD_EXPIRED user a reset password email with an Okta activation token', () => { + cy + .createTestUser({ + isGuestUser: false, + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + cy.expireOktaUserPassword(emailAddress).then(() => { + cy.getTestOktaUser(emailAddress).then((oktaUser) => { + expect(oktaUser.status).to.eq(Status.PASSWORD_EXPIRED); + + cy.visit('/register/email?useOktaClassic=true'); + const timeRequestWasMade = new Date(); + + cy.get('input[name=email]').type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); + + cy.contains('Check your inbox'); + cy.contains(emailAddress); + cy.contains('send again'); + cy.contains('try another address'); + + cy.checkForEmailAndGetDetails( + emailAddress, + timeRequestWasMade, + /reset-password\/([^"]*)/, + ).then(({ links, body }) => { + expect(body).to.have.string('Password reset'); + expect(body).to.have.string('Reset password'); + expect(links.length).to.eq(3); + const resetPasswordLink = links.find((s) => + s.text?.includes('Reset password'), + ); + expect(resetPasswordLink?.href ?? '').not.to.have.string( + 'useOkta=true', + ); + cy.visit(resetPasswordLink?.href as string); + cy.contains('Create new password'); + cy.contains(emailAddress); + }); + }); + }); + }); + }); + it('should display an error if a SUSPENDED user attempts to register', () => { + cy + .createTestUser({ + isGuestUser: false, + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + cy.suspendOktaUser(emailAddress).then(() => { + cy.getTestOktaUser(emailAddress).then((oktaUser) => { + expect(oktaUser.status).to.eq(Status.SUSPENDED); + + cy.visit('/register/email?useOktaClassic=true'); + + cy.get('input[name=email]').type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); + + cy.contains( + 'There was a problem registering, please try again.', + ); + }); + }); + }); + }); + }, + ); + context('Extra checks', () => { it('should navigate back to the correct page when change email is clicked', () => { cy.visit('/register/email-sent'); @@ -866,7 +1276,7 @@ describe('Registration flow - Split 2/2', () => { cy.get('[data-cy="main-form-submit-button"]').click(); - cy.contains('Check your inbox'); + cy.contains('Enter your code'); cy.contains(emailAddress); cy.contains('send again'); cy.contains('try another address'); @@ -889,7 +1299,7 @@ describe('Registration flow - Split 2/2', () => { 'not.exist', ); - cy.contains('Check your inbox'); + cy.contains('Enter your code'); cy.contains(emailAddress); cy.checkForEmailAndGetDetails(emailAddress, timeRequestWasMade); diff --git a/cypress/integration/mocked/registerController.1.cy.ts b/cypress/integration/mocked/registerController.1.cy.ts index 0678ab871..354117e7c 100644 --- a/cypress/integration/mocked/registerController.1.cy.ts +++ b/cypress/integration/mocked/registerController.1.cy.ts @@ -1,27 +1,21 @@ import userStatuses from '../../support/okta/userStatuses'; import userResponse from '../../fixtures/okta-responses/success/user.json'; -import userGroupsResponse from '../../fixtures/okta-responses/success/valid-user-groups.json'; -import socialUserResponse from '../../fixtures/okta-responses/success/social-user.json'; -import userExistsError from '../../fixtures/okta-responses/error/user-exists.json'; -import successTokenResponse from '../../fixtures/okta-responses/success/token.json'; -import resetPasswordResponse from '../../fixtures/okta-responses/success/reset-password.json'; import idxInteractResponse from '../../fixtures/okta-responses/success/idx-interact-response.json'; import idxIntrospectDefaultResponse from '../../fixtures/okta-responses/success/idx-introspect-default-response.json'; import idxEnrollResponse from '../../fixtures/okta-responses/success/idx-enroll-response.json'; import idxEnrollNewResponse from '../../fixtures/okta-responses/success/idx-enroll-new-response.json'; import idxEnrollNewSelectAuthenticatorResponse from '../../fixtures/okta-responses/success/idx-enroll-new-response-select-authenticator.json'; import idxEnrollNewExistingUserResponse from '../../fixtures/okta-responses/error/idx-enroll-new-existing-user-response.json'; -import updateUser from '../../fixtures/okta-responses/success/update-user.json'; +import { identifyResponse } from '../../fixtures/okta-responses/success/idx-identify-response'; +import idxChallengeResponseEmail from '../../fixtures/okta-responses/success/idx-challenge-response-email.json'; +import idxChallengeResponsePassword from '../../fixtures/okta-responses/success/idx-challenge-response-password.json'; +import { dangerouslySetPlaceholderPasswordMocks } from './resetPasswordController.4.cy'; +import idxChallengeAnswerPasswordEnrollEmailResponse from '../../fixtures/okta-responses/success/idx-challenge-answer-password-enroll-email-response.json'; beforeEach(() => { cy.mockPurge(); }); -const verifyInRegularEmailSentPage = () => { - cy.contains('Check your inbox'); - cy.contains('send again'); -}; - const baseIdxPasscodeRegistrationMocks = () => { // interact cy.mockNext(idxInteractResponse.code, idxInteractResponse.response); @@ -34,6 +28,16 @@ const baseIdxPasscodeRegistrationMocks = () => { cy.mockNext(idxEnrollResponse.code, idxEnrollResponse.response); }; +export const idxPasscodeExistingUserMocks = () => { + // interact + cy.mockNext(idxInteractResponse.code, idxInteractResponse.response); + // introspect + cy.mockNext( + idxIntrospectDefaultResponse.code, + idxIntrospectDefaultResponse.response, + ); +}; + const verifyInPasscodeEmailSentPage = () => { cy.contains('Enter your code'); cy.contains('send again'); @@ -48,7 +52,7 @@ userStatuses.forEach((status) => { }); switch (status) { case false: - specify("Then I should be shown the 'Check your inbox' page", () => { + specify("Then I should be shown the 'Enter your code' page", () => { baseIdxPasscodeRegistrationMocks(); cy.mockNext( idxEnrollNewSelectAuthenticatorResponse.code, @@ -64,7 +68,7 @@ userStatuses.forEach((status) => { break; case 'ACTIVE': specify( - "Then I should be shown the 'Check your inbox' page if I have a validated email", + "Then I should be shown the 'Enter your code' page if I have a validated email (ACTIVE - email + password)", () => { baseIdxPasscodeRegistrationMocks(); cy.mockNext( @@ -80,83 +84,19 @@ userStatuses.forEach((status) => { password: {}, }, }; - cy.mockNext(userExistsError.code, userExistsError.response); cy.mockNext(userResponse.code, responseWithPassword); - cy.mockNext(userGroupsResponse.code, userGroupsResponse.response); - cy.get('button[type=submit]').click(); - verifyInRegularEmailSentPage(); - }, - ); - specify( - "Then I should be shown the 'Check your inbox' page if I have a validated email but no password", - () => { - baseIdxPasscodeRegistrationMocks(); - cy.mockNext( - idxEnrollNewExistingUserResponse.code, - idxEnrollNewExistingUserResponse.response, - ); - // Set the correct user status on the response - const response = { ...userResponse.response, status }; - cy.mockNext(userExistsError.code, userExistsError.response); - cy.mockNext(userResponse.code, response); - cy.mockNext(userGroupsResponse.code, userGroupsResponse.response); - // dangerouslyResetPassword() - cy.mockNext(200, { - resetPasswordUrl: - 'https://example.com/reset_password/XE6wE17zmphl3KqAPFxO', - }); - // validateRecoveryToken() - cy.mockNext(200, { - stateToken: 'sometoken', - }); - // authenticationResetPassword() - cy.mockNext(200, {}); - // set email validated/password set securely flags to false - cy.mockNext(updateUser.code, updateUser.response); - // from sendEmailToUnvalidatedUser() --> forgotPassword() - cy.mockNext(200, { - resetPasswordUrl: - 'https://example.com/signin/reset-password/XE6wE17zmphl3KqAPFxO', - }); - cy.get('button[type=submit]').click(); - verifyInRegularEmailSentPage(); - }, - ); - specify( - "Then I should be shown the 'Check your inbox' page for social user", - () => { - baseIdxPasscodeRegistrationMocks(); + idxPasscodeExistingUserMocks(); + cy.mockNext(200, identifyResponse(true, true)); cy.mockNext( - idxEnrollNewExistingUserResponse.code, - idxEnrollNewExistingUserResponse.response, + idxChallengeResponseEmail.code, + idxChallengeResponseEmail.response, ); - cy.mockNext(userExistsError.code, userExistsError.response); - cy.mockNext(socialUserResponse.code, socialUserResponse.response); - cy.mockNext(userGroupsResponse.code, userGroupsResponse.response); - // dangerouslyResetPassword() - cy.mockNext(200, { - resetPasswordUrl: - 'https://example.com/reset_password/XE6wE17zmphl3KqAPFxO', - }); - // validateRecoveryToken() - cy.mockNext(200, { - stateToken: 'sometoken', - }); - // authenticationResetPassword() - cy.mockNext(200, {}); - // set email validated/password set securely flags to false - cy.mockNext(updateUser.code, updateUser.response); - // from sendEmailToUnvalidatedUser() --> forgotPassword() - cy.mockNext(200, { - resetPasswordUrl: - 'https://example.com/signin/reset-password/XE6wE17zmphl3KqAPFxO', - }); cy.get('button[type=submit]').click(); - verifyInRegularEmailSentPage(); + verifyInPasscodeEmailSentPage(); }, ); specify( - "Then I should be shown the 'Check your inbox' page if I don't have a validated email and don't have a password set", + "Then I should be shown the 'Enter your code' page if I have a validated email but no password (ACTIVE - social or passwordless)", () => { baseIdxPasscodeRegistrationMocks(); cy.mockNext( @@ -165,42 +105,19 @@ userStatuses.forEach((status) => { ); // Set the correct user status on the response const response = { ...userResponse.response, status }; - cy.mockNext(userExistsError.code, userExistsError.response); - // This user response doesn't have a password credential cy.mockNext(userResponse.code, response); - const userGroupsResponseWithoutEmailValidated = - userGroupsResponse.response.filter( - (group) => - group.profile.name !== 'GuardianUser-EmailValidated', - ); + idxPasscodeExistingUserMocks(); + cy.mockNext(200, identifyResponse(true, false)); cy.mockNext( - userGroupsResponse.code, - userGroupsResponseWithoutEmailValidated, + idxChallengeResponseEmail.code, + idxChallengeResponseEmail.response, ); - // dangerouslyResetPassword() - cy.mockNext(200, { - resetPasswordUrl: - 'https://example.com/reset_password/XE6wE17zmphl3KqAPFxO', - }); - // validateRecoveryToken() - cy.mockNext(200, { - stateToken: 'sometoken', - }); - // authenticationResetPassword() - cy.mockNext(200, {}); - // set email validated/password set securely flags to false - cy.mockNext(updateUser.code, updateUser.response); - // from sendEmailToUnvalidatedUser() --> forgotPassword() - cy.mockNext(200, { - resetPasswordUrl: - 'https://example.com/signin/reset-password/XE6wE17zmphl3KqAPFxO', - }); cy.get('button[type=submit]').click(); - verifyInRegularEmailSentPage(); + verifyInPasscodeEmailSentPage(); }, ); specify( - "Then I should be shown the 'Check your inbox' page if I don't have a validated email and do have a password set", + "Then I should be shown the 'Check your inbox' page if I don't have a validated email and do have a password set (ACTIVE - password only, email not verified)", () => { baseIdxPasscodeRegistrationMocks(); cy.mockNext( @@ -216,11 +133,28 @@ userStatuses.forEach((status) => { password: {}, }, }; - cy.mockNext(userExistsError.code, userExistsError.response); cy.mockNext(userResponse.code, responseWithPassword); - cy.mockNext(userGroupsResponse.code, userGroupsResponse.response); + idxPasscodeExistingUserMocks(); + cy.mockNext(200, identifyResponse(false, true)); + dangerouslySetPlaceholderPasswordMocks('example@example.com'); + cy.mockNext( + idxChallengeResponsePassword.code, + idxChallengeResponsePassword.response, + ); + cy.mockNext( + idxChallengeResponsePassword.code, + idxChallengeResponsePassword.response, + ); + cy.mockNext( + idxChallengeAnswerPasswordEnrollEmailResponse.code, + idxChallengeAnswerPasswordEnrollEmailResponse.response, + ); + cy.mockNext( + idxChallengeResponseEmail.code, + idxChallengeResponseEmail.response, + ); cy.get('button[type=submit]').click(); - verifyInRegularEmailSentPage(); + verifyInPasscodeEmailSentPage(); }, ); //not sure if we need to do anything for the social case here. the only mocked response I can change is the user response @@ -228,29 +162,10 @@ userStatuses.forEach((status) => { break; case 'PROVISIONED': case 'STAGED': - // Then Gateway should generate an activation token - specify("Then I should be shown the 'Check your inbox' page", () => { - baseIdxPasscodeRegistrationMocks(); - cy.mockNext( - idxEnrollNewExistingUserResponse.code, - idxEnrollNewExistingUserResponse.response, - ); - // Set the correct user status on the response - const response = { ...userResponse.response, status }; - cy.mockNext(userExistsError.code, userExistsError.response); - cy.mockNext(userResponse.code, response); - cy.mockNext( - successTokenResponse.code, - successTokenResponse.response, - ); - cy.get('button[type=submit]').click(); - verifyInRegularEmailSentPage(); - }); - break; case 'RECOVERY': case 'PASSWORD_EXPIRED': - // Then Gateway should generate a reset password token - specify("Then I should be shown the 'Check your inbox' page", () => { + // Then Gateway should generate an activation token + specify("Then I should be shown the 'Enter your code' page", () => { baseIdxPasscodeRegistrationMocks(); cy.mockNext( idxEnrollNewExistingUserResponse.code, @@ -258,14 +173,21 @@ userStatuses.forEach((status) => { ); // Set the correct user status on the response const response = { ...userResponse.response, status }; - cy.mockNext(userExistsError.code, userExistsError.response); - cy.mockNext(userResponse.code, response); + cy.mockNext(userResponse.code, { + ...response, + status, + }); + cy.mockNext(200, {}); + dangerouslySetPlaceholderPasswordMocks('test@example.com'); + cy.mockNext(200, { ...userResponse.response, status: 'ACTIVE' }); + idxPasscodeExistingUserMocks(); + cy.mockNext(200, identifyResponse(true, true)); cy.mockNext( - resetPasswordResponse.code, - resetPasswordResponse.response, + idxChallengeResponseEmail.code, + idxChallengeResponseEmail.response, ); cy.get('button[type=submit]').click(); - verifyInRegularEmailSentPage(); + verifyInPasscodeEmailSentPage(); }); break; } diff --git a/cypress/integration/mocked/resendEmailController.3.cy.ts b/cypress/integration/mocked/resendEmailController.3.cy.ts index 65ead9207..414879b3c 100644 --- a/cypress/integration/mocked/resendEmailController.3.cy.ts +++ b/cypress/integration/mocked/resendEmailController.3.cy.ts @@ -11,7 +11,10 @@ import idxEnrollResponse from '../../fixtures/okta-responses/success/idx-enroll- import idxEnrollNewResponse from '../../fixtures/okta-responses/success/idx-enroll-new-response.json'; import idxEnrollNewSelectAuthenticatorResponse from '../../fixtures/okta-responses/success/idx-enroll-new-response-select-authenticator.json'; import idxEnrollNewExistingUserResponse from '../../fixtures/okta-responses/error/idx-enroll-new-existing-user-response.json'; -import updateUser from '../../fixtures/okta-responses/success/update-user.json'; +import { identifyResponse } from '../../fixtures/okta-responses/success/idx-identify-response'; +import { idxPasscodeExistingUserMocks } from './registerController.1.cy'; +import idxChallengeResponseEmail from '../../fixtures/okta-responses/success/idx-challenge-response-email.json'; +import { dangerouslySetPlaceholderPasswordMocks } from './resetPasswordController.4.cy'; beforeEach(() => { cy.mockPurge(); @@ -300,7 +303,7 @@ userStatuses.forEach((status) => { }); switch (status) { case false: - specify("Then I should be shown the 'Check your inbox' page", () => { + specify("Then I should be shown the 'Enter your code' page", () => { baseIdxPasscodeRegistrationMocks(); cy.mockNext( idxEnrollNewSelectAuthenticatorResponse.code, @@ -315,7 +318,7 @@ userStatuses.forEach((status) => { }); break; case 'ACTIVE': - specify("Then I should be shown the 'Check your inbox' page", () => { + specify("Then I should be shown the 'Enter your code' page", () => { baseIdxPasscodeRegistrationMocks(); cy.mockNext( idxEnrollNewExistingUserResponse.code, @@ -330,70 +333,23 @@ userStatuses.forEach((status) => { password: {}, }, }; - cy.mockNext(userExistsError.code, userExistsError.response); cy.mockNext(userResponse.code, responseWithPassword); - cy.mockNext(userGroupsResponse.code, userGroupsResponse.response); - cy.get('[data-cy="main-form-submit-button"]').click(); - verifyInRegularEmailSentPage(); - }); - specify( - "Then I should be shown the 'Check your inbox' page for social user", - () => { - baseIdxPasscodeRegistrationMocks(); - cy.mockNext( - idxEnrollNewExistingUserResponse.code, - idxEnrollNewExistingUserResponse.response, - ); - cy.mockNext(userExistsError.code, userExistsError.response); - cy.mockNext(socialUserResponse.code, socialUserResponse.response); - cy.mockNext(userGroupsResponse.code, userGroupsResponse.response); - // dangerouslyResetPassword() - cy.mockNext(200, { - resetPasswordUrl: - 'https://example.com/reset_password/XE6wE17zmphl3KqAPFxO', - }); - // validateRecoveryToken() - cy.mockNext(200, { - stateToken: 'sometoken', - }); - // authenticationResetPassword() - cy.mockNext(200, {}); - // set email validated/password set securely flags to false - cy.mockNext(updateUser.code, updateUser.response); - // from sendEmailToUnvalidatedUser() --> forgotPassword() - cy.mockNext(200, { - resetPasswordUrl: - 'https://example.com/signin/reset-password/XE6wE17zmphl3KqAPFxO', - }); - cy.get('[data-cy="main-form-submit-button"]').click(); - verifyInRegularEmailSentPage(); - }, - ); - break; - case 'PROVISIONED': - case 'STAGED': - // Then Gateway should generate an activation token - specify("Then I should be shown the 'Check your inbox' page", () => { - baseIdxPasscodeRegistrationMocks(); - cy.mockNext( - idxEnrollNewExistingUserResponse.code, - idxEnrollNewExistingUserResponse.response, - ); - // Set the correct user status on the response - const response = { ...userResponse.response, status }; - cy.mockNext(userExistsError.code, userExistsError.response); - cy.mockNext(userResponse.code, response); + idxPasscodeExistingUserMocks(); + cy.mockNext(200, identifyResponse(true, true)); cy.mockNext( - successTokenResponse.code, - successTokenResponse.response, + idxChallengeResponseEmail.code, + idxChallengeResponseEmail.response, ); + cy.get('[data-cy="main-form-submit-button"]').click(); - verifyInRegularEmailSentPage(); + verifyInPasscodeEmailSentPage(); }); break; + case 'PROVISIONED': + case 'STAGED': case 'RECOVERY': case 'PASSWORD_EXPIRED': - // Then Gateway should generate a reset password token + // Then Gateway should generate an activation token specify("Then I should be shown the 'Check your inbox' page", () => { baseIdxPasscodeRegistrationMocks(); cy.mockNext( @@ -402,14 +358,21 @@ userStatuses.forEach((status) => { ); // Set the correct user status on the response const response = { ...userResponse.response, status }; - cy.mockNext(userExistsError.code, userExistsError.response); - cy.mockNext(userResponse.code, response); + cy.mockNext(userResponse.code, { + ...response, + status, + }); + cy.mockNext(200, {}); + dangerouslySetPlaceholderPasswordMocks('test@example.com'); + cy.mockNext(200, { ...userResponse.response, status: 'ACTIVE' }); + idxPasscodeExistingUserMocks(); + cy.mockNext(200, identifyResponse(true, true)); cy.mockNext( - resetPasswordResponse.code, - resetPasswordResponse.response, + idxChallengeResponseEmail.code, + idxChallengeResponseEmail.response, ); cy.get('[data-cy="main-form-submit-button"]').click(); - verifyInRegularEmailSentPage(); + verifyInPasscodeEmailSentPage(); }); break; } @@ -421,7 +384,7 @@ userStatuses.forEach((status) => { }); switch (status) { case false: - specify("Then I should be shown the 'Check your inbox' page", () => { + specify("Then I should be shown the 'Enter your code' page", () => { baseIdxPasscodeRegistrationMocks(); cy.mockNext( idxEnrollNewSelectAuthenticatorResponse.code, @@ -436,7 +399,7 @@ userStatuses.forEach((status) => { }); break; case 'ACTIVE': - specify("Then I should be shown the 'Check your inbox' page", () => { + specify("Then I should be shown the 'Enter your code' page", () => { baseIdxPasscodeRegistrationMocks(); cy.mockNext( idxEnrollNewExistingUserResponse.code, @@ -451,70 +414,23 @@ userStatuses.forEach((status) => { password: {}, }, }; - cy.mockNext(userExistsError.code, userExistsError.response); cy.mockNext(userResponse.code, responseWithPassword); - cy.mockNext(userGroupsResponse.code, userGroupsResponse.response); - cy.get('[data-cy="main-form-submit-button"]').click(); - verifyInRegularEmailSentPage(); - }); - specify( - "Then I should be shown the 'Check your inbox' page for social users", - () => { - baseIdxPasscodeRegistrationMocks(); - cy.mockNext( - idxEnrollNewExistingUserResponse.code, - idxEnrollNewExistingUserResponse.response, - ); - cy.mockNext(userExistsError.code, userExistsError.response); - cy.mockNext(socialUserResponse.code, socialUserResponse.response); - cy.mockNext(userGroupsResponse.code, userGroupsResponse.response); - // dangerouslyResetPassword() - cy.mockNext(200, { - resetPasswordUrl: - 'https://example.com/reset_password/XE6wE17zmphl3KqAPFxO', - }); - // validateRecoveryToken() - cy.mockNext(200, { - stateToken: 'sometoken', - }); - // authenticationResetPassword() - cy.mockNext(200, {}); - // set email validated/password set securely flags to false - cy.mockNext(updateUser.code, updateUser.response); - // from sendEmailToUnvalidatedUser() --> forgotPassword() - cy.mockNext(200, { - resetPasswordUrl: - 'https://example.com/signin/reset-password/XE6wE17zmphl3KqAPFxO', - }); - cy.get('[data-cy="main-form-submit-button"]').click(); - verifyInRegularEmailSentPage(); - }, - ); - break; - case 'PROVISIONED': - case 'STAGED': - // Then Gateway should generate an activation token - specify("Then I should be shown the 'Check your inbox' page", () => { - baseIdxPasscodeRegistrationMocks(); + idxPasscodeExistingUserMocks(); + cy.mockNext(200, identifyResponse(true, true)); cy.mockNext( - idxEnrollNewExistingUserResponse.code, - idxEnrollNewExistingUserResponse.response, - ); - // Set the correct user status on the response - const response = { ...userResponse.response, status }; - cy.mockNext(userExistsError.code, userExistsError.response); - cy.mockNext(userResponse.code, response); - cy.mockNext( - successTokenResponse.code, - successTokenResponse.response, + idxChallengeResponseEmail.code, + idxChallengeResponseEmail.response, ); + cy.get('[data-cy="main-form-submit-button"]').click(); - verifyInRegularEmailSentPage(); + verifyInPasscodeEmailSentPage(); }); break; + case 'PROVISIONED': + case 'STAGED': case 'RECOVERY': case 'PASSWORD_EXPIRED': - // Then Gateway should generate a reset password token + // Then Gateway should generate an activation token specify("Then I should be shown the 'Check your inbox' page", () => { baseIdxPasscodeRegistrationMocks(); cy.mockNext( @@ -523,14 +439,21 @@ userStatuses.forEach((status) => { ); // Set the correct user status on the response const response = { ...userResponse.response, status }; - cy.mockNext(userExistsError.code, userExistsError.response); - cy.mockNext(userResponse.code, response); + cy.mockNext(userResponse.code, { + ...response, + status, + }); + cy.mockNext(200, {}); + dangerouslySetPlaceholderPasswordMocks('test@example.com'); + cy.mockNext(200, { ...userResponse.response, status: 'ACTIVE' }); + idxPasscodeExistingUserMocks(); + cy.mockNext(200, identifyResponse(true, true)); cy.mockNext( - resetPasswordResponse.code, - resetPasswordResponse.response, + idxChallengeResponseEmail.code, + idxChallengeResponseEmail.response, ); cy.get('[data-cy="main-form-submit-button"]').click(); - verifyInRegularEmailSentPage(); + verifyInPasscodeEmailSentPage(); }); break; } diff --git a/src/client/pages/WelcomeExisting.stories.tsx b/src/client/pages/WelcomeExisting.stories.tsx new file mode 100644 index 000000000..1b155113a --- /dev/null +++ b/src/client/pages/WelcomeExisting.stories.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Meta } from '@storybook/react'; + +import { + WelcomeExisting, + WelcomeExistingProps, +} from '@/client/pages/WelcomeExisting'; + +export default { + title: 'Pages/WelcomeExisting', + component: WelcomeExisting, + args: { + queryParams: { + returnUrl: 'https://www.theguardian.com/uk', + }, + }, +} as Meta; + +export const Default = (args: WelcomeExistingProps) => ( + +); +Default.story = { + name: 'default', +}; + +export const WithEmail = (args: WelcomeExistingProps) => ( + +); +WithEmail.story = { + name: 'with email', +}; diff --git a/src/client/pages/WelcomeExisting.tsx b/src/client/pages/WelcomeExisting.tsx new file mode 100644 index 000000000..de08c2c23 --- /dev/null +++ b/src/client/pages/WelcomeExisting.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { MinimalLayout } from '@/client/layouts/MinimalLayout'; +import { QueryParams } from '@/shared/model/QueryParams'; +import { + primaryButtonStyles, + secondaryButtonStyles, +} from '@/client/styles/Shared'; +import { ExternalLinkButton } from '@/client/components/ExternalLink'; +import { MainBodyText } from '../components/MainBodyText'; + +export interface WelcomeExistingProps { + queryParams: QueryParams; + accountManagementUrl?: string; + email?: string; +} + +export const WelcomeExisting = ({ + queryParams, + email, + accountManagementUrl = 'https://manage.theguardian.com', +}: WelcomeExistingProps) => { + return ( + + + We noticed you already have a Guardian account + {email && ( + <> + {' '} + with {email} + + )} + . + + + You're now signed in and can access all the benefits of your Guardian + account. + + + Return to the Guardian + + + Manage my Guardian account + + + ); +}; diff --git a/src/client/pages/WelcomeExistingPage.tsx b/src/client/pages/WelcomeExistingPage.tsx new file mode 100644 index 000000000..44dcfd16a --- /dev/null +++ b/src/client/pages/WelcomeExistingPage.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import useClientState from '@/client/lib/hooks/useClientState'; + +import { WelcomeExisting } from '@/client/pages/WelcomeExisting'; + +export const WelcomeExistingPage = () => { + const clientState = useClientState(); + const { pageData: { accountManagementUrl, email } = {}, queryParams } = + clientState; + + return ( + + ); +}; diff --git a/src/client/routes.tsx b/src/client/routes.tsx index c08c6392e..6d882839c 100644 --- a/src/client/routes.tsx +++ b/src/client/routes.tsx @@ -42,6 +42,7 @@ import { NewAccountNewslettersPage } from '@/client/pages/NewAccountNewslettersP import { VerifyEmailResetPasswordPage } from '@/client/pages/VerifyEmailResetPasswordPage'; import { ResetPasswordEmailSentPage } from '@/client/pages/ResetPasswordEmailSentPage'; import { SignInPasscodeEmailSentPage } from '@/client/pages/SignInPasscodeEmailSentPage'; +import { WelcomeExistingPage } from '@/client/pages/WelcomeExistingPage'; export type RoutingConfig = { clientState: ClientState; @@ -138,6 +139,10 @@ const routes: Array<{ path: '/welcome/resend', element: , }, + { + path: '/welcome/existing', + element: , + }, { path: '/welcome/expired', element: , diff --git a/src/server/controllers/signInControllers.ts b/src/server/controllers/signInControllers.ts index cdcd6450a..515d3e586 100644 --- a/src/server/controllers/signInControllers.ts +++ b/src/server/controllers/signInControllers.ts @@ -27,7 +27,10 @@ import { submitPasscode, submitPassword, } from '@/server/lib/okta/idx/shared/submitPasscode'; -import { startIdxFlow } from '@/server/lib/okta/idx/startIdxFlow'; +import { + startIdxFlow, + StartIdxFlowParams, +} from '@/server/lib/okta/idx/startIdxFlow'; import { sendOphanComponentEventFromQueryParamsServer } from '@/server/lib/ophan'; import { renderer } from '@/server/lib/renderer'; import { mergeRequestState } from '@/server/lib/requestState'; @@ -42,7 +45,6 @@ import { RegistrationErrors, SignInErrors, } from '@/shared/model/Errors'; -import { handleAsyncErrors } from '@/server/lib/expressWrappers'; import { convertExpiresAtToExpiryTimeInMs } from '@/server/lib/okta/idx/shared/convertExpiresAtToExpiryTimeInMs'; import { handlePasscodeError } from '@/server/lib/okta/idx/shared/errorHandling'; import { validateEmailAndPasswordSetSecurely } from '@/server/lib/okta/validateEmail'; @@ -142,17 +144,19 @@ const startIdxSignInFlow = async ({ email, req, res, + authorizationCodeFlowOptions = {}, }: { email: string; req: Request; res: ResponseWithRequestState; + authorizationCodeFlowOptions?: StartIdxFlowParams['authorizationCodeFlowOptions']; }): Promise => { // at this point the user will be in the ACTIVE state // start the IDX flow by calling interact and introspect const introspectResponse = await startIdxFlow({ req, res, - authorizationCodeFlowOptions: {}, + authorizationCodeFlowOptions, }); // call "identify", essentially to start an authentication process @@ -178,16 +182,23 @@ const startIdxSignInFlow = async ({ * @description Use the Okta IDX API to attempt to send the user a passcode to sign in * @param {Request} req - Express request object * @param {ResponseWithRequestState} res - Express response object + * @param {boolean} loopDetectionFlag - Flag to detect if the user is in a loop + * @param {string} emailSentPage - The page to redirect to when the passcode email is sent, defaults to /signin/code + * @param {string} confirmationPagePath - The path to redirect to after the user has signed in, default undefined (user will be directed to returnUrl) * @returns {Promise} */ export const oktaIdxApiSignInPasscodeController = async ({ req, res, loopDetectionFlag = false, + emailSentPage = '/signin/code', + confirmationPagePath, }: { req: Request; res: ResponseWithRequestState; loopDetectionFlag?: boolean; + emailSentPage?: Extract; + confirmationPagePath?: StartIdxFlowParams['authorizationCodeFlowOptions']['confirmationPagePath']; }): Promise => { const { email = '' } = req.body; @@ -227,6 +238,9 @@ export const oktaIdxApiSignInPasscodeController = async ({ email, req, res, + authorizationCodeFlowOptions: { + confirmationPagePath, + }, }); // check for the "email" authenticator, we can authenticate with email (passcode) @@ -278,7 +292,7 @@ export const oktaIdxApiSignInPasscodeController = async ({ return res.redirect( 303, - addQueryParamsToPath('/signin/code', res.locals.queryParams), + addQueryParamsToPath(emailSentPage, res.locals.queryParams), ); } @@ -301,7 +315,7 @@ export const oktaIdxApiSignInPasscodeController = async ({ return res.redirect( 303, - addQueryParamsToPath('/signin/code', res.locals.queryParams), + addQueryParamsToPath(emailSentPage, res.locals.queryParams), ); } @@ -335,6 +349,8 @@ export const oktaIdxApiSignInPasscodeController = async ({ req, res, loopDetectionFlag: true, + emailSentPage, + confirmationPagePath, }); } catch (error) { logger.error( @@ -361,7 +377,7 @@ export const oktaIdxApiSignInPasscodeController = async ({ return res.redirect( 303, - addQueryParamsToPath('/signin/code', res.locals.queryParams), + addQueryParamsToPath(emailSentPage, res.locals.queryParams), ); } }; @@ -624,147 +640,157 @@ export const oktaIdxApiSignInController = async ({ * * @param {Request} req - Express request object * @param {ResponseWithRequestState} res - Express response object + * @param {string} emailSentPage - The page to redirect to when the passcode email is sent, defaults to /signin/code + * @param {string} expiredPage - The page to redirect to when the passcode email has expired, defaults to /signin/code/expired * @returns {Promise} */ -export const oktaIdxApiSubmitPasscodeController = handleAsyncErrors( - async (req: Request, res: ResponseWithRequestState) => { - const { code } = req.body; - - const encryptedState = readEncryptedStateCookie(req); - - if (encryptedState?.stateHandle && code) { - const { stateHandle, userState } = encryptedState; - - try { - // check for non-existent user state - // in this case throw an error to show the user the passcode is invalid - if (userState === 'NON_EXISTENT') { - throw new OAuthError({ - error: 'api.authn.error.PASSCODE_INVALID', - error_description: RegistrationErrors.PASSCODE_INVALID, - }); - } +export const oktaIdxApiSubmitPasscodeController = async ({ + req, + res, + emailSentPage = '/signin/code', + expiredPage = '/signin/code/expired', +}: { + req: Request; + res: ResponseWithRequestState; + emailSentPage?: Extract; + expiredPage?: Extract; +}) => { + const { code } = req.body; - // attempt to answer the passcode challenge, if this fails, it falls through to the catch block where we handle the error - const challengeAnswerResponse = await submitPasscode({ - passcode: code, - stateHandle, - introspectRemediation: - // if the user is in the `ACTIVE_PASSWORD_ONLY` state, then when they sign in with a passcode - // they will need the `select-authenticator-enroll` remediation to enroll in the email authenticator - // other users will have the `challenge-authenticator` remediation - userState === 'ACTIVE_PASSWORD_ONLY' - ? 'select-authenticator-enroll' - : 'challenge-authenticator', - ip: req.ip, + const encryptedState = readEncryptedStateCookie(req); + + if (encryptedState?.stateHandle && code) { + const { stateHandle, userState } = encryptedState; + + try { + // check for non-existent user state + // in this case throw an error to show the user the passcode is invalid + if (userState === 'NON_EXISTENT') { + throw new OAuthError({ + error: 'api.authn.error.PASSCODE_INVALID', + error_description: RegistrationErrors.PASSCODE_INVALID, }); + } - // user should be authenticated by this point, so check if the response is a complete login response - // if not, we return an error - if (!isChallengeAnswerCompleteLoginResponse(challengeAnswerResponse)) { - throw new OAuthError({ - error: 'invalid_response', - error_description: - 'Invalid challenge/answer response - no complete login response', - }); - } + // attempt to answer the passcode challenge, if this fails, it falls through to the catch block where we handle the error + const challengeAnswerResponse = await submitPasscode({ + passcode: code, + stateHandle, + introspectRemediation: + // if the user is in the `ACTIVE_PASSWORD_ONLY` state, then when they sign in with a passcode + // they will need the `select-authenticator-enroll` remediation to enroll in the email authenticator + // other users will have the `challenge-authenticator` remediation + userState === 'ACTIVE_PASSWORD_ONLY' + ? 'select-authenticator-enroll' + : 'challenge-authenticator', + ip: req.ip, + }); - // retrieve the user groups - const groups = await getUserGroups( - challengeAnswerResponse.user.value.id, - req.ip, - ); + // user should be authenticated by this point, so check if the response is a complete login response + // if not, we return an error + if (!isChallengeAnswerCompleteLoginResponse(challengeAnswerResponse)) { + throw new OAuthError({ + error: 'invalid_response', + error_description: + 'Invalid challenge/answer response - no complete login response', + }); + } - // check if the user has their email validated based on group membership - const emailValidated = groups.some( - (group) => group.profile.name === 'GuardianUser-EmailValidated', - ); + // retrieve the user groups + const groups = await getUserGroups( + challengeAnswerResponse.user.value.id, + req.ip, + ); - // if the user is not in the GuardianUser-EmailValidated group, we should update the user's emailValidated flag - // as they've now validated their email - if (!emailValidated) { - await validateEmailAndPasswordSetSecurely({ - id: challengeAnswerResponse.user.value.id, - ip: req.ip, - flagStatus: true, - updateEmail: true, - updatePassword: false, - }); - } + // check if the user has their email validated based on group membership + const emailValidated = groups.some( + (group) => group.profile.name === 'GuardianUser-EmailValidated', + ); - // update the encrypted state cookie to show the passcode was used - // so that if the user clicks back to the email sent page, they will be shown a message - updateEncryptedStateCookie(req, res, { - passcodeUsed: true, - stateHandle: undefined, - stateHandleExpiresAt: undefined, - userState: undefined, + // if the user is not in the GuardianUser-EmailValidated group, we should update the user's emailValidated flag + // as they've now validated their email + if (!emailValidated) { + await validateEmailAndPasswordSetSecurely({ + id: challengeAnswerResponse.user.value.id, + ip: req.ip, + flagStatus: true, + updateEmail: true, + updatePassword: false, }); + } - // continue allowing the user to log in - const loginRedirectUrl = getLoginRedirectUrl(challengeAnswerResponse); - - // fire ophan component event if applicable - if (res.locals.queryParams.componentEventParams) { - void sendOphanComponentEventFromQueryParamsServer( - res.locals.queryParams.componentEventParams, - 'SIGN_IN', - 'web', - res.locals.ophanConfig.consentUUID, - ); - } + // update the encrypted state cookie to show the passcode was used + // so that if the user clicks back to the email sent page, they will be shown a message + updateEncryptedStateCookie(req, res, { + passcodeUsed: true, + stateHandle: undefined, + stateHandleExpiresAt: undefined, + userState: undefined, + }); - // if the user has made it here, they've successfully authenticated - trackMetric('OktaIdxSignIn::Success'); - trackMetric('OktaIdxPasscodeSignIn::Success'); + // continue allowing the user to log in + const loginRedirectUrl = getLoginRedirectUrl(challengeAnswerResponse); - // redirect the user to set a global session and then back to completing the authorization flow - return res.redirect(303, loginRedirectUrl); - } catch (error) { - // handle passcode specific error - handlePasscodeError({ - error, - req, - res, - emailSentPage: '/signin/code', - expiredPage: '/signin/code/expired', - }); + // fire ophan component event if applicable + if (res.locals.queryParams.componentEventParams) { + void sendOphanComponentEventFromQueryParamsServer( + res.locals.queryParams.componentEventParams, + 'SIGN_IN', + 'web', + res.locals.ophanConfig.consentUUID, + ); + } - // if we redirected away during the handlePasscodeError function, we can't redirect again - if (res.headersSent) { - return; - } + // if the user has made it here, they've successfully authenticated + trackMetric('OktaIdxSignIn::Success'); + trackMetric('OktaIdxPasscodeSignIn::Success'); - // log the error - logger.error('Okta oktaIdxApiSignInPasscodeController failed', error); + // redirect the user to set a global session and then back to completing the authorization flow + return res.redirect(303, loginRedirectUrl); + } catch (error) { + // handle passcode specific error + handlePasscodeError({ + error, + req, + res, + emailSentPage, + expiredPage, + }); - // error metric - trackMetric('OktaIdxSignIn::Failure'); - trackMetric('OktaIdxPasscodeSignIn::Failure'); + // if we redirected away during the handlePasscodeError function, we can't redirect again + if (res.headersSent) { + return; + } - // handle any other error, show generic error message - const html = renderer('/signin/code', { - requestState: mergeRequestState(res.locals, { - queryParams: { - ...res.locals.queryParams, - emailSentSuccess: false, + // log the error + logger.error('Okta oktaIdxApiSignInPasscodeController failed', error); + + // error metric + trackMetric('OktaIdxSignIn::Failure'); + trackMetric('OktaIdxPasscodeSignIn::Failure'); + + // handle any other error, show generic error message + const html = renderer(emailSentPage, { + requestState: mergeRequestState(res.locals, { + queryParams: { + ...res.locals.queryParams, + emailSentSuccess: false, + }, + pageData: { + email: encryptedState?.email, + timeUntilTokenExpiry: convertExpiresAtToExpiryTimeInMs( + encryptedState?.stateHandleExpiresAt, + ), + formError: { + message: GenericErrors.DEFAULT, + severity: 'UNEXPECTED', }, - pageData: { - email: encryptedState?.email, - timeUntilTokenExpiry: convertExpiresAtToExpiryTimeInMs( - encryptedState?.stateHandleExpiresAt, - ), - formError: { - message: GenericErrors.DEFAULT, - severity: 'UNEXPECTED', - }, - token: code, - }, - }), - pageTitle: 'Check Your Inbox', - }); - return res.type('html').send(html); - } + token: code, + }, + }), + pageTitle: 'Check Your Inbox', + }); + return res.type('html').send(html); } - }, -); + } +}; diff --git a/src/server/lib/okta/idx/startIdxFlow.ts b/src/server/lib/okta/idx/startIdxFlow.ts index 8fd96d69a..5cfe8f710 100644 --- a/src/server/lib/okta/idx/startIdxFlow.ts +++ b/src/server/lib/okta/idx/startIdxFlow.ts @@ -13,7 +13,7 @@ import { ResponseWithRequestState } from '@/server/models/Express'; import { PerformAuthorizationCodeFlowOptions } from '@/server/lib/okta/oauth'; import { RegistrationConsents } from '@/shared/model/RegistrationConsents'; -type StartIdxFlowParams = { +export type StartIdxFlowParams = { req: Request; res: ResponseWithRequestState; authorizationCodeFlowOptions: Pick< diff --git a/src/server/models/okta/User.ts b/src/server/models/okta/User.ts index a860a2d05..ec9ec8df9 100644 --- a/src/server/models/okta/User.ts +++ b/src/server/models/okta/User.ts @@ -138,7 +138,7 @@ export type SessionResponse = z.infer; * @values ACTIVE_EMAIL_PASSWORD - ACTIVE - user has email + password authenticator (okta idx email verified) * @values ACTIVE_PASSWORD_ONLY - ACTIVE - user has only password authenticator (okta idx email not verified) * @values ACTIVE_EMAIL_ONLY - ACTIVE - user has only email authenticator (okta idx email verified) - not currently used in any code - * @values NOT_ACTIVE - user not in ACTIVE state - not currently used in any code + * @values NOT_ACTIVE - user not in ACTIVE state - likely a new user */ export type InternalOktaUserState = | 'NON_EXISTENT' diff --git a/src/server/routes/register.ts b/src/server/routes/register.ts index a537b3f6a..7e7a6da6f 100644 --- a/src/server/routes/register.ts +++ b/src/server/routes/register.ts @@ -1,4 +1,4 @@ -import { Request } from 'express'; +import { NextFunction, Request } from 'express'; import handleRecaptcha from '@/server/lib/recaptcha'; import { readEncryptedStateCookie, @@ -54,6 +54,10 @@ import { convertExpiresAtToExpiryTimeInMs } from '@/server/lib/okta/idx/shared/c import { submitPasscode } from '@/server/lib/okta/idx/shared/submitPasscode'; import { findAuthenticatorId } from '@/server/lib/okta/idx/shared/findAuthenticatorId'; import { handlePasscodeError } from '@/server/lib/okta/idx/shared/errorHandling'; +import { + oktaIdxApiSignInPasscodeController, + oktaIdxApiSubmitPasscodeController, +} from '@/server/controllers/signInControllers'; const { passcodesEnabled: passcodesEnabled } = getConfiguration(); @@ -144,101 +148,115 @@ router.get('/register/code', (req: Request, res: ResponseWithRequestState) => { router.post( '/register/code', redirectIfLoggedIn, - handleAsyncErrors(async (req: Request, res: ResponseWithRequestState) => { - const { code } = req.body; + handleAsyncErrors( + async (req: Request, res: ResponseWithRequestState, next: NextFunction) => { + const { code } = req.body; + + const encryptedState = readEncryptedStateCookie(req); + + // make sure we have the encrypted state cookie and the code otherwise redirect to the email registration page + if (encryptedState?.stateHandle && code) { + const { stateHandle, userState } = encryptedState; + + try { + // if the user is not in the NON_ACTIVE state, we know they're an existing user + // going through the create account flow, so we use the oktaIdxApiSubmitPasscodeController + // instead of the flow for new users + if (userState && userState !== 'NOT_ACTIVE') { + return oktaIdxApiSubmitPasscodeController({ + req, + res, + emailSentPage: '/register/email-sent', + expiredPage: '/register/email', + }); + } - const encryptedState = readEncryptedStateCookie(req); + // attempt to answer the passcode challenge, if this fails, it falls through to the catch block where we handle the error + const challengeAnswerResponse = await submitPasscode({ + passcode: code, + stateHandle, + introspectRemediation: 'enroll-authenticator', + ip: req.ip, + }); - // make sure we have the encrypted state cookie and the code otherwise redirect to the email registration page - if (encryptedState?.stateHandle && code) { - const { stateHandle } = encryptedState; + if (isChallengeAnswerCompleteLoginResponse(challengeAnswerResponse)) { + throw new OAuthError({ + error: 'invalid_response', + error_description: + 'Invalid challenge/answer response - got a complete login response', + }); + } - try { - // attempt to answer the passcode challenge, if this fails, it falls through to the catch block where we handle the error - const challengeAnswerResponse = await submitPasscode({ - passcode: code, - stateHandle, - introspectRemediation: 'enroll-authenticator', - ip: req.ip, - }); + // check if the remediation array contains the correct remediation object supplied + // if it does, then we know that we're in the correct state and the passcode was correct + validateChallengeAnswerRemediation( + challengeAnswerResponse, + 'select-authenticator-enroll', + ); - if (isChallengeAnswerCompleteLoginResponse(challengeAnswerResponse)) { - throw new OAuthError({ - error: 'invalid_response', - error_description: - 'Invalid challenge/answer response - got a complete login response', + // if passcode challenge is successful, we can proceed to the next step + // which is to enroll a password authenticator, as we still need users to set a password for the time being + // we first look for the password authenticator id deep in the response + const passwordAuthenticatorId = findAuthenticatorId({ + response: challengeAnswerResponse, + remediationName: 'select-authenticator-enroll', + authenticator: 'password', }); - } - // check if the remediation array contains the correct remediation object supplied - // if it does, then we know that we're in the correct state and the passcode was correct - validateChallengeAnswerRemediation( - challengeAnswerResponse, - 'select-authenticator-enroll', - ); - - // if passcode challenge is successful, we can proceed to the next step - // which is to enroll a password authenticator, as we still need users to set a password for the time being - // we first look for the password authenticator id deep in the response - const passwordAuthenticatorId = findAuthenticatorId({ - response: challengeAnswerResponse, - remediationName: 'select-authenticator-enroll', - authenticator: 'password', - }); + if (!passwordAuthenticatorId) { + throw new OAuthError( + { + error: 'idx_error', + error_description: 'Password authenticator id not found', + }, + 400, + ); + } - if (!passwordAuthenticatorId) { - throw new OAuthError( - { - error: 'idx_error', - error_description: 'Password authenticator id not found', - }, - 400, + // start the credential enroll flow + await credentialEnroll( + stateHandle, + { id: passwordAuthenticatorId, methodType: 'password' }, + req.ip, ); - } - - // start the credential enroll flow - await credentialEnroll( - stateHandle, - { id: passwordAuthenticatorId, methodType: 'password' }, - req.ip, - ); - updateEncryptedStateCookie(req, res, { - passcodeUsed: true, - }); + updateEncryptedStateCookie(req, res, { + passcodeUsed: true, + }); - // redirect to the password page to set a password - return res.redirect( - 303, - addQueryParamsToPath('/welcome/password', res.locals.queryParams), - ); - } catch (error) { - // track and log the failure - logger.error(`IDX API - ${req.path} error:`, error); + // redirect to the password page to set a password + return res.redirect( + 303, + addQueryParamsToPath('/welcome/password', res.locals.queryParams), + ); + } catch (error) { + // track and log the failure + logger.error(`IDX API - ${req.path} error:`, error); + + handlePasscodeError({ + error, + req, + res, + emailSentPage: '/register/email-sent', + expiredPage: '/welcome/expired', + }); - handlePasscodeError({ - error, - req, - res, - emailSentPage: '/register/email-sent', - expiredPage: '/welcome/expired', - }); + // if we redirected away during the handlePasscodeError function, we can't redirect again + if (res.headersSent) { + return; + } - // if we redirected away during the handlePasscodeError function, we can't redirect again - if (res.headersSent) { - return; + // at this point fall back to the legacy Okta registration flow } - - // at this point fall back to the legacy Okta registration flow } - } - // if we reach this point, redirect back to the email registration page, as something has gone wrong - return res.redirect( - 303, - addQueryParamsToPath('/register/email', res.locals.queryParams), - ); - }), + // if we reach this point, redirect back to the email registration page, as something has gone wrong + return res.redirect( + 303, + addQueryParamsToPath('/register/email', res.locals.queryParams), + ); + }, + ), ); // Route to resend the email for passcode registration @@ -251,7 +269,19 @@ router.post( // make sure we have the state handle if (encryptedState?.stateHandle) { - const { stateHandle } = encryptedState; + const { stateHandle, userState } = encryptedState; + + // if the user is not in the NON_ACTIVE state, we know they're an existing user + // going through the create account flow, so we use the oktaIdxApiSignInPasscodeController + // instead of the flow for new users + if (userState && userState !== 'NOT_ACTIVE') { + return oktaIdxApiSignInPasscodeController({ + req, + res, + emailSentPage: '/register/email-sent', + confirmationPagePath: '/welcome/existing', + }); + } try { // check the state handle is valid @@ -422,6 +452,7 @@ const oktaIdxCreateAccount = async ( email, stateHandle: enrollNewWithEmailResponse.stateHandle, stateHandleExpiresAt: enrollNewWithEmailResponse.expiresAt, + userState: 'NOT_ACTIVE', // lets us know that the user is a new user }); // fire ophan component event if applicable @@ -447,6 +478,14 @@ const oktaIdxCreateAccount = async ( // case for user already exists // will implement when full passwordless is implemented trackMetric('ExistingUserInCreateAccountFlow'); + + // instead we use the passcode sign in controller, and redirect to /welcome/existing at the end + return oktaIdxApiSignInPasscodeController({ + req, + res, + emailSentPage: '/register/email-sent', + confirmationPagePath: '/welcome/existing', + }); } } diff --git a/src/server/routes/signIn.ts b/src/server/routes/signIn.ts index 8e14b3571..6f0f59f47 100644 --- a/src/server/routes/signIn.ts +++ b/src/server/routes/signIn.ts @@ -310,7 +310,9 @@ router.get( router.post( '/signin/code', redirectIfLoggedIn, - oktaIdxApiSubmitPasscodeController, + handleAsyncErrors(async (req: Request, res: ResponseWithRequestState) => { + return await oktaIdxApiSubmitPasscodeController({ req, res }); + }), ); router.get( diff --git a/src/server/routes/welcome.ts b/src/server/routes/welcome.ts index 06d0c78ea..90eda1235 100644 --- a/src/server/routes/welcome.ts +++ b/src/server/routes/welcome.ts @@ -451,6 +451,22 @@ router.post( }, ); +// existing user using create account flow page +router.get( + '/welcome/existing', + (req: Request, res: ResponseWithRequestState) => { + const html = renderer('/welcome/existing', { + requestState: mergeRequestState(res.locals, { + pageData: { + email: readEmailCookie(req), + }, + }), + pageTitle: 'Welcome back', + }); + return res.type('html').send(html); + }, +); + // welcome page, check token and display set password page router.get( '/welcome/:token', diff --git a/src/shared/model/PageTitle.ts b/src/shared/model/PageTitle.ts index 7e7b91938..55fae6e7b 100644 --- a/src/shared/model/PageTitle.ts +++ b/src/shared/model/PageTitle.ts @@ -35,7 +35,8 @@ export type PageTitle = | 'Account Deletion' | 'Account Deletion Complete' | 'Account Deletion Blocked' - | 'Choose Newsletters'; + | 'Choose Newsletters' + | 'Welcome back'; export type PasswordPageTitle = Extract< 'Welcome' | 'Create Password' | 'Change Password', diff --git a/src/shared/model/Routes.ts b/src/shared/model/Routes.ts index 288554cbe..43f804944 100644 --- a/src/shared/model/Routes.ts +++ b/src/shared/model/Routes.ts @@ -76,6 +76,7 @@ export const ValidRoutePathsArray = [ '/welcome/:app/complete', '/welcome/complete', '/welcome/email-sent', + '/welcome/existing', '/welcome/expired', '/welcome/resend', '/welcome/google',