From ee8105df5db672fb1d8421a5017df24c1e4b3b33 Mon Sep 17 00:00:00 2001 From: Mahesh Makani Date: Wed, 16 Oct 2024 10:02:08 +0100 Subject: [PATCH] refactor(idx): create `oktaIdxShared` controller to share code and refactor `sentChangePasswordEmail` controller --- src/server/controllers/oktaIdxShared.ts | 257 ++++++++++++++++++ .../controllers/sendChangePasswordEmail.ts | 204 ++------------ src/server/lib/okta/idx/identify.ts | 2 +- 3 files changed, 281 insertions(+), 182 deletions(-) create mode 100644 src/server/controllers/oktaIdxShared.ts diff --git a/src/server/controllers/oktaIdxShared.ts b/src/server/controllers/oktaIdxShared.ts new file mode 100644 index 000000000..3c5dfb185 --- /dev/null +++ b/src/server/controllers/oktaIdxShared.ts @@ -0,0 +1,257 @@ +import { Request } from 'express'; +import { setEncryptedStateCookie } from '@/server/lib/encryptedStateCookie'; +import dangerouslySetPlaceholderPassword from '@/server/lib/okta/dangerouslySetPlaceholderPassword'; +import { + challenge, + validateChallengeRemediation, + isChallengeAnswerCompleteLoginResponse, + validateChallengeAnswerRemediation, +} from '@/server/lib/okta/idx/challenge'; +import { credentialEnroll } from '@/server/lib/okta/idx/credential'; +import { findAuthenticatorId } from '@/server/lib/okta/idx/shared/findAuthenticatorId'; +import { submitPassword } from '@/server/lib/okta/idx/shared/submitPasscode'; +import { trackMetric } from '@/server/lib/trackMetric'; +import { OktaError } from '@/server/models/okta/Error'; +import { UserResponse } from '@/server/models/okta/User'; +import { ResponseWithRequestState } from '@/server/models/Express'; +import { IdentifyResponse } from '@/server/lib/okta/idx/identify'; +import { + resetPassword, + validateRecoveryToken, +} from '@/server/lib/okta/api/authentication'; +import { deactivateUser, activateUser } from '@/server/lib/okta/api/users'; +import { validateEmailAndPasswordSetSecurely } from '@/server/lib/okta/validateEmail'; +import { logger } from '@/server/lib/serverSideLogger'; + +/** + * @name sendVerifyEmailAuthenticatorIdx + * @description IMPORTANT: Use this method to send a email verification email to the user in the ACTIVE_PASSWORD_ONLY state. Please read the following: + * + * When a user only has the password authenticator, they have not yet verified their email factor in okta using a passcode. + * + * A user can get into this state when they fail to verify their email using a passcode during the create account flow, and + * they attempt to recover their account using the Okta Classic API reset password flow (no longer the default, but some users + * can still be in this state), therefore they are left with the password authenticator, but no email authenticator. + * + * We say that the user is in the follow state: + * ACTIVE_PASSWORD_ONLY - 2. ACTIVE users - has only password authenticator (okta idx email not verified) + * + * In order to fix these users, we have to send them a passcode to verify their email factor in Okta using the IDX API, but in + * order to do so we have to first authenticate them. + * + * We do this by setting a placeholder password for the user, making sure to unset the `emailValidated` and `passwordSetSecurely` + * flags if they are current set to `true`. + * We then use the placeholder password to authenticate the user using the password authenticator in the IDX API. + * When we do so, the IDX API will tell us that the user needs to enroll in the `email` authenticator, which we use to send + * the user a passcode to verify the `email` authenticator. + * + * This method sends the passcode, and sets the encrypted state cookie to persist the email and stateHandle. To use this method + * the IDX API transaction must be in the `IdentifyResponse` state, and the `passwordAuthenticatorId` must be known. + * + * Since this method doesn't return anything, any redirects or responses should be handled in the calling function. + * + * This method doesn't handle the passcode submission, that should be handled depending on the context and user flow. + * + * @param {UserResponse} user - The user object from Okta + * @param {IdentifyResponse} identifyResponse - The response from the identify endpoint + * @param {string} passwordAuthenticatorId - The password authenticator id + * @param {Request} req - The express request object + * @param {ResponseWithRequestState} res - The express response object + */ +export const sendVerifyEmailAuthenticatorIdx = async ({ + user, + identifyResponse, + passwordAuthenticatorId, + req, + res, +}: { + user: UserResponse; + identifyResponse: IdentifyResponse; + passwordAuthenticatorId: string; + req: Request; + res: ResponseWithRequestState; +}) => { + // set a placeholder password for the user + const placeholderPassword = await dangerouslySetPlaceholderPassword({ + id: user.id, + ip: req.ip, + returnPlaceholderPassword: true, + }); + + // call "challenge" to start the password authentication process + const challengePasswordResponse = await challenge( + identifyResponse.stateHandle, + { + id: passwordAuthenticatorId, + methodType: 'password', + }, + req.ip, + ); + + // validate that the response from the challenge endpoint is a password authenticator + validateChallengeRemediation( + challengePasswordResponse, + 'challenge-authenticator', + 'password', + ); + + // call "challenge/answer" to answer the password challenge + const challengeAnswerResponse = await submitPassword({ + password: placeholderPassword, + stateHandle: challengePasswordResponse.stateHandle, + introspectRemediation: 'challenge-authenticator', + ip: req.ip, + }); + + // check the response from the challenge/answer endpoint + // if it's a "CompleteLoginResponse" then Okta is in the state + // where email verification or enrollment is disabled, and a user + // can authenticate with a password only + // in this case we want to fall back to the classic reset password flow + // as this is the only way for these users to reset their password while + // Okta is in this state + if (isChallengeAnswerCompleteLoginResponse(challengeAnswerResponse)) { + // track the metric so we can see if we accidentally hit this case + trackMetric('OktaIDXEmailVerificationDisabled'); + // throw an error to fall back to the classic reset password flow + throw new OktaError({ + message: `Okta changePasswordEmailIdx failed as email verification or enrollment is disabled in Okta`, + }); + } + + // otherwise the response is a "ChallengeAnswerResponse" and we can continue + // but first we have to check that the response remediation is the "select-authenticator-enroll" + validateChallengeAnswerRemediation( + challengeAnswerResponse, + 'select-authenticator-enroll', + ); + + // check for the email authenticator id in the response to make sure that it's the correct enrollment flow + const challengeAnswerEmailAuthenticatorId = findAuthenticatorId({ + authenticator: 'email', + response: challengeAnswerResponse, + remediationName: 'select-authenticator-enroll', + }); + + // if the email authenticator id is not found, then throw an error to fall back to the classic reset password flow + if (!challengeAnswerEmailAuthenticatorId) { + throw new OktaError({ + message: `Okta sendVerifyEmailAuthenticatorIdx failed as email authenticator id is not found in the response`, + }); + } + + // call the "challenge" endpoint to start the email challenge process + // and send the user a passcode + const challengeEmailResponse = await credentialEnroll( + challengeAnswerResponse.stateHandle, + { + id: challengeAnswerEmailAuthenticatorId, + methodType: 'email', + }, + req.ip, + ); + + // set the encrypted state cookie to persist the email and stateHandle + // which we need to persist during the passcode flow + setEncryptedStateCookie(res, { + email: user.profile.email, + stateHandle: challengeEmailResponse.stateHandle, + stateHandleExpiresAt: challengeEmailResponse.expiresAt, + userState: 'ACTIVE_PASSWORD_ONLY', + }); +}; + +/** + * @name forceUserIntoActiveState + * @description IMPORTANT: Use this method to force a user into the ACTIVE state from a non-ACTIVE state. Please read the following: + * + * In order for a user in a non-ACTIVE state to be able to receive a passcode, they must be in the ACTIVE state, so we have to + * force them into the ACTIVE state. + * + * The best way to do this is to first deactivate the user, which works on all user states and puts them + * into the DEPROVISIONED state. + * Then we can activate the user, which will put them into the PROVISIONED state and return us a recovery token. + * We then use the recovery token to set a placeholder password for the user, which transitions them into the ACTIVE state, + * and also makes sure to unset the `emailValidated` and `passwordSetSecurely` flags if they are currently set to `true`. + * + * This will put the user in one of the following ACTIVE states: + * 1. ACTIVE users - has email + password authenticator (okta idx email verified) + * 2. ACTIVE users - has only password authenticator (okta idx email not verified) + * + * They can't be in the 3. ACTIVE users - has only email authenticator (SOCIAL users, no password), as these users will + * have a password. + * + * From this point the calling function will be able to send the user a passcode. + */ +export const forceUserIntoActiveState = async ({ + req, + user, +}: { + req: Request; + user: UserResponse; +}) => { + // 1. deactivate the user + try { + await deactivateUser({ + id: user.id, + ip: req.ip, + }); + trackMetric('OktaDeactivateUser::Success'); + } catch (error) { + trackMetric('OktaDeactivateUser::Failure'); + logger.error( + 'Okta user deactivation failed', + error instanceof OktaError ? error.message : error, + ); + throw error; + } + + // 2. activate the user + try { + const tokenResponse = await activateUser({ + id: user.id, + ip: req.ip, + }); + if (!tokenResponse?.token.length) { + throw new OktaError({ + message: `Okta force activation failed: missing activation token`, + }); + } + + // 3. use the recovery token to set a placeholder password for the user + // Validate the token + const { stateToken } = await validateRecoveryToken({ + recoveryToken: tokenResponse.token, + ip: req.ip, + }); + // Check if state token is defined + if (!stateToken) { + throw new OktaError({ + message: + 'Okta set placeholder password failed: state token is undefined', + }); + } + // Set the placeholder password as a cryptographically secure UUID + const placeholderPassword = crypto.randomUUID(); + await resetPassword( + { + stateToken, + newPassword: placeholderPassword, + }, + req.ip, + ); + + // Unset the emailValidated and passwordSetSecurely flags + await validateEmailAndPasswordSetSecurely({ + id: user.id, + ip: req.ip, + flagStatus: false, + }); + } catch (error) { + logger.error( + 'Okta force activation failed', + error instanceof OktaError ? error.message : error, + ); + throw error; + } +}; diff --git a/src/server/controllers/sendChangePasswordEmail.ts b/src/server/controllers/sendChangePasswordEmail.ts index 721925623..66b54e5e1 100644 --- a/src/server/controllers/sendChangePasswordEmail.ts +++ b/src/server/controllers/sendChangePasswordEmail.ts @@ -40,19 +40,14 @@ import { } from '@/server/lib/okta/idx/identify'; import { challenge, - isChallengeAnswerCompleteLoginResponse, - validateChallengeAnswerRemediation, validateChallengeRemediation, } from '@/server/lib/okta/idx/challenge'; import { recover } from '@/server/lib/okta/idx/recover'; import { findAuthenticatorId } from '@/server/lib/okta/idx/shared/findAuthenticatorId'; -import { submitPassword } from '@/server/lib/okta/idx/shared/submitPasscode'; -import { credentialEnroll } from '@/server/lib/okta/idx/credential'; import { - resetPassword, - validateRecoveryToken, -} from '@/server/lib/okta/api/authentication'; -import { validateEmailAndPasswordSetSecurely } from '@/server/lib/okta/validateEmail'; + forceUserIntoActiveState, + sendVerifyEmailAuthenticatorIdx, +} from '@/server/controllers/oktaIdxShared'; const { passcodesEnabled } = getConfiguration(); @@ -246,119 +241,23 @@ export const changePasswordEmailIdx = async ({ addQueryParamsToPath(emailSentPage, res.locals.queryParams), ); } else if (passwordAuthenticatorId && !emailAuthenticatorId) { - // user has only password authenticator so: + // user has only password authenticator so they're in the // 2. ACTIVE users - has only password authenticator (okta idx email not verified) - - // If the user only has a password authenticator, then they failed to use a passcode - // to verify their account when they created it, and instead set a password using - // the okta classic reset password flow. In this case, we have to have to enroll - // the user in the email authenticator to allow them to reset their password. - - // We do this by first setting a placeholder password for the user, then using the - // identify flow to authenticate the user with the placeholder password. After - // authenticating the user, the IDX API will tell us that the user needs to enroll - // in the email authenticator, which we can then use to send them a passcode to verify - // their account. - // Once they've verified their account, we use the okta classic api to generate a - // recover token, and instantly show the set password page to the user for them to - // set their password. - - // set a placeholder password for the user - const placeholderPassword = await dangerouslySetPlaceholderPassword({ - id: user.id, - ip: req.ip, - returnPlaceholderPassword: true, - }); - - // call "challenge" to start the password authentication process - const challengePasswordResponse = await challenge( - introspectResponse.stateHandle, - { - id: passwordAuthenticatorId, - methodType: 'password', - }, - req.ip, - ); - - // validate that the response from the challenge endpoint is a password authenticator - // and has the "recover" remediation - validateChallengeRemediation( - challengePasswordResponse, - 'challenge-authenticator', - 'password', - ); - - // call "challenge/answer" to answer the password challenge - const challengeAnswerResponse = await submitPassword({ - password: placeholderPassword, - stateHandle: challengePasswordResponse.stateHandle, - introspectRemediation: 'challenge-authenticator', - ip: req.ip, - }); - - // check the response from the challenge/answer endpoint - // if it's a "CompleteLoginResponse" then Okta is in the state - // where email verification or enrollment is disabled, and a user - // can authenticate with a password only - // in this case we want to fall back to the classic reset password flow - // as this is the only way for these users to reset their password while - // Okta is in this state - if (isChallengeAnswerCompleteLoginResponse(challengeAnswerResponse)) { - // track the metric so we can see if we accidentally hit this case - trackMetric('OktaIDXEmailVerificationDisabled'); - // throw an error to fall back to the classic reset password flow - throw new OktaError({ - message: `Okta changePasswordEmailIdx failed as email verification or enrollment is disabled in Okta`, - }); - } - - // otherwise the response is a "ChallengeAnswerResponse" and we can continue - // but first we have to check that the response remediation is the "select-authenticator-enroll" - validateChallengeAnswerRemediation( - challengeAnswerResponse, - 'select-authenticator-enroll', - ); - - // check for the email authenticator id in the response to make sure that it's the correct enrollment flow - const challengeAnswerEmailAuthenticatorId = findAuthenticatorId({ - authenticator: 'email', - response: challengeAnswerResponse, - remediationName: 'select-authenticator-enroll', + // We need to send these users a verification email with passcode to verify their + // email before they can reset their password. + // See the method documentation for additional context + await sendVerifyEmailAuthenticatorIdx({ + user, + identifyResponse, + passwordAuthenticatorId, + req, + res, }); - // if the email authenticator id is not found, then throw an error to fall back to the classic reset password flow - if (!challengeAnswerEmailAuthenticatorId) { - throw new OktaError({ - message: `Okta changePasswordEmailIdx failed as email authenticator id is not found in the response`, - }); - } - - // call the "challenge" endpoint to start the email challenge process - // and send the user a passcode - const challengeEmailResponse = await credentialEnroll( - challengeAnswerResponse.stateHandle, - { - id: challengeAnswerEmailAuthenticatorId, - methodType: 'email', - }, - req.ip, - ); - // track the success metrics trackMetric('OktaIDXResetPasswordSend::Success'); trackMetric(`OktaIDXResetPasswordSend::${user.status}::Success`); - // at this point the user will have been sent an email with a passcode - - // 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: 'ACTIVE_PASSWORD_ONLY', - }); - // show the email sent page, with passcode instructions return res.redirect( 303, @@ -413,77 +312,20 @@ export const changePasswordEmailIdx = async ({ }); } - // 1. deactivate the user try { - await deactivateUser({ - id: user.id, - ip: req.ip, - }); - trackMetric('OktaDeactivateUser::Success'); - } catch (error) { - trackMetric('OktaDeactivateUser::Failure'); - logger.error( - 'Okta user deactivation failed', - error instanceof OktaError ? error.message : error, - ); - throw error; - } - - // 2. activate the user - try { - const tokenResponse = await activateUser({ - id: user.id, - ip: req.ip, - }); - if (!tokenResponse?.token.length) { - throw new OktaError({ - message: `Okta user activation failed: missing activation token`, - }); - } - - // 3. use the recovery token to set a placeholder password for the user - // Validate the token - const { stateToken } = await validateRecoveryToken({ - recoveryToken: tokenResponse.token, - ip: req.ip, - }); - // Check if state token is defined - if (!stateToken) { - throw new OktaError({ - message: - 'Okta set placeholder password failed: state token is undefined', - }); - } - // Set the placeholder password as a cryptographically secure UUID - const placeholderPassword = crypto.randomUUID(); - await resetPassword( - { - stateToken, - newPassword: placeholderPassword, - }, - req.ip, - ); - - // Unset the emailValidated and passwordSetSecurely flags - await validateEmailAndPasswordSetSecurely({ - id: user.id, - ip: req.ip, - flagStatus: false, + // 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, }); - // 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) - // so we can call this method again to send the user a passcode - // They can't be in the 3. ACTIVE users - has only email authenticator (SOCIAL users, no password) - // as we've just set a placeholder password for them - // but we first need to get the updated user object + // get the updated user to get the correct status const updatedUser = await getUser(user.id, req.ip); + + // call this method again to send the user a passcode as they'll now be in one of the ACTIVE states return changePasswordEmailIdx({ req, res, diff --git a/src/server/lib/okta/idx/identify.ts b/src/server/lib/okta/idx/identify.ts index ee2a35fc8..725602dd0 100644 --- a/src/server/lib/okta/idx/identify.ts +++ b/src/server/lib/okta/idx/identify.ts @@ -35,7 +35,7 @@ const identifyResponseSchema = idxBaseResponseSchema.merge( }), }), ); -type IdentifyResponse = z.infer; +export type IdentifyResponse = z.infer; // Body type for the identify request type IdentifyBody = {