Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Passcodes | Set up passcodes for registration #2639

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open

Conversation

coldlink
Copy link
Member

@coldlink coldlink commented Mar 21, 2024

What does this change?

We've now upgraded to Okta Identity Engine. This unlocks a number of new features which previously weren't feasible. One of these is "passwordless", or the idea of eliminating, or reducing the need for passwords.

We also currently have an issue within our OAuth flows where users don't always end up signed in the same browser context that they registered from. This can occur if a user clicks a reset password/registration verification link in their on another browser or device, or if deeplinking/https interception isn't working properly on the user's device.

One way of fixing both these issues it to use One Time Passcodes (OTPs), where instead of a user clicking a link in an email, they have to type a 6 digit number into an input field which performs the same action of verifying the user. This means that the user always stays within the same browser device/context, while also securely verifying the user.

To begin with we're going to only implement OTPs for registration only, with the user still having to set a password, this is the focus of this PR. After this we will being rolling out passcodes for sign in and password reset too, thus eliminating passwords altogether, or at lease making them optional.

This PR builds on the work previously done in #2625 where the initial work to set up the IDX API was done, and #2637 where most of the APIs for passcodes were set up. The focus of this PR is to wire up the registration functionality to use the passcode API, and set up the frontend to handle this.

This PR however does not enable passcodes directly in PROD, the usePasscodesRegistration query parameter flag must be manually set in order to opt in, and the switch has to be enabled in PROD, it is currently set to false. This allows us to slowly opt in and first test on the CODE environment, followed by PROD behind the flag, followed by enabling it without the flag.

We're currently only enabling passcodes for new users who are registering. Existing users that are attempting to go through the registration flow will fallback to the legacy/current flow without passcodes for now until full passwordless is implemented. The full flowchart can be seen in #2567.

The Journey

The steps to enable passcode registration are as follows:

  1. User attempts to register with a new email, this calls POST /register
  2. POST /register
    1. Checks if registrationPasscodesEnabled feature switch enabled and usePasscodeRegistration query parameter is set (this step is temporary before rolling out to all users)
      1. If it is, attempt passcode registration flow
      2. Otherwise use classic flow
    2. Assuming 1) is stratified, first start the interaction code flow, and get the interaction_handle + authState
      1. See Okta IDX API | API setup for passcode registration #2637 and IDX API / Interaction Code Flow | API Setup + Social Sign In #2625 for more details on specifics of what the interaction code flow/IDX API is doing
    3. Introspect the interaction_handle to get the stateHandle
    4. Encrypt any consents we need to preserve during the registration flow, and update the authState with the stateToken and encryptedRegistrationConsents
    5. Check if we have the select-enroll-profile remediation property which means registration is allowed using the IDX API
    6. Call the /enroll endpoint to attempt to start the registration process
    7. Call the /enroll/new endpoint to attempt to register the user with email
    8. If successful, the user will have been send an email with a passcode by Okta
    9. Set the encrypted state cookie to persist the email and stateHandle through the flow/between pages
    10. Finally redirect to the email sent page
    11. If at any point there is an error, we log the error and fallback to the classic registration flow
  3. User shown the Email Sent page, but it has a passcode input field instead! User grabs the 6 digit passcode from their email. They type it in and click "Submit Passcode". This calls POST /register/code
  4. POST /register/code
    1. Grab the code from the request body and read the stateHandle from the encrypted state cookie
    2. Check if the stateHandle is valid with the introspect endpoint
    3. Call the /challenge/answer endpoint with the code and stateHandle
      i. If valid continue
      ii. If invalid, we throw and handle a specific error case for invalid passcode: api.authn.error.PASSCODE_INVALID
    4. We need the user to set a password, so we need to enroll them in the "password" factor, find the passwordAuthenticatorId
    5. Call the /credentials/enroll method with the authenticator id and methodType set to password
    6. Redirect the user to the /welcome/password page where they'll set a password
  5. GET /welcome/password
    1. This is intercepted as a GET /welcome/:token route, so we update the existing handler, which uses the checkTokenInOkta from checkPasswordToken.ts
    2. Checks if registrationPasscodesEnabled feature switch enabled and usePasscodeRegistration query parameter is set (this step is temporary before rolling out to all users)
      1. If it is, attempt passcode registration flow
      2. Otherwise use classic flow, i.e assume the token parameter is a token
    3. If it is, read the encrypted state cookie for the email and state handle
    4. Check if the stateHandle is valid with the introspect endpoint and making sure it has the enroll-authenticator object so we know it's in the correct state
    5. Show the set password page
  6. User enters a new password and submits, calls POST /welcome/password
    1. This is intercepted as a POST /welcome/:token route, so we update the existing handler, which uses the changePasswordInOkta from changePassword.ts
    2. Checks if registrationPasscodesEnabled feature switch enabled and usePasscodeRegistration query parameter is set (this step is temporary before rolling out to all users)
      1. If it is, attempt passcode registration flow
      2. Otherwise use classic flow, i.e assume the token parameter is a token
    3. If it is, read the encrypted state cookie for the email and state handle
    4. Check if the stateHandle is valid with the introspect endpoint and making sure it has the enroll-authenticator object so we know it's in the correct state
    5. Validate the password field and check if breached
    6. Call the setPasswordAndRedirect function, which uses the /challenge/answer endpoint to set a password. This sets a global okta session, and completes the flow by redirecting the user to the interaction code callback endpoint, but with a code parameter (not an interaction_code) and the state.
  7. In this callback endpoint, use a standard OAuth library that supports Authorization Code Flow with PKCE, and complete the flow. The user is authenticated at this point, and everything else remains the same as what we currently have
  8. At any point if there is an error, we catch the error, log it, and fallback to the existing flow.

