src/server/controllers/changePassword.ts (202 lines of code) (raw):
import { Request } from 'express';
import { handleAsyncErrors } from '@/server/lib/expressWrappers';
import { logger } from '@/server/lib/serverSideLogger';
import { ResponseWithRequestState } from '@/server/models/Express';
import { trackMetric } from '@/server/lib/trackMetric';
import {
readEncryptedStateCookie,
updateEncryptedStateCookie,
} from '@/server/lib/encryptedStateCookie';
import { PasswordRoutePath, RoutePaths } from '@/shared/model/Routes';
import { PasswordPageTitle } from '@/shared/model/PageTitle';
import {
getErrorMessage,
validatePasswordFieldForOkta,
} from '@/server/lib/validatePasswordField';
import { addQueryParamsToPath } from '@/shared/lib/queryParams';
import { getConfiguration } from '@/server/lib/getConfiguration';
import {
resetPassword as resetPasswordInOkta,
validateRecoveryToken as validateTokenInOkta,
} from '@/server/lib/okta/api/authentication';
import { OAuthError, OktaError } from '@/server/models/okta/Error';
import {
checkTokenInOkta,
getExpectedRemediationByPath,
} from '@/server/controllers/checkPasswordToken';
import {
performAuthorizationCodeFlow,
scopesForAuthentication,
} from '@/server/lib/okta/oauth';
import { validateEmailAndPasswordSetSecurely } from '@/server/lib/okta/validateEmail';
import { setupJobsUserInOkta } from '@/server/lib/jobs';
import { sendOphanComponentEventFromQueryParamsServer } from '@/server/lib/ophan';
import { ProfileOpenIdClientRedirectUris } from '@/server/lib/okta/openid-connect';
import { decryptOktaRecoveryToken } from '@/server/lib/deeplink/oktaRecoveryToken';
import { changePasswordMetric } from '@/server/models/Metrics';
import { getAppPrefix } from '@/shared/lib/appNameUtils';
import { setPasswordAndRedirect } from '@/server/lib/okta/idx/shared/submitPasscode';
const { passcodesEnabled } = getConfiguration();
/**
* Okta IDX API Flow
*
* @name oktaIdxApiPasswordHandler
* @description Handles the password (re)set for the Okta IDX API flow.
*
* Note: Use res.headersSent to check if headers have been sent after calling this function
* to avoid errors if we respond within this function.
*
* @param {Request} req - The request object.
* @param {ResponseWithRequestState} res - The response object.
* @param {PasswordPageTitle} pageTitle - The page title.
* @param {string} password - The password to set.
* @param {PasswordRoutePath} path - The path of the page.
*
* @returns {Promise<void>} - The function does not return anything, or it redirects the user.
*/
const oktaIdxApiPasswordHandler = async ({
req,
res,
pageTitle,
password,
path,
}: {
req: Request;
res: ResponseWithRequestState;
pageTitle: PasswordPageTitle;
password: string;
path: PasswordRoutePath;
}) => {
const state = res.locals;
try {
// Read the encrypted state cookie to get the state handle and email
const encryptedState = readEncryptedStateCookie(req);
if (encryptedState?.email && encryptedState.stateHandle) {
const introspectRemediation = getExpectedRemediationByPath(path);
// track the password change metric
trackMetric(changePasswordMetric(path, 'Success', true));
// Set the password in Okta, redirect the user to set a global session, and then complete
// the interaction code flow, eventually redirecting the user back to where they need to go.
return await setPasswordAndRedirect({
stateHandle: encryptedState.stateHandle,
password,
expressReq: req,
expressRes: res,
introspectRemediation,
path,
ip: req.ip,
});
}
} catch (error) {
logger.error('Okta IDX setPassword failure', error);
trackMetric(changePasswordMetric(path, 'Failure', true));
if (error instanceof OAuthError) {
// case for session expired
if (error.name === 'idx.session.expired') {
return res.redirect(
303,
addQueryParamsToPath(`${path}/expired`, state.queryParams),
);
}
}
const { globalError, fieldErrors } = getErrorMessage(error);
// If the recovery token is valid, this call will redirect the client back
// to the same page, but with an error message. If the token is invalid, the
// client will be redirected to the /reset-password/expired page and asked
// to request a new reset password link.
return await checkTokenInOkta(
path,
pageTitle,
req,
res,
globalError,
fieldErrors,
);
}
};
export const setPasswordController = (
path: PasswordRoutePath,
pageTitle: PasswordPageTitle,
successRedirectPath: RoutePaths,
) =>
handleAsyncErrors(async (req: Request, res: ResponseWithRequestState) => {
const { token: encryptedRecoveryToken } = req.params;
const { password, firstName, secondName } = req.body;
const { clientId, useOktaClassic } = res.locals.queryParams;
// OKTA IDX API FLOW
// If the user is using the passcode flow for registration/reset password,
// we need to handle the password change/creation.
// If there are specific failures, we fall back to the legacy Okta change password flow.
if (passcodesEnabled && !useOktaClassic) {
await oktaIdxApiPasswordHandler({
req,
res,
pageTitle,
password,
path,
});
// don't continue with the legacy flow if we've already responded with the IDX flow
// res.headersSent is true if we've responded with a page or redirect
// res.headersSent is false if we haven't responded yet, which is the case where an error occurs
// and we want to attempt the legacy flow
if (res.headersSent) {
return;
}
}
try {
if (!encryptedRecoveryToken) {
throw new OktaError({ message: 'Okta recovery token missing' });
}
validatePasswordFieldForOkta(password);
// decrypt the recovery token
const decryptedRecoveryToken = decryptOktaRecoveryToken({
encryptedToken: encryptedRecoveryToken,
});
const [recoveryToken, encryptedRegistrationConsents] =
decryptedRecoveryToken;
// We exchange the Okta recovery token for a freshly minted short-lived state
// token, to complete this change password operation. If the recovery token
// is invalid, we will show the user the link expired page.
const { stateToken } = await validateTokenInOkta({
recoveryToken,
ip: req.ip,
});
if (stateToken) {
const { sessionToken, _embedded } = await resetPasswordInOkta(
{
stateToken,
newPassword: password,
},
req.ip,
);
const { id } = _embedded?.user ?? {};
if (id) {
await validateEmailAndPasswordSetSecurely({
id,
ip: req.ip,
});
} else {
logger.error(
'Failed to set validation flags in Okta as there was no id',
undefined,
);
}
// When a jobs user is registering, we add them to the GRS group and set their name
if (clientId === 'jobs' && path === '/welcome') {
if (id) {
await setupJobsUserInOkta(firstName, secondName, id, req.ip);
trackMetric('JobsGRSGroupAgree::Success');
} else {
logger.error(
'Failed to set jobs user name and field in Okta as there was no id',
undefined,
);
}
}
updateEncryptedStateCookie(req, res, {
// Update the passwordSetOnWelcomePage only when we are on the welcome page
...(path === '/welcome' && { passwordSetOnWelcomePage: true }),
// We want to remove all query params from the cookie after the password is set,
queryParams: undefined,
});
// fire ophan component event if applicable
if (res.locals.queryParams.componentEventParams) {
void sendOphanComponentEventFromQueryParamsServer(
res.locals.queryParams.componentEventParams,
'SIGN_IN',
'web',
res.locals.ophanConfig.consentUUID,
);
}
trackMetric(changePasswordMetric(path, 'Success'));
return await performAuthorizationCodeFlow(req, res, {
sessionToken,
confirmationPagePath: successRedirectPath,
closeExistingSession: true,
prompt: 'none',
scopes: scopesForAuthentication,
redirectUri: ProfileOpenIdClientRedirectUris.AUTHENTICATION,
extraData: {
// We only set this when we're setting the password on a welcome flow (i.e. when it's a new user)
isEmailRegistration: path === '/welcome',
encryptedRegistrationConsents,
appPrefix: getAppPrefix(encryptedRecoveryToken),
},
});
} else {
throw new OktaError({ message: 'Okta state token missing' });
}
} catch (error) {
logger.error('Okta change password failure', error);
trackMetric(changePasswordMetric(path, 'Failure'));
// see the comment above around the success metrics
if (clientId === 'jobs' && path === '/welcome') {
trackMetric('JobsGRSGroupAgree::Failure');
}
const { globalError, fieldErrors } = getErrorMessage(error);
// If the recovery token is valid, this call will redirect the client back
// to the same page, but with an error message. If the token is invalid, the
// client will be redirected to the /reset-password/expired page and asked
// to request a new reset password link.
await checkTokenInOkta(
path,
pageTitle,
req,
res,
globalError,
fieldErrors,
);
}
});