diff --git a/cypress/integration/ete/reset_password_passcode.7.cy.ts b/cypress/integration/ete/reset_password_passcode.7.cy.ts index d88708edd..1770b3a9b 100644 --- a/cypress/integration/ete/reset_password_passcode.7.cy.ts +++ b/cypress/integration/ete/reset_password_passcode.7.cy.ts @@ -756,4 +756,29 @@ describe('Password reset recovery flows - with Passcodes', () => { }); }); }); + + context('NON_EXISTENT user', () => { + it('shows the passcode page with no account info, and using passcode returns error', () => { + const emailAddress = randomMailosaurEmail(); + cy.visit(`/reset-password?usePasscodesResetPassword=true`); + + cy.contains('Reset password'); + cy.get('input[name=email]').type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); + + // passcode page + cy.url().should('include', '/reset-password/email-sent'); + cy.contains('Enter your one-time code'); + cy.contains('Don’t have an account?'); + + cy.get('input[name=code]').clear().type('123456'); + cy.contains('Submit one-time code').click(); + + cy.url().should('include', '/reset-password/code'); + cy.contains('Enter your one-time code'); + cy.contains('Don’t have an account?'); + + cy.contains('Incorrect code'); + }); + }); }); diff --git a/src/client/pages/PasscodeEmailSent.stories.tsx b/src/client/pages/PasscodeEmailSent.stories.tsx index ed30ee8eb..58e6d9ae4 100644 --- a/src/client/pages/PasscodeEmailSent.stories.tsx +++ b/src/client/pages/PasscodeEmailSent.stories.tsx @@ -47,6 +47,20 @@ WithPasscode.story = { name: 'with passcode', }; +export const WithNoAccountInfo = () => ( + +); +WithNoAccountInfo.story = { + name: 'with no account info', +}; + export const WithPasscodeError = () => ( { const [recaptchaErrorMessage, setRecaptchaErrorMessage] = useState(''); const [recaptchaErrorContext, setRecaptchaErrorContext] = @@ -147,6 +149,7 @@ export const PasscodeEmailSent = ({ formError={formError} queryString={queryString} shortRequestId={shortRequestId} + noAccountInfo={noAccountInfo} /> ); diff --git a/src/client/pages/ResetPasswordEmailSentPage.tsx b/src/client/pages/ResetPasswordEmailSentPage.tsx index 2b2733c99..f1c5f9c90 100644 --- a/src/client/pages/ResetPasswordEmailSentPage.tsx +++ b/src/client/pages/ResetPasswordEmailSentPage.tsx @@ -42,6 +42,7 @@ export const ResetPasswordEmailSentPage = () => { fieldErrors={fieldErrors} passcode={token} expiredPage={buildUrl('/reset-password/expired')} + noAccountInfo /> ); } diff --git a/src/server/controllers/sendChangePasswordEmail.ts b/src/server/controllers/sendChangePasswordEmail.ts index 19da1f882..73ebaa7a0 100644 --- a/src/server/controllers/sendChangePasswordEmail.ts +++ b/src/server/controllers/sendChangePasswordEmail.ts @@ -100,7 +100,7 @@ const setEncryptedCookieOkta = ( * - [x] With only password authenticator * - [x] With only email authenticator * - [x] Non-ACTIVE user states - * - [ ] Non-Existent users + * - [x] Non-Existent users - In `sendEmailInOkta` method * * @param {Request} req - Express request object * @param {ResponseWithRequestState} res - Express response object @@ -472,6 +472,11 @@ const changePasswordEmailIdx = async ( flagStatus: false, }); + // track the success metrics + trackMetric( + `OktaIDXResetPasswordSend::${user.status as Status}::Success`, + ); + // now that the placeholder password has been set, the user will be in // 1. ACTIVE users - has email + password authenticator (okta idx email verified) // or 2. ACTIVE users - has only password authenticator (okta idx email not verified) @@ -495,6 +500,9 @@ const changePasswordEmailIdx = async ( logger.error('Okta changePasswordEmailIdx failed', error); + // track the failure metrics + trackMetric(`OktaIDXResetPasswordSend::${user.status as Status}::Failure`); + // don't throw the error, so we can fall back to okta classic flow } }; @@ -812,6 +820,34 @@ export const sendEmailInOkta = async ( error.status === 404 && error.code === 'E0000007' ) { + // if we're using passcodes, then show the email sent page with OTP input + // even if the user doesn't exist + if (passcodesEnabled && usePasscodesResetPassword) { + // set the encrypted state cookie to persist the email and stateHandle + // which we need to persist during the passcode reset flow + setEncryptedStateCookie(res, { + email, + stateHandle: `02.id.${crypto.randomBytes(30).toString('base64')}`, // generate a 40 character random string to use in the 46 character stateHandle + // 30 minutes in the future + stateHandleExpiresAt: new Date( + Date.now() + 30 * 60 * 1000, + ).toISOString(), + userState: 'NON_EXISTENT', // set the user state to non-existent, so we can handle this case if the user attempts to submit the passcode + }); + + // track the success metrics + trackMetric(`OktaIDXResetPasswordSend::NON_EXISTENT::Success`); + + // show the email sent page, with passcode instructions + return res.redirect( + 303, + addQueryParamsToPath( + '/reset-password/email-sent', + res.locals.queryParams, + ), + ); + } + setEncryptedCookieOkta(res, email); return res.redirect( diff --git a/src/server/lib/encryptedStateCookie.ts b/src/server/lib/encryptedStateCookie.ts index 2a5c926d9..31a15549b 100644 --- a/src/server/lib/encryptedStateCookie.ts +++ b/src/server/lib/encryptedStateCookie.ts @@ -21,11 +21,27 @@ export const setEncryptedStateCookie = ( res: Response, state: EncryptedState, ) => { + // validate and modify any fields before encrypting + const validated: EncryptedState = { + ...state, + // We only need the first part of the state handle before the delimiter + // which is also much shorter to reduce the size of the cookie, but everything + // continues to work as expected + stateHandle: state.stateHandle?.split('~')[0], + }; + + // remove any undefined values + const cleaned: EncryptedState = Object.fromEntries( + Object.entries(validated).filter(([, value]) => value !== undefined), + ); + + // encrypt the state const encrypted = encrypt( - JSON.stringify(state), + JSON.stringify(cleaned), getConfiguration().encryptionSecretKey, // prevent the key from lingering in memory by only calling when needed ); + // set the cookie return res.cookie( encryptedStateCookieName, encrypted, diff --git a/src/server/lib/okta/idx/shared/errorHandling.ts b/src/server/lib/okta/idx/shared/errorHandling.ts index 7a6776933..2f7ffd9a8 100644 --- a/src/server/lib/okta/idx/shared/errorHandling.ts +++ b/src/server/lib/okta/idx/shared/errorHandling.ts @@ -48,7 +48,7 @@ export const handlePasscodeError = ({ const html = renderer(emailSentPage, { requestState: mergeRequestState(state, { queryParams: { - returnUrl: state.queryParams.returnUrl, + ...state.queryParams, emailSentSuccess: false, }, pageData: { diff --git a/src/server/models/Metrics.ts b/src/server/models/Metrics.ts index 0fdcc24a5..a0e484253 100644 --- a/src/server/models/Metrics.ts +++ b/src/server/models/Metrics.ts @@ -38,6 +38,7 @@ type ConditionalMetrics = | 'OktaIDXInteract' | 'OktaIDXRegister' | 'OktaIDXResetPasswordSend' + | `OktaIDXResetPasswordSend::NON_EXISTENT` | `OktaIDXResetPasswordSend::${Status}` | `OktaIDX::${IDXPath}` | 'OktaRegistration' diff --git a/src/server/routes/resetPassword.ts b/src/server/routes/resetPassword.ts index df6660f36..948305463 100644 --- a/src/server/routes/resetPassword.ts +++ b/src/server/routes/resetPassword.ts @@ -25,6 +25,8 @@ import { import { OAuthError } from '@/server/models/okta/Error'; import { forgotPassword } from '@/server/lib/okta/api/users'; import { buildUrlWithQueryParams } from '@/shared/lib/routeUtils'; +import { GenericErrors, RegistrationErrors } from '@/shared/model/Errors'; +import { logger } from '@/server/lib/serverSideLogger'; // reset password email form router.get('/reset-password', (req: Request, res: ResponseWithRequestState) => { @@ -145,9 +147,18 @@ router.post( // make sure we have the encrypted state cookie and the code otherwise redirect to the reset page if (encryptedState?.stateHandle && code) { - const { stateHandle } = encryptedState; + 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, + }); + } + // submit the passcode to Okta const challengeAnswerResponse = await submitPasscode({ passcode: code, @@ -250,6 +261,32 @@ router.post( if (res.headersSent) { return; } + + // log the error + logger.error(`${req.method} ${req.originalUrl} Error`, error); + + // handle any other error, show generic error message + const html = renderer('/reset-password/code', { + requestState: mergeRequestState(res.locals, { + queryParams: { + ...res.locals.queryParams, + emailSentSuccess: false, + }, + 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); } }