Skip to content

Commit b96e8dd

Browse files
committed
WebAuthn: Add user id to PublicKeyCredentialsCreateOptions, Authenticator and WebAuthnCredentials (#580, #581)
1 parent c9da04a commit b96e8dd

File tree

9 files changed

+262
-20
lines changed

9 files changed

+262
-20
lines changed

vertx-auth-webauthn/src/main/generated/io/vertx/ext/auth/webauthn/AuthenticatorConverter.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ public static void fromJson(Iterable<java.util.Map.Entry<String, Object>> json,
6060
obj.setUserName((String)member.getValue());
6161
}
6262
break;
63+
case "userId":
64+
if (member.getValue() instanceof String) {
65+
obj.setUserId((String)member.getValue());
66+
}
6367
}
6468
}
6569
}
@@ -91,5 +95,8 @@ public static void toJson(Authenticator obj, java.util.Map<String, Object> json)
9195
if (obj.getUserName() != null) {
9296
json.put("userName", obj.getUserName());
9397
}
98+
if (obj.getUserId() != null) {
99+
json.put("userId", obj.getUserId());
100+
}
94101
}
95102
}

vertx-auth-webauthn/src/main/generated/io/vertx/ext/auth/webauthn/WebAuthnCredentialsConverter.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ public static void fromJson(Iterable<java.util.Map.Entry<String, Object>> json,
4545
obj.setWebauthn(((JsonObject)member.getValue()).copy());
4646
}
4747
break;
48+
case "userId":
49+
if (member.getValue() instanceof String) {
50+
obj.setUserId((String)member.getValue());
51+
}
52+
break;
4853
}
4954
}
5055
}
@@ -69,5 +74,8 @@ public static void toJson(WebAuthnCredentials obj, java.util.Map<String, Object>
6974
if (obj.getWebauthn() != null) {
7075
json.put("webauthn", obj.getWebauthn());
7176
}
77+
if (obj.getUserId() != null) {
78+
json.put("userId", obj.getUserId());
79+
}
7280
}
7381
}