Demo

Screen.Recording.2024-05-22.at.15.59.22.mov

Plan

  • Merge this PR in behind feature flag and switch
  • Perform testing on CODE apps and web
    • The query flag is usePasscodeRegistration=true
  • Enable switch on PROD
    • PR required
  • Perform testing on PROD behind
  • Remove feature flag on PROD
    • Big PR required

While improving/making refinements.

Tested?

  • Cypress tests
  • CODE - New user web
  • CODE - Existing user web
  • CODE - New user app
  • CODE - Existing user app

@coldlink coldlink force-pushed the mm/passcodes-ete branch 2 times, most recently from 9693763 to 40201c7 Compare March 28, 2024 17:12
@coldlink coldlink force-pushed the mm/passcodes-ete branch 5 times, most recently from 619b9a9 to 3b6394c Compare April 3, 2024 09:21
Base automatically changed from mm/idx-api-part-ii to main April 8, 2024 13:37
@coldlink coldlink force-pushed the mm/passcodes-ete branch 4 times, most recently from 09b3a5c to 50de391 Compare May 21, 2024 07:57
@coldlink coldlink requested review from guardian-ci and removed request for guardian-ci May 21, 2024 12:43
@coldlink coldlink force-pushed the mm/passcodes-ete branch 2 times, most recently from 2abba14 to 40406c7 Compare May 22, 2024 10:50
@coldlink coldlink marked this pull request as ready for review May 22, 2024 10:52
@coldlink coldlink requested a review from a team as a code owner May 22, 2024 10:52
@coldlink coldlink requested review from guardian-ci and removed request for guardian-ci May 22, 2024 10:54
@coldlink coldlink changed the title Set up passcodes for registration Passcodes | Set up passcodes for registration May 22, 2024
@coldlink coldlink requested review from guardian-ci and removed request for guardian-ci May 22, 2024 15:11
@raphaelkabo
Copy link
Contributor

raphaelkabo commented May 24, 2024

Some questions/notes (I think I've answered the first two of these for myself by reading the code but I wanted to double check because they're edge cases):

  • As a user, what's the experience if there's a token expiry while I'm on the passcode field? What happens if I wait for ages there, then put in the right passcode? What if it's the wrong passcode?
  • Is there a situation where a token expiry can occur between the passcode input field and the password input field?
  • For simplicity, I would just make /welcome/password its own GET and POST routes, and fail with an error if the feature flag and query parameter aren't present. We wouldn't be letting real users use the query parameter on production anyway, and it'll avoid overloading the /welcome/:token routes with logic that doesn't belong in them ('password' can never be a valid token).

Copy link
Contributor

@raphaelkabo raphaelkabo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm very happy (and impressed) with this! I'm not approving it since it needs rebase with main anyway, and I've left some comments, but generally speaking I think it's very much ready for a CODE deploy behind a flag.

