From 49b6ed02e1a1d3c3c659af2caad80a281dd491ce Mon Sep 17 00:00:00 2001 From: Mahesh Makani Date: Wed, 16 Oct 2024 09:58:03 +0100 Subject: [PATCH 01/10] feat(routes): add `/signin/code` and `/signin/code/expired` to list --- src/shared/model/Routes.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/shared/model/Routes.ts b/src/shared/model/Routes.ts index b13856d65..68035ae8a 100644 --- a/src/shared/model/Routes.ts +++ b/src/shared/model/Routes.ts @@ -53,6 +53,8 @@ export const ValidRoutePathsArray = [ '/set-password/resend', '/signed-in-as', '/signin', + '/signin/code', + '/signin/code/expired', '/signin/refresh', '/signin/:social', '/signin/email-sent', From f995d4e9f58f4253ec876036d043d89606d729c7 Mon Sep 17 00:00:00 2001 From: Mahesh Makani Date: Wed, 16 Oct 2024 09:59:30 +0100 Subject: [PATCH 02/10] feat: add `SignInPasscodeEmailSentPage` component --- .../pages/SignInPasscodeEmailSentPage.tsx | 49 +++++++++++++++++++ src/client/routes.tsx | 5 ++ 2 files changed, 54 insertions(+) create mode 100644 src/client/pages/SignInPasscodeEmailSentPage.tsx diff --git a/src/client/pages/SignInPasscodeEmailSentPage.tsx b/src/client/pages/SignInPasscodeEmailSentPage.tsx new file mode 100644 index 000000000..6d26d253d --- /dev/null +++ b/src/client/pages/SignInPasscodeEmailSentPage.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import useClientState from '@/client/lib/hooks/useClientState'; +import { buildQueryParamsString } from '@/shared/lib/queryParams'; +import { buildUrl } from '@/shared/lib/routeUtils'; +import { PasscodeEmailSent } from '@/client/pages/PasscodeEmailSent'; +import { SignedInAsPage } from './SignedInAsPage'; + +export const SignInPasscodeEmailSentPage = () => { + const clientState = useClientState(); + const { + pageData = {}, + queryParams, + globalMessage = {}, + recaptchaConfig, + shortRequestId, + } = clientState; + const { email, fieldErrors, token, passcodeUsed } = pageData; + const { emailSentSuccess } = queryParams; + const { error } = globalMessage; + const { recaptchaSiteKey } = recaptchaConfig; + + const queryString = buildQueryParamsString(queryParams, { + emailSentSuccess: true, + }); + + // if the passcode has already been used, show the signed in as page + if (passcodeUsed) { + return ; + } + + return ( + + ); +}; diff --git a/src/client/routes.tsx b/src/client/routes.tsx index 3c3e9bb56..273981d62 100644 --- a/src/client/routes.tsx +++ b/src/client/routes.tsx @@ -41,6 +41,7 @@ import { NewAccountReviewPage } from '@/client/pages/NewAccountReviewPage'; import { NewAccountNewslettersPage } from '@/client/pages/NewAccountNewslettersPage'; import { VerifyEmailResetPasswordPage } from '@/client/pages/VerifyEmailResetPasswordPage'; import { ResetPasswordEmailSentPage } from '@/client/pages/ResetPasswordEmailSentPage'; +import { SignInPasscodeEmailSentPage } from '@/client/pages/SignInPasscodeEmailSentPage'; export type RoutingConfig = { clientState: ClientState; @@ -65,6 +66,10 @@ const routes: Array<{ ), }, + { + path: '/signin/code', + element: , + }, { path: '/reauthenticate', element: , From 43f7d2b4f3fbe1b94e857f473ef723f9d59d7dcf Mon Sep 17 00:00:00 2001 From: Mahesh Makani Date: Wed, 16 Oct 2024 10:05:16 +0100 Subject: [PATCH 03/10] chore(errors): add `PASSCODE_EXPIRED` error --- src/shared/model/Errors.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/model/Errors.ts b/src/shared/model/Errors.ts index 3f7881a4a..5855b5cf3 100644 --- a/src/shared/model/Errors.ts +++ b/src/shared/model/Errors.ts @@ -24,6 +24,7 @@ export enum SignInErrors { GENERIC = 'There was a problem signing in, please try again.', AUTHENTICATION_FAILED = 'Email and password don’t match', SOCIAL_SIGNIN_ERROR = 'Social sign-in unsuccessful', + PASSCODE_EXPIRED = 'Your code has expired', } export enum RegistrationErrors { From 3495c9b5ebb20ce9c58f98f19ca5a4b98b1dc351 Mon Sep 17 00:00:00 2001 From: Mahesh Makani Date: Wed, 16 Oct 2024 10:08:30 +0100 Subject: [PATCH 04/10] feat(passcodes): sign in with passcodes server functionality --- src/server/controllers/signInControllers.ts | 378 +++++++++++++++++++- src/server/models/Metrics.ts | 2 + src/server/routes/signIn.ts | 63 ++++ 3 files changed, 439 insertions(+), 4 deletions(-) diff --git a/src/server/controllers/signInControllers.ts b/src/server/controllers/signInControllers.ts index 87ab9dc7e..c786ba74c 100644 --- a/src/server/controllers/signInControllers.ts +++ b/src/server/controllers/signInControllers.ts @@ -1,7 +1,12 @@ +import crypto from 'node:crypto'; import { Request } from 'express'; import { addQueryParamsToPath } from '@/shared/lib/queryParams'; import { isBreachedPassword } from '@/server/lib/breachedPasswordCheck'; -import { setEncryptedStateCookie } from '@/server/lib/encryptedStateCookie'; +import { + readEncryptedStateCookie, + setEncryptedStateCookie, + updateEncryptedStateCookie, +} from '@/server/lib/encryptedStateCookie'; import { isOktaError } from '@/server/lib/okta/api/errors'; import { getUser, getUserGroups } from '@/server/lib/okta/api/users'; import { @@ -18,7 +23,10 @@ import { } from '@/server/lib/okta/idx/identify'; import { findAuthenticatorId } from '@/server/lib/okta/idx/shared/findAuthenticatorId'; import { getLoginRedirectUrl } from '@/server/lib/okta/idx/shared/idxFetch'; -import { submitPassword } from '@/server/lib/okta/idx/shared/submitPasscode'; +import { + submitPasscode, + submitPassword, +} from '@/server/lib/okta/idx/shared/submitPasscode'; import { startIdxFlow } from '@/server/lib/okta/idx/startIdxFlow'; import { sendOphanComponentEventFromQueryParamsServer } from '@/server/lib/ophan'; import { renderer } from '@/server/lib/renderer'; @@ -28,8 +36,21 @@ import { trackMetric } from '@/server/lib/trackMetric'; import { ResponseWithRequestState } from '@/server/models/Express'; import { OAuthError, OktaError } from '@/server/models/okta/Error'; import { changePasswordEmailIdx } from '@/server/controllers/sendChangePasswordEmail'; -import { GatewayError, SignInErrors } from '@/shared/model/Errors'; +import { + GatewayError, + GenericErrors, + 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'; import { UserResponse } from '@/server/models/okta/User'; +import { + forceUserIntoActiveState, + sendVerifyEmailAuthenticatorIdx, +} from '@/server/controllers/oktaIdxShared'; /** * @name SignInError @@ -148,6 +169,194 @@ const startIdxSignInFlow = async ({ return [identifyResponse, user]; }; +/** + * @name oktaIdxApiSignInPasscodeController + * @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 + * @returns {Promise} + */ +const oktaIdxApiSignInPasscodeController = async ({ + req, + res, + loopDetectionFlag = false, +}: { + req: Request; + res: ResponseWithRequestState; + loopDetectionFlag?: boolean; +}) => { + const { email = '' } = req.body; + + try { + // start the IDX flow to sign the user in and get the identify response and user object + const [identifyResponse, user] = await startIdxSignInFlow({ + email, + req, + res, + }); + + // determine the user status and what action to take + switch (user.status) { + case 'ACTIVE': { + /** + * + * If the user is ACTIVE, then they'll be in one of 3 states: + * 1. ACTIVE users - has email + password authenticator (okta idx email verified) + * 2. ACTIVE users - has only password authenticator (okta idx email not verified) + * 3. ACTIVE users - has only email authenticator (SOCIAL users - no password, or passcode only users (not implemented yet)) + * + * We can identify the users state by calling the IDX API /identify endpoint + * and checking the authenticators available + * + * Depending on their state, we have to perform different steps to reset their password + * + * This only happens when the "Username enumeration protection" setting is disabled in Okta + * under Security > General > User enumeration prevention + * + * When this is enabled, the IDX API will behave the same for every user, + * regardless of their status + * + * When disabled, the IDX API will return different remediations based on the + * user's status, which is helpful for us to determine the correct flow + * + */ + // check for the "email" authenticator, we can authenticate with email (passcode) + // if this authenticator is present + const emailAuthenticatorId = findAuthenticatorId({ + authenticator: 'email', + response: identifyResponse, + remediationName: 'select-authenticator-authenticate', + }); + + // we also check for the "password" authenticator, to identify the user state + const passwordAuthenticatorId = findAuthenticatorId({ + authenticator: 'password', + response: identifyResponse, + remediationName: 'select-authenticator-authenticate', + }); + + // if the user has email authenticator, we can send them a passcode + if (emailAuthenticatorId) { + // user has email authenticator so: + // 1. ACTIVE users - has email + password authenticator (okta idx email verified) + // 3. ACTIVE users - has only email authenticator (SOCIAL users, no password) + // these users can be sent a passcode to sign in + + // call the "challenge" endpoint to start the email challenge process + // and send the user a passcode + const challengeEmailResponse = await challenge( + identifyResponse.stateHandle, + { + id: emailAuthenticatorId, + methodType: 'email', + }, + req.ip, + ); + + // track send metric + trackMetric('OktaIdxSendPasscodeSignIn::Success'); + + // set the encrypted state cookie to persist the email and stateHandle + // which we need to persist during the passcode reset flow + setEncryptedStateCookie(res, { + email: user.profile.email, + stateHandle: challengeEmailResponse.stateHandle, + stateHandleExpiresAt: challengeEmailResponse.expiresAt, + userState: passwordAuthenticatorId + ? 'ACTIVE_EMAIL_PASSWORD' + : 'ACTIVE_EMAIL_ONLY', + }); + + return res.redirect( + 303, + addQueryParamsToPath('/signin/code', res.locals.queryParams), + ); + } + + if (passwordAuthenticatorId) { + // user has only password authenticator so they're in the + // 2. ACTIVE users - has only password authenticator (okta idx email not verified) + // We need to send these users a verification email with passcode to verify their + // email before they can sign in. + // See the method documentation for additional context + await sendVerifyEmailAuthenticatorIdx({ + user, + identifyResponse, + passwordAuthenticatorId, + req, + res, + }); + + // track send metric + trackMetric('OktaIdxSendPasscodeSignIn::Success'); + + return res.redirect( + 303, + addQueryParamsToPath('/signin/code', res.locals.queryParams), + ); + } + + // if they don't have either authenticator, we cannot authenticate the user + // so throw an error + throw new OktaError({ + code: 'E0000004', + message: 'User does not have email or password authenticator', + }); + } + default: { + // if a loop is detected, then throw early to prevent infinite loop + if (loopDetectionFlag) { + throw new OktaError({ + message: `Okta oktaIdxApiSignInPasscodeController failed with loop detection flag under non-ACTIVE user state ${user.status}`, + }); + } + + try { + // force the user into the ACTIVE state + // either + // 1. ACTIVE users - has email + password authenticator (okta idx email verified) + // 2. ACTIVE users - has only password authenticator (okta idx email not verified) + await forceUserIntoActiveState({ + req, + user, + }); + + // call this method again to send the user a passcode as they'll now be in one of the ACTIVE states + return oktaIdxApiSignInPasscodeController({ + req, + res, + loopDetectionFlag: true, + }); + } catch (error) { + logger.error( + 'Okta force active user failed', + error instanceof OktaError ? error.message : error, + ); + throw error; + } + } + } + } catch (error) { + logger.error('Okta oktaIdxApiSignInPasscodeController failed', error); + + trackMetric('OktaIdxSendPasscodeSignIn::Failure'); + + // we always want to show the email sent page, even if an error has occurred + // to prevent user enumeration, so we mock the idx behaviour + setEncryptedStateCookie(res, { + email, + stateHandle: `02.id.${crypto.randomBytes(30).toString('base64')}`, // generate a 40 character random string to use in the 46 character stateHandle + stateHandleExpiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30 minutes in the future + userState: 'NON_EXISTENT', // set the user state to non-existent, so we can handle this case if the user attempts to submit the passcode + }); + + return res.redirect( + 303, + addQueryParamsToPath('/signin/code', res.locals.queryParams), + ); + } +}; + /** * @name oktaIdxApiSignInController * @description Start the Okta IDX flow to attempt to sign the user in with a password @@ -164,9 +373,19 @@ export const oktaIdxApiSignInController = async ({ res: ResponseWithRequestState; }) => { // get the email and password from the request body - const { email = '', password = '' } = req.body; + const { email = '', password = '', passcode } = req.body; try { + // `'on'` if checked, or `undefined` if not checked + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#value + // so check if it exists + const usePasscode = !!passcode; + + // if we do, hand off to the oktaIdxApiSignInPasscodeController + if (usePasscode) { + return oktaIdxApiSignInPasscodeController({ req, res }); + } + // start the IDX flow to sign the user in and get the identify response and user object const [identifyResponse, user] = await startIdxSignInFlow({ email, @@ -357,3 +576,154 @@ export const oktaIdxApiSignInController = async ({ return res.status(status).type('html').send(html); } }; + +/** + * @name oktaIdxApiSubmitPasscodeController + * @description Controller to handle the passcode submission from the passcode email sent page + * + * @param {Request} req - Express request object + * @param {ResponseWithRequestState} res - Express response object + * @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, + }); + } + + // 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, + }); + + // 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', + }); + } + + // retrieve the user groups + const groups = await getUserGroups( + challengeAnswerResponse.user.value.id, + req.ip, + ); + + // check if the user has their email validated based on group membership + const emailValidated = groups.some( + (group) => group.profile.name === 'GuardianUser-EmailValidated', + ); + + // 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, + }); + } + + // 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, + }); + + // 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, + ); + } + + // if the user has made it here, they've successfully authenticated + trackMetric('OktaIdxSignIn::Success'); + trackMetric('OktaIdxPasscodeSignIn::Success'); + + // 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', + }); + + // if we redirected away during the handlePasscodeError function, we can't redirect again + if (res.headersSent) { + return; + } + + // 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('/signin/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); + } + } + }, +); diff --git a/src/server/models/Metrics.ts b/src/server/models/Metrics.ts index cc2b38039..cd5132fe2 100644 --- a/src/server/models/Metrics.ts +++ b/src/server/models/Metrics.ts @@ -49,6 +49,8 @@ type ConditionalMetrics = | 'OktaSetPassword' | 'OktaSignIn' | 'OktaIdxSignIn' + | 'OktaIdxPasscodeSignIn' + | 'OktaIdxSendPasscodeSignIn' | 'OktaSignOut' | 'OktaSignOutGlobal' | 'OktaUpdatePassword' diff --git a/src/server/routes/signIn.ts b/src/server/routes/signIn.ts index a4603d0c6..8b343005b 100644 --- a/src/server/routes/signIn.ts +++ b/src/server/routes/signIn.ts @@ -48,8 +48,10 @@ import { } from '@/server/lib/okta/idx/introspect'; import { oktaIdxApiSignInController, + oktaIdxApiSubmitPasscodeController, oktaSignInControllerErrorHandler, } from '@/server/controllers/signInControllers'; +import { convertExpiresAtToExpiryTimeInMs } from '@/server/lib/okta/idx/shared/convertExpiresAtToExpiryTimeInMs'; const { okta, accountManagementUrl, defaultReturnUri, passcodesEnabled } = getConfiguration(); @@ -74,6 +76,11 @@ export const getErrorMessageFromQueryParams = ( if (error === RegistrationErrors.PROVISIONING_FAILURE) { return error; } + + if (error === SignInErrors.PASSCODE_EXPIRED) { + return SignInErrors.PASSCODE_EXPIRED; + } + // TODO: we're propagating a generic error message for now until we know what we're doing with the error_description parameter if (error_description) { return SignInErrors.GENERIC; @@ -246,6 +253,7 @@ router.post( router.post( '/signin', handleRecaptcha, + redirectIfLoggedIn, handleAsyncErrors((req: Request, res: ResponseWithRequestState) => { const { queryParams: { appClientId }, @@ -260,6 +268,61 @@ router.post( }), ); +// Essentially the email-sent page, but for passcode sign in +// we're not using /signin/email-sent as that route is used by the security/email validation flow +router.get( + '/signin/code', + redirectIfLoggedIn, + (req: Request, res: ResponseWithRequestState) => { + const state = res.locals; + + const encryptedState = readEncryptedStateCookie(req); + + if (encryptedState?.email && encryptedState.stateHandle) { + try { + const html = renderer('/signin/code', { + requestState: mergeRequestState(state, { + pageData: { + email: readEncryptedStateCookie(req)?.email, + timeUntilTokenExpiry: convertExpiresAtToExpiryTimeInMs( + encryptedState.stateHandleExpiresAt, + ), + }, + }), + pageTitle: 'Check Your Inbox', + }); + return res.type('html').send(html); + } catch (error) { + logger.error(`${req.method} ${req.originalUrl} Error`, error); + } + } + + // on error, redirect to the sign in page + return res.redirect( + 303, + addQueryParamsToPath('/signin', state.queryParams), + ); + }, +); + +router.post( + '/signin/code', + redirectIfLoggedIn, + oktaIdxApiSubmitPasscodeController, +); + +router.get( + '/signin/code/expired', + (_: Request, res: ResponseWithRequestState) => { + return res.redirect( + 303, + addQueryParamsToPath('/signin', res.locals.queryParams, { + error: SignInErrors.PASSCODE_EXPIRED, + }), + ); + }, +); + const oktaSignInController = async ({ req, res, From 79a913db839f1b9f4e219689c43fc9dd6e13e9bc Mon Sep 17 00:00:00 2001 From: Mahesh Makani Date: Wed, 16 Oct 2024 15:30:18 +0100 Subject: [PATCH 05/10] feat(passcodes): add resend functionality for sign in with passcodes --- src/server/controllers/signInControllers.ts | 4 ++-- src/server/routes/signIn.ts | 12 ++++++++++++ src/shared/model/Routes.ts | 1 + 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/server/controllers/signInControllers.ts b/src/server/controllers/signInControllers.ts index c786ba74c..c7b58f9c6 100644 --- a/src/server/controllers/signInControllers.ts +++ b/src/server/controllers/signInControllers.ts @@ -176,7 +176,7 @@ const startIdxSignInFlow = async ({ * @param {ResponseWithRequestState} res - Express response object * @returns {Promise} */ -const oktaIdxApiSignInPasscodeController = async ({ +export const oktaIdxApiSignInPasscodeController = async ({ req, res, loopDetectionFlag = false, @@ -184,7 +184,7 @@ const oktaIdxApiSignInPasscodeController = async ({ req: Request; res: ResponseWithRequestState; loopDetectionFlag?: boolean; -}) => { +}): Promise => { const { email = '' } = req.body; try { diff --git a/src/server/routes/signIn.ts b/src/server/routes/signIn.ts index 8b343005b..6f67d2eeb 100644 --- a/src/server/routes/signIn.ts +++ b/src/server/routes/signIn.ts @@ -48,6 +48,7 @@ import { } from '@/server/lib/okta/idx/introspect'; import { oktaIdxApiSignInController, + oktaIdxApiSignInPasscodeController, oktaIdxApiSubmitPasscodeController, oktaSignInControllerErrorHandler, } from '@/server/controllers/signInControllers'; @@ -323,6 +324,17 @@ router.get( }, ); +// route to resend the email for passcode sign in +// Essentially the same as POST /signin, but call the correct controller +router.post( + '/signin/code/resend', + redirectIfLoggedIn, + handleRecaptcha, + handleAsyncErrors(async (req: Request, res: ResponseWithRequestState) => { + await oktaIdxApiSignInPasscodeController({ req, res }); + }), +); + const oktaSignInController = async ({ req, res, diff --git a/src/shared/model/Routes.ts b/src/shared/model/Routes.ts index 68035ae8a..71424ccfb 100644 --- a/src/shared/model/Routes.ts +++ b/src/shared/model/Routes.ts @@ -54,6 +54,7 @@ export const ValidRoutePathsArray = [ '/signed-in-as', '/signin', '/signin/code', + '/signin/code/resend', '/signin/code/expired', '/signin/refresh', '/signin/:social', From c4a054dfabf58682a03be6dbef86f1f463c253f3 Mon Sep 17 00:00:00 2001 From: Mahesh Makani Date: Wed, 16 Oct 2024 10:09:05 +0100 Subject: [PATCH 06/10] feat(SignIn): update sign in page for passcode functionality --- src/client/pages/SignIn.stories.tsx | 48 +++++++++++++ src/client/pages/SignIn.tsx | 102 +++++++++++++++++++++++++--- 2 files changed, 142 insertions(+), 8 deletions(-) diff --git a/src/client/pages/SignIn.stories.tsx b/src/client/pages/SignIn.stories.tsx index b6256a84d..99e134f59 100644 --- a/src/client/pages/SignIn.stories.tsx +++ b/src/client/pages/SignIn.stories.tsx @@ -125,3 +125,51 @@ export const IsReauthenticate = (args: SignInProps) => ( IsReauthenticate.story = { name: 'showing /reauthenticate page', }; + +export const WithPasscodeSelectedDefaultPassword = (args: SignInProps) => ( + +); +WithPasscodeSelectedDefaultPassword.story = { + name: 'with passcode checkbox checked', +}; + +export const WithPasscodeSelectedDefaultPasswordError = (args: SignInProps) => ( + +); +WithPasscodeSelectedDefaultPassword.story = { + name: 'with passcode checkbox checked', +}; + +export const WithPasscodeSelectedDefaultPasscode = (args: SignInProps) => ( + +); +WithPasscodeSelectedDefaultPasscode.story = { + name: 'with passcode checkbox checked', +}; + +export const WithPasscodeSelectedDefaultPasscodeError = (args: SignInProps) => ( + +); +WithPasscodeSelectedDefaultPasscodeError.story = { + name: 'with passcode checkbox checked', +}; + +export const WithPasswordSelectedDefaultPasscode = (args: SignInProps) => ( + +); +WithPasswordSelectedDefaultPasscode.story = { + name: 'with password checkbox checked', +}; diff --git a/src/client/pages/SignIn.tsx b/src/client/pages/SignIn.tsx index 3c9e5c76f..f79f2978d 100644 --- a/src/client/pages/SignIn.tsx +++ b/src/client/pages/SignIn.tsx @@ -23,6 +23,9 @@ import locations from '@/shared/lib/locations'; import { SUPPORT_EMAIL } from '@/shared/model/Configuration'; import { MinimalLayout } from '@/client/layouts/MinimalLayout'; import ThemedLink from '@/client/components/ThemedLink'; +import { Checkbox } from '@guardian/source/react-components'; + +type View = 'passcode' | 'password'; export type SignInProps = { queryParams: QueryParams; @@ -34,6 +37,9 @@ export type SignInProps = { recaptchaSiteKey: string; isReauthenticate?: boolean; shortRequestId?: string; + // Determines whether passcode view or password view is shown by default + defaultView?: View; + currentView?: View; }; const resetPassword = css` @@ -90,6 +96,21 @@ const getErrorContext = ( {SUPPORT_EMAIL} ); + } else if (error === SignInErrors.PASSCODE_EXPIRED) { + return ( + <> +
Please request a new one-time code to sign in.
+
+
+ If you are still having trouble, please contact our customer service + team at{' '} + + {SUPPORT_EMAIL} + + . +
+ + ); } }; @@ -116,6 +137,35 @@ const showAuthProviderButtons = ( } }; +const ViewSelectorCheckbox = ({ + defaultView, + selectedView, + onChange, +}: { + defaultView: View; + selectedView: View; + onChange: React.ChangeEventHandler; +}) => { + // Checkbox label - if the default view is passcode, the label should be to show password, and vice versa + const checkboxLabel = + defaultView === 'passcode' + ? 'Use a password to sign in' + : 'Request a one-time code to sign in'; + + return ( + + ); +}; + export const SignIn = ({ email, pageError, @@ -124,7 +174,12 @@ export const SignIn = ({ recaptchaSiteKey, isReauthenticate = false, shortRequestId, + defaultView = 'password', + currentView = defaultView, }: SignInProps) => { + // status of the OTP checkbox + const [selectedView, setSelectedView] = React.useState(currentView); + const formTrackingName = 'sign-in'; // The page level error is equivalent to this enum if social signin has been blocked. @@ -135,6 +190,20 @@ export const SignIn = ({ const formErrorMessage = extractMessage(formError); usePageLoadOphanInteraction(formTrackingName); + const selectorChange: React.ChangeEventHandler = ( + event, + ) => { + event.preventDefault(); + // if the default view is passcode, the checkbox should show password, and vice versa + if (defaultView === 'passcode') { + setSelectedView(event.target.checked ? 'password' : 'passcode'); + } + // if the default view is password, the checkbox should show passcode, and vice versa + if (defaultView === 'password') { + setSelectedView(event.target.checked ? 'passcode' : 'password'); + } + }; + return ( - - - Reset password - + {selectedView === 'password' && ( + <> + + + Reset password + + + )} + { + // Hidden input to determine whether passcode view is selected + selectedView === 'passcode' && ( + + ) + } + {!isReauthenticate && ( <> From 2cc0439877abedd78608a21bb5078abcbb63848e Mon Sep 17 00:00:00 2001 From: Mahesh Makani Date: Thu, 17 Oct 2024 09:50:31 +0100 Subject: [PATCH 07/10] feat(passcodes): add `usePasscodeSignIn` and `signInCurrentView` params - `usePasscodeSignIn` determines if the option to sign in with passcodes is available - `signInCurrentView` tracks whether the `password` or `password` view of the sign in page at the point of user submission, so if the user navigates back to the sign in page, it would be on the view they last selected --- src/client/pages/SignIn.stories.tsx | 22 +++++++++++-- src/client/pages/SignIn.tsx | 36 +++++++++++++-------- src/client/pages/SignInPage.tsx | 2 ++ src/server/controllers/signInControllers.ts | 18 ++++++----- src/server/lib/middleware/requestState.ts | 3 +- src/server/lib/queryParams.ts | 19 +++++++++++ src/shared/__tests__/queryParams.test.ts | 2 ++ src/shared/lib/queryParams.ts | 2 ++ src/shared/model/ClientState.ts | 4 +++ src/shared/model/QueryParams.ts | 8 ++++- 10 files changed, 90 insertions(+), 26 deletions(-) diff --git a/src/client/pages/SignIn.stories.tsx b/src/client/pages/SignIn.stories.tsx index 99e134f59..c76ba8e71 100644 --- a/src/client/pages/SignIn.stories.tsx +++ b/src/client/pages/SignIn.stories.tsx @@ -127,7 +127,14 @@ IsReauthenticate.story = { }; export const WithPasscodeSelectedDefaultPassword = (args: SignInProps) => ( - + ); WithPasscodeSelectedDefaultPassword.story = { name: 'with passcode checkbox checked', @@ -139,6 +146,7 @@ export const WithPasscodeSelectedDefaultPasswordError = (args: SignInProps) => ( ...args, defaultView: 'password', currentView: 'passcode', + usePasscodeSignIn: true, pageError: SignInErrors.PASSCODE_EXPIRED, }} /> @@ -148,7 +156,7 @@ WithPasscodeSelectedDefaultPassword.story = { }; export const WithPasscodeSelectedDefaultPasscode = (args: SignInProps) => ( - + ); WithPasscodeSelectedDefaultPasscode.story = { name: 'with passcode checkbox checked', @@ -159,6 +167,7 @@ export const WithPasscodeSelectedDefaultPasscodeError = (args: SignInProps) => ( {...{ ...args, defaultView: 'passcode', + usePasscodeSignIn: true, pageError: SignInErrors.PASSCODE_EXPIRED, }} /> @@ -168,7 +177,14 @@ WithPasscodeSelectedDefaultPasscodeError.story = { }; export const WithPasswordSelectedDefaultPasscode = (args: SignInProps) => ( - + ); WithPasswordSelectedDefaultPasscode.story = { name: 'with password checkbox checked', diff --git a/src/client/pages/SignIn.tsx b/src/client/pages/SignIn.tsx index f79f2978d..9c5066cfa 100644 --- a/src/client/pages/SignIn.tsx +++ b/src/client/pages/SignIn.tsx @@ -24,8 +24,7 @@ import { SUPPORT_EMAIL } from '@/shared/model/Configuration'; import { MinimalLayout } from '@/client/layouts/MinimalLayout'; import ThemedLink from '@/client/components/ThemedLink'; import { Checkbox } from '@guardian/source/react-components'; - -type View = 'passcode' | 'password'; +import { SignInView } from '@/shared/model/ClientState'; export type SignInProps = { queryParams: QueryParams; @@ -38,8 +37,10 @@ export type SignInProps = { isReauthenticate?: boolean; shortRequestId?: string; // Determines whether passcode view or password view is shown by default - defaultView?: View; - currentView?: View; + defaultView?: SignInView; + currentView?: SignInView; + // temp flag to determine if the checkbox is visible + usePasscodeSignIn?: boolean; }; const resetPassword = css` @@ -142,8 +143,8 @@ const ViewSelectorCheckbox = ({ selectedView, onChange, }: { - defaultView: View; - selectedView: View; + defaultView: SignInView; + selectedView: SignInView; onChange: React.ChangeEventHandler; }) => { // Checkbox label - if the default view is passcode, the label should be to show password, and vice versa @@ -176,9 +177,13 @@ export const SignIn = ({ shortRequestId, defaultView = 'password', currentView = defaultView, + usePasscodeSignIn = false, }: SignInProps) => { // status of the OTP checkbox - const [selectedView, setSelectedView] = React.useState(currentView); + const [selectedView, setSelectedView] = React.useState( + // if usePasscodeSignIn is not enabled, the view should always be password + usePasscodeSignIn ? currentView : 'password', + ); const formTrackingName = 'sign-in'; @@ -252,14 +257,19 @@ export const SignIn = ({ { // Hidden input to determine whether passcode view is selected selectedView === 'passcode' && ( - + + ) + } + { + // only show the checkbox if sign in with passcode is enabled + usePasscodeSignIn && ( + ) } - {!isReauthenticate && ( <> diff --git a/src/client/pages/SignInPage.tsx b/src/client/pages/SignInPage.tsx index 6d5879755..d68ef7795 100644 --- a/src/client/pages/SignInPage.tsx +++ b/src/client/pages/SignInPage.tsx @@ -30,6 +30,8 @@ export const SignInPage = ({ isReauthenticate = false }: Props) => { recaptchaSiteKey={recaptchaSiteKey} isReauthenticate={isReauthenticate} shortRequestId={clientState.shortRequestId} + usePasscodeSignIn={queryParams.usePasscodeSignIn} + currentView={queryParams.signInCurrentView} /> ); }; diff --git a/src/server/controllers/signInControllers.ts b/src/server/controllers/signInControllers.ts index c7b58f9c6..0ba2095be 100644 --- a/src/server/controllers/signInControllers.ts +++ b/src/server/controllers/signInControllers.ts @@ -376,14 +376,16 @@ export const oktaIdxApiSignInController = async ({ const { email = '', password = '', passcode } = req.body; try { - // `'on'` if checked, or `undefined` if not checked - // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#value - // so check if it exists - const usePasscode = !!passcode; - - // if we do, hand off to the oktaIdxApiSignInPasscodeController - if (usePasscode) { - return oktaIdxApiSignInPasscodeController({ req, res }); + // only attempt to sign in with a passcode if the user currently has the query parameter set + // this should be removed when we're ready to enable this for all users + if (res.locals.queryParams.usePasscodeSignIn) { + // if the value exists, we're using passcodes + const usePasscode = !!passcode; + + // if we do, hand off to the oktaIdxApiSignInPasscodeController + if (usePasscode) { + return oktaIdxApiSignInPasscodeController({ req, res }); + } } // start the IDX flow to sign the user in and get the identify response and user object diff --git a/src/server/lib/middleware/requestState.ts b/src/server/lib/middleware/requestState.ts index 9ddf80068..a0110fd92 100644 --- a/src/server/lib/middleware/requestState.ts +++ b/src/server/lib/middleware/requestState.ts @@ -41,7 +41,7 @@ const getRequestState = async ( const [abTesting, abTestAPI] = getABTesting(req, tests); // tracking parameters might be from body too - const { ref, refViewId } = req.body; + const { ref, refViewId, passcode } = req.body; const queryParams = parseExpressQueryParams( req.method, @@ -53,6 +53,7 @@ const getRequestState = async ( { ref, refViewId, + signInCurrentView: passcode ? 'passcode' : undefined, }, ); diff --git a/src/server/lib/queryParams.ts b/src/server/lib/queryParams.ts index 0a3a6ee18..9c7ed029e 100644 --- a/src/server/lib/queryParams.ts +++ b/src/server/lib/queryParams.ts @@ -3,6 +3,7 @@ import { validateReturnUrl, validateRefUrl } from '@/server/lib/validateUrl'; import { validateClientId } from '@/server/lib/validateClientId'; import { isStringBoolean } from './isStringBoolean'; import { validateFromUri } from './validateFromUri'; +import { SignInView } from '@/shared/model/ClientState'; const validateGetOnlyError = ( method: string, @@ -16,6 +17,18 @@ const validateGetOnlyError = ( } }; +const validateSignInView = ( + signInCurrentView?: string, +): SignInView | undefined => { + if (signInCurrentView === 'password') { + return 'password'; + } + + if (signInCurrentView === 'passcode') { + return 'passcode'; + } +}; + const stringToNumber = ( maybeNumber: string | undefined, ): number | undefined => { @@ -52,6 +65,8 @@ export const parseExpressQueryParams = ( appClientId, maxAge, useOktaClassic, + usePasscodeSignIn, + signInCurrentView, }: Record, // parameters from req.query // some parameters may be manually passed in req.body too, // generally for tracking purposes @@ -76,6 +91,10 @@ export const parseExpressQueryParams = ( appClientId, maxAge: stringToNumber(maxAge), useOktaClassic: isStringBoolean(useOktaClassic), + usePasscodeSignIn: isStringBoolean(usePasscodeSignIn), + signInCurrentView: validateSignInView( + bodyParams.signInCurrentView || signInCurrentView, + ), }; }; diff --git a/src/shared/__tests__/queryParams.test.ts b/src/shared/__tests__/queryParams.test.ts index 4fd79ebb3..a2e5dab68 100644 --- a/src/shared/__tests__/queryParams.test.ts +++ b/src/shared/__tests__/queryParams.test.ts @@ -37,6 +37,8 @@ describe('getPersistableQueryParams', () => { appClientId: 'appClientId', useOktaClassic: undefined, listName: undefined, + usePasscodeSignIn: undefined, + signInCurrentView: undefined, }; expect(output).toStrictEqual(expected); diff --git a/src/shared/lib/queryParams.ts b/src/shared/lib/queryParams.ts index 995dbcebb..fb718e1b2 100644 --- a/src/shared/lib/queryParams.ts +++ b/src/shared/lib/queryParams.ts @@ -42,6 +42,8 @@ export const getPersistableQueryParams = ( fromURI: params.fromURI, appClientId: params.appClientId, useOktaClassic: params.useOktaClassic, + usePasscodeSignIn: params.usePasscodeSignIn, + signInCurrentView: params.signInCurrentView, }); /** diff --git a/src/shared/model/ClientState.ts b/src/shared/model/ClientState.ts index c9e7883bf..44364e114 100644 --- a/src/shared/model/ClientState.ts +++ b/src/shared/model/ClientState.ts @@ -26,6 +26,9 @@ interface GlobalMessage { export type IsNativeApp = 'android' | 'ios' | undefined; +// determine what the sign in page view should be +export type SignInView = 'passcode' | 'password'; + export interface PageData { // general page data returnUrl?: string; @@ -69,6 +72,7 @@ export interface PageData { // delete specific contentAccess?: UserAttributesResponse['contentAccess']; + // okta idx api specific hasStateHandle?: boolean; // determines if the state handle is present in the encrypted state, so we know if we're in an Okta IDX flow passcodeUsed?: boolean; // determines if the passcode has been used in the Okta IDX flow, so don't show the passcode page again diff --git a/src/shared/model/QueryParams.ts b/src/shared/model/QueryParams.ts index e6e581bee..298437ad1 100644 --- a/src/shared/model/QueryParams.ts +++ b/src/shared/model/QueryParams.ts @@ -1,4 +1,5 @@ -import { ValidClientId } from '../lib/clientId'; +import { ValidClientId } from '@/shared/lib/clientId'; +import { SignInView } from '@/shared/model/ClientState'; type Stringifiable = string | boolean | number | null | undefined; @@ -26,6 +27,9 @@ export interface TrackingQueryParams { // as well as getting confused with other parameters, so we thought it best to pass it as a URL encoded string, and then do the decoding once it gets to IDAPI componentEventParams?: string; listName?: string; + // tracks the view that the user had selected on the sign in page, i.e password or passcode, so that we can show the last selected view + // if the user navigated back to the sign in page + signInCurrentView?: SignInView; } /** @@ -47,6 +51,8 @@ export interface PersistableQueryParams appClientId?: string; // fallback to Okta Classic if needed useOktaClassic?: boolean; + // Flag to enable sign in with passcode + usePasscodeSignIn?: boolean; } /** From 2e77e1df6cba69c005c4c721fe68f98801538897 Mon Sep 17 00:00:00 2001 From: Mahesh Makani Date: Thu, 17 Oct 2024 11:04:58 +0100 Subject: [PATCH 08/10] feat(passcode): use passcode as default option behind flag --- src/client/pages/SignIn.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/pages/SignIn.tsx b/src/client/pages/SignIn.tsx index 9c5066cfa..17327e16b 100644 --- a/src/client/pages/SignIn.tsx +++ b/src/client/pages/SignIn.tsx @@ -150,7 +150,7 @@ const ViewSelectorCheckbox = ({ // Checkbox label - if the default view is passcode, the label should be to show password, and vice versa const checkboxLabel = defaultView === 'passcode' - ? 'Use a password to sign in' + ? 'Use a password to sign in instead' : 'Request a one-time code to sign in'; return ( @@ -175,7 +175,7 @@ export const SignIn = ({ recaptchaSiteKey, isReauthenticate = false, shortRequestId, - defaultView = 'password', + defaultView = 'passcode', currentView = defaultView, usePasscodeSignIn = false, }: SignInProps) => { From 5de4e633316416819c180c7c12250796b70f0743 Mon Sep 17 00:00:00 2001 From: Mahesh Makani Date: Thu, 17 Oct 2024 16:21:13 +0100 Subject: [PATCH 09/10] refactor(signInControllers): separate get user into own call --- src/server/controllers/signInControllers.ts | 86 +++++++++++++-------- 1 file changed, 53 insertions(+), 33 deletions(-) diff --git a/src/server/controllers/signInControllers.ts b/src/server/controllers/signInControllers.ts index 0ba2095be..f6e8d57a9 100644 --- a/src/server/controllers/signInControllers.ts +++ b/src/server/controllers/signInControllers.ts @@ -94,26 +94,17 @@ export const oktaSignInControllerErrorHandler = ( }; /** - * @name startIdxSignInFlow - * @description Start the IDX flow to sign the user in, and return the identify response and user object. Shared between the password and passcode sign in controllers + * @name getUserForIdxSignIn + * @description A wrapper around the getUser function to handle errors and convert them to the correct error type * @param {string} email - The email address of the user - * @param {Request} req - Express request object - * @param {ResponseWithRequestState} res - Express response object - * @returns {Promise<[IdentifyResponse, UserResponse]>} - The identify response and user object + * @param {string} ip - The IP address of the user + * @returns {Promise} - The user object */ -const startIdxSignInFlow = async ({ - email, - req, - res, -}: { - email: string; - req: Request; - res: ResponseWithRequestState; -}): Promise<[IdentifyResponse, UserResponse]> => { - // First we want to check the user status in Okta - // to see if they are in the ACTIVE state - // if they are not, we will not allow them to sign in - const user = await getUser(email, req.ip).catch((error) => { +const getUserForIdxSignIn = async ( + email: string, + ip?: string, +): Promise => { + const user = await getUser(email, ip).catch((error) => { // handle any getUser errors here instead of the outer catch block if (isOktaError(error)) { // convert the user not found error to generic authentication error to outer catch block @@ -135,14 +126,26 @@ const startIdxSignInFlow = async ({ throw error; }); - if (user.status !== 'ACTIVE') { - // throw authentication error if user is not in the ACTIVE state - throw new OktaError({ - code: 'E0000004', - message: 'User is not in the ACTIVE state', - }); - } + return user; +}; +/** + * @name startIdxSignInFlow + * @description Start the IDX flow to sign the user in, and return the identify response and user object. Shared between the password and passcode sign in controllers + * @param {string} email - The email address of the user + * @param {Request} req - Express request object + * @param {ResponseWithRequestState} res - Express response object + * @returns {Promise<[IdentifyResponse, UserResponse]>} - The identify response and user object + */ +const startIdxSignInFlow = async ({ + email, + req, + res, +}: { + email: string; + req: Request; + res: ResponseWithRequestState; +}): 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({ @@ -166,7 +169,7 @@ const startIdxSignInFlow = async ({ ); // return the response and user objects to the calling function - return [identifyResponse, user]; + return identifyResponse; }; /** @@ -188,12 +191,10 @@ export const oktaIdxApiSignInPasscodeController = async ({ const { email = '' } = req.body; try { - // start the IDX flow to sign the user in and get the identify response and user object - const [identifyResponse, user] = await startIdxSignInFlow({ - email, - req, - res, - }); + // First we want to check the user status in Okta + // to see if they are in the ACTIVE state + // if they are not, we will force them into the ACTIVE state + const user = await getUserForIdxSignIn(email, req.ip); // determine the user status and what action to take switch (user.status) { @@ -220,6 +221,13 @@ export const oktaIdxApiSignInPasscodeController = async ({ * user's status, which is helpful for us to determine the correct flow * */ + // start the IDX flow to sign the user in and get the identify response and user object + const identifyResponse = await startIdxSignInFlow({ + email, + req, + res, + }); + // check for the "email" authenticator, we can authenticate with email (passcode) // if this authenticator is present const emailAuthenticatorId = findAuthenticatorId({ @@ -388,8 +396,20 @@ export const oktaIdxApiSignInController = async ({ } } + // First we want to check the user status in Okta + // to see if they are in the ACTIVE state + // if they are not, we will not allow them to sign in + const user = await getUserForIdxSignIn(email, req.ip); + if (user.status !== 'ACTIVE') { + // throw authentication error if user is not in the ACTIVE state + throw new OktaError({ + code: 'E0000004', + message: 'User is not in the ACTIVE state', + }); + } + // start the IDX flow to sign the user in and get the identify response and user object - const [identifyResponse, user] = await startIdxSignInFlow({ + const identifyResponse = await startIdxSignInFlow({ email, req, res, From a1638a590cfcaf231ebdaad6ff3cbd86e3a6b6a0 Mon Sep 17 00:00:00 2001 From: Mahesh Makani Date: Thu, 17 Oct 2024 16:21:34 +0100 Subject: [PATCH 10/10] test(ete): add tests for sign in using passcodes --- .github/workflows/cypress-ete.yml | 2 +- .../integration/ete/sign_in_passcode.8.cy.ts | 373 ++++++++++++++++++ 2 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 cypress/integration/ete/sign_in_passcode.8.cy.ts diff --git a/.github/workflows/cypress-ete.yml b/.github/workflows/cypress-ete.yml index 58af28c45..33c879c0a 100644 --- a/.github/workflows/cypress-ete.yml +++ b/.github/workflows/cypress-ete.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - group: [1, 2, 3, 4, 5, 6, 7] + group: [1, 2, 3, 4, 5, 6, 7, 8] timeout-minutes: 15 services: redis: diff --git a/cypress/integration/ete/sign_in_passcode.8.cy.ts b/cypress/integration/ete/sign_in_passcode.8.cy.ts new file mode 100644 index 000000000..e54793a7f --- /dev/null +++ b/cypress/integration/ete/sign_in_passcode.8.cy.ts @@ -0,0 +1,373 @@ +import { Status } from '../../../src/server/models/okta/User'; +import { + randomMailosaurEmail, + randomPassword, +} from '../../support/commands/testUser'; + +describe('Sign In flow, with passcode', () => { + // 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'; + + const sendEmailAndValidatePasscode = ({ + 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(`/signin?${params ? `${params}&` : ''}usePasscodeSignIn=true`); + 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', '/signin/code'); + cy.contains('Enter your one-time 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 one-time code').click(); + + cy.url().should('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', '/signin'); + break; + case 'passcode-incorrect': + cy.get('input[name=code]').type(`${+code! + 1}`); + + cy.contains('Submit one-time code').click(); + + cy.url().should('include', '/signin/code'); + + cy.contains('Incorrect code'); + cy.get('input[name=code]').clear().type(code!); + + cy.contains('Submit one-time code').click(); + + cy.url().should('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 one-time code').click(); + + cy.url().should('include', expectedReturnUrl); + + cy.getTestOktaUser(emailAddress).then((user) => { + expect(user.status).to.eq('ACTIVE'); + expect(user.profile.emailValidated).to.eq(true); + }); + } + } + }, + ); + }; + + beforeEach(() => { + // Intercept the external redirect pages. + // We just want to check that the redirect happens, not that the page loads. + cy.intercept('GET', 'https://m.code.dev-theguardian.com/', (req) => { + req.reply(200); + }); + cy.intercept('GET', returnUrl, (req) => { + req.reply(200); + }); + cy.intercept( + 'GET', + `https://${Cypress.env('BASE_URI')}${decodeURIComponent(fromURI)}`, + (req) => { + req.reply(200); + }, + ); + }); + + context('ACTIVE user - with email authenticator', () => { + it('should sign in with passcode', () => { + cy + .createTestUser({ + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + sendEmailAndValidatePasscode({ + emailAddress, + }); + }); + }); + + it('should sign in with passocde - preserve returnUrl', () => { + cy + .createTestUser({ + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + sendEmailAndValidatePasscode({ + emailAddress, + expectedReturnUrl: returnUrl, + params: `returnUrl=${encodedReturnUrl}`, + }); + }); + }); + + it('should sign in with passcode - preserve fromURI', () => { + cy + .createTestUser({ + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + sendEmailAndValidatePasscode({ + emailAddress, + expectedReturnUrl: fromURI, + params: `fromURI=${fromURI}&appClientId=${appClientId}`, + }); + }); + }); + + it('selects password option to sign in', () => { + cy + .createTestUser({ + isUserEmailValidated: true, + }) + ?.then(({ emailAddress, finalPassword }) => { + cy.visit(`/signin?usePasscodeSignIn=true`); + cy.get('input[name=email]').type(emailAddress); + cy.contains('Use a password to sign in instead').click(); + cy.get('input[name=password]').type(finalPassword); + cy.get('[data-cy="main-form-submit-button"]').click(); + cy.url().should('include', 'https://m.code.dev-theguardian.com/'); + }); + }); + + it('should sign in with passcode - resend email', () => { + cy + .createTestUser({ + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + sendEmailAndValidatePasscode({ + emailAddress, + additionalTests: 'resend-email', + }); + }); + }); + + it('should sign in with passcode - change email', () => { + cy + .createTestUser({ + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + sendEmailAndValidatePasscode({ + emailAddress, + additionalTests: 'change-email', + }); + }); + }); + + it('should sign in with passcode - passcode incorrect', () => { + cy + .createTestUser({ + isUserEmailValidated: true, + }) + ?.then(({ emailAddress }) => { + sendEmailAndValidatePasscode({ + emailAddress, + additionalTests: 'passcode-incorrect', + }); + }); + }); + }); + + 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 timeRequestWasMade = new Date(); + cy.get('input[name=email]').type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); + + cy.contains('Enter your code'); + cy.contains(emailAddress); + + 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}$/); + + // 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('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'), + ); + + cy.visit(setPasswordLink?.href as string); + + const password = randomPassword(); + cy.get('input[name=password]').type(password); + + cy.get('[data-cy="main-form-submit-button"]') + .click() + .should('be.disabled'); + cy.contains('Password created'); + cy.contains(emailAddress.toLowerCase()); + + /** + * END - SETUP USER WITH ONLY PASSWORD AUTHENTICATOR + */ + cy.visit('/signin?usePasscodeSignIn=true'); + cy.contains('Sign in with a different email').click(); + sendEmailAndValidatePasscode({ + emailAddress, + expectedEmailBody: 'Your verification code', + }); + }); + }, + ); + }); + }); + + 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.STAGED); + + sendEmailAndValidatePasscode({ + emailAddress, + }); + }); + }); + }); + + 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); + + sendEmailAndValidatePasscode({ 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); + + sendEmailAndValidatePasscode({ 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); + + sendEmailAndValidatePasscode({ emailAddress }); + }); + }); + }); + }); + + it('NON_EXISTENT user - should show email sent page with no email sent', () => { + const emailAddress = randomMailosaurEmail(); + cy.visit(`/signin?usePasscodeSignIn=true`); + + cy.contains('Sign in'); + cy.get('input[name=email]').type(emailAddress); + cy.get('[data-cy="main-form-submit-button"]').click(); + + // passcode page + cy.url().should('include', '/signin/code'); + 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', '/signin/code'); + cy.contains('Enter your one-time code'); + cy.contains('Don’t have an account?'); + + cy.contains('Incorrect code'); + }); + }); +});