Skip to content

Commit

Permalink
feat(idx): Add oktaIdxApiSignInController to be able to sign in wit…
Browse files Browse the repository at this point in the history
…h password using the Okta IDX API
  • Loading branch information
coldlink committed Oct 1, 2024
1 parent 2039cc1 commit b9ed300
Showing 1 changed file with 295 additions and 6 deletions.
301 changes: 295 additions & 6 deletions src/server/routes/signIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ import {
introspect,
redirectIdpSchema,
} 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 { 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';

const { okta, accountManagementUrl, defaultReturnUri, passcodesEnabled } =
getConfiguration();
Expand Down Expand Up @@ -256,18 +273,21 @@ router.post(
});
}),
);

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 OktaError &&
error.name === 'AuthenticationFailedError') ||
(error instanceof OAuthError && error.code.includes('E0000004'))
) {
return {
status: error.status,
status: 401, // always return 401 for authentication failed
gatewayError: {
message: SignInErrors.AUTHENTICATION_FAILED,
severity: 'BAU',
Expand All @@ -284,6 +304,268 @@ const oktaSignInControllerErrorHandler = (error: unknown): SignInError => {
};
};

/**
* @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<void>}
*/
const oktaIdxApiSignInController = async ({
req,
res,
}: {
req: Request;
res: ResponseWithRequestState;
}) => {
// TODO: remove when the useIdxSignIn feature flag is removed
// placeholder warning message
logger.warn(
'IDX API password authentication flow is not fully implemented yet',
);

// get the email and password from the request body
const { email = '', password = '' } = req.body;

try {
// 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,
Expand Down Expand Up @@ -323,9 +605,16 @@ const oktaSignInController = async ({
try {
// idx api flow
if (passcodesEnabled && res.locals.queryParams.useIdxSignIn) {
logger.warn(
'IDX API password authentication flow is not fully implemented yet',
);
// try to start the IDX flow to sign in the user with a password
await oktaIdxApiSignInController({
req,
res,
});
// if successful, the user will be redirected
// so we need to check if the headers have been sent to prevent further processing
if (res.headersSent) {
return;
}
}

// attempt to authenticate with okta
Expand Down

0 comments on commit b9ed300

Please sign in to comment.