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 (#580, #581)
  • Loading branch information
mnylen committed Aug 6, 2022
1 parent c9da04a commit acae48b
Show file tree
Hide file tree
Showing 9 changed files with 244 additions and 19 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 @@ -59,6 +59,13 @@ 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>
*
* The caller <strong>must</strong> extract the generated challenge and be able to re-produce it
* later for the {@link #authenticate(JsonObject)} call.
*
* The user object <strong>must</strong> contain base64 url encoded <code>id</code> attribute representing the
* user handle (see <a href="https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialuserentity-id">https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialuserentity-id</a>)
*
* @param user - the user object with name and optionally displayName and icon
* @param handler server encoded make credentials request
* @return fluent self
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,17 @@ public WebAuthn authenticatorUpdater(Function<Authenticator, Future<Void>> updat

@Override
public Future<JsonObject> createCredentialsOptions(JsonObject user) {
String userId;
if (user.getString("id") != null) {
userId = base64UrlEncode(user.getString("id").getBytes());
} else if (user.getString("rawId") != null) {
userId = user.getString("rawId");
} else {
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 +185,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 +303,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 +349,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 +369,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 +391,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 +406,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 +416,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 = UUID.randomUUID().toString();

// Authenticator to test excludedCredentials
database.add(
new Authenticator()
.setUserId(Codec.base64UrlEncode(userId.getBytes()))
.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,75 @@ 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);
assertArrayEquals(user.getString("id").getBytes(), Codec.base64UrlDecode(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);

// Dummy user
JsonObject user = new JsonObject()
.put("rawId", "000000000000000000000000")
.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 acae48b

Please sign in to comment.