diff --git a/vertx-auth-webauthn4j/src/main/asciidoc/index.adoc b/vertx-auth-webauthn4j/src/main/asciidoc/index.adoc index 9a72024e6..d81292910 100644 --- a/vertx-auth-webauthn4j/src/main/asciidoc/index.adoc +++ b/vertx-auth-webauthn4j/src/main/asciidoc/index.adoc @@ -94,6 +94,16 @@ The process takes 2 steps: If the solution is correct, the new authenticator should be added to the storage and be usable for login purposes. +The `user` object given to `createCredentialsOptions` may contain an `id`: the https://www.w3.org/TR/webauthn/#user-handle[user handle], +a stable, non user identifiable, identifier of the account (for example the primary key of the user record) encoded as +`base64url` (at most 64 bytes). Browsers and authenticators use it to recognise that a credential belongs to an existing +account, so it should be the same across registrations of the same user and different across users. When no `id` is given a +random one is generated. Pass the same value in {@link io.vertx.ext.auth.webauthn4j.WebAuthn4JCredentials#setUserId(String)} +when calling `authenticate` with the solution: it is stored in the +{@link io.vertx.ext.auth.webauthn4j.Authenticator#setUserId(String)} property, returned in the principal of the authenticated +user and, at login time, checked against the `userHandle` returned by the authenticator so that a credential can only be used +by the account it was created for. + == Login Like the registration, login is a 2 step process: diff --git a/vertx-auth-webauthn4j/src/main/generated/io/vertx/ext/auth/webauthn4j/AuthenticatorConverter.java b/vertx-auth-webauthn4j/src/main/generated/io/vertx/ext/auth/webauthn4j/AuthenticatorConverter.java index febc2abde..7e69d512d 100644 --- a/vertx-auth-webauthn4j/src/main/generated/io/vertx/ext/auth/webauthn4j/AuthenticatorConverter.java +++ b/vertx-auth-webauthn4j/src/main/generated/io/vertx/ext/auth/webauthn4j/AuthenticatorConverter.java @@ -17,6 +17,11 @@ static void fromJson(Iterable> json, Authent obj.setUsername((String)member.getValue()); } break; + case "userId": + if (member.getValue() instanceof String) { + obj.setUserId((String)member.getValue()); + } + break; case "type": if (member.getValue() instanceof String) { obj.setType((String)member.getValue()); @@ -69,6 +74,9 @@ static void toJson(Authenticator obj, java.util.Map json) { if (obj.getUsername() != null) { json.put("username", obj.getUsername()); } + if (obj.getUserId() != null) { + json.put("userId", obj.getUserId()); + } if (obj.getType() != null) { json.put("type", obj.getType()); } diff --git a/vertx-auth-webauthn4j/src/main/generated/io/vertx/ext/auth/webauthn4j/WebAuthn4JCredentialsConverter.java b/vertx-auth-webauthn4j/src/main/generated/io/vertx/ext/auth/webauthn4j/WebAuthn4JCredentialsConverter.java index d91def021..68e90494b 100644 --- a/vertx-auth-webauthn4j/src/main/generated/io/vertx/ext/auth/webauthn4j/WebAuthn4JCredentialsConverter.java +++ b/vertx-auth-webauthn4j/src/main/generated/io/vertx/ext/auth/webauthn4j/WebAuthn4JCredentialsConverter.java @@ -27,6 +27,11 @@ static void fromJson(Iterable> json, WebAuth obj.setUsername((String)member.getValue()); } break; + case "userId": + if (member.getValue() instanceof String) { + obj.setUserId((String)member.getValue()); + } + break; case "origin": if (member.getValue() instanceof String) { obj.setOrigin((String)member.getValue()); @@ -55,6 +60,9 @@ static void toJson(WebAuthn4JCredentials obj, java.util.Map json if (obj.getUsername() != null) { json.put("username", obj.getUsername()); } + if (obj.getUserId() != null) { + json.put("userId", obj.getUserId()); + } if (obj.getOrigin() != null) { json.put("origin", obj.getOrigin()); } diff --git a/vertx-auth-webauthn4j/src/main/java/examples/WebAuthN4JExamples.java b/vertx-auth-webauthn4j/src/main/java/examples/WebAuthN4JExamples.java index 51f8d8767..23278226a 100644 --- a/vertx-auth-webauthn4j/src/main/java/examples/WebAuthN4JExamples.java +++ b/vertx-auth-webauthn4j/src/main/java/examples/WebAuthN4JExamples.java @@ -56,9 +56,9 @@ public Future updateCounter(Authenticator authenticator) { // some user JsonObject user = new JsonObject() - // id is expected to be a base64url string + // the user handle: a stable, non user identifiable, id of the account + // (base64url encoded, at most 64 bytes); generated when omitted .put("id", "000000000000000000000000") - .put("rawId", "000000000000000000000000") .put("name", "john.doe@email.com") // optionally .put("displayName", "John Doe") @@ -112,6 +112,8 @@ public Future updateCounter(Authenticator authenticator) { new WebAuthn4JCredentials() // the username you want to link to .setUsername("paulo") + // the user handle (user.id) sent on the previous step, if any + .setUserId("000000000000000000000000") // the server origin .setOrigin("https://192.168.178.206.xip.io:8443") // the server domain diff --git a/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/Authenticator.java b/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/Authenticator.java index b5eed5b0b..89b5066fb 100644 --- a/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/Authenticator.java +++ b/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/Authenticator.java @@ -39,6 +39,17 @@ public class Authenticator { */ private String username; + /** + * The user handle linked to this authenticator, as a base64url encoded string. + *

+ * This is the {@code user.id} sent in the {@code PublicKeyCredentialCreationOptions} at registration time + * and the {@code userHandle} returned by the authenticator in the assertion response. It is a stable, + * non user identifiable, identifier of the account (see + * https://www.w3.org/TR/webauthn/#user-handle) and + * may be {@code null} for authenticators registered before this property was introduced. + */ + private String userId; + /** * The type of key (must be "public-key") */ @@ -96,6 +107,15 @@ public Authenticator setUsername(String username) { return this; } + public String getUserId() { + return userId; + } + + public Authenticator setUserId(String userId) { + this.userId = userId; + return this; + } + public String getType() { return type; } diff --git a/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/WebAuthn4J.java b/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/WebAuthn4J.java index c214a9689..3c18ae227 100644 --- a/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/WebAuthn4J.java +++ b/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/WebAuthn4J.java @@ -58,7 +58,8 @@ static WebAuthn4J create(Vertx vertx, WebAuthn4JOptions options) { *

* The object being returned is described here https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptions * - * @param user - the user object with name and optionally displayName and icon + * @param user - the user object with name and optionally id (the base64url encoded user handle, generated when + * absent), displayName and icon * @return a future notified with the encoded make credentials request */ Future createCredentialsOptions(JsonObject user); diff --git a/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/WebAuthn4JCredentials.java b/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/WebAuthn4JCredentials.java index c06c5375e..2d1f34759 100644 --- a/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/WebAuthn4JCredentials.java +++ b/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/WebAuthn4JCredentials.java @@ -28,6 +28,7 @@ public class WebAuthn4JCredentials implements Credentials { private String challenge; private JsonObject webauthn; private String username; + private String userId; private String origin; private String domain; @@ -65,6 +66,23 @@ public WebAuthn4JCredentials setUsername(String username) { return this; } + /** + * The user handle (base64url encoded) of the user performing the ceremony, if known. + *

+ * At registration ({@code webauthn.create}) it is the {@code user.id} that was sent to the browser in the + * creation options and it is stored with the new authenticator. At authentication ({@code webauthn.get}) it + * is optional; when the relying party has identified the user before the ceremony, setting it ensures that + * the credential used belongs to that user. + */ + public String getUserId() { + return userId; + } + + public WebAuthn4JCredentials setUserId(String userId) { + this.userId = userId; + return this; + } + public String getOrigin() { return origin; } diff --git a/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/impl/WebAuthn4JImpl.java b/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/impl/WebAuthn4JImpl.java index b22a581ad..f1742ab55 100644 --- a/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/impl/WebAuthn4JImpl.java +++ b/vertx-auth-webauthn4j/src/main/java/io/vertx/ext/auth/webauthn4j/impl/WebAuthn4JImpl.java @@ -225,6 +225,43 @@ private static String uUIDtoBase64Url(UUID uuid) { return base64UrlEncode(buffer.getBytes()); } + /** + * A user handle is an opaque byte sequence of at most 64 bytes, and must not be empty + * (https://www.w3.org/TR/webauthn/#user-handle). In JSON it is carried as a base64url string. + * + * @throws IllegalArgumentException when the given value is not a valid user handle + */ + private static void validateUserHandle(String userHandle) { + if (userHandle == null || userHandle.isEmpty()) { + throw new IllegalArgumentException("user handle cannot be empty"); + } + final byte[] bytes; + try { + bytes = base64UrlDecode(userHandle); + } catch (RuntimeException e) { + throw new IllegalArgumentException("user handle must be base64url encoded", e); + } + if (bytes.length == 0) { + throw new IllegalArgumentException("user handle cannot be empty"); + } + if (bytes.length > 64) { + throw new IllegalArgumentException("user handle cannot exceed 64 bytes"); + } + } + + /** + * Compares two base64url encoded user handles by value, so that different (padded/unpadded) encodings of the + * same bytes are considered equal. + */ + private static boolean sameUserHandle(String a, String b) { + try { + return Arrays.equals(base64UrlDecode(a), base64UrlDecode(b)); + } catch (RuntimeException e) { + // not base64url, fallback to plain comparison + return a.equals(b); + } + } + @Override public WebAuthn4J credentialStorage(CredentialStorage credentialStorage) { if (credentialStorage == null) { @@ -237,6 +274,19 @@ public WebAuthn4J credentialStorage(CredentialStorage credentialStorage) { @Override public Future createCredentialsOptions(JsonObject user) { + // the user handle: given by the relying party (base64url encoded) or generated + final String userId; + if (user.containsKey("id")) { + userId = user.getString("id"); + try { + validateUserHandle(userId); + } catch (IllegalArgumentException e) { + return Future.failedFuture(new WebAuthn4JException("Invalid user.id: " + e.getMessage(), e)); + } + } else { + userId = uUIDtoBase64Url(UUID.randomUUID()); + } + return credentialStorage.find(user.getString("name"), null) .map(authenticators -> { // empty structure with all required fields @@ -252,7 +302,7 @@ public Future createCredentialsOptions(JsonObject user) { putOpt(json.getJsonObject("rp"), "name", options.getRelyingParty().getName()); // 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")); @@ -438,6 +488,8 @@ public Future authenticate(Credentials credentials) { // 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.setUsername(username); + // the user handle sent to the browser in the creation options, if the relying party kept it + authrInfo.setUserId(authInfo.getUserId()); // the create challenge is complete we can finally save this // new authenticator to the storage @@ -473,6 +525,18 @@ public Future authenticate(Credentials credentials) { return Future.failedFuture("Cannot find authenticator with id: " + webauthn.getString("id")); } else if (authenticators.size() == 1) { Authenticator authenticator = authenticators.get(0); + // https://www.w3.org/TR/webauthn/#sctn-verifying-assertion + // the user identified by the response userHandle (or by the relying party before the + // ceremony) must be the owner of the credential + if (authenticator.getUserId() != null) { + final String userHandle = response.getString("userHandle"); + if (userHandle != null && !userHandle.isEmpty() && !sameUserHandle(authenticator.getUserId(), userHandle)) { + return Future.failedFuture("User handle does not match the owner of credential id: " + credentialId); + } + if (authInfo.getUserId() != null && !sameUserHandle(authenticator.getUserId(), authInfo.getUserId())) { + return Future.failedFuture("Credential id: " + credentialId + " does not belong to the expected user"); + } + } return verifyWebAuthNGet(response, authInfo, clientDataJSON, authenticator) .compose(counter -> { // update the counter on the authenticator diff --git a/vertx-auth-webauthn4j/src/test/java/io/vertx/tests/UserHandleTest.java b/vertx-auth-webauthn4j/src/test/java/io/vertx/tests/UserHandleTest.java new file mode 100644 index 000000000..98881cd75 --- /dev/null +++ b/vertx-auth-webauthn4j/src/test/java/io/vertx/tests/UserHandleTest.java @@ -0,0 +1,289 @@ +package io.vertx.tests; + +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.webauthn4j.Authenticator; +import io.vertx.ext.auth.webauthn4j.RelyingParty; +import io.vertx.ext.auth.webauthn4j.WebAuthn4J; +import io.vertx.ext.auth.webauthn4j.WebAuthn4JCredentials; +import io.vertx.ext.auth.webauthn4j.WebAuthn4JOptions; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.RunTestOnContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Base64; + +/** + * Tests for the user handle ({@code user.id}) handling: it must be taken from the caller when provided at + * registration (issue #580) and it must be stored, returned in the principal and verified against the + * assertion {@code userHandle} at authentication (issue #581). + */ +@RunWith(VertxUnitRunner.class) +public class UserHandleTest { + + // 16 bytes, base64url without padding + private static final String USER_ID = "AAECAwQFBgcICQoLDA0ODw"; + private static final String OTHER_USER_ID = "Dw4NDAsKCQgHBgUEAwIBAA"; + + private static final String CRED_ID = "rYLaf9xagyA2YnO-W3CZDW8udSg8VeMMm25nenU7nCSxUqy1pEzOdb9oFrDxZZDmrp3odfuTPuONQCiSMH-Tyg"; + private static final String PUBLIC_KEY = "pQECAyYgASFYILBNcdWmiMsmjA1QkNpG91GpEbhMIOqWLieDP6mLnGETIlggGMiqXz8BuSiPa0ovGVxxxbdUbJVm6THKNhUCifFhJCE"; + private static final String ORIGIN = "https://192.168.178.206.xip.io:8443"; + private static final String GET_CHALLENGE = "zNaIWnCmwVF7A5aZDF04_jthPmZTdziI7sXDkYEJxLDH1d1Eycc6kE_Rf1LZiSD0FGCrjzrYq9NmYrBmcDFF_g"; + + private final DummyStore database = new DummyStore(); + + @Rule + public final RunTestOnContext rule = new RunTestOnContext(); + + private WebAuthn4J webAuthN; + + @Before + public void setUp() { + database.clear(); + webAuthN = WebAuthn4J.create( + rule.vertx(), + new WebAuthn4JOptions().setRelyingParty(new RelyingParty().setName("ACME Corporation"))) + .credentialStorage(database); + } + + // ---- issue #580: createCredentialsOptions must honour user.id ---- + + @Test + public void testCreateOptionsUsesGivenUserId(TestContext should) { + final Async test = should.async(); + + JsonObject user = new JsonObject() + .put("id", USER_ID) + .put("name", "john.doe@email.com") + .put("displayName", "John Doe"); + + webAuthN + .createCredentialsOptions(user) + .onFailure(should::fail) + .onSuccess(options -> { + should.assertEquals(USER_ID, options.getJsonObject("user").getString("id")); + test.complete(); + }); + } + + @Test + public void testCreateOptionsGeneratesUserIdWhenMissing(TestContext should) { + final Async test = should.async(); + + webAuthN + .createCredentialsOptions(new JsonObject().put("name", "john.doe@email.com")) + .onFailure(should::fail) + .onSuccess(options -> { + String id = options.getJsonObject("user").getString("id"); + should.assertNotNull(id); + // valid base64url, 16 bytes (a UUID) + should.assertEquals(16, Base64.getUrlDecoder().decode(id).length); + // and different for every call + webAuthN + .createCredentialsOptions(new JsonObject().put("name", "john.doe@email.com")) + .onFailure(should::fail) + .onSuccess(options2 -> { + should.assertNotEquals(id, options2.getJsonObject("user").getString("id")); + test.complete(); + }); + }); + } + + @Test + public void testCreateOptionsRejectsNonBase64UrlUserId(TestContext should) { + final Async test = should.async(); + + webAuthN + .createCredentialsOptions(new JsonObject().put("id", "not base64url!").put("name", "john.doe@email.com")) + .onSuccess(options -> should.fail("user.id that is not base64url must be rejected")) + .onFailure(err -> test.complete()); + } + + @Test + public void testCreateOptionsRejectsTooLongUserId(TestContext should) { + final Async test = should.async(); + + // 65 bytes, the spec limits the user handle to 64 bytes + String tooLong = Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[65]); + + webAuthN + .createCredentialsOptions(new JsonObject().put("id", tooLong).put("name", "john.doe@email.com")) + .onSuccess(options -> should.fail("user.id longer than 64 bytes must be rejected")) + .onFailure(err -> test.complete()); + } + + @Test + public void testCreateOptionsRejectsEmptyUserId(TestContext should) { + final Async test = should.async(); + + webAuthN + .createCredentialsOptions(new JsonObject().put("id", "").put("name", "john.doe@email.com")) + .onSuccess(options -> should.fail("empty user.id must be rejected")) + .onFailure(err -> test.complete()); + } + + // ---- issue #581: registration stores the user id and exposes it in the principal ---- + + @Test + public void testRegisterStoresUserId(TestContext should) { + final Async test = should.async(); + + webAuthN + .authenticate(registrationCredentials().setUserId(USER_ID)) + .onFailure(should::fail) + .onSuccess(user -> { + should.assertEquals(USER_ID, user.principal().getString("userId")); + database.find("paulo", null) + .onFailure(should::fail) + .onSuccess(authenticators -> { + should.assertEquals(1, authenticators.size()); + should.assertEquals(USER_ID, authenticators.get(0).getUserId()); + test.complete(); + }); + }); + } + + @Test + public void testRegisterWithoutUserIdIsStillAllowed(TestContext should) { + final Async test = should.async(); + + webAuthN + .authenticate(registrationCredentials()) + .onFailure(should::fail) + .onSuccess(user -> { + should.assertNull(user.principal().getString("userId")); + test.complete(); + }); + } + + // ---- issue #581: authentication verifies the assertion userHandle against the stored user id ---- + + @Test + public void testLoginMatchingUserHandle(TestContext should) { + final Async test = should.async(); + + database.add(storedAuthenticator().setUserId(USER_ID)); + + webAuthN + .authenticate(loginCredentials(USER_ID)) + .onFailure(should::fail) + .onSuccess(user -> { + should.assertEquals(USER_ID, user.principal().getString("userId")); + test.complete(); + }); + } + + @Test + public void testLoginMismatchingUserHandleIsRejected(TestContext should) { + final Async test = should.async(); + + database.add(storedAuthenticator().setUserId(USER_ID)); + + webAuthN + .authenticate(loginCredentials(OTHER_USER_ID)) + .onSuccess(user -> should.fail("assertion userHandle does not match the credential owner")) + .onFailure(err -> test.complete()); + } + + @Test + public void testLoginMismatchingExpectedUserIdIsRejected(TestContext should) { + final Async test = should.async(); + + database.add(storedAuthenticator().setUserId(USER_ID)); + + // the relying party identified the user before the ceremony, but the credential belongs to someone else + webAuthN + .authenticate(loginCredentials(USER_ID).setUserId(OTHER_USER_ID)) + .onSuccess(user -> should.fail("credential does not belong to the expected user")) + .onFailure(err -> test.complete()); + } + + @Test + public void testLoginMatchingExpectedUserId(TestContext should) { + final Async test = should.async(); + + database.add(storedAuthenticator().setUserId(USER_ID)); + + webAuthN + .authenticate(loginCredentials(USER_ID).setUserId(USER_ID)) + .onFailure(should::fail) + .onSuccess(user -> test.complete()); + } + + @Test + public void testLoginLegacyAuthenticatorWithoutUserId(TestContext should) { + final Async test = should.async(); + + // authenticators registered before the user id was stored must keep working, whatever the userHandle + database.add(storedAuthenticator()); + + webAuthN + .authenticate(loginCredentials(OTHER_USER_ID)) + .onFailure(should::fail) + .onSuccess(user -> { + should.assertNull(user.principal().getString("userId")); + test.complete(); + }); + } + + @Test + public void testLoginEmptyUserHandleIsIgnored(TestContext should) { + final Async test = should.async(); + + // some authenticators return an empty user handle for non discoverable credentials + database.add(storedAuthenticator().setUserId(USER_ID)); + + webAuthN + .authenticate(loginCredentials("")) + .onFailure(should::fail) + .onSuccess(user -> test.complete()); + } + + // ---- fixtures ---- + + private static Authenticator storedAuthenticator() { + return new Authenticator() + .setUsername("paulo") + .setCredID(CRED_ID) + .setPublicKey(PUBLIC_KEY) + .setCounter(4); + } + + private static WebAuthn4JCredentials loginCredentials(String userHandle) { + JsonObject response = new JsonObject() + .put("authenticatorData", "fxV8VVBPmz66RLzscHpg5yjRhO28Y_fPwYO5AVwzBEIBAAAACA") + .put("clientDataJSON", "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiek5hSVduQ213VkY3QTVhWkRGMDRfanRoUG1aVGR6aUk3c1hEa1lFSnhMREgxZDFFeWNjNmtFX1JmMUxaaVNEMEZHQ3JqenJZcTlObVlyQm1jREZGX2ciLCJvcmlnaW4iOiJodHRwczovLzE5Mi4xNjguMTc4LjIwNi54aXAuaW86ODQ0MyIsImNyb3NzT3JpZ2luIjpmYWxzZX0") + .put("signature", "MEUCIFXjL0ONRuLP1hkdlRJ8d0ofuRAS12c6w8WgByr-0yQZAiEAw-C6UZ8U8pi8irAcD6jXXaZMtezbzVwZXLGqY3sbFyA"); + if (userHandle != null) { + response.put("userHandle", userHandle); + } + return new WebAuthn4JCredentials() + .setWebauthn(new JsonObject() + .put("id", CRED_ID) + .put("rawId", CRED_ID) + .put("type", "public-key") + .put("response", response)) + .setUsername("paulo") + .setOrigin(ORIGIN) + .setChallenge(GET_CHALLENGE); + } + + private static WebAuthn4JCredentials registrationCredentials() { + return new WebAuthn4JCredentials() + .setUsername("paulo") + .setOrigin(ORIGIN) + .setDomain("192.168.178.206.xip.io") + .setChallenge("BH7EKIDXU6Ct_96xTzG0l62qMhW_Ef_K4MQdDLoVNc1UXMQY4qN9ag5yDNmLI7vFRslkQbbj0JZWJxGVfMugXg") + .setWebauthn(new JsonObject() + .put("id", "Q-MHP0Xq20CKM5LW3qBt9gu5vdOYLNZc3jCcgyyLncRav5Ivd7T1dav3eWrI7CT8HmzU_yAYJrmja4in8OFL3A") + .put("rawId", "Q-MHP0Xq20CKM5LW3qBt9gu5vdOYLNZc3jCcgyyLncRav5Ivd7T1dav3eWrI7CT8HmzU_yAYJrmja4in8OFL3A") + .put("type", "public-key") + .put("response", new JsonObject() + .put("attestationObject", "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEfxV8VVBPmz66RLzscHpg5yjRhO28Y_fPwYO5AVwzBEJBAAAAAwAAAAAAAAAAAAAAAAAAAAAAQEPjBz9F6ttAijOS1t6gbfYLub3TmCzWXN4wnIMsi53EWr-SL3e09XWr93lqyOwk_B5s1P8gGCa5o2uIp_DhS9ylAQIDJiABIVggN_D3u-03a0GzONOHfaML881QZtOCc5oTNRB2wlyqUEUiWCD3878XoO_bIJf0mEPDILODFhVmkc4QeR6hOIDvwvXzYQ") + .put("clientDataJSON", "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiQkg3RUtJRFhVNkN0Xzk2eFR6RzBsNjJxTWhXX0VmX0s0TVFkRExvVk5jMVVYTVFZNHFOOWFnNXlETm1MSTd2RlJzbGtRYmJqMEpaV0p4R1ZmTXVnWGciLCJvcmlnaW4iOiJodHRwczovLzE5Mi4xNjguMTc4LjIwNi54aXAuaW86ODQ0MyIsImNyb3NzT3JpZ2luIjpmYWxzZX0"))); + } +}