Passkeys are a technology that have gained a lot of traction, which is understandable given the promise of having more convenience without sacrificing security. Now we are seeing companies such as CVS and Best Buy shipping implementations, I believe the adoption rate will continue to accelerate fast and users will start to expect passkeys more. After experimenting with https://webauthn.io I was quickly convinced I wanted to implement them also. So I got to work, reading up the specification, and implementing the library - Webauthn4j - github As soon as I got a basic implementation going it dawned on me, how am I going to test this?
Monday rolled around, and as fun as it was opening a browser and testing the registration and authentication flow manually, I much prefer to cover this flow via integration tests.
# Terminology
Relying Party - Is an entity which controls access. They determine the rules or checks in order to grant access.
Challenges - In order to prevent replay attacks, WebAuthn utilise challenges during the registration and authentication ceremonies. A challenge is a payload, greater than 16 bytes randomly generated. The challenge is created by the relying parties in a trusted environment (the server). The challenge exists until the operation is complete.
Attestation Object - The content of this object could be compared to a certificate or blue print that an authenticator provides to prove it’s trustworthy. The content typically includes details such as a signature, credential IDs, and a public key. When received by the relying party the content is checked to determine if the client can be trusted.
Client Data JSON - This JSON object contains contextual information relating to the relaying party, it contains information such as the challenge issued, the origin and what type of process is being performed (webauthn.create or webauthn.get).
# What are we testing?
WebAuthn has two main flows, registration and authentication.
Registration - or the registration ceremony is where a user and a relying party (the server) communicate in an effort to create and exchange authentication information. The end goal is the server receives the user’s public key to be used for authentication in later authentication attempts.
Authentication - follows a similar pattern to registration in that the client and relying party exchange information in a structured manner. However the goal this time being, can the server verify if the user is who they claim to be. This is achieved by the client producing a signature using the servers issued challenge with their private key. The server verifies the signature using the public key it acquired during the registration phase.
Registration flow:
- The client requests permission to store their authentication details (1.1).
- The server provides the options for the client to achieve this as well as a challenge (1.3).
- The client generates an attestation object and returns it to the server (2.1).
- The server verifies the response and if satisfied stores the authentication data (2.2 - 2.6).
- The server returns a success, registration is complete (2.7).
Authentication flow
- The client sends their username requests permission to authenticate (1.1).
- The server locates the keys associated with that user and generates a challenge (1.2 - 1.5).
- The client uses its private key to create a signature from the challenge and sends it back to the server (2.1).
- The server verifies the user and grants access (2.2 - 2.8).
The challenge for both of these flows is that they have a physical element, the client’s device.
The device is responsible for safeguarding the clients private key and other cryptographic activities. Typically this device is the user’s computer, USB stick or phone.
# How can we test this?
WebAuthn4j provides a library for testing: mvnrepository link
The library contains a emulator utility which can act simulate authenticators such as FIDO U2F Keys. Emulator Github
My setup
- JUnit
- Spring Boot test
- MockMvc
- Liquibase
- Testcontainers - (for dependencies such as databases)
Note The example code below gives a general direction, but be sure to add more tests cases.
Firstly I have a base abstract class which is helpful for the general setup of all my integration test as well as configuring things such as test containers in one place:
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ActiveProfiles({"test", "console"})
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public abstract class AbstractIntegrationTest {
private static final MySQLContainer<?> MYSQL_SQL_CONTAINER;
static {
MYSQL_SQL_CONTAINER = new MySQLContainer(DockerImageName.parse("mysql"))
.withDatabaseName("mydb")
.withUsername("user")
.withPassword("secret");
MYSQL_SQL_CONTAINER.withImagePullPolicy(PullPolicy.alwaysPull());
MYSQL_SQL_CONTAINER.withReuse(true);
MYSQL_SQL_CONTAINER.withEnv("MYSQL_ROOT_PASSWORD", "secret");
MYSQL_SQL_CONTAINER.start();
}
protected final ObjectWriter ow = new ObjectMapper()
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.findAndRegisterModules()
.writer()
.withDefaultPrettyPrinter();
@DynamicPropertySource
static void overrideTestProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL_SQL_CONTAINER::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL_SQL_CONTAINER::getUsername);
registry.add("spring.datasource.password", MYSQL_SQL_CONTAINER::getPassword);
}
}
Testing Registration Flow
To test the registration flow, we can start out simple, lets verify that our registration options are returned and that it contains a challenge:
class PasskeyRegistrationControllerIntegrationTest extends AbstractIntegrationTest {
private static final String PASSKEY_URL = "/passkey";
private static final String PASSKEY_REGISTER_URL = PASSKEY_URL + "/register";
private static final String PASSKEY_REGISTER_VERIFY_URL = PASSKEY_URL + "/register-verify";
private final ObjectMapper om = new ObjectMapper();
@Autowired
private MockMvc mockMvc;
@Test
@WithMockCustomUser(userName = "billybob@bill.com")
void passkey_registration_verify_options() throws Exception {
final String rpName = "finaps";
mockMvc.perform(post(PASSKEY_REGISTER_URL)
.content(ow.writeValueAsString(PasskeyRegisterRequestDto.builder().build()))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andExpect(jsonPath("$.rp.name", is(rpName)))
.andExpect(jsonPath("$.challenge", is(not(emptyString()))));
}
}
From the registration diagram this should cover steps 1.1 - 1.3.
During the registration and authentication the server issues challenges. These challenges are kept on the server and typically have a short lifespan of a couple minutes. In my example scenario I simply keep the challenge in an in memory list.
@Test
@WithMockCustomUser(userName = "billybob@bill.com")
void passkey_registration_flow() throws Exception {
String regRes = mockMvc.perform(post(PASSKEY_REGISTER_URL)
.content(ow.writeValueAsString(PasskeyRegisterRequestDto.builder().build()))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
PasskeyRegisterResponseDto res = om.readValue(regRes, PasskeyRegisterResponseDto.class);
ClientPlatform client = EmulatorUtil.createClientPlatform();
mockMvc.perform(post(PASSKEY_REGISTER_VERIFY_URL)
.content(ow.writeValueAsString(createPasskeyVerifyRequest(client, res)))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isNoContent());
}
For my complete flow test, I call the first endpoint to get the registration settings from the server and parse the content and pass it into the ClientPlatform
which is the emulator provided by WebAuthn4j
The method createPasskeyVerifyRequest
is a helper method for transforming my data transfer object into the format expected by the emulator and generating a verification request.
public class PasskeyEmulatorUtilities {
public static PasskeyRegisterVerifyRequestDto createPasskeyVerifyRequest(
ClientPlatform clientPlatform,
PasskeyRegisterResponseDto registerResponseDto) {
PublicKeyCredential<AuthenticatorAttestationResponse, RegistrationExtensionClientOutput> key =
createPasskey(clientPlatform, registerResponseDto);
PasskeyRegisterVerifyRequestResponseDto responseObj = PasskeyRegisterVerifyRequestResponseDto.builder()
.attestationObject(Base64UrlUtil.encodeToString(key.getAuthenticatorResponse().getAttestationObject()))
.clientDataJSON(Base64UrlUtil.encodeToString(key.getAuthenticatorResponse().getClientDataJSON()))
.build();
return PasskeyRegisterVerifyRequestDto.builder()
.id(key.getId())
.rawId(new String(key.getRawId()))
.response(responseObj)
.build();
}
public static PublicKeyCredential<AuthenticatorAttestationResponse, RegistrationExtensionClientOutput> createPasskey(
ClientPlatform clientPlatform,
PasskeyRegisterResponseDto registerResponseDto) {
return clientPlatform.create(createPublicKeyCredentialCreationOptions(registerResponseDto));
}
private static PublicKeyCredentialCreationOptions createPublicKeyCredentialCreationOptions(
PasskeyRegisterResponseDto registerResponseDto) {
Challenge challenge = new DefaultChallenge(registerResponseDto.getChallenge());
return new PublicKeyCredentialCreationOptions(
parseRp(registerResponseDto.getRp()),
parseUser(registerResponseDto.getUser()),
challenge,
parsePubKeys(registerResponseDto.getPubKeyCredParams()),
6000L,
Collections.emptyList(),
parseAuthenticatorSelection(registerResponseDto.getAuthenticatorSelection()),
null,
null
);
}
}
These two tests cover the ‘happy flow’ outlined in the sequence diagram above. But its good to add unhappy flows such as - what if the challenge is changed? If this were to occur it should violate the registration ceremony.
@Test
@WithMockCustomUser(userName = "billybob@bill.com")
void passkey_registration_flow_fails_due_to_challenge() throws Exception {
String regRes = mockMvc.perform(post(PASSKEY_REGISTER_URL)
.content(ow.writeValueAsString(PasskeyRegisterRequestDto.builder().build()))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
PasskeyRegisterResponseDto res = om.readValue(regRes, PasskeyRegisterResponseDto.class);
res.setChallenge("reallyWrongChallenge");
ClientPlatform client = EmulatorUtil.createClientPlatform();
mockMvc.perform(post(PASSKEY_REGISTER_VERIFY_URL)
.content(ow.writeValueAsString(createPasskeyVerifyRequest(client, res)))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isBadRequest())
.andExpect(jsonPath("$.detail", is(REGISTRATION_FAILED)));
}
Testing Authentication flow
As we stated, for authentication to work, we need the client and server to register information about each other before they can perform an authentication. However having to make a request each test to register a key and then perform a login would get messy and more importantly make our tests slower.
However the emulator from webauthn4j makes this easy, when performing the clientPlatform.get
the emulator uses the identifier from the allowed credentials as a seed to generate the key pair needed.
public static @NonNull KeyPair createKeyPair(@Nullable byte[] seed, @NonNull ECParameterSpec ecParameterSpec) {
KeyPairGenerator keyPairGenerator = createKeyPairGenerator();
SecureRandom random;
try {
if (seed != null) {
random = SecureRandom.getInstance("SHA1PRNG"); // to make it deterministic
random.setSeed(seed);
}
else {
random = secureRandom;
}
keyPairGenerator.initialize(ecParameterSpec, random);
return keyPairGenerator.generateKeyPair();
} catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException e) {
throw new UnexpectedCheckedException(e);
}
}
Therefore, if we seed our database with a key id and public key we’ve previously generated using the emulator we should be good.
For the authentication scenarios I leverage liquibase to pre-seed the database with a user and key credentials acquired from the emulator.
This allows me to keep my authentication tests simple:
class PasskeyAuthenticationControllerIntegrationTest extends AbstractIntegrationTest {
private static final String PASSKEY_URL = "/passkey";
private static final String PASSKEY_LOGIN_URL = PASSKEY_URL + "/login";
private static final String PASSKEY_LOGIN_VERIFY_URL = PASSKEY_URL + "/login-verify";
private final ObjectMapper om = new ObjectMapper();
private final PasskeyAuthenticationRequestDto userWithPasskeyRequest = PasskeyAuthenticationRequestDto.builder()
.username("passkey@smh.com")
.build();
private final PasskeyAuthenticationRequestDto userWithoutPasskey = PasskeyAuthenticationRequestDto.builder()
.username("nomates@billy.com")
.build();
@Autowired
private MockMvc mockMvc;
@Test
void passkey_login_verify_options() throws Exception {
final String rpName = "localhost";
mockMvc.perform(post(PASSKEY_LOGIN_URL)
.content(ow.writeValueAsString(userWithPasskeyRequest))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andExpect(jsonPath("$.rpId", is(rpName)))
.andExpect(jsonPath("$.challenge", is(not(emptyString()))));
}
@Test
void passkey_login_flow() throws Exception {
String regRes = mockMvc.perform(post(PASSKEY_LOGIN_URL)
.content(ow.writeValueAsString(userWithPasskeyRequest))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
PasskeyAuthenticationResponseDto res = om.readValue(regRes, PasskeyAuthenticationResponseDto.class);
ClientPlatform client = EmulatorUtil.createClientPlatform(new FIDOU2FAuthenticator());
mockMvc.perform(post(PASSKEY_LOGIN_VERIFY_URL)
.content(ow.writeValueAsString(createPasskeyAuthVerifyRequest(client, res)))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andExpect(cookie().exists("refreshToken"))
.andExpect(jsonPath("$.loginStatus", is(LoginStatusDto.SUCCESS.getValue())))
.andExpect(jsonPath("$.accessToken", is(not(emptyString()))))
.andExpect(jsonPath("$.expiresIn", any(Integer.class)));
}
}
And here is the logic for createPasskeyAuthVerifyRequest
public class PasskeyEmulatorUtilities {
public static PasskeyAuthenticationVerifyRequestDto createPasskeyAuthVerifyRequest(
ClientPlatform clientPlatform,
PasskeyAuthenticationResponseDto authenticationRequestDto) {
final String id = authenticationRequestDto.getAllowedCredentials().get(0).getId();
final PublicKeyCredential<AuthenticatorAssertionResponse, AuthenticationExtensionClientOutput> authenticatePasskey =
authenticatePasskey(clientPlatform, authenticationRequestDto);
final PasskeyAuthenticationVerifyRequestResponseDto response = PasskeyAuthenticationVerifyRequestResponseDto.builder()
.signature(Base64UrlUtil.encodeToString(authenticatePasskey.getAuthenticatorResponse().getSignature()))
.authenticatorData(Base64UrlUtil.encodeToString(authenticatePasskey.getAuthenticatorResponse().getAuthenticatorData()))
.clientDataJSON(Base64UrlUtil.encodeToString(authenticatePasskey.getAuthenticatorResponse().getClientDataJSON()))
.build();
return PasskeyAuthenticationVerifyRequestDto.builder()
.id(id)
.rawId(id)
.type("public-key")
.response(response)
.build();
}
public static PublicKeyCredential<AuthenticatorAssertionResponse, AuthenticationExtensionClientOutput> authenticatePasskey(
ClientPlatform clientPlatform,
PasskeyAuthenticationResponseDto authenticationRequestDto) {
return clientPlatform.get(createPublicKeyCredentialRequestOptions(authenticationRequestDto));
}
private static PublicKeyCredentialCreationOptions createPublicKeyCredentialCreationDefaultOptions(
PasskeyRegisterResponseDto registerResponseDto) {
Challenge challenge = new DefaultChallenge(registerResponseDto.getChallenge());
return new PublicKeyCredentialCreationOptions(
parseRp(registerResponseDto.getRp()),
parseUser(registerResponseDto.getUser()),
challenge,
parsePubKeys(registerResponseDto.getPubKeyCredParams())
);
}
private static List<PublicKeyCredentialDescriptor> parsePublicKeyCredentialDescriptors(
List<PasskeyCredentialsDto> allowedCredentials) {
return allowedCredentials.stream()
.map(m -> new PublicKeyCredentialDescriptor(
PublicKeyCredentialType.PUBLIC_KEY,
Base64UrlUtil.decode(m.getId()), //Don't forget to decode
null
))
.toList();
}
}
This approach allows us to quickly build up confidence in our passkey implementation. I’m keen to see if I can take it up a notch and leverage something like playwright to control a browser and perform the flows in that sort of manner. But for now this was a good first step.
If you have an suggestions of improvement of observations feel free to let me know.