vertx-auth-webauthn/src/main/java/io/vertx/ext/auth/webauthn/Authenticator.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ public class Authenticator {
7373
private AttestationCertificates attestationCertificates;
7474
private String fmt;
7575

76+
/**
77+
* The base64 url encoded user handle associated with this authenticator.
78+
*/
79+
private String userId;
80+
7681
public Authenticator() {}
7782
public Authenticator(JsonObject json) {
7883
AuthenticatorConverter.fromJson(json, this);
@@ -168,4 +173,13 @@ public Authenticator setAaguid(String aaguid) {
168173
public String getAaguid() {
169174
return aaguid;
170175
}
176+
177+
public String getUserId() {
178+
return userId;
179+
}
180+
181+
public Authenticator setUserId(String userId) {
182+
this.userId = userId;
183+
return this;
184+
}
171185
}

vertx-auth-webauthn/src/main/java/io/vertx/ext/auth/webauthn/WebAuthn.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import io.vertx.codegen.annotations.VertxGen;
2121
import io.vertx.core.*;
2222
import io.vertx.core.json.JsonObject;
23+
import io.vertx.ext.auth.User;
2324
import io.vertx.ext.auth.authentication.AuthenticationProvider;
2425
import io.vertx.ext.auth.webauthn.impl.WebAuthnImpl;
2526

@@ -59,7 +60,24 @@ static WebAuthn create(Vertx vertx, WebAuthnOptions options) {
5960
* Gets a challenge and any other parameters for the {@code navigator.credentials.create()} call.
6061
*
6162
* The object being returned is described here <a href="https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptions">https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptions</a>
62-
* @param user - the user object with name and optionally displayName and icon
63+
*
64+
* The caller should extract the generated challenge and store it, so it can be fetched later for the
65+
* {@link #authenticate(JsonObject)} call. The challenge could for example be stored in a session and later
66+
* pulled from there.
67+
*
68+
* The user object should contain base64 url encoded id (the user handle), name and, optionally, displayName and icon.
69+
* See the above link for more documentation on the content of the different fields. The user handle should be base64
70+
* url encoded. You can use <code>java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(byte[])</code>
71+
* to encode any user id bytes to base64 url format.
72+
*
73+
* For backwards compatibility, if user id is not defined, a random UUID will be generated instead. This has some
74+
* drawbacks, as it might cause user to register the same authenticator multiple times.
75+
*
76+
* Will use the configured {@link #authenticatorFetcher(Function)} to fetch any existing authenticators
77+
* by the user id or name. Any authenticators found will be added as excludedCredentials, so the application
78+
* knows not to register those again.
79+
*
80+
* @param user - the user object with id, name and optionally displayName and icon
6381
* @param handler server encoded make credentials request
6482
* @return fluent self
6583
*/
@@ -106,6 +124,7 @@ default WebAuthn getCredentialsOptions(@Nullable String name, Handler<AsyncResul
106124
*
107125
* The implementation must consider the following fields <strong>exclusively</strong>, while performing the lookup:
108126
* <ul>
127+
* <li>{@link Authenticator#getUserId()}</li>
109128
* <li>{@link Authenticator#getUserName()}</li>
110129
* <li>{@link Authenticator#getCredID()} ()}</li>
111130
* </ul>

vertx-auth-webauthn/src/main/java/io/vertx/ext/auth/webauthn/WebAuthnCredentials.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public class WebAuthnCredentials implements Credentials {
2626
private String challenge;
2727
private JsonObject webauthn;
2828
private String username;
29+
private String userId;
2930
private String origin;
3031
private String domain;
3132

@@ -80,6 +81,15 @@ public WebAuthnCredentials setDomain(String domain) {
8081
return this;
8182
}
8283

84+
public String getUserId() {
85+
return userId;
86+
}
87+
88+
public WebAuthnCredentials setUserId(String userId) {
89+
this.userId = userId;
90+
return this;
91+
}
92+
8393
@Override
8494
public <V> void checkValid(V arg) throws CredentialValidationException {
8595
if (challenge == null || challenge.length() == 0) {

vertx-auth-webauthn/src/main/java/io/vertx/ext/auth/webauthn/impl/WebAuthnImpl.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,20 @@ public WebAuthn authenticatorUpdater(Function<Authenticator, Future<Void>> updat
159159

160160
@Override
161161
public Future<JsonObject> createCredentialsOptions(JsonObject user) {
162+
String userId;
163+
164+
if (user.getString("id") != null) {
165+
userId = user.getString("id");
166+
} else if (user.getString("rawId") != null) {
167+
// For backwards compatibility, allow using rawId in place of id. Should be removed in future.
168+
userId = user.getString("rawId");
169+
} else {
170+
// For backwards compatibility, if both id and rawId is missing, use a random base64url encoded UUID
171+
userId = uUIDtoBase64Url(UUID.randomUUID());
172+
}
162173

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

179190
// put non null values for User
180-
putOpt(json.getJsonObject("user"), "id", uUIDtoBase64Url(UUID.randomUUID()));
191+
putOpt(json.getJsonObject("user"), "id", userId);
181192
putOpt(json.getJsonObject("user"), "name", user.getString("name"));
182193
putOpt(json.getJsonObject("user"), "displayName", user.getString("displayName"));
183194
putOpt(json.getJsonObject("user"), "icon", user.getString("icon"));
195+
184196
// put the public key credentials parameters
185197
for (PublicKeyCredential pubKeyCredParam : options.getPubKeyCredParams()) {
186198
addOpt(
@@ -294,6 +306,7 @@ public void authenticate(Credentials credentials, Handler<AsyncResult<User>> han
294306
WebAuthnCredentials authInfo = (WebAuthnCredentials) credentials;
295307
// check
296308
authInfo.checkValid(null);
309+
297310
// The basic data supplied with any kind of validation is:
298311
// {
299312
// "rawId": "base64url",
@@ -339,6 +352,7 @@ public void authenticate(Credentials credentials, Handler<AsyncResult<User>> han
339352
}
340353

341354
// optional data
355+
342356
if (clientData.containsKey("tokenBinding")) {
343357
JsonObject tokenBinding = clientData.getJsonObject("tokenBinding");
344358
if (tokenBinding == null) {
@@ -358,6 +372,7 @@ public void authenticate(Credentials credentials, Handler<AsyncResult<User>> han
358372
}
359373
}
360374

375+
final String userId = authInfo.getUserId();
361376
final String username = authInfo.getUsername();
362377

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

384400
// the create challenge is complete we can finally safe this
@@ -393,6 +409,7 @@ public void authenticate(Credentials credentials, Handler<AsyncResult<User>> han
393409
return;
394410
case "webauthn.get":
395411
Authenticator query = new Authenticator();
412+
396413
if (options.getRequireResidentKey()) {
397414
// username are not provided (RK) we now need to lookup by id
398415
query.setCredID(webauthn.getString("id"));
@@ -402,9 +419,14 @@ public void authenticate(Credentials credentials, Handler<AsyncResult<User>> han
402419
handler.handle(Future.failedFuture("username can't be null!"));
403420
return;
404421
}
422+
405423
query.setUserName(username);
406424
}
407425

426+
if (userId != null) {
427+
query.setUserId(userId);
428+
}
429+
408430
fetcher.apply(query)
409431
.onFailure(err -> handler.handle(Future.failedFuture(err)))
410432
.onSuccess(authenticators -> {

vertx-auth-webauthn/src/test/java/io/vertx/ext/auth/webauthn/DummyStore.java

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import java.util.ArrayList;
66
import java.util.List;
7+
import java.util.Objects;
78
import java.util.stream.Collectors;
89

910
public class DummyStore {
@@ -20,17 +21,25 @@ public void clear() {
2021
}
2122

2223
public Future<List<Authenticator>> fetch(Authenticator query) {
24+
if (query.getUserName() == null && query.getCredID() == null && query.getUserId() == null) {
25+
return Future.failedFuture(new IllegalArgumentException("Bad authenticator query! All conditions were null"));
26+
}
27+
2328
return Future.succeededFuture(
2429
database.stream()
2530
.filter(entry -> {
31+
boolean matches = true;
2632
if (query.getUserName() != null) {
27-
return query.getUserName().equals(entry.getUserName());
33+
matches = query.getUserName().equals(entry.getUserName());
2834
}
2935
if (query.getCredID() != null) {
30-
return query.getCredID().equals(entry.getCredID());
36+
matches = matches || query.getCredID().equals(entry.getCredID());
3137
}
32-
// This is a bad query! both username and credID are null
33-
return false;
38+
if (query.getUserId() != null) {
39+
matches = matches || query.getUserId().equals(entry.getUserId());
40+
}
41+
42+
return matches;
3443
})
3544
.collect(Collectors.toList())
3645
);

vertx-auth-webauthn/src/test/java/io/vertx/ext/auth/webauthn/NavigatorCredentialsCreate.java

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package io.vertx.ext.auth.webauthn;
22

3+
import io.vertx.core.json.JsonArray;
34
import io.vertx.core.json.JsonObject;
5+
import io.vertx.ext.auth.impl.Codec;
46
import io.vertx.ext.unit.Async;
57
import io.vertx.ext.unit.TestContext;
68
import io.vertx.ext.unit.junit.RunTestOnContext;
@@ -10,7 +12,13 @@
1012
import org.junit.Test;
1113
import org.junit.runner.RunWith;
1214

13-
import static org.junit.Assert.assertNotNull;
15+
import javax.naming.AuthenticationException;
16+
17+
import java.util.Arrays;
18+
import java.util.List;
19+
import java.util.UUID;
20+
21+
import static org.junit.Assert.*;
1422

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

47+
final String userId = Codec.base64UrlEncode(UUID.randomUUID().toString().getBytes());
48+
49+
// Authenticator to test excludedCredentials
50+
database.add(
51+
new Authenticator()
52+
.setUserId(userId)
53+
.setType("public-key")
54+
.setCredID("-r1iW_eHUyIpU93f77odIrdUlNVfYzN-JPCTWGtdn-1wxdLxhlS9NmzLNbYsQ7XVZlGSWbh_63E5oFHcNh4JNw")
55+
);
56+
3957
// Dummy user
4058
JsonObject user = new JsonObject()
41-
// id is expected to be a base64url string
42-
.put("id", "000000000000000000000000")
59+
.put("id", userId)
4360
.put("name", "[email protected]")
4461
.put("displayName", "John Doe")
4562
.put("icon", "https://pics.example.com/00/p/aBjjjpqPb.png");
@@ -56,7 +73,77 @@ public void testRequestRegister(TestContext should) {
5673
assertNotNull(challengeResponse.getJsonArray("pubKeyCredParams"));
5774
// ensure that challenge and user.id are base64url encoded
5875
assertNotNull(challengeResponse.getBinary("challenge"));
59-
assertNotNull(challengeResponse.getJsonObject("user").getBinary("id"));
76+
77+
final JsonObject challengeResponseUser = challengeResponse.getJsonObject("user");
78+
assertNotNull(challengeResponseUser);
79+
assertEquals(userId, challengeResponseUser.getString("id"));
80+
assertEquals(user.getString("name"), challengeResponseUser.getString("name"));
81+
assertEquals(user.getString("displayName"), challengeResponseUser.getString("displayName"));
82+
assertEquals(user.getString("icon"), challengeResponseUser.getString("icon"));
83+
84+
final JsonArray excludeCredentials = challengeResponse.getJsonArray("excludeCredentials");
85+
assertEquals(1, excludeCredentials.size());
86+
87+
final JsonObject excludeCredential = excludeCredentials.getJsonObject(0);
88+
assertEquals("public-key", excludeCredential.getString("type"));
89+
assertEquals("-r1iW_eHUyIpU93f77odIrdUlNVfYzN-JPCTWGtdn-1wxdLxhlS9NmzLNbYsQ7XVZlGSWbh_63E5oFHcNh4JNw", excludeCredential.getString("id"));
90+
assertEquals(new JsonArray(Arrays.asList("usb", "nfc", "ble", "internal")), excludeCredential.getJsonArray("transports"));
91+
92+
test.complete();
93+
});
94+
}
95+
96+
@Test
97+
public void testRequestRegisterWithRawId(TestContext should) {
98+
final Async test = should.async();
99+
100+
WebAuthn webAuthN = WebAuthn.create(
101+
rule.vertx(),
102+
new WebAuthnOptions().setRelyingParty(new RelyingParty().setName("ACME Corporation"))
103+
.setAttestation(Attestation.of("direct")))
104+
.authenticatorFetcher(database::fetch)
105+
.authenticatorUpdater(database::store);
106+
107+
final String userId = Codec.base64UrlEncode(UUID.randomUUID().toString().getBytes());
108+
109+
// Dummy user
110+
JsonObject user = new JsonObject()
111+
.put("rawId", userId)
112+
.put("displayName", "John Doe");
113+
114+
webAuthN
115+
.createCredentialsOptions(user)
116+
.onFailure(should::fail)
117+
.onSuccess(challengeResponse -> {
118+
final JsonObject challengeResponseUser = challengeResponse.getJsonObject("user");
119+
assertNotNull(challengeResponseUser);
120+
assertEquals("rawId should have been used as-is", user.getString("rawId"), challengeResponseUser.getString("id"));
121+
test.complete();
122+
});
123+
}
124+
125+
@Test
126+
public void testRequestRegisterWithNoId(TestContext should) {
127+
final Async test = should.async();
128+
129+
WebAuthn webAuthN = WebAuthn.create(
130+
rule.vertx(),
131+
new WebAuthnOptions().setRelyingParty(new RelyingParty().setName("ACME Corporation"))
132+
.setAttestation(Attestation.of("direct")))
133+
.authenticatorFetcher(database::fetch)
134+
.authenticatorUpdater(database::store);
135+
136+
// Dummy user
137+
JsonObject user = new JsonObject()
138+
.put("displayName", "John Doe");
139+
140+
webAuthN
141+
.createCredentialsOptions(user)
142+
.onFailure(should::fail)
143+
.onSuccess(challengeResponse -> {
144+
final JsonObject challengeResponseUser = challengeResponse.getJsonObject("user");
145+
assertNotNull(challengeResponseUser);
146+
assertNotNull("random id should have been generated", challengeResponseUser.getBinary("id"));
60147
test.complete();
61148
});
62149
}

0 commit comments

Comments
 (0)