Skip to content

Commit

Permalink
WebAuthn: Add user id to PublicKeyCredentialsCreateOptions, Authentic…
Browse files Browse the repository at this point in the history
…ator and WebAuthnCredentials (eclipse-vertx#580, eclipse-vertx#581)
  • Loading branch information
mnylen committed Aug 6, 2022
1 parent c9da04a commit b96e8dd
Show file tree
Hide file tree
Showing 9 changed files with 262 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ public static void fromJson(Iterable<java.util.Map.Entry<String, Object>> json,
obj.setUserName((String)member.getValue());
}
break;
case "userId":
if (member.getValue() instanceof String) {
obj.setUserId((String)member.getValue());
}
}
}
}
Expand Down Expand Up @@ -91,5 +95,8 @@ public static void toJson(Authenticator obj, java.util.Map<String, Object> json)
if (obj.getUserName() != null) {
json.put("userName", obj.getUserName());
}
if (obj.getUserId() != null) {
json.put("userId", obj.getUserId());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ public static void fromJson(Iterable<java.util.Map.Entry<String, Object>> json,
obj.setWebauthn(((JsonObject)member.getValue()).copy());
}
break;
case "userId":
if (member.getValue() instanceof String) {
obj.setUserId((String)member.getValue());
}
break;
}
}
}
Expand All @@ -69,5 +74,8 @@ public static void toJson(WebAuthnCredentials obj, java.util.Map<String, Object>
if (obj.getWebauthn() != null) {
json.put("webauthn", obj.getWebauthn());
}
if (obj.getUserId() != null) {
json.put("userId", obj.getUserId());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ public class Authenticator {
private AttestationCertificates attestationCertificates;
private String fmt;

/**
* The base64 url encoded user handle associated with this authenticator.
*/
private String userId;

public Authenticator() {}
public Authenticator(JsonObject json) {
AuthenticatorConverter.fromJson(json, this);
Expand Down Expand Up @@ -168,4 +173,13 @@ public Authenticator setAaguid(String aaguid) {
public String getAaguid() {
return aaguid;
}

public String getUserId() {
return userId;
}

public Authenticator setUserId(String userId) {
this.userId = userId;
return this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import io.vertx.codegen.annotations.VertxGen;
import io.vertx.core.*;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.User;
import io.vertx.ext.auth.authentication.AuthenticationProvider;
import io.vertx.ext.auth.webauthn.impl.WebAuthnImpl;

Expand Down Expand Up @@ -59,7 +60,24 @@ static WebAuthn create(Vertx vertx, WebAuthnOptions options) {
* Gets a challenge and any other parameters for the {@code navigator.credentials.create()} call.
*
* The object being returned is described here <a href="https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptions">https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptions</a>
* @param user - the user object with name and optionally displayName and icon
*
* The caller should extract the generated challenge and store it, so it can be fetched later for the
* {@link #authenticate(JsonObject)} call. The challenge could for example be stored in a session and later
* pulled from there.
*
* The user object should contain base64 url encoded id (the user handle), name and, optionally, displayName and icon.
* See the above link for more documentation on the content of the different fields. The user handle should be base64
* url encoded. You can use <code>java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(byte[])</code>
* to encode any user id bytes to base64 url format.
*
* For backwards compatibility, if user id is not defined, a random UUID will be generated instead. This has some
* drawbacks, as it might cause user to register the same authenticator multiple times.
*
* Will use the configured {@link #authenticatorFetcher(Function)} to fetch any existing authenticators
* by the user id or name. Any authenticators found will be added as excludedCredentials, so the application
* knows not to register those again.
*
* @param user - the user object with id, name and optionally displayName and icon
* @param handler server encoded make credentials request
* @return fluent self
*/
Expand Down Expand Up @@ -106,6 +124,7 @@ default WebAuthn getCredentialsOptions(@Nullable String name, Handler<AsyncResul
*
* The implementation must consider the following fields <strong>exclusively</strong>, while performing the lookup:
* <ul>
* <li>{@link Authenticator#getUserId()}</li>
* <li>{@link Authenticator#getUserName()}</li>
* <li>{@link Authenticator#getCredID()} ()}</li>
* </ul>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public class WebAuthnCredentials implements Credentials {
private String challenge;
private JsonObject webauthn;
private String username;
private String userId;
private String origin;
private String domain;

Expand Down Expand Up @@ -80,6 +81,15 @@ public WebAuthnCredentials setDomain(String domain) {
return this;
}

public String getUserId() {
return userId;
}

public WebAuthnCredentials setUserId(String userId) {
this.userId = userId;
return this;
}

@Override
public <V> void checkValid(V arg) throws CredentialValidationException {
if (challenge == null || challenge.length() == 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,20 @@ public WebAuthn authenticatorUpdater(Function<Authenticator, Future<Void>> updat

@Override
public Future<JsonObject> createCredentialsOptions(JsonObject user) {
String userId;

if (user.getString("id") != null) {
userId = user.getString("id");
} else if (user.getString("rawId") != null) {
// For backwards compatibility, allow using rawId in place of id. Should be removed in future.
userId = user.getString("rawId");
} else {
// For backwards compatibility, if both id and rawId is missing, use a random base64url encoded UUID
userId = uUIDtoBase64Url(UUID.randomUUID());
}

return fetcher
.apply(new Authenticator().setUserName(user.getString("name")))
.apply(new Authenticator().setUserId(userId).setUserName(user.getString("name")))
.map(authenticators -> {
// empty structure with all required fields
JsonObject json = new JsonObject()
Expand All @@ -177,10 +188,11 @@ public Future<JsonObject> createCredentialsOptions(JsonObject user) {
putOpt(json.getJsonObject("rp"), "icon", options.getRelyingParty().getIcon());

// put non null values for User
putOpt(json.getJsonObject("user"), "id", uUIDtoBase64Url(UUID.randomUUID()));
putOpt(json.getJsonObject("user"), "id", userId);
putOpt(json.getJsonObject("user"), "name", user.getString("name"));
putOpt(json.getJsonObject("user"), "displayName", user.getString("displayName"));
putOpt(json.getJsonObject("user"), "icon", user.getString("icon"));

// put the public key credentials parameters
for (PublicKeyCredential pubKeyCredParam : options.getPubKeyCredParams()) {
addOpt(
Expand Down Expand Up @@ -294,6 +306,7 @@ public void authenticate(Credentials credentials, Handler<AsyncResult<User>> han
WebAuthnCredentials authInfo = (WebAuthnCredentials) credentials;
// check
authInfo.checkValid(null);

// The basic data supplied with any kind of validation is:
// {
// "rawId": "base64url",
Expand Down Expand Up @@ -339,6 +352,7 @@ public void authenticate(Credentials credentials, Handler<AsyncResult<User>> han
}

// optional data

if (clientData.containsKey("tokenBinding")) {
JsonObject tokenBinding = clientData.getJsonObject("tokenBinding");
if (tokenBinding == null) {
Expand All @@ -358,6 +372,7 @@ public void authenticate(Credentials credentials, Handler<AsyncResult<User>> han
}
}

final String userId = authInfo.getUserId();
final String username = authInfo.getUsername();

// Step #4
Expand All @@ -379,6 +394,7 @@ public void authenticate(Credentials credentials, Handler<AsyncResult<User>> han
final Authenticator authrInfo = verifyWebAuthNCreate(authInfo, clientDataJSON);
// by default the store can upsert if a credential is missing, the user has been verified so it is valid
// the store however might disallow this operation
authrInfo.setUserId(userId);
authrInfo.setUserName(username);

// the create challenge is complete we can finally safe this
Expand All @@ -393,6 +409,7 @@ public void authenticate(Credentials credentials, Handler<AsyncResult<User>> han
return;
case "webauthn.get":
Authenticator query = new Authenticator();

if (options.getRequireResidentKey()) {
// username are not provided (RK) we now need to lookup by id
query.setCredID(webauthn.getString("id"));
Expand All @@ -402,9 +419,14 @@ public void authenticate(Credentials credentials, Handler<AsyncResult<User>> han
handler.handle(Future.failedFuture("username can't be null!"));
return;
}

query.setUserName(username);
}

if (userId != null) {
query.setUserId(userId);
}

fetcher.apply(query)
.onFailure(err -> handler.handle(Future.failedFuture(err)))
.onSuccess(authenticators -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

public class DummyStore {
Expand All @@ -20,17 +21,25 @@ public void clear() {
}

public Future<List<Authenticator>> fetch(Authenticator query) {
if (query.getUserName() == null && query.getCredID() == null && query.getUserId() == null) {
return Future.failedFuture(new IllegalArgumentException("Bad authenticator query! All conditions were null"));
}

return Future.succeededFuture(
database.stream()
.filter(entry -> {
boolean matches = true;
if (query.getUserName() != null) {
return query.getUserName().equals(entry.getUserName());
matches = query.getUserName().equals(entry.getUserName());
}
if (query.getCredID() != null) {
return query.getCredID().equals(entry.getCredID());
matches = matches || query.getCredID().equals(entry.getCredID());
}
// This is a bad query! both username and credID are null
return false;
if (query.getUserId() != null) {
matches = matches || query.getUserId().equals(entry.getUserId());
}

return matches;
})
.collect(Collectors.toList())
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.vertx.ext.auth.webauthn;

import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.impl.Codec;
import io.vertx.ext.unit.Async;
import io.vertx.ext.unit.TestContext;
import io.vertx.ext.unit.junit.RunTestOnContext;
Expand All @@ -10,7 +12,13 @@
import org.junit.Test;
import org.junit.runner.RunWith;

import static org.junit.Assert.assertNotNull;
import javax.naming.AuthenticationException;

import java.util.Arrays;
import java.util.List;
import java.util.UUID;

import static org.junit.Assert.*;

@RunWith(VertxUnitRunner.class)
public class NavigatorCredentialsCreate {
Expand All @@ -36,10 +44,19 @@ public void testRequestRegister(TestContext should) {
.authenticatorFetcher(database::fetch)
.authenticatorUpdater(database::store);

final String userId = Codec.base64UrlEncode(UUID.randomUUID().toString().getBytes());

// Authenticator to test excludedCredentials
database.add(
new Authenticator()
.setUserId(userId)
.setType("public-key")
.setCredID("-r1iW_eHUyIpU93f77odIrdUlNVfYzN-JPCTWGtdn-1wxdLxhlS9NmzLNbYsQ7XVZlGSWbh_63E5oFHcNh4JNw")
);

// Dummy user
JsonObject user = new JsonObject()
// id is expected to be a base64url string
.put("id", "000000000000000000000000")
.put("id", userId)
.put("name", "[email protected]")
.put("displayName", "John Doe")
.put("icon", "https://pics.example.com/00/p/aBjjjpqPb.png");
Expand All @@ -56,7 +73,77 @@ public void testRequestRegister(TestContext should) {
assertNotNull(challengeResponse.getJsonArray("pubKeyCredParams"));
// ensure that challenge and user.id are base64url encoded
assertNotNull(challengeResponse.getBinary("challenge"));
assertNotNull(challengeResponse.getJsonObject("user").getBinary("id"));

final JsonObject challengeResponseUser = challengeResponse.getJsonObject("user");
assertNotNull(challengeResponseUser);
assertEquals(userId, challengeResponseUser.getString("id"));
assertEquals(user.getString("name"), challengeResponseUser.getString("name"));
assertEquals(user.getString("displayName"), challengeResponseUser.getString("displayName"));
assertEquals(user.getString("icon"), challengeResponseUser.getString("icon"));

final JsonArray excludeCredentials = challengeResponse.getJsonArray("excludeCredentials");
assertEquals(1, excludeCredentials.size());

final JsonObject excludeCredential = excludeCredentials.getJsonObject(0);
assertEquals("public-key", excludeCredential.getString("type"));
assertEquals("-r1iW_eHUyIpU93f77odIrdUlNVfYzN-JPCTWGtdn-1wxdLxhlS9NmzLNbYsQ7XVZlGSWbh_63E5oFHcNh4JNw", excludeCredential.getString("id"));
assertEquals(new JsonArray(Arrays.asList("usb", "nfc", "ble", "internal")), excludeCredential.getJsonArray("transports"));

test.complete();
});
}

@Test
public void testRequestRegisterWithRawId(TestContext should) {
final Async test = should.async();

WebAuthn webAuthN = WebAuthn.create(
rule.vertx(),
new WebAuthnOptions().setRelyingParty(new RelyingParty().setName("ACME Corporation"))
.setAttestation(Attestation.of("direct")))
.authenticatorFetcher(database::fetch)
.authenticatorUpdater(database::store);

final String userId = Codec.base64UrlEncode(UUID.randomUUID().toString().getBytes());

// Dummy user
JsonObject user = new JsonObject()
.put("rawId", userId)
.put("displayName", "John Doe");

webAuthN
.createCredentialsOptions(user)
.onFailure(should::fail)
.onSuccess(challengeResponse -> {
final JsonObject challengeResponseUser = challengeResponse.getJsonObject("user");
assertNotNull(challengeResponseUser);
assertEquals("rawId should have been used as-is", user.getString("rawId"), challengeResponseUser.getString("id"));
test.complete();
});
}

@Test
public void testRequestRegisterWithNoId(TestContext should) {
final Async test = should.async();

WebAuthn webAuthN = WebAuthn.create(
rule.vertx(),
new WebAuthnOptions().setRelyingParty(new RelyingParty().setName("ACME Corporation"))
.setAttestation(Attestation.of("direct")))
.authenticatorFetcher(database::fetch)
.authenticatorUpdater(database::store);

// Dummy user
JsonObject user = new JsonObject()
.put("displayName", "John Doe");

webAuthN
.createCredentialsOptions(user)
.onFailure(should::fail)
.onSuccess(challengeResponse -> {
final JsonObject challengeResponseUser = challengeResponse.getJsonObject("user");
assertNotNull(challengeResponseUser);
assertNotNull("random id should have been generated", challengeResponseUser.getBinary("id"));
test.complete();
});
}
Expand Down
Loading

0 comments on commit b96e8dd

Please sign in to comment.