A Passwordless Future: Passkeys for Java Developers

deepu105

Deepu K Sasidharan

Posted on January 2, 2024

A Passwordless Future: Passkeys for Java Developers

Originally published at auth0.com

This blog post is a continuation of my previous blog post on passkeys. In this post, you will learn how to implement passkeys using Auth0 and the WebAuthn4j library on your Java applications.

If you have not read the previous blog post, I would highly recommend you read it first to understand the basics of passkeys and WebAuthn.

Passkeys

A passkey is a unique cryptographic key pair that allows you to access online services without using passwords. It is based on asymmetric public-key cryptography.

Passkeys are passwordless FIDO credentials implemented using WebAuthn.

Why Passkeys?

Passkeys are superior to password + traditional OTP MFA in terms of security and usability and they are as secure and more convenient than password + FIDO MFA. Most importantly, you don’t have to remember anything.

password vs passkeys

Let's See Passkeys in Action with Auth0

Let's build a Spring Boot web app and secure it using passkeys with the help of Auth0 by Okta. You can find a sample app on GitHub if you just want to try passkeys.

Before you get started, you will need the following:

  • Java 17 or higher. You can use SDKMAN! to install Java if you don't have it already.
  • A free Auth0 account. Sign up if you don't have one already.
  • The Auth0 CLI. Install the CLI if you don't have it and log in to your Auth0 account using the auth0 login command.

Create a Spring Boot application

Create a new Spring Boot application using the Spring Initializr. You can use the web version or the curl command below. Use the default for most of the options. For the dependencies, select web, and okta. For the build tool, select Gradle.

curl -G https://start.spring.io/starter.tgz \
  -d dependencies=web,okta \
  -d baseDir=passkey-demo \
 | tar -xzvf -
Enter fullscreen mode Exit fullscreen mode
  • The web dependency provides Spring Web MVC with basic HTTP REST functionality.
  • The okta dependency provides the Okta Spring Boot Starter, which provides the required dependencies and configuration to add OIDC authentication to your application.

Add a web controller

Imports are omitted for brevity so make sure to import them using your IDE.

Open the created starter application in your favorite IDE. Add a simple web controller to the application. Create a new file src/main/java/com/example/demo/HomeController.java with the following content:

@RestController
class HomeController {
    @GetMapping("/")
    public String home(@AuthenticationPrincipal OidcUser user) {
        return "Hello, " + user.getFullName() + "!";
    }
}
Enter fullscreen mode Exit fullscreen mode

This controller will handle requests to the / path.

If you run the application using ./gradlew bootRun, you will see a login page from the Okta Spring Boot starter instead of your home screen. This is OK, and you will be able to configure this soon. You can comment out the okta-spring-boot-starter dependency in the build.gradle file if you want to run the application at this point.

Enable passkeys on your Auth0 tenant

  1. Log in to your Auth0 Dashboard and navigate to Authentication > Database > Username-Password-Authentication.
    1. If the second tab says Authentication Methods, your tenant supports passkeys, proceed to the next step.
    2. If the second tab says Password Policy, your tenant doesn't support passkeys, Create a new tenant and proceed to the next step.
  2. Navigate to Authentication > Authentication Profile and select Identifier First. Save your changes.
  3. Navigate to Authentication > Database > Username-Password-Authentication and select the Authentication Methods tab and enable Passkey.

Configure OIDC Authentication with Auth0

Configure the application to use Auth0 as the Identity Provider (IdP). You can use the Auth0 CLI to create a new authorization server application. Run the following command to create a new application:

auth0 apps create \
  --name "Spring Boot Passkeys" \
  --description "Spring Boot Example" \
  --type regular \
  --callbacks http://localhost:8080/login/oauth2/code/okta \
  --logout-urls http://localhost:8080 \
  --reveal-secrets
Enter fullscreen mode Exit fullscreen mode
  • The --type option specifies that you use a regular web application.
  • The --callbacks option specifies the callback URL for the application.
  • The --logout-urls option specifies the logout URL for the application.
  • The --reveal-secrets option will display the client secret in the output.