src/server/lib/okta/idx/challenge.ts Show resolved Hide resolved
src/server/lib/okta/idx/introspect.ts Show resolved Hide resolved
requestState: mergeRequestState(state, {
pageData: {
email: readEncryptedStateCookie(req)?.email,
timeUntilTokenExpiry: encryptedState.stateHandleExpiresAt
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could be pedantic/avoid confusion and call this msUntilTokenExpiry. Just because some OAuth stuff uses seconds instead.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it this way as timeUntilTokenExpiry was already a thing in Gateway, and wanted to avoid a refactor.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hopefully the new function name makes it clearer

Comment on lines +203 to +235
const passwordAuthenticatorId =
challengeAnswerResponse.remediation.value
.flatMap((remediation) => {
if (remediation.name === 'select-authenticator-enroll') {
const parsedRemediation =
selectAuthenticationEnrollSchema.safeParse(remediation);

if (parsedRemediation.success) {
return parsedRemediation.data.value.flatMap((value) => {
if (value.name === 'authenticator') {
return value.options.flatMap((option) => {
if (option.label === 'Password') {
if (
option.value.form.value.some(
(v) => v.value === 'password',
)
) {
return [
option.value.form.value.find(
(v) => v.name === 'id',
)?.value,
];
}
}
});
}
});
}
}
})
.filter(
(id): id is string => typeof id === 'string' && id.length > 0,
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is terrifying. No suggestions to fix it, it just is. :(

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A further thought: is there any way at all that we could leverage zod to handle this complex nested object? It feels like a maintenance nightmare otherwise.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is leveraging Zod as best I can 😱

it's just annoying at there are so many states to what the IDX API returns, it's not consistent, so we always have to double check everything.

(id): id is string => typeof id === 'string' && id.length > 0,
);

if (!passwordAuthenticatorId.length) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised Typescript can promise that passwordAuthenticatorId is set to anything after all those nested flatMaps and ifs! Maybe passwordAuthenticatorId?.length is safer anyway?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!passwordAuthenticatorId.length) { is effectively the same as doing if (passwordAuthenticatorId?.length) { in this scenario?

<>
{email ? (
<MainBodyText>
We’ve sent an email to <b>{email}</b> with verification
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is odd wording ('verification instructions and a verification code') - surely just 'with a verification code' is fine?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll speak to @nickhartyyy about this

Comment on lines +142 to +144
<MainBodyText>
We’ve sent you an email with verification instructions and a
verification code.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar odd wording here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this

Comment on lines +124 to +132
} else {
logger.error(
'Failed to set validation flags in Okta as there was no id',
undefined,
{
request_id,
},
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does anything rely on this? If the email isn't validated successfully, what happens to the short-lived user session next? What happens to any new user sessions? (Basically, are we happy leaving the error only logged?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one of the cases where it should be theoretically impossible to get to, but helpful if we log an error message

on next sign in they'd be sent the "security email" if they were ever to end up in this scenario

cypress/integration/ete-okta/registration_2.6.cy.ts Outdated Show resolved Hide resolved
@coldlink
Copy link
Member Author

coldlink commented May 28, 2024

Some questions/notes (I think I've answered the first two of these for myself by reading the code but I wanted to double check because they're edge cases):

  • As a user, what's the experience if there's a token expiry while I'm on the passcode field? What happens if I wait for ages there, then put in the right passcode? What if it's the wrong passcode?
  • Is there a situation where a token expiry can occur between the passcode input field and the password input field?
  • For simplicity, I would just make /welcome/password its own GET and POST routes, and fail with an error if the feature flag and query parameter aren't present. We wouldn't be letting real users use the query parameter on production anyway, and it'll avoid overloading the /welcome/:token routes with logic that doesn't belong in them ('password' can never be a valid token).
  • As a user, what's the experience if there's a token expiry while I'm on the passcode field? What happens if I wait for ages there, then put in the right passcode? What if it's the wrong passcode?
    • Everything should be handled correctly, if there is a passcode expiry at any point this would be caught and the user shown a "link expired" page, thanks to the timeUntilTokenExpiry code
    • This also works because we introspect the stateHandle on every request to make sure it's still valid, regardless of where the user is within the process.
  • Is there a situation where a token expiry can occur between the passcode input field and the password input field?
    • No, once the passcode is used, the stateHandle is updated to allow for additional time for the password field input (I think up to an hour), the timeUntilTokenExpiry is updated to reflect this
  • For simplicity, I would just make /welcome/password its own GET and POST routes, and fail with an error if the feature flag and query parameter aren't present. We wouldn't be letting real users use the query parameter on production anyway, and it'll avoid overloading the /welcome/:token routes with logic that doesn't belong in them ('password' can never be a valid token).
    • I did it this way for two reasons.
      1. To make it easier to migrate to passcodes in the future without having to rewrite any routes, and to reuse as much code as possible
      1. To make it easier to fallback to the existing code if for some reason something goes wrong

@coldlink coldlink requested review from a team and removed request for a team May 28, 2024 13:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants