Reputation: 5950
I'm thinking about adding support for WebAuthn / passkeys to my web app, but the fact that you need to have separate register and sign-in flows, and usernames are still required, make it pretty much a no-go for me. And I am really wondering if I am missing something here, or if this can be made invisible to the user somehow.
For example I am currently offering sign-in via email: the user simply enters their email address, and receives a sign-in link. No matter if they are an existing user or not; I can simply create a new account if it's a new email address. To the user this is completely invisible, they don't have to choose between a "register" and "sign in" button, they don't have to try and remember if they already have an account or not. Just enter your email address and that's it.
I am also offering Sign In with Apple which has the same flow: I get a unique Apple user ID (subject) in the token from Apple, check if I have a user with this ID in the database, and log that user in. And if not, I create a new user, store that ID, and log them in. Once again, the user never has to choose between "register" and "sign in", they don't have to enter any personal information at all.
But with WebAuthn you have to create or get the credentials with a username. So I'd have to show a username input field on the website, send that to the server, the server checks if that user exists, and based on that the website can either create or get the credentials. But usernames suck, everybody having to choose a unique one is always frustrating once you have enough users. So instead I could ask for an email address but I want to store as little personal identifiable information as possible (yes, I offer the sign in via email method, but that's why I want to offer WebAuthn as an alternative option).
So, how can this be streamlined? Is there a way around the username requirement, where the browser simply asks the authenticator if they have a public key for this website, and if not, create it. That way I can offer a single "sign in with passkey" button on the website without the need to ask for a username (or email address).
Upvotes: 8
Views: 3485
Reputation: 586
I gave up on this, and just used webAuthN as a second factor. That way you can know if the user has a device or not.
I know it's ridiculous, but the member and contributors of webAuthN (write specs, never actually deal with real life apps) have different mindset than us (real devs, with customers and stuff). Source: https://github.com/w3c/webauthn/issues/1749
There are other factors that make webAuthN adoption slow, for example, the fact that you cannot chose other curves (IIRC).
Upvotes: 0
Reputation: 7060
I attempted to implement simple "use keypass" sign in/up flow for my auth project Charon I am working on. I wanted that user can click just one button to sign in or sign up without having to know if they have already signed up or not. I do not care about username or e-mail either (that can be added at a later time).
I think approach by @IAmKale is what is currently possible but I have few comments:
user
field matching your site is much more reasonable. So user: {id: new Uint8Array([0]), name: "site.origin.example.com", displayName: "Site name"}
name
matching id
from rp
field, and displayName
matching name
from rp
field. To the user I think this looks clear and simple. It is duplicated, but when shown as prompt in UI it makes sense, you are signing into the site with site name. It also stays the same and does not create bunch of different users.create
call or get
call. It is by current webauthn spec that there is no way to determine if get
call failed because user declined or because there is no credential to begin with. Which makes it currently not really possible to create one button to just sign-in/up. There is this issue with suggested getOrCreate
which could help here.Upvotes: 0
Reputation: 332
There are 2 separate things.
Webauthn is not federated credential so you cannot just outsource them authentication like to an email provider or to apple, google etc. If you want to do that, you need to do that but you are already doing it and want something different, I guess? A had the feeling you want to get rid of federated but you want to keep outsourcing authentication, which is: federated (have the cake and eat it too or the other way around).
By federated your user is the owner of the email account or of the apple, google etc. account. No need to register because your user registered elsewhere. You get an id and an authentication service from others...
If you want webauthn, you need to authenticate yourself and register your user with create() first. On the server you store internal uid -> pid (passkey id which is called user id and user handle in the webauthn specs but it is actually a passkey id) and pid->public key. On the client side the pass manager (authenticator) stores: xyz.com -> {pid, private key} and sync it. I will call passkey the private key... some use it for the key pair.
Your users anonymous identity is the pid or rather a set of pids since it seems to go to the direction of creating more than one passkeys per account instead of solving the syncing of one passkey... you verify the identity or the pid with the verification key which is the public key.
By a sign in process get() is called and a random dynamic server challenge is signed by the pass manager with the private key and you verify the signature and hence the pid as identity with the corresponding public key on your server.
It is very nice math and anonymous and fishing resistant etc. but it is you(!) that verifies the identity and not someone else! So first you have to create it, even worse: as of now on apple, google, microsoft and later on linux platforms separate ones (a set of pids, a set of public keys).
I am not sure it is not the very thing that will bury webauthn passkeys since it will be a maintenance hell for users/website owners in the long run. A security problem too since plenty of users will start to create hordes of passkeys without ever used a pass manager before or without knowing that they actually use one... Webauthn passkeys require a pass manager and there are people who do not want to use one or will not know they use one... it might work in the beginning but it implies that people will all be able to handle a pass manager. I am not sure of that...
I think you are correct and it is cool you do not use usernames and email address is an option. The sad thing is, webauthn is logically usernameless (pid is the real anonymous identity and pass manager use the pid, the 2 usernames are just labels in case more than one account per domain) but the webauthn spec has a logical misstep by requiring not just one but 2 username fields... I personally think it is very bad and at least they should make these fields optional. The default thinking should be that one real person has one account per domain and then the whole UX would be super nice and simple: "create passkey for xyz.com" and "use your passkey for xyz.com" (one entry per domain per pass manager without any usernames, with simplified UX with less unnecessary text).
If someone creates more than one account which is possible, this person has the responsibility to manage pass manager entry labels and differentiate the accounts (one optional note field instead of 2 username fields would suffice...). It is not common thinking but extremely logical :) Webauthn should totally abolish username fields since they added pid (user id, user handle - they manage to call it 2 different things). It is not the web site owners responsibility to micromanage pass manager labels in the users pass manager. The conflict of having 2 accounts per domain is actually a misgeburt and created by the user and it is a design smell (like websites using emails as usernames and users want a burner account too which they would not want in the first place if accounts that do not require real identity would stop gathering personally identifiable information).
More about this topic here: User name and displayName change for existing passkey
All in all, webauthn is not federated, it is you who has to check the identity by "sign in" using get() and for this you need a first step to register an anonymous identity via create().
With the username mess I think you have the right feeling, it should not be there.
Upvotes: 1
Reputation: 159
Non-discoverable credentials require the relying party website to provide a list of previously registered credential handles. From this, the local client will ask any authenticators if they understand these credential handles. If so, the user is able to use that authenticator, and release an authentication method signed with the public key associated with that credential handle.
This is typically used for two-factor authentication, where some primary factor (such as username and password, or a prior authentication session), is used to look up potential credential handles.
There are sites which will ask for a username, then return credential handles and use non-discoverable credentials for primary factor, but there are disadvantages with this:
The prior specification for how authenticators work, U2F, did not support any additional user verification. So this sort of authentication would only provide a possession factor - someone who knows you can take your yubikey and use it to get into these services.
Since you are releasing credential handles on entry of a username/email without any initial authentication, you leak information about the user - that they have an account with that email, that they are using WebAuthn, how many authenticators are registered to their account, and so on.
To contrast, Discoverable credentials can be used without providing a credential handle. This allows for these 'usernameless' flows - when triggered, the relying party simply asks 'do you have any credentials I can use', and the client asks authenticators what they have stored which is appropriate.
The disadvantages of this approach are availability:
older U2F hardware keys do not support discoverable credentials
modern hardware keys which support discoverable credentials may have limited flash storage for them.
However, passkeys being integrated into platforms with comparatively limitless storage will change the availability equation soon.
I would not recommend @IAmKale's approach of using random values for a discoverable credential. For one, users may very well be presented the name and displayName values, and a garbage string will not be a good user experience.
Secondly, you are assured that you can register a credential with the same user id to overwrite the existing credential. You do not, however, have any way to delete credentials previously registered with your site. Giving a different randomly generated value on each registration might cause the user to be presented with a choice of credentials, even if only one of those credentials will lead to a successful login. Even if the client or platform has a way for the user to delete spurious entries, this isn't a great experience.
Instead, I would recommend:
Upvotes: 1
Reputation: 3426
So, how can this be streamlined? Is there a way around the username requirement, where the browser simply asks the authenticator if they have a public key for this website, and if not, create it. That way I can offer a single "sign in with passkey" button on the website without the need to ask for a username (or email address).
If you want to fully commit to "usernameless" use of WebAuthn, where the user doesn't need to provide any information prior to creating an account, then just make something up for the three required values in user
that you pass to navigator.credentials.get()
:
const credential = await navigator.credentials.create({
publicKey: {
// ...
user: {
id: randomUserIDBytesGeneratedByServer,
name: bytesToString(randomUserIDBytesGeneratedByServer),
displayName: bytesToString(randomUserIDBytesGeneratedByServer),
},
authenticatorSelection: {
residentKey: 'required',
userVerification: true
},
},
});
randomUserIDBytesGeneratedByServer
is some random, unique ID that you generate upon a new user clicking your Register button. Provide some kind of encoded value for name
and displayName
(I use bytesToString()
as a shorthand for something you do to encode those random bytes into some kind of string, for example to base64url).
Send the discoverable credential
back to your server, verify it, and upon successful completion create in your database a new "User
" account that gets assigned randomBytesGeneratedByServer
as its ID. Make sure to also associate credential.id
to randomUserIDBytesGeneratedByServer
in another table for later.
When the user returns to log in, have them click an Authenticate button to trigger navigator.credentials.get()
with an empty allowCredentials
array. This will enable the user to select the discoverable credential that was registered earlier:
const credential = await navigator.credentials.get({
publicKey: {
// ...
allowCredentials: [],
},
});
Upon successful verification of the response returned from the call to .get()
, you can look up the User
's ID by grabbing randomUserIDBytesGeneratedByServer
from the table that you used to remember which user credential.id
belonged to. At that point you can establish an authenticated session for your user by whatever means is appropriate to your setup.
This might sound a bit extreme, but in truth there's nothing about WebAuthn that prevents you from requesting credentials and allowing authentication without gathering any user-identifiable information of any kind.
Upvotes: 5
Reputation: 1240
While you technically don't need a username for discoverable WebAuthn credentials (passkeys), you still need a human recognizable string for the user to recognize for the credential.
For example, if the user has two different accounts for a site, you don't want them seeing a random string when selecting an account from the passkey credential list, as that won't be helpful for the user to select the proper credential.
Upvotes: 5