From 48bac86f5404d3dc425d81e4efdb64f45186832d Mon Sep 17 00:00:00 2001 From: Mahesh Makani Date: Tue, 15 Oct 2024 17:24:52 +0100 Subject: [PATCH] wip --- src/server/controllers/oktaIdxShared.ts | 155 ++++ .../controllers/sendChangePasswordEmail.ts | 121 +-- src/server/controllers/signInControllers.ts | 654 ++++++++++++++++ src/server/lib/okta/idx/identify.ts | 2 +- src/server/routes/signIn.ts | 738 +----------------- 5 files changed, 832 insertions(+), 838 deletions(-) create mode 100644 src/server/controllers/oktaIdxShared.ts create mode 100644 src/server/controllers/signInControllers.ts diff --git a/src/server/controllers/oktaIdxShared.ts b/src/server/controllers/oktaIdxShared.ts new file mode 100644 index 000000000..2ae3b02da --- /dev/null +++ b/src/server/controllers/oktaIdxShared.ts @@ -0,0 +1,155 @@ +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'; + +/** + * @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', + }); +}; diff --git a/src/server/controllers/sendChangePasswordEmail.ts b/src/server/controllers/sendChangePasswordEmail.ts index 721925623..6bd3bfaa2 100644 --- a/src/server/controllers/sendChangePasswordEmail.ts +++ b/src/server/controllers/sendChangePasswordEmail.ts @@ -40,19 +40,16 @@ 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'; +import { sendVerifyEmailAuthenticatorIdx } from '@/server/controllers/oktaIdxShared'; const { passcodesEnabled } = getConfiguration(); @@ -246,119 +243,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, diff --git a/src/server/controllers/signInControllers.ts b/src/server/controllers/signInControllers.ts new file mode 100644 index 000000000..d35abc6ad --- /dev/null +++ b/src/server/controllers/signInControllers.ts @@ -0,0 +1,654 @@ +import crypto from 'node:crypto'; +import { Request } from 'express'; +import { addQueryParamsToPath } from '@/shared/lib/queryParams'; +import { isBreachedPassword } from '@/server/lib/breachedPasswordCheck'; +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 { + challenge, + validateChallengeRemediation, + isChallengeAnswerCompleteLoginResponse, + validateChallengeAnswerRemediation, +} from '@/server/lib/okta/idx/challenge'; +import { credentialEnroll } from '@/server/lib/okta/idx/credential'; +import { + identify, + IdentifyResponse, + validateIdentifyRemediation, +} 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 { + 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'; +import { mergeRequestState } from '@/server/lib/requestState'; +import { logger } from '@/server/lib/serverSideLogger'; +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, + GenericErrors, + 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 { sendVerifyEmailAuthenticatorIdx } from '@/server/controllers/oktaIdxShared'; + +/** + * @name SignInError + * @description The error object to return from the sign in controllers + */ +interface SignInError { + status: number; + gatewayError: GatewayError; +} + +/** + * @name oktaSignInControllerErrorHandler + * @description Handle errors from the Okta sign in controllers + * @param error - The error to handle + * @returns SignInError - The error object to return + */ +export const oktaSignInControllerErrorHandler = ( + error: unknown, +): SignInError => { + if ( + (error instanceof OktaError && + error.name === 'AuthenticationFailedError') || + (error instanceof OAuthError && error.code.includes('E0000004')) + ) { + return { + status: 401, // always return 401 for authentication failed + gatewayError: { + message: SignInErrors.AUTHENTICATION_FAILED, + severity: 'BAU', + }, + }; + } + + return { + status: 500, + gatewayError: { + severity: 'UNEXPECTED', + message: SignInErrors.GENERIC, + }, + }; +}; + +/** + * @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<[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) => { + // 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 + if (error.status === 404 && error.code === 'E0000007') { + throw new OktaError({ + code: 'E0000004', + message: 'User not found', + }); + } + + // otherwise throw the error to outer catch block + throw new OktaError({ + code: error.code, + message: error.message, + }); + } + + // and any other error to outer catch block + 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', + }); + } + + // 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: {}, + }); + + // call "identify", essentially to start an authentication process + const identifyResponse = await identify( + introspectResponse.stateHandle, + email, + req.ip, + ); + + // validate that the response from the identify endpoint is the "select-authenticator-authenticate" remediation + // which is what we expect when we want to authenticate the user + validateIdentifyRemediation( + identifyResponse, + 'select-authenticator-authenticate', + ); + + 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, +}: { + req: Request; + res: ResponseWithRequestState; +}) => { + 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, + }); + + // 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', + }); + } 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 + * + * @param {Request} req - Express request object + * @param {ResponseWithRequestState} res - Express response object + * @returns {Promise} + */ +export const oktaIdxApiSignInController = async ({ + req, + res, +}: { + req: Request; + res: ResponseWithRequestState; +}) => { + // get the email and password from the request 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, + req, + res, + }); + + // check for the "password" authenticator, we can only authenticate with a password + // if this authenticator is present + const passwordAuthenticatorId = findAuthenticatorId({ + authenticator: 'password', + response: identifyResponse, + remediationName: 'select-authenticator-authenticate', + }); + + // if the password authenticator is not found, we cannot authenticate with a password + // this would be a case where the user is a passwordless or SOCIAL user + if (!passwordAuthenticatorId) { + throw new OktaError({ + code: 'E0000004', + message: 'Password authenticator not found', + }); + } + + // 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 + // and has the "recover" remediation + validateChallengeRemediation( + challengePasswordResponse, + 'challenge-authenticator', + 'password', + ); + + // call "challenge/answer" to answer the password challenge + const challengeAnswerResponse = await submitPassword({ + password, + stateHandle: challengePasswordResponse.stateHandle, + introspectRemediation: 'challenge-authenticator', + ip: req.ip, + }); + + // if the user has made it here, they've successfully authenticated + trackMetric('OktaIdxSignIn::Success'); + + // check the response from the challenge/answer endpoint + // if not a "CompleteLoginResponse" then Okta is in the state + // where the user needs to enroll in the "email" authenticator + if (!isChallengeAnswerCompleteLoginResponse(challengeAnswerResponse)) { + // 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 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, + ); + + // 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: challengeEmailResponse.stateHandle, + stateHandleExpiresAt: challengeEmailResponse.expiresAt, + userState: 'ACTIVE_PASSWORD_ONLY', + }); + + // show the email sent page, with passcode instructions + return res.redirect( + 303, + addQueryParamsToPath('/signin/email-sent', res.locals.queryParams), + ); + } + + // 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', + ); + + // check the user password strength + const hasWeakPassword = await isBreachedPassword(password); + + // We want to log if the user is in one of the 4 following states + // 1. User is in the GuardianUser-EmailValidated group and has a strong password + // 2. User is in the GuardianUser-EmailValidated group and has a weak password + // 3. User is not in the GuardianUser-EmailValidated group and has a strong password + // 4. User is not in the GuardianUser-EmailValidated group and has a weak password + if (emailValidated && !hasWeakPassword) { + trackMetric('User-EmailValidated-StrongPassword'); + } else if (emailValidated && hasWeakPassword) { + trackMetric('User-EmailValidated-WeakPassword'); + } else if (!emailValidated && !hasWeakPassword) { + trackMetric('User-EmailNotValidated-StrongPassword'); + } else if (!emailValidated && hasWeakPassword) { + trackMetric('User-EmailNotValidated-WeakPassword'); + } + + // if the user doesn't have their email validated, we need to verify their email + if (!emailValidated) { + // use the idx reset password flow to send the user a passcode + await changePasswordEmailIdx({ + req, + res, + user, + emailSentPage: '/signin/email-sent', + }); + // if successful, the user will be redirected to the email sent page + // so we need to check if the headers have been sent to prevent further processing + if (res.headersSent) { + return; + } else { + // if the headers have not been sent there has been an unexpected error + // so throw an error + throw new OktaError({ + message: 'Okta changePasswordEmailIdx in signin failed', + }); + } + } + + // otherwise 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, + ); + } + + // redirect the user to set a global session and then back to completing the authorization flow + return res.redirect(303, loginRedirectUrl); + } catch (error) { + logger.error('Okta oktaIdxApiSignInController failed', error); + + trackMetric('OktaIdxSignIn::Failure'); + + const { status, gatewayError } = oktaSignInControllerErrorHandler(error); + + const html = renderer('/signin', { + requestState: mergeRequestState(res.locals, { + pageData: { + email, + formError: gatewayError, + }, + }), + pageTitle: 'Sign in', + }); + + return res.status(status).type('html').send(html); + } +}; + +/** + * @name oktaIdxApiSignInController + * @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 { + // 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', + }); + + // 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/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 = { diff --git a/src/server/routes/signIn.ts b/src/server/routes/signIn.ts index f28fb14f5..c6735f3b3 100644 --- a/src/server/routes/signIn.ts +++ b/src/server/routes/signIn.ts @@ -9,7 +9,6 @@ import { getConfiguration } from '@/server/lib/getConfiguration'; import { decrypt } from '@/server/lib/idapi/decryptToken'; import { FederationErrors, - GatewayError, GenericErrors, RegistrationErrors, SignInErrors, @@ -34,7 +33,6 @@ import { addQueryParamsToPath } from '@/shared/lib/queryParams'; import { readEncryptedStateCookie, setEncryptedStateCookie, - updateEncryptedStateCookie, } from '@/server/lib/encryptedStateCookie'; import { sendEmailToUnvalidatedUser } from '@/server/lib/unvalidatedEmail'; import { @@ -47,32 +45,13 @@ import { interact } from '@/server/lib/okta/idx/interact'; import { introspect, redirectIdpSchema, - validateIntrospectRemediation, } from '@/server/lib/okta/idx/introspect'; -import { startIdxFlow } from '@/server/lib/okta/idx/startIdxFlow'; -import { isOktaError } from '@/server/lib/okta/api/errors'; import { - identify, - validateIdentifyRemediation, -} from '@/server/lib/okta/idx/identify'; -import { findAuthenticatorId } from '@/server/lib/okta/idx/shared/findAuthenticatorId'; -import { - challenge, - isChallengeAnswerCompleteLoginResponse, - validateChallengeAnswerRemediation, - validateChallengeRemediation, -} from '@/server/lib/okta/idx/challenge'; -import { - submitPasscode, - submitPassword, -} from '@/server/lib/okta/idx/shared/submitPasscode'; -import { getLoginRedirectUrl } from '@/server/lib/okta/idx/shared/idxFetch'; -import { credentialEnroll } from '@/server/lib/okta/idx/credential'; -import { changePasswordEmailIdx } from '@/server/controllers/sendChangePasswordEmail'; -import { validateEmailAndPasswordSetSecurely } from '@/server/lib/okta/validateEmail'; -import dangerouslySetPlaceholderPassword from '@/server/lib/okta/dangerouslySetPlaceholderPassword'; + oktaIdxApiSignInController, + oktaIdxApiSubmitPasscodeController, + oktaSignInControllerErrorHandler, +} from '@/server/controllers/signInControllers'; import { convertExpiresAtToExpiryTimeInMs } from '@/server/lib/okta/idx/shared/convertExpiresAtToExpiryTimeInMs'; -import { handlePasscodeError } from '../lib/okta/idx/shared/errorHandling'; const { okta, accountManagementUrl, defaultReturnUri, passcodesEnabled } = getConfiguration(); @@ -288,35 +267,14 @@ router.post( // we're not using /signin/email-sent as that route is used by the security/email validation flow router.get( '/signin/code', - handleAsyncErrors(async (req: Request, res: ResponseWithRequestState) => { + redirectIfLoggedIn, + (req: Request, res: ResponseWithRequestState) => { const state = res.locals; const encryptedState = readEncryptedStateCookie(req); - if ( - encryptedState?.email && - encryptedState.stateHandle && - encryptedState.userState - ) { + if (encryptedState?.email && encryptedState.stateHandle) { try { - // validate the state handle through the introspect endpoint - const introspectResponse = await introspect( - { - stateHandle: encryptedState.stateHandle, - }, - req.ip, - ); - - validateIntrospectRemediation( - introspectResponse, - // 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 - encryptedState.userState === 'ACTIVE_PASSWORD_ONLY' - ? 'select-authenticator-enroll' - : 'challenge-authenticator', - ); - const html = renderer('/signin/code', { requestState: mergeRequestState(state, { pageData: { @@ -333,695 +291,21 @@ router.get( logger.error(`${req.method} ${req.originalUrl} Error`, error); } } - // todo maybe show an error page here + + // on error, redirect to the sign in page return res.redirect( 303, addQueryParamsToPath('/signin', state.queryParams), ); - }), + }, ); router.post( '/signin/code', redirectIfLoggedIn, - handleAsyncErrors(async (req: Request, res: ResponseWithRequestState) => { - const { code } = req.body; - - const encryptedState = readEncryptedStateCookie(req); - - if (encryptedState?.stateHandle && code) { - const { stateHandle, userState } = encryptedState; - - 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: - // 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', - }); - - // 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); - } - } - }), + oktaIdxApiSubmitPasscodeController, ); -interface SignInError { - status: number; - gatewayError: GatewayError; -} - -// handles errors in the catch block to return a error to display to the user -const oktaSignInControllerErrorHandler = (error: unknown): SignInError => { - if ( - (error instanceof OktaError && - error.name === 'AuthenticationFailedError') || - (error instanceof OAuthError && error.code.includes('E0000004')) - ) { - return { - status: 401, // always return 401 for authentication failed - gatewayError: { - message: SignInErrors.AUTHENTICATION_FAILED, - severity: 'BAU', - }, - }; - } - - return { - status: 500, - gatewayError: { - severity: 'UNEXPECTED', - message: SignInErrors.GENERIC, - }, - }; -}; - -const oktaIdxApiSignInPasscodeController = async ({ - req, - res, -}: { - req: Request; - res: ResponseWithRequestState; -}) => { - const { email = '' } = req.body; - - try { - // TODO: lots of shared stuff with the oktaIdxApiSignInController, refactor to share code - // 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) => { - // 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 - if (error.status === 404 && error.code === 'E0000007') { - throw new OktaError({ - code: 'E0000004', - message: 'User not found', - }); - } - - // otherwise throw the error to outer catch block - throw new OktaError({ - code: error.code, - message: error.message, - }); - } - - // and any other error to outer catch block - 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', - }); - } - - // 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: {}, - }); - - // call "identify", essentially to start an authentication process - const identifyResponse = await identify( - introspectResponse.stateHandle, - email, - req.ip, - ); - - validateIdentifyRemediation( - identifyResponse, - 'select-authenticator-authenticate', - ); - - // 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: - // 2. ACTIVE users - has only password authenticator (okta idx email not verified) - // we need to send these users a verification email to sign in - // 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, then they've signed in successfully! - - // TODO refactor this code to share with sendChangePasswordEmail - - // 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 - 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 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 send metric - trackMetric('OktaIdxSendPasscodeSignIn::Success'); - - // 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', - }); - - 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', - }); - } 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 - 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 - * - * @param {Request} req - Express request object - * @param {ResponseWithRequestState} res - Express response object - * @returns {Promise} - */ -const oktaIdxApiSignInController = async ({ - req, - res, -}: { - req: Request; - res: ResponseWithRequestState; -}) => { - // get the email and password from the request 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 }); - } - - // Otherwise, we want to sign in with a password - // 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) => { - // 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 - if (error.status === 404 && error.code === 'E0000007') { - throw new OktaError({ - code: 'E0000004', - message: 'User not found', - }); - } - - // otherwise throw the error to outer catch block - throw new OktaError({ - code: error.code, - message: error.message, - }); - } - - // and any other error to outer catch block - 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', - }); - } - - // 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: {}, - }); - - // call "identify", essentially to start an authentication process - const identifyResponse = await identify( - introspectResponse.stateHandle, - email, - req.ip, - ); - - validateIdentifyRemediation( - identifyResponse, - 'select-authenticator-authenticate', - ); - - // check for the "password" authenticator, we can only authenticate with a password - // if this authenticator is present - const passwordAuthenticatorId = findAuthenticatorId({ - authenticator: 'password', - response: identifyResponse, - remediationName: 'select-authenticator-authenticate', - }); - - // if the password authenticator is not found, we cannot authenticate with a password - // this would be a case where the user is a passwordless or SOCIAL user - if (!passwordAuthenticatorId) { - throw new OktaError({ - code: 'E0000004', - message: 'Password authenticator not found', - }); - } - - // 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, - stateHandle: challengePasswordResponse.stateHandle, - introspectRemediation: 'challenge-authenticator', - ip: req.ip, - }); - - // if the user has made it here, they've successfully authenticated - trackMetric('OktaIdxSignIn::Success'); - - // check the response from the challenge/answer endpoint - // if not a "CompleteLoginResponse" then Okta is in the state - // where the user needs to enroll in the "email" authenticator - if (!isChallengeAnswerCompleteLoginResponse(challengeAnswerResponse)) { - // 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 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, - ); - - // 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: challengeEmailResponse.stateHandle, - stateHandleExpiresAt: challengeEmailResponse.expiresAt, - userState: 'ACTIVE_PASSWORD_ONLY', - }); - - // show the email sent page, with passcode instructions - return res.redirect( - 303, - addQueryParamsToPath('/signin/email-sent', res.locals.queryParams), - ); - } - - // 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', - ); - - // check the user password strength - const hasWeakPassword = await isBreachedPassword(password); - - // We want to log if the user is in one of the 4 following states - // 1. User is in the GuardianUser-EmailValidated group and has a strong password - // 2. User is in the GuardianUser-EmailValidated group and has a weak password - // 3. User is not in the GuardianUser-EmailValidated group and has a strong password - // 4. User is not in the GuardianUser-EmailValidated group and has a weak password - if (emailValidated && !hasWeakPassword) { - trackMetric('User-EmailValidated-StrongPassword'); - } else if (emailValidated && hasWeakPassword) { - trackMetric('User-EmailValidated-WeakPassword'); - } else if (!emailValidated && !hasWeakPassword) { - trackMetric('User-EmailNotValidated-StrongPassword'); - } else if (!emailValidated && hasWeakPassword) { - trackMetric('User-EmailNotValidated-WeakPassword'); - } - - // if the user doesn't have their email validated, we need to verify their email - if (!emailValidated) { - // use the idx reset password flow to send the user a passcode - await changePasswordEmailIdx({ - req, - res, - user, - emailSentPage: '/signin/email-sent', - }); - // if successful, the user will be redirected to the email sent page - // so we need to check if the headers have been sent to prevent further processing - if (res.headersSent) { - return; - } else { - // if the headers have not been sent there has been an unexpected error - // so throw an error - throw new OktaError({ - message: 'Okta changePasswordEmailIdx in signin failed', - }); - } - } - - // otherwise 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, - ); - } - - // redirect the user to set a global session and then back to completing the authorization flow - return res.redirect(303, loginRedirectUrl); - } catch (error) { - logger.error('Okta oktaIdxApiSignInController failed', error); - - trackMetric('OktaIdxSignIn::Failure'); - - const { status, gatewayError } = oktaSignInControllerErrorHandler(error); - - const html = renderer('/signin', { - requestState: mergeRequestState(res.locals, { - pageData: { - email, - formError: gatewayError, - }, - }), - pageTitle: 'Sign in', - }); - - return res.status(status).type('html').send(html); - } -}; - const oktaSignInController = async ({ req, res,