Write a Spring Boot Starter (WebAuthn)

Mihaita Tinta
ING Hubs Romania
Published in
10 min readMay 27, 2021

One thing I like about the Spring ecosystem is that you can find a spring boot starter for almost anything you want. Usually you need to add it to your dependencies list and with a minimum configuration you are good to go.

In this series we will write a new spring boot starter ourselves for the WebAuthn use case inspired by this blog.

Long story short, with WebAuthn you can authenticate in an application without sending your password to any remote server by using either your OS credentials, fingerprint or face recognition etc.

You can find the source code here

Web Authentication is a W3C recommendation for defining an API enabling the creation and use of strong, attested, scoped, public key-based credentials by web applications, for the purpose of strongly authenticating users.

There are two important uses cases:

  • Registration — creating the user and credentials on the backend side. The user usually contains an identifier like the username. The credentials represent the information used to authenticate the user on the next visit.
  • Authentication — associate the request with an existing user based on the username, credentials tuple

In both use cases, the private key never leaves the Authenticator device. Only the public key gets to our server.

Some important aspects are around the validation regarding cross origin requests. The server validates the signed challenges come only from specific domains. We can also enforce the user presence in the interaction between the User and the Authenticator device (i.e: use fingerprint, face recognition etc)

There are a few requirements for our new library:

  • it has to store users and credentials — we will use spring-data-jpa
  • it has to authenticate users — we will use spring-security
  • it has to create and validate webauthn challenges — we will use webauthn-server-core from yubico
  • the library needs to allow users to configure different variations in the flow (cors origins, server information)

Spring Data Repositories

The library deals with only two classes: users (WebAuthnUser) and credentials (WebAuthnCredentials). One user can have many credentials, one user can use multiple devices to link them to the same account.

It may not be a wise idea to have this kind of dependency for our library. Developers may choose other options to persist data, but for simplicity reasons we will stick with it. A better idea would be to externalise this persistence part and leave it up to the consumers to choose whatever they need.

One advantage of using spring-data as you may already know is the fast and easy way of declaring our methods (what we need) and leave it up to the framework to implement our interfaces. By using this abstraction, we don’t really care what kind of SQL database we use (at least for development)

This is how the user repository looks like:

public interface WebAuthnUserRepository extends CrudRepository<WebAuthnUser, Long> {

Optional<WebAuthnUser> findByUsername(String username);

Optional<WebAuthnUser> findByAddTokenAndRegistrationAddStartAfter(byte[] token, LocalDateTime after);

Optional<WebAuthnUser> findByRecoveryToken(byte[] token);
}

Besides the 3 methods we declared, we also inherit others from the CrudRepository interface

One practical way to understand what happens is to create a slice test, focused on the repository layer.

@DataJpaTest
class WebAuthnUserRepositoryTest {

@Autowired
WebAuthnUserRepository userRepository;

@Test
public void test() {
WebAuthnUser user = new WebAuthnUser();
user.setUsername("junit");
userRepository.save(user);

assertNotNull(user.getId());

}
}

When we run the test, we can see there are some useful defaults for our troubleshooting. From the documentation:

By default, tests annotated with @DataJpaTest are transactional and roll back at the end of each test. They also use an embedded in-memory database (replacing any explicit or usually auto-configured DataSource). The @AutoConfigureTestDatabase annotation can be used to override these settings.
SQL queries are logged by default by setting the spring.jpa.show-sql property to true. This can be disabled using the showSql attribute.