You can also use the auth0 apps update command to update the application with the callback and logout URLs.

Note down the Auth0 issuer (for example, https://dev-12345678.us.auth0.com/), CLIENT ID, and CLIENT SECRET from the output. You will use these values in the next step.

Configure the Spring Boot application

Configure the application by creating an application.properties file in the applications root folder with the following content:

# trailing `/` is important for issuer URI
okta.oauth2.issuer=https://<AUTH0_domain>/
okta.oauth2.client-id=<AUTH0_clientId>
okta.oauth2.client-secret=<AUTH0_clientSecret>
Enter fullscreen mode Exit fullscreen mode

Add the application.properties file to the .gitignore file to avoid committing the secrets to the repository.

Run the application

To run the application, execute the following command:

./gradlew bootRun
Enter fullscreen mode Exit fullscreen mode

The application should start successfully. Navigate to http://localhost:8080 in your browser. You will be redirected to the Auth0 universal login page for authentication.

Click on the Sign up link to register a new user. Enter any email address and click Continue. You will now be prompted to register a passkey.

Registration Screen

Create a passkey using your platform authenticator or roaming authenticator like YubiKey. Once you have registered a passkey, you should be redirected back to the application and see the welcome message.

Open a new incognito window and navigate to http://localhost:8080. You will be prompted to sign in using your passkey. Once you have signed in, you will see the welcome message.

Isn't that cool? You just implemented passkeys in your Spring Boot application with so little effort thanks to Auth0.

WebAuthn for Java

Though Web Authentication’s user experience is a client-side implementation using JavaScript, the backend or Relying party can be a Java server. Ideally using an IdP like Auth0 would be the best option since it takes care of all the heavy lifting for you. But if you want to implement it yourself and walk the harder path, you can use one of the below libraries.

  • WebAuthn4j: A 100% FIDO2 conformant library with support for all attestation formats and validation. It is used by Keycloak and Spring Security.
  • java-webauthn-server: A library from Yubico that supports many attestation format. But it is not 100% FIDO2 conformant.

WebAuthn4j with Spring Security in Action

Let's look at a simple Spring Boot application that uses passkeys for authentication without using an IdP. You can find the sample app on GitHub.

Clone and run the application

Start by cloning the application.

git clone https://github.com/deepu105/webauthn4j-spring-boot-passkeys-demo.git

cd webauthn4j-spring-boot-passkeys-demo
./gradlew bootRun
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:8080/. You should see the below screen. Try registering a new user with passkeys and log in.

Sign up Screen

WebAuthn4j configuration

Let's look at some of the important parts of the application.

  • The webauthn4j-spring-security-core dependency, in build.gradle file, provides the Spring Security integration for WebAuthn4j.
  • The required beans for WebAuthn4j are configured in src/main/java/com/example/demo/config/WebSecurityBeanConfig.java.
  • The InMemoryWebAuthnAuthenticatorManager is used to keep things simple but it means authenticator data is lost on application restart. For production use, it is better to implement the WebAuthnAuthenticatorManager interface and persist credential IDs for users.
  • WebAuthn4j is configured using the standard Spring Security filter chain in src/main/java/com/example/demo/config/WebSecurityConfig.java.
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    ...
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception {
        // WebAuthn Login
        http.apply(WebAuthnLoginConfigurer.webAuthnLogin())
            .defaultSuccessUrl("/", true)
            .failureHandler((request, response, exception) -> {
                logger.error("Login error", exception);
                response.sendRedirect("/login?error=Login failed: " + exception.getMessage());
            })
            .attestationOptionsEndpoint()
            .rp()
            .name("WebAuthn4J Passkeys Demo")
            .and()
            .pubKeyCredParams(
                // supported algorithms for cryptography
                new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256),
                new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.RS256)
            )
            .attestation(AttestationConveyancePreference.DIRECT)
            .extensions()
            .uvm(true)
            .credProps(true)
            .extensionProviders()
            .and()
            .assertionOptionsEndpoint()
            .extensions()
            .extensionProviders();

        http.headers(headers -> {
            // 'publickey-credentials-get *' allows getting WebAuthn credentials to all nested browsing contexts (iframes) regardless of their origin.
            headers.permissionsPolicy(config -> config.policy("publickey-credentials-get *"));
            // Disable "X-Frame-Options" to allow cross-origin iframe access
            headers.frameOptions(Customizer.withDefaults()).disable();
        });

        // Authorization
        http.authorizeHttpRequests(authz -> authz
            .requestMatchers(HttpMethod.GET, "/login").permitAll()
            .requestMatchers(HttpMethod.POST, "/signup").permitAll()
            .anyRequest().access(getWebExpressionAuthorizationManager("@webAuthnSecurityExpression.isWebAuthnAuthenticated(authentication)"))
        );

        http.exceptionHandling(eh -> eh.accessDeniedHandler((request, response, accessDeniedException) -> {
            logger.error("Access denied", accessDeniedException);
            response.sendRedirect("/login");
        }));

        http.authenticationManager(authenticationManager);

        // As WebAuthn has its own CSRF protection mechanism (challenge), CSRF token is disabled here
        http.csrf(csrf -> {
            csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
            csrf.ignoringRequestMatchers("/webauthn/**");
        });

        return http.build();
    }
    ...
}

