WebAuthn/Passkey Login With JavaSapi
Tue Jul 05 14:05:23 EDT 2022
- Poking Around With JavaSapi
- Per-NSF-Scoped JWT Authorization With JavaSapi
- WebAuthn/Passkey Login With JavaSapi
Over the weekend, I got a wild hair to try something that had been percolating in my mind recently: get Passkeys, Apple's term for WebAuthn/FIDO technologies, working with the new Safari against a Domino server. And, though some aspects were pretty non-obvious (it turns out there's a LOT of binary-data stuff in JavaScript now), I got it working thanks to a few tools:
- JavaSapi, the "yeah, I know it's not supported" DSAPI Java peer
- webauthn.guide, a handy resource
- java-webauthn-server, a Java implementation of the WebAuthn server components
Definition
First off: what is all this? All the terms - Passkey, FIDO, WebAuthn - for our purposes here deal with a mechanism for doing public/private key authentication with a remote server. In a sense, it's essentially a web version of what you do with Notes IDs or SSH keys, where the only actual user-provided authentication (a password, biometric input, or the like) happens locally, and then the local machine uses its private key combined with the server's knowledge of the public key to prove its identity.
WebAuthn accomplishes this with the navigator.credentials
object in the browser, which handles dealing with the local security storage. You pass objects between this store and the server, first to register your local key and then subsequently to log in with it. There are a lot of details I'm fuzzing over here, in large part because I don't know the specifics myself, but that will cover it.
While browsers have technically had similar cryptographic authentication for a very long time by way of client SSL certificates, this set of technologies makes great strides across the board - enough to make it actually practical to deploy for normal use. With Safari, anything with Touch ID or Face ID can participate in this, while on other platforms and browsers you can use either a security key or similar cryptographic storage. Since I have a little MacBook Air with Touch ID, I went with that.
The Flow
Before getting too much into the specifics, I'll talk about the flow. The general idea with WebAuthn is that the user gets to a point where they're creating a new account (where password doesn't yet matter) or logged in via another mechanism, and then have their browser generate the keypair. In this case, I logged in using the normal Domino login form.
Once in, I have a button on the page that will request that the browser create the keypair. This first makes a request to the server for a challenge object appropriate for the user, then creates the keypair locally, and then POSTs the results back to the server for verification. To the user, that looks like:
That keypair will remain in the local device's storage - in this case, Keychain, synced via iCloud in upcoming versions.
Then, I have another button that performs a challenge. In practice, this challenge would be configured on the login page, but it's on the same page for this proof-of-concept. The button causes the browser to request from the server a list of acceptable public keys to go with a given username, and then prompts the user to verify that they want to use a matching one:
The implementation details of what to do on success and failure are kind of up to you. Here, I ended up storing active authentication sessions on a server in a Map keyed the SessionID cookie value to user name, but all options are open.
Dance Implementation
As I mentioned above, my main tool on the server side was java-webauthn-server, which handles a lot of the details of the processs. For this experiment, I cribbed their InMemoryRegistrationStorage
class, but a real implementation would presumably store this information in an NSF.
For the client side, there's a npm module to handle the specifics, but I was doing this all on a single HTML page and so I just borrowed from it in pieces (particularly the Base64/ByteArray stuff).
On the server, I created an OSGi plugin that contained a com.ibm.pvc.webcontainer.application
webapp as well as the JavaSapi service: since they're both in the same OSGi bundle, that meant I could share the same classes and memory space, without having to worry about coordination between two very-distinct parts (as would be the case with DSAPI).
The webapp part itself actually does more of the work, and does so by way of four Servlets: one for the "create keys" options, one for registering a created key, one for the "I want to start a login" pre-flight, and finally one for actually handling the final authentication. As an implementation note, getting this working involved removing guava.jar from the classpath temporarily, as the library in question made heavy use of a version slightly newer than what Domino ships with.
Key Creation
The first Servlet assumes that the user is logged in and then provides a JSON object to the client describing what sort of keypair it should create:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | Session session = ContextInfo.getUserSession(); PublicKeyCredentialCreationOptions request = WebauthnManager.instance.getRelyingParty() .startRegistration( StartRegistrationOptions.builder() // Creates or retrieves an in-memory object with an associated random "handle" value .user(WebauthnManager.instance.locateUser(session)) .build() ); // Store in the HTTP session for later verification. Could also be done via cookie or other pairing req.getSession(true).setAttribute(WebauthnManager.REQUEST_KEY, request); String json = request.toCredentialsCreateJson(); resp.setStatus(200); resp.setHeader("Content-Type", "application/json"); //$NON-NLS-1$ //$NON-NLS-2$ resp.getOutputStream().write(String.valueOf(json).getBytes()); |
The client side retrieves these options, parses some Base64'd parts to binary arrays (this is what the npm module would do), and then sends that back to the server to create the registration:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | fetch("/webauthn/creationOptions", { "credentials": "same-origin" }) .then(res => res.json()) .then(json => { json.publicKey.challenge = base64urlToBuffer(json.publicKey.challenge, c => c.charCodeAt(0)); json.publicKey.user.id = base64urlToBuffer(json.publicKey.user.id, c => c.charCodeAt(0)); if(json.publicKey.excludeCredentials) { for(var i = 0; i < json.publicKey.excludeCredentials.length; i++) { var cred = json.publicKey.excludeCredentials[i]; cred.id = base64urlToBuffer(cred.id); } } navigator.credentials.create(json) .then(credential => { // Create a JSON-friendly payload to send to the server const payload = { type: credential.type, id: credential.id, response: { attestationObject: bufferToBase64url(credential.response.attestationObject), clientDataJSON: bufferToBase64url(credential.response.clientDataJSON) }, clientExtensionResults: credential.getClientExtensionResults() } fetch("/webauthn/create", { method: "POST", body: JSON.stringify(payload) }) }) }) |
The code for the second call on the server parses out the POST'd JSON and stores the registration in the in-memory storage (which would properly be an NSF):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | String json = StreamUtil.readString(req.getReader()); PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> pkc = PublicKeyCredential .parseRegistrationResponseJson(json); // Retrieve the request we had stored in the session earlier PublicKeyCredentialCreationOptions request = (PublicKeyCredentialCreationOptions) req.getSession(true) .getAttribute(WebauthnManager.REQUEST_KEY); // Perform registration, which verifies that the incoming JSON matches the initiated request RelyingParty rp = WebauthnManager.instance.getRelyingParty(); RegistrationResult result = rp .finishRegistration(FinishRegistrationOptions.builder().request(request).response(pkc).build()); // Gather the registration information to store in the server's credential repository DominoRegistrationStorage repo = WebauthnManager.instance.getRepository(); CredentialRegistration reg = new CredentialRegistration(); reg.setAttestationMetadata(Optional.ofNullable(pkc.getResponse().getAttestation())); reg.setUserIdentity(request.getUser()); reg.setRegistrationTime(Instant.now()); RegisteredCredential credential = RegisteredCredential.builder() .credentialId(result.getKeyId().getId()) .userHandle(request.getUser().getId()) .publicKeyCose(result.getPublicKeyCose()) .signatureCount(result.getSignatureCount()) .build(); reg.setCredential(credential); reg.setTransports(pkc.getResponse().getTransports()); Session session = ContextInfo.getUserSession(); repo.addRegistrationByUsername(session.getEffectiveUserName(), reg); |
Login/Assertion
Once the client has a keypair and the server knows about the public key, then the client can ask the server for what it would need if one were to log in as a given name, and then uses that information to make a second call. The dance on the client side looks like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | var un = /* fetch the username from somewhere, such as a login form */ fetch("/webauthn/assertionRequest?un=" + encodeURIComponent(un)) .then(res => res.json()) .then(json => { json.publicKey.challenge = base64urlToBuffer(json.publicKey.challenge, c => c.charCodeAt(0)); if(json.publicKey.allowCredentials) { for(var i = 0; i < json.publicKey.allowCredentials.length; i++) { var cred = json.publicKey.allowCredentials[i]; cred.id = base64urlToBuffer(cred.id); } } navigator.credentials.get(json) .then(credential => { const payload = { type: credential.type, id: credential.id, response: { authenticatorData: bufferToBase64url(credential.response.authenticatorData), clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), signature: bufferToBase64url(credential.response.signature), userHandle: bufferToBase64url(credential.response.userHandle) }, clientExtensionResults: credential.getClientExtensionResults() } fetch("/webauthn/assertion", { method: "POST", body: JSON.stringify(payload), credentials: "same-origin" }) }) }) |
That's pretty similar to the middle code block above, really, and contains the same sort of ferrying to and from transport-friendly JSON objects and native credential objects.
On the server side, the first Servlet - which looks up the available public keys for a user name - is comparatively simple:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | String userName = req.getParameter("un"); //$NON-NLS-1$ AssertionRequest request = WebauthnManager.instance.getRelyingParty() .startAssertion( StartAssertionOptions.builder() .username(userName) .build() ); // Stash the current assertion request req.getSession(true).setAttribute(WebauthnManager.ASSERTION_REQUEST_KEY, request); String json = request.toCredentialsGetJson(); resp.setStatus(200); resp.setHeader("Content-Type", "application/json"); //$NON-NLS-1$ //$NON-NLS-2$ resp.getOutputStream().write(String.valueOf(json).getBytes()); |
The final Servlet handles parsing out the incoming assertion (login) and stashing it in memory as associated with the "SessionID" cookie. That value could be anything that the browser will send with its requests, but "SessionID" works here.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | String json = StreamUtil.readString(req.getReader()); PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs> pkc = PublicKeyCredential.parseAssertionResponseJson(json); // Retrieve the request we had stored in the session earlier AssertionRequest request = (AssertionRequest) req.getSession(true).getAttribute(WebauthnManager.ASSERTION_REQUEST_KEY); // Perform verification, which will ensure that the signed value matches the public key and challenge RelyingParty rp = WebauthnManager.instance.getRelyingParty(); AssertionResult result = rp.finishAssertion(FinishAssertionOptions.builder() .request(request) .response(pkc) .build()); if(result.isSuccess()) { // Keep track of logins WebauthnManager.instance.getRepository().updateSignatureCount(result); // Find the session cookie, which they will have by now String sessionId = Arrays.stream(req.getCookies()) .filter(c -> "SessionID".equalsIgnoreCase(c.getName())) .map(Cookie::getValue) .findFirst() .get(); WebauthnManager.instance.registerAuthenticatedUser(sessionId, result.getUsername()); } |
Trusting the Key
At this point, there's a dance between the client and server that results in the client being able to perform secure, password-less authentication with the server and the server knowing about the association, and so now the remaining job is just getting the server to actually trust this assertion. That's where JavaSapi comes in.
Above, I used the "SessionID" cookie as a mechanism to store an in-memory association between a browser cookie (which is independent of authentication) to a trusted user. Then, I made a JavaSapi service that looks for this and tries to find an authenticated user in its authenticate
method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | @Override public int authenticate(IJavaSapiHttpContextAdapter context) { Cookie[] cookies = context.getRequest().getCookies(); if(cookies != null) { Optional<Cookie> cookie = Arrays.stream(context.getRequest().getCookies()) .filter(c -> "SessionID".equalsIgnoreCase(c.getName())) //$NON-NLS-1$ .findFirst(); if(cookie.isPresent()) { String sessionId = cookie.get().getValue(); Optional<String> user = WebauthnManager.instance.getAuthenticatedUser(sessionId); if(user.isPresent()) { context.getRequest().setAuthenticatedUserName(user.get(), "WebAuthn"); //$NON-NLS-1$ return HTEXTENSION_REQUEST_AUTHENTICATED; } } } return HTEXTENSION_EVENT_DECLINED; } |
And that's all there is to it on the JavaSapi side. Because it shares the same active memory space as the webapp doing the dance, it can use the same WebauthnManager
instance to read the in-memory association. You could in theory do this another way with DSAPI - storing the values in an NSF or some other mechanism that can be shared - but this is much, much simpler to do.
Conclusion
This was a neat little project, and it was a good way to learn a bit about some of the native browser objects and data types that I haven't had occasion to work with before. I think this is also something that should be in the product; if you agree, go vote for the ideas from a few years ago.