Adding a breakpoint takes our understanding a little further. We can see:

  1. there is a transaction interceptor dealing with the transaction attributes, bounded to the executing thread (TransactionAspectSupport#bindToThread)
  2. A SimpleJpaRepository instance is using the entityManager to save a new WebAuthnUser instance

In the log: Hibernate: call next value for hibernate_sequence we can see the Id Auto generation strategy takes place and a new user id is generated by the backing datasource using a sequence. You may want to look here to change this default approach.

If you look at the QueryExecutionResultHandler and QueryExecutionConverters you can also find out how spring data supports different return types (i.e: Slice, Page, List, Future, ListenableFuture, vavr types etc). Reactive support is also there since (spring-data 2.0) depending on your configuration: Publisher, Single, Observable, Completable, Single, Maybe, Observable, Completable, Flowable, Single, Maybe, Observable, Completable, Flowable, Mono, Flux (see: ReactiveWrappers). For example we could use a method like the one below:

CompletableFuture<WebAuthnUser> findByAddToken(byte[] token);

If more methods are declared this way, we could chain them in parallel hoping to get a better performance.

When we use the Optional return type it becomes easier to compose different actions. For example, we can pass a consumer and a runnable to the ifPresentOrElse method:

userRepository.findByUsername("junit")
.ifPresentOrElse(u -> log.info("found user: {}", user),
() -> fail("user not found"));

Spring data works just fine with the java.time classes too:

userRepository.findByAddTokenAndRegistrationAddStartAfter(token, LocalDateTime.now().minusMinutes(10))
.ifPresentOrElse(u -> log.info("found user: {}", user),
() -> fail("user not found"));

Configuration properties

Spring Boot lets you externalize your configuration so that you can work with the same application code in different environments. You can use properties files, YAML files, environment variables, and command-line arguments to externalize configuration

Our ambition is to define some properties that are specific for our users. These fields are required for the yubico webauthn library while creating and validating the challenges.

Any application at some point moves from one environment to another until the changes are deployed to production. We don’t want to rebuild our applications just to update some environment specific parameters for some methods. Therefore we can use the same binary to run the application in different environments.

Using the @ ConfigurationProperties annotation is useful in many ways. We can apply validation on the startup. The fail-fast principle can be useful because it prevents runtime errors when misconfiguring the application. For this to happen, we need to activate the validation on the bean spring creates for us by using the @ Validated annotation. After this step is done we are free to use all the javax.validation.constraints we need. For example if we don’t provide some properties, we should expect a BindValidationException to happen. An alternative would be to use a postConstruct to validate the state of the properties bean.

Another advantage of using configuration properties is the documentation we can see in our IDE while searching for more information. We can add meaningful details to help our users.

As usual, we can also validate our code with another test:

In practice we can inject our properties bean where it is needed.

Spring SecurityConfigurer

If you are not familiar with Spring Security, better checkout the official documentation as a starting point.

Our entrypoint into the Spring Security configuration is the HttpSecurity#apply method. Here we can pass our own SecurityConfigurer implementation that operates on the HttpSecurity object.

The most simple approach to gain some control over the logic we execute on the requests is to create our own WebAuthnFilter class. The SecurityConfigurer we implement would just activate this filter via http.addFilterBefore

Allows adding a Filter before one of the known Filter classes. The known Filter instances are either a Filter listed in HttpSecurityBuilder.addFilter(Filter) or a Filter that has already been added using HttpSecurityBuilder.addFilterAfter(Filter, Class) or HttpSecurityBuilder.addFilterBefore(Filter, Class).

Our WebAuthnFilter has a complex job. There are 5 different endpoints it needs to react on: registration start/end/add new device and assertion start/end.

For this we can use a request matcher and depending on the result we may activate the right logic.

@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain chain)
throws IOException, ServletException {

HttpServletRequest req = (HttpServletRequest) request;
if (this.registrationStartPath.matches(req)) {
// some logic
} else if (this.registrationFinishPath.matches(req)) {
// ...

In this situation it would simplify our job if we isolate the endpoint specific logic with some strategies. Therefore we need to define 5 of them:

private WebAuthnRegistrationStartStrategy startStrategy;
private WebAuthnRegistrationAddStrategy addStrategy;
private WebAuthnRegistrationFinishStrategy finishStrategy;
private WebAuthnAssertionStartStrategy assertionStartStrategy;
private WebAuthnAssertionFinishStrategy assertionFinishStrategy;

Registration start

The registration itself also supports 3 variations: register a new user, register a new device and recover user (delete previous credentials and create new ones). Depending on the flow, in the /registration/start we expect in the request body one of the fields below:

private String username;
private String registrationAddToken;
private String recoveryToken;

Here we use our spring data repositories to create new users and credentials.

To generate the webauthn challenge we have to pass the userId and username to the relyingParty object. This also adds information regarding our server instance (id, name, cors properties)

To generate the registrationId, recovery tokens or add tokens we are using the SecureRandom implementation to minimise the possibility for someone to guess the values.

The response we provide to the client needs to be cached. Here our library could use the spring cache abstraction to allow our users to run more severs in parallel in the future.

Registration finish

The most important part when finishing the registration is to validate the signed challenge received from the client against the criteria used when creating the challenge. We need to retrieve our own registration start information related to the registrationId from the client.

We have to link the new credentials we create here with the initial userId from the start step. If this is a recovery flow, we better save the recovery code for the user too. We need to also clear any add token information.

Registration Add

This part is somehow easier. For an authenticated user we just need to add a token add information and the timestamp to (let’s say) in 10 minutes users need to complete the flow — add the new device. This duration value could be also externalised to our properties in the future.

My first temptation was to create a new interface to allow our developer users to provide the current authenticated user. Instead, I think it’s better to just use an existing functional interface. A default Supplier<WebAuthnUser> can be easily implemented with a lambda expression:

For this to work we need to also add an implementation for the moment when an user is authenticated. Users of the library should decide how to store the authenticated user state. By default we will leave this information in the ThreadLocal via the SecurityContextHolder

The nice part about this approach is that users can customize their own WebAuthnConfigurer with something else. They can use for example their own domain class or other Authentication implementations

Assertion Start

This step corresponds to the first authentication step — generate a challenge. Because we don’t have the user’s credentials (like a password), we rely on the private/public key properties to validate our challenge was signed by the right private key, meaning the authentication was successful.

The relyingParty uses a default credentials lookup implementation based on our spring data credentials repository.

The main idea is to have a link between users identified by either an username or an user handle (userId represented as a byte array) and their credentials (used to validate the signed challenge received from the clients)

Assertion End

When we receive the signed challenge we have to verify it with our public key stored in the registration step for the given user. If everything is ok, we pass the authenticated to be user to the success handler (usually to be stored in the SecurityContextHolder)

Spring Boot demo project

To use the library we need to add it in our dependencies list

<dependency>
<groupId>io.github.mihaita-tinta</groupId>
<artifactId>spring-boot-starter-webauthn</artifactId>
<version>0.0.2-SNAPSHOT</version>
</dependency>

To activate our library we need to add the @ EnableWebAuthn annotation

and apply our new WebauthnConfigurer:

H2 console

When we register an user we can explore the data we have in the database from the H2 console. We need to copy the jdbc url from the logs:

When accessing our local instance: http://localhost:8080/h2-console/

we can execute some queries to understand how the data is represented

If you made it so far and you are still interested how things look on the frontend side, here you can find an angular example. You may need to adjust some urls to match our endpoints (i.e: /registration-add -> /registration/add)

--

--

Mihaita Tinta
ING Hubs Romania

A new kind of plumber working with Java, Spring, Kubernetes. Follow me to receive practical coding examples.