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

Add WebAuthn authentication flow #6792

Merged
merged 10 commits into from
Feb 21, 2024
Merged

Add WebAuthn authentication flow #6792

merged 10 commits into from
Feb 21, 2024

Conversation

scotttrinh
Copy link
Contributor

Builds on #6791

Authentication is almost the same as registration:

  1. Fetch the "options" from the server which include a challenge and allowCredentials that identifies the user based on the provided email + user_handle
  2. The authenticator will sign with the stored private key and generate an assertion which includes the signed challenge, and other information about the authenticator.
  3. The server verifies the signature and some other metadata, and if it passes considers the session authenticated.

Like the registration flow, it uses PKCE to do the final exchange of the actual token.

@scotttrinh scotttrinh force-pushed the webauthn-authentication branch from dd9fed5 to f6dd579 Compare February 7, 2024 15:42
@scotttrinh scotttrinh force-pushed the webauthn-registration branch from 8c68aa2 to d404dab Compare February 7, 2024 20:21
@scotttrinh scotttrinh force-pushed the webauthn-authentication branch 2 times, most recently from 3fa39a0 to 095b654 Compare February 8, 2024 16:43
Base automatically changed from webauthn-registration to master February 8, 2024 17:50
@scotttrinh scotttrinh force-pushed the webauthn-authentication branch from 095b654 to 06109e0 Compare February 8, 2024 17:51
@scotttrinh scotttrinh requested a review from jaclarke February 8, 2024 17:52
)
result_json = json.loads(result.decode())
user_handles: set[str] = {x["user_handle"] for x in result_json}
assert len(user_handles) == 1
Copy link
Member

Choose a reason for hiding this comment

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

Why do we assert that there's only a single user_handle associated with a given email? I thought we wanted to allow you to use the same email with multiple authenticators: #6791 (comment)
Unless they should all have the same user_handle (which I guess was the reason for dropping the exclusive constraint on (.email, .user_handle)?), in which case in create_registration_options_for_email we should check if there's already a WebAuthnFactor for that email and re-use the user_handle. I don't know if there's some security issue with being able to retrieve an email's user_handle like that though?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, we're supposed to use the same user_handle if the user is "the same user" to allow multiple WebAuthn factors for a single user. You're right that I'm missing that check from the create_registration_options_for_email but I need to think about the UX here a bit more.

The ostensible reason for allowing multiple factors is to allow what are basically back-up factors (or multi-factors) so you can seamlessly move between passkeys, hardware authenticators, etc. but in our "registration" flow it's not super obvious that you might have multiple identities with the same email which point to a single User in the downstream application because we kind of leave that up to the application developer to do.

But, yeah I think at the very least we should reuse the same user handle if you provide an existing email, will fix.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

From testing it seems there's another issue with this assert. If the email isn't registered, then there's no user-handles,the assert fails and we return an ise. I'm not sure what the correct error should be here? I know we're trying not to leak the existence of emails, but I don't think we can get around that as we can only return a challenge if we have the stored credentials for an email.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, good catch: assert is the wrong tool here, we need to raise a specific exception for proper error handling.

As far as data leakage is concerned, I think we talked about register being fine to leak whether an email is signed up or not, but authenticate would always pretend it succeeds. From that perspective, I think we can return a sensible error here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jaclarkehttps://github.com/edgedb/edgedb/pull/6792/files/40d636291fca7bb5db36ebd142e7e58fa2214bdd..1431063a019694d9028966b1ae1cbce2d8c6e93b

Turns out I needed a bit more work to ensure the invariant that multiple factors with the same email have the same user_handle. Added a test for the new trigger.

});

if (!authenticateResponse.ok) {
console.error(
Copy link
Member

Choose a reason for hiding this comment

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

I don't think this handles the case where email verification is required, so we'll end up returning a misleading error that webauthn auth failed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point. Now that we have stable type names in our error json, I can detect this case and at least throw a more helpful error. We still navigate you to the "error" state of the sign in page in the email+password case, so I think that throwing the correct error should do the right thing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@scotttrinh scotttrinh requested a review from jaclarke February 12, 2024 17:09
@scotttrinh scotttrinh force-pushed the webauthn-authentication branch 2 times, most recently from 00cd8cd to 04a063e Compare February 16, 2024 20:47
@scotttrinh scotttrinh force-pushed the webauthn-authentication branch from b3608e1 to 91c23fc Compare February 20, 2024 16:25
@scotttrinh scotttrinh force-pushed the webauthn-authentication branch from 91c23fc to 40d6362 Compare February 20, 2024 16:54
@scotttrinh scotttrinh force-pushed the webauthn-authentication branch 3 times, most recently from 1f14960 to 60a5c24 Compare February 21, 2024 02:16
@scotttrinh scotttrinh force-pushed the webauthn-authentication branch from 60a5c24 to 1431063 Compare February 21, 2024 02:16
select detached ext::auth::WebAuthnFactor
filter .email = __new__.email
and not .id = __new__.id
).user_handle) ?? true,
Copy link
Member

Choose a reason for hiding this comment

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

Minor thing, but I think ?? true probably isn't needed. If the result of the select is an empty set, the assertion just won't run anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

TIL about the empty set behavior of assert. I'll add an example to the documentation for assert.

Copy link
Member

Choose a reason for hiding this comment

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

I think it just follows the normal set behaviour, where if the function arg isn't set of (like in count, for example) then it's expanded out. So assert({true, false}) is executed as {assert(true), assert(false)}, and similarly assert({}) becomes {}.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That makes sense. The documentation hints at this to since it says:

If the input bool is false, assert raises a QueryAssertionError. Otherwise, this function returns true. (emphasis mine)

I think it'd just be helpful to show some examples of calling this function with a sets to show that. I'll follow up.

Comment on lines 288 to 296
assert len(user_handles) == 1
if len(user_handles) > 1:
raise errors.WebAuthnAuthenticationFailed(
"Multiple user handles found for the email."
)
Copy link
Member

Choose a reason for hiding this comment

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

Could still be an assert. It's really more of an ISE if there's multiple user handles, since the db should enforce that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeah, that's a good point. I'd want to still log a useful error message in the ISE, so I'll throw something together.

Empty set isn't evaluated by `assert`, so this passes when the select
returns an empty set. And since `=` returns the empty set for
comparison, the logic is the same.
@scotttrinh scotttrinh merged commit bbec7fc into master Feb 21, 2024
23 checks passed
@scotttrinh scotttrinh deleted the webauthn-authentication branch February 21, 2024 15:02
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.

3 participants