Enter fullscreen mode Exit fullscreen mode

The endpoints are configured in src/main/java/com/example/demo/web/WebAuthnSampleController.java. The / and /login endpoints are quite simple and self-explanatory. The /signup endpoint handles the WebAuthn registration request using WebAuthn4j. The request is first validated using WebAuthnRegistrationRequestValidator and then the authenticator is created using WebAuthnAuthenticatorManager.

@Controller
public class WebAuthnSampleController {
    ...
    @PostMapping(value = "/signup")
    public String create(HttpServletRequest request, @Valid @ModelAttribute("userForm") UserCreateForm userCreateForm, BindingResult result, Model model, RedirectAttributes redirectAttributes) {
        try {
            if (result.hasErrors()) {
                model.addAttribute("errorMessage", "Your input needs correction.");
                logger.error("User input validation failed.");
                return VIEW_LOGIN;
            }
            WebAuthnRegistrationRequestValidationResponse registrationRequestValidationResponse;
            try {
                registrationRequestValidationResponse = registrationRequestValidator.validate(
                    request,
                    userCreateForm.getClientDataJSON(),
                    userCreateForm.getAttestationObject(),
                    userCreateForm.getTransports(),
                    userCreateForm.getClientExtensions()
                );
            } catch (WebAuthnException | WebAuthnAuthenticationException e) {
                model.addAttribute("errorMessage", "Authenticator registration request validation failed. Please try again.");
                logger.error("WebAuthn registration request validation failed.", e);
                return VIEW_LOGIN;
            }
            var username = userCreateForm.getUsername();
            var authenticator = new WebAuthnAuthenticatorImpl(
                "authenticator",
                username,
                registrationRequestValidationResponse.getAttestationObject().getAuthenticatorData().getAttestedCredentialData(),
                registrationRequestValidationResponse.getAttestationObject().getAttestationStatement(),
                registrationRequestValidationResponse.getAttestationObject().getAuthenticatorData().getSignCount(),
                registrationRequestValidationResponse.getTransports(),
                registrationRequestValidationResponse.getRegistrationExtensionsClientOutputs(),
                registrationRequestValidationResponse.getAttestationObject().getAuthenticatorData().getExtensions()
            );
            try {
                webAuthnAuthenticatorManager.createAuthenticator(authenticator);
            } catch (IllegalArgumentException ex) {
                model.addAttribute("errorMessage", "Registration failed. The user may already be registered.");
                logger.error("Registration failed.", ex);
                return VIEW_LOGIN;
            }
        } catch (RuntimeException ex) {
            model.addAttribute("errorMessage", "Registration failed by unexpected error.");
            logger.error("Registration failed.", ex);
            return VIEW_LOGIN;
        }
        model.addAttribute("successMessage", "User registration successful. Please login.");
        return VIEW_LOGIN;
    }
}
Enter fullscreen mode Exit fullscreen mode

Client-side configuration

The file src/main/resources/templates/login.html handles login and sign-up. The login button will invoke the navigator.credentials.get() API and the register button will invoke the navigator.credentials.create() API. The buttons submit the corresponding forms with the input data in them. All inputs except the username field are hidden as their data will be set using JavaScript.

WebAuthn4j exposes /webauthn/attestation/options endpoint in the application to fetch the registration options. Some of the option parameters need to be decoded from base64URL. The base64url-arraybuffer library is used for this. The options are then passed to the navigator.credentials.create() API. The response from the API is then updated to the form fields and submitted to the /signup endpoint.

document.getElementById('signup-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    const userHandle = document.getElementById('userHandle').value;
    const username = document.getElementById('username').value;
    try {
        const optionsRes = await fetch('/webauthn/attestation/options');
        const options = await optionsRes.json();
        const publicKey = {
            ...options,
            challenge: base64url.decode(options.challenge, true),
            user: {
                id: base64url.decode(userHandle, true),
                name: username,
                displayName: username,
            },
            excludeCredentials: options.excludeCredentials.map((credential) => ({
                ...credential,
                id: base64url.decode(credential.id, true),
            })),
            authenticatorSelection: {
                requireResidentKey: true,
                userVerification: 'discouraged',
            },
        };
        const credential = await navigator.credentials.create({ publicKey });
        document.getElementById('clientDataJSON').value = base64url.encode(credential.response.clientDataJSON);
        document.getElementById('attestationObject').value = base64url.encode(credential.response.attestationObject);
        document.getElementById('clientExtensions').value = JSON.stringify(credential.getClientExtensionResults());
        document.getElementById('signup-form').submit();
    } catch (error) {
        console.error('Error:%s, Message:%s', error.name, error.message);
    }
});
Enter fullscreen mode Exit fullscreen mode

WebAuthn4j exposes /webauthn/assertion/options endpoint in the application to fetch the authentication options. Some of the option parameters need to be decoded from base64URL. The options are then passed to the navigator.credentials.get() API. The response from the API is then updated to the form fields and submitted to the /login endpoint.

document.getElementById('login-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    try {
        const optionsRes = await fetch('/webauthn/assertion/options');
        const options = await optionsRes.json();
        const publicKey = {
            ...options,
            challenge: base64url.decode(options.challenge, true),
            userVerification: 'preferred',
        };
        const credential = await navigator.credentials.get({ publicKey });
        document.getElementById('credentialId').value = credential.id;
        document.getElementById('loginClientDataJSON').value = base64url.encode(credential.response.clientDataJSON);
        document.getElementById('authenticatorData').value = base64url.encode(credential.response.authenticatorData);
        document.getElementById('signature').value = base64url.encode(credential.response.signature);
        document.getElementById('loginClientExtensions').value = JSON.stringify(credential.getClientExtensionResults());
        document.getElementById('login-form').submit();
    } catch (error) {
        console.error('Error:%s, Message:%s', error.name, error.message);
    }
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

You have now learned:

  • How to implement passkeys using an IdP like Auth0.
  • You also learned how to configure the application to use Auth0 as the Identity Provider and how to configure Auth0 for passkey support.
  • Roll your own passkey solution using WebAuthn4j and Spring Security.

Passkeys are the future of authentication. They are more secure and convenient than traditional passwords and OTPs. Though you could roll your own solution using WebAuthn4j, it is always better to use an IdP like Auth0 to handle the heavy lifting for you and take care of all the security best practices.

Resources

I hope that you found this article helpful. Here are some additional resources to learn more about WebAuthn and passkeys.


If you like this article, please leave a like or a comment.

You can follow me on Mastodon and LinkedIn.

💖 💪 🙅 🚩
deepu105
Deepu K Sasidharan

Posted on January 2, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related