Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
coldlink committed Oct 15, 2024
1 parent 2d8a92d commit 48bac86
Show file tree
Hide file tree
Showing 5 changed files with 832 additions and 838 deletions.
155 changes: 155 additions & 0 deletions src/server/controllers/oktaIdxShared.ts
Original file line number Diff line number Diff line change
@@ -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',
});
};
121 changes: 11 additions & 110 deletions src/server/controllers/sendChangePasswordEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 48bac86

Please sign in to comment.