1
0

Add passkeys project: WebAuthn ceremonies, a software authenticator and the one-time-token fallback

Fourth Maven project in the repository. Registration and authentication run end to
end with no browser and no hardware key: VirtualAuthenticator emits real CBOR
attestation objects and real ES256 assertion signatures, and tools/PasskeyCeremony.java
drives the live HTTP endpoints with them.

Profiles cover userVerification REQUIRED, DIRECT attestation, a disallowed origin and
JDBC persistence. Eleven doc chapters and twelve captured transcripts under docs/passkeys
and docs/output/pk-*.txt, all regenerated by passkeys/scripts/run-all.sh.
This commit is contained in:
2026-08-25 22:54:40 +05:30
parent e9381dc5be
commit f6dd692177
59 changed files with 3567 additions and 4 deletions

84
passkeys/pom.xml Normal file
View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>passkeys-demo</artifactId>
<version>1.0.0</version>
<name>passkeys-demo</name>
<description>Passkeys and WebAuthn with Spring Security 7.1 on Spring Boot 4.1 - runnable companion for ankurm.com</description>
<!--
One application, port 8080, rpId "localhost".
The interesting part of this module is that it needs no browser. tools/PasskeyCeremony.java
drives the real HTTP endpoints (/webauthn/register/options, /webauthn/register,
/webauthn/authenticate/options, /login/webauthn) using a software authenticator that
produces genuine CBOR attestation objects and genuine ES256 assertion signatures -
see src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java and
docs/passkeys/04-virtual-authenticator.md.
spring-security-webauthn is NOT part of spring-boot-starter-security. As of Spring
Security 7.0 the WebAuthn classes live in their own artifact; in 6.4 and 6.5 they were
inside spring-security-web. See docs/passkeys/01-versions.md.
-->
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- The one dependency the passkey docs are easy to miss. Version managed by Boot. -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-webauthn</artifactId>
</dependency>
<!-- Only used by the "jdbc" profile, to show what JDBC persistence actually requires. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Ask for DIRECT attestation. Register with fmt "none" and an all-zero AAGUID anyway.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app attestationdirect > /dev/null
{
header "attestation: DIRECT requested, attestation: none accepted"
ceremony register-and-login
} 2>&1 | tee "$OUTPUT_DIR/pk-attestation.txt"
stop_app

9
passkeys/scripts/bootstrap.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# The chicken-and-egg problem: a passkey cannot be a user's first credential.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "Bootstrapping - registering a passkey requires an existing authenticated session"
ceremony bootstrap
} 2>&1 | tee "$OUTPUT_DIR/pk-bootstrap.txt"
stop_app

11
passkeys/scripts/ceremony.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# The whole thing, end to end: password login, passkey registration, logout, passkey login.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "Registration and authentication ceremonies, driven without a browser"
echo "Spring Security 7.1.1, Spring Boot 4.1.1, rpId localhost, default settings."
echo "The authenticator is src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java."
ceremony register-and-login
} 2>&1 | tee "$OUTPUT_DIR/pk-ceremony.txt"
stop_app

9
passkeys/scripts/counter.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Three assertions with an increasing signature counter, then a replay of a stale one.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "Signature counter: stored on every assertion, compared against on none"
ceremony clone-counter
} 2>&1 | tee "$OUTPUT_DIR/pk-counter.txt"
stop_app

9
passkeys/scripts/duplicate.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# excludeCredentials, and what happens when the client ignores it.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "Registering the same credential id twice"
ceremony duplicate
} 2>&1 | tee "$OUTPUT_DIR/pk-duplicate.txt"
stop_app

9
passkeys/scripts/filters.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Where the four WebAuthn filters and the two one-time-token filters sit in the chain.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "The security filter chain with webAuthn() and oneTimeTokenLogin() configured"
ceremony filters
} 2>&1 | tee "$OUTPUT_DIR/pk-filters.txt"
stop_app

13
passkeys/scripts/jdbc.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Credentials in a database, using the DDL that ships inside spring-security-web.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app jdbc > /dev/null
{
header "JDBC persistence - H2, with Spring Security's own schema"
echo "schema-locations point at classpath:org/springframework/security/user-entities-schema.sql"
echo "and user-credentials-schema.sql, which live in spring-security-web, not in"
echo "spring-security-webauthn. Nothing creates these tables for you."
echo
ceremony register-and-login
} 2>&1 | tee "$OUTPUT_DIR/pk-jdbc.txt"
stop_app

63
passkeys/scripts/lib.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Shared helpers. Sourced by every script in this directory.
#
# Two traps are baked in here because both have cost real time:
#
# * never `pkill -f spring-boot` - the pattern matches the shell that is running this
# script and kills it. Kill by main class instead, which is what stop_app does.
# * `mvn -o` cannot run the Boot plugin until one online build has cached it, so the first
# run of run.sh is deliberately not offline.
set -euo pipefail
MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUTPUT_DIR="$(cd "$MODULE_DIR/.." && pwd)/docs/output"
MAIN_CLASS="PasskeysDemoApplication"
BASE_URL="${BASE_URL:-http://localhost:8080}"
APP_LOG="${APP_LOG:-/tmp/passkeys-demo.log}"
mkdir -p "$OUTPUT_DIR"
stop_app() {
for pid in $(ps -eo pid,cmd | grep "[${MAIN_CLASS:0:1}]${MAIN_CLASS:1}" | awk '{print $1}'); do
kill -9 "$pid" 2>/dev/null || true
done
# ss -lptn sometimes reports the port with no PID, so a port-based kill silently does
# nothing and the stale process keeps serving. Wait for the port to actually close.
for _ in $(seq 1 20); do
curl -sf -o /dev/null "$BASE_URL/health" || return 0
sleep 0.5
done
}
start_app() {
local profiles="${1:-}"
stop_app
cd "$MODULE_DIR"
local args=(-B org.springframework.boot:spring-boot-maven-plugin:run)
[ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
setsid nohup mvn "${args[@]}" > "$APP_LOG" 2>&1 < /dev/null &
for _ in $(seq 1 90); do
curl -sf -o /dev/null "$BASE_URL/health" && { echo "started${profiles:+ with profiles: $profiles}"; return 0; }
sleep 2
done
echo "the application did not come up; see $APP_LOG" >&2
tail -40 "$APP_LOG" >&2
return 1
}
classpath() {
cd "$MODULE_DIR"
[ -f target/deps.txt ] || mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/deps.txt -DincludeScope=runtime
echo "target/classes:$(cat target/deps.txt)"
}
ceremony() {
cd "$MODULE_DIR"
java --class-path "$(classpath)" tools/PasskeyCeremony.java "$@"
}
header() {
echo "=============================================================================="
echo "$1"
echo "=============================================================================="
}

15
passkeys/scripts/origin.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# What a phishing attempt looks like from the relying party's side, in both ceremonies.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "Registration from a disallowed origin"
ceremony wrong-origin
echo
echo "--- what the server logged ---"
grep -A4 -m1 -E 'BadOriginException|InconsistentClientDataTypeException' "$APP_LOG" || tail -5 "$APP_LOG"
header "Assertion from a disallowed origin"
ceremony wrong-origin-login
} 2>&1 | tee "$OUTPUT_DIR/pk-origin.txt"
stop_app

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# The one-time token path: generate, redeem, then try to redeem again.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "One-time token login - the way in, and the way back after a lost device"
ceremony ott
} 2>&1 | tee "$OUTPUT_DIR/pk-ott.txt"
stop_app

13
passkeys/scripts/run-all.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Regenerates every docs/output/pk-*.txt file in this repository.
#
# Timings and instants differ between runs; nothing else should.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
cd "$MODULE_DIR"
mvn -B -q compile
for script in ceremony counter user-verification origin attestation duplicate bootstrap step-up ott-fallback jdbc filters test-run; do
echo ">>> scripts/$script.sh"
"./scripts/$script.sh" > /dev/null
done
stop_app
ls -la "$OUTPUT_DIR"/pk-*.txt

13
passkeys/scripts/run.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Start the demo with the given profiles.
#
# ./scripts/run.sh defaults: rpId localhost, UV preferred, in memory
# ./scripts/run.sh uvrequired user verification REQUIRED on both ceremonies
# ./scripts/run.sh attestationdirect ask for DIRECT attestation and watch nothing change
# ./scripts/run.sh badorigin relying party expects an origin the client won't send
# ./scripts/run.sh jdbc credentials in H2 using Spring Security's own DDL
# ./scripts/run.sh trace every WebAuthn log line the framework emits
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "${1:-}"
echo "log: $APP_LOG"
echo "browser: $BASE_URL/login (user/password, then $BASE_URL/webauthn/register)"

9
passkeys/scripts/step-up.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# The same user, three sessions, one endpoint that only one of them can reach.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "FactorGrantedAuthority - password, magic link and passkey are not interchangeable"
ceremony stepup
} 2>&1 | tee "$OUTPUT_DIR/pk-step-up.txt"
stop_app

5
passkeys/scripts/test-run.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# The contract tests. These need no running server.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
cd "$MODULE_DIR"
mvn -B test 2>&1 | sed -n '/T E S T S/,$p' | tee "$OUTPUT_DIR/pk-test-run.txt"

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# The same authenticator, with the UV flag clear, against both settings.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
{
header "userVerification PREFERRED - the default"
start_app "" > /dev/null
ceremony no-uv
stop_app
header "userVerification REQUIRED - the uvrequired profile"
start_app uvrequired > /dev/null
ceremony no-uv
stop_app
} 2>&1 | tee "$OUTPUT_DIR/pk-user-verification.txt"

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Passkeys and WebAuthn on Spring Security 7.1 / Spring Boot 4.1.
*
* <p>Runs on port 8080 with a relying party id of {@code localhost}, because {@code localhost}
* is the one origin browsers treat as a secure context without TLS. Every other host needs
* HTTPS before {@code navigator.credentials.create()} will even be offered.
*
* <p>Nothing here is auto-configured. Spring Boot 4.1 ships no WebAuthn auto-configuration and
* no {@code spring.security.webauthn.*} properties - see docs/passkeys/01-versions.md.
*/
@SpringBootApplication
public class PasskeysDemoApplication {
public static void main(String[] args) {
SpringApplication.run(PasskeysDemoApplication.class, args);
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
/**
* Two users, in memory, with passwords - which is the first thing worth noticing about a
* passkeys demo.
*
* <p>{@code WebAuthnConfigurer.configure} throws {@code IllegalStateException: Missing
* UserDetailsService Bean} without one, and
* {@code Webauthn4JRelyingPartyOperations.createPublicKeyCredentialCreationOptions} throws
* {@code IllegalArgumentException: Authentication must be authenticated} unless the caller is
* already logged in. A passkey cannot be the first credential a user has; something else has
* to carry them to the point where they can register one. See
* docs/passkeys/06-the-bootstrap-problem.md.
*/
@Configuration
public class AppUsers {
@Bean
UserDetailsService userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
UserDetails admin = User.withDefaultPasswordEncoder()
.username("admin")
.password("password")
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.security.web.webauthn.management.JdbcPublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.JdbcUserCredentialRepository;
import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.MapUserCredentialRepository;
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
/**
* Where credentials live.
*
* <p>{@code WebAuthnConfigurer} will happily create {@code MapUserCredentialRepository} and
* {@code MapPublicKeyCredentialUserEntityRepository} for you if no beans exist. Declaring them
* explicitly costs nothing and buys two things: the diagnostics endpoint can read them, and
* the in-memory default stops being invisible. It is in-memory - every passkey your users
* registered disappears on restart, and they have no password to fall back on.
*
* <p>The {@code jdbc} profile switches to the JDBC repositories. Their schema is not created
* for you; {@code schema-jdbc.sql} in this module is Spring Security's own DDL, loaded through
* {@code spring.sql.init}. See docs/passkeys/09-persistence.md.
*/
@Configuration
public class PersistenceConfig {
@Configuration
@Profile("!jdbc")
static class InMemory {
@Bean
PublicKeyCredentialUserEntityRepository userEntityRepository() {
return new MapPublicKeyCredentialUserEntityRepository();
}
@Bean
UserCredentialRepository userCredentialRepository() {
return new MapUserCredentialRepository();
}
}
@Configuration
@Profile("jdbc")
static class Jdbc {
@Bean
PublicKeyCredentialUserEntityRepository userEntityRepository(JdbcOperations jdbc) {
return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
}
@Bean
UserCredentialRepository userCredentialRepository(JdbcOperations jdbc) {
return new JdbcUserCredentialRepository(jdbc);
}
}
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.config;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.web.webauthn.api.AttestationConveyancePreference;
import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
import org.springframework.security.web.webauthn.api.ResidentKeyRequirement;
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations;
/**
* The relying party operations bean, built by hand so the defaults can be changed one at a
* time and the difference observed.
*
* <p>Exposing this bean at all is a decision with a consequence: from that point on the
* {@code rpId}, {@code rpName} and {@code allowedOrigins} in {@link SecurityConfig} are dead
* configuration. {@code WebAuthnConfigurer.webAuthnRelyingPartyOperations} looks for a bean
* of this type first and, when it finds one, never reads its own fields. That is why the
* values are repeated here rather than shared.
*
* <p>Profiles:
*
* <ul>
* <li><b>uvrequired</b> - raises user verification from the default {@code PREFERRED} to
* {@code REQUIRED}, on both ceremonies. The default accepts a credential created and asserted
* with the UV flag clear, which means "something was touched" rather than "someone was
* verified".</li>
* <li><b>attestationdirect</b> - asks for {@code DIRECT} attestation instead of the default
* {@code NONE}. Registration still succeeds against an authenticator that sends
* {@code fmt: "none"} and an all-zero AAGUID, because the default
* {@code WebAuthnManager.createNonStrictWebAuthnManager()} verifies no attestation at all.
* Asking is not checking.</li>
* <li><b>badorigin</b> - the same relying party, told to expect a different origin. This is
* what a phishing attempt looks like from the server's side.</li>
* </ul>
*
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/05-defaults.md">docs/passkeys/05-defaults.md</a>
*/
@Configuration
@Profile({ "uvrequired", "attestationdirect", "badorigin" })
public class RelyingPartyConfig {
@Bean
WebAuthnRelyingPartyOperations relyingPartyOperations(PublicKeyCredentialUserEntityRepository userEntities,
UserCredentialRepository userCredentials, @Value("${demo.rp-id:localhost}") String rpId,
@Value("${demo.allowed-origin:http://localhost:8080}") String allowedOrigin,
@Value("${demo.user-verification-required:false}") boolean userVerificationRequired,
@Value("${demo.attestation-direct:false}") boolean attestationDirect) {
PublicKeyCredentialRpEntity rp = PublicKeyCredentialRpEntity.builder()
.id(rpId)
.name("ankurm passkeys demo")
.build();
Webauthn4JRelyingPartyOperations operations = new Webauthn4JRelyingPartyOperations(userEntities,
userCredentials, rp, Set.of(allowedOrigin));
if (userVerificationRequired) {
// Both halves have to be set. registerCredential() reads userVerification off the
// creation options; authenticate() reads it off the request options. Setting only
// one leaves the other ceremony at PREFERRED, which verifies nothing.
operations.setCustomizeCreationOptions((options) -> options
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
.userVerification(UserVerificationRequirement.REQUIRED)
.residentKey(ResidentKeyRequirement.REQUIRED)
.build()));
operations.setCustomizeRequestOptions(
(options) -> options.userVerification(UserVerificationRequirement.REQUIRED));
}
if (attestationDirect) {
operations
.setCustomizeCreationOptions((options) -> options.attestation(AttestationConveyancePreference.DIRECT));
}
return operations;
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.config;
import com.ankurm.passkeys.ott.ConsoleOneTimeTokenHandler;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* The whole passkey configuration is the {@code webAuthn(..)} block. Everything else is
* scaffolding.
*
* <p>Three things about this file are worth more than they look:
*
* <ul>
* <li>{@code rpId} and {@code allowedOrigins} are two separate settings that must agree. The
* rpId is the domain the credential is scoped to; the origin is the exact scheme, host and
* port the browser reports. {@code localhost} and {@code http://localhost:8080} agree.
* {@code localhost} and {@code http://127.0.0.1:8080} do not, and the failure arrives as a
* flat 401 - see docs/passkeys/07-failure-modes.md.</li>
* <li>If a {@code WebAuthnRelyingPartyOperations} bean exists, the {@code rpId},
* {@code rpName} and {@code allowedOrigins} set here are silently ignored:
* {@code WebAuthnConfigurer.webAuthnRelyingPartyOperations} returns the bean and never reads
* its own fields. {@link RelyingPartyConfig} exposes such a bean under several profiles,
* which is precisely why the values are repeated there.</li>
* <li>{@code oneTimeTokenLogin} is not decoration. Registering a passkey requires an existing
* authenticated session, so a passwordless system still needs a way in and a way back after a
* lost device.</li>
* </ul>
*
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/02-minimum-configuration.md">docs/passkeys/02-minimum-configuration.md</a>
*/
@Configuration
public class SecurityConfig {
private final String rpId;
private final String allowedOrigin;
public SecurityConfig(@Value("${demo.rp-id:localhost}") String rpId,
@Value("${demo.allowed-origin:http://localhost:8080}") String allowedOrigin) {
this.rpId = rpId;
this.allowedOrigin = allowedOrigin;
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http, ConsoleOneTimeTokenHandler oneTimeTokenHandler)
throws Exception {
http.authorizeHttpRequests((requests) -> requests.requestMatchers("/", "/health")
.permitAll()
// Step-up. A session established by a magic link carries FACTOR_OTT; one
// established by a passkey carries FACTOR_WEBAUTHN. Both are ordinary
// authorities, so requiring a real passkey for a sensitive endpoint is one
// matcher - see docs/passkeys/06-the-bootstrap-problem.md.
.requestMatchers("/passkey-only")
.hasAuthority("FACTOR_WEBAUTHN")
.anyRequest()
.authenticated())
.formLogin(Customizer.withDefaults())
// The magic-link fallback. Without a OneTimeTokenGenerationSuccessHandler the
// context fails to start - Spring Security refuses to guess how to deliver a
// token. See docs/passkeys/08-one-time-token-fallback.md.
.oneTimeTokenLogin((ott) -> ott.tokenGenerationSuccessHandler(oneTimeTokenHandler))
.webAuthn((webAuthn) -> webAuthn.rpId(this.rpId)
.rpName("ankurm passkeys demo")
.allowedOrigins(this.allowedOrigin))
.logout(Customizer.withDefaults());
return http.build();
}
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.diag;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import jakarta.servlet.Filter;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
import org.springframework.security.web.webauthn.api.CredentialRecord;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Prints the state that is otherwise invisible: what a stored {@code CredentialRecord}
* actually contains, and where the WebAuthn filters sit in the chain.
*
* <p>{@code /diag/credentials} is the exhibit for the signature counter. The
* {@code signatureCount} it reports is the value the relying party persisted after the last
* assertion - which is not the value it compares the next assertion against. See
* docs/passkeys/10-signature-counter.md.
*
* <p>Delete this class before shipping. It reports credential ids and user handles to any
* authenticated caller.
*/
@RestController
public class CredentialDiagnostics {
private final PublicKeyCredentialUserEntityRepository userEntities;
private final UserCredentialRepository userCredentials;
private final FilterChainProxy filterChainProxy;
public CredentialDiagnostics(PublicKeyCredentialUserEntityRepository userEntities,
UserCredentialRepository userCredentials, FilterChainProxy filterChainProxy) {
this.userEntities = userEntities;
this.userCredentials = userCredentials;
this.filterChainProxy = filterChainProxy;
}
@GetMapping("/diag/credentials")
public Map<String, Object> credentials(Authentication authentication) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("principal", authentication.getName());
result.put("principalType", authentication.getClass().getSimpleName());
result.put("authorities", authentication.getAuthorities().stream().map(Object::toString).sorted().toList());
PublicKeyCredentialUserEntity userEntity = this.userEntities.findByUsername(authentication.getName());
if (userEntity == null) {
result.put("userEntity", null);
result.put("credentials", List.of());
return result;
}
result.put("userEntity", Map.of("id", userEntity.getId().toBase64UrlString(), "name", userEntity.getName(),
"displayName", userEntity.getDisplayName()));
List<Map<String, Object>> records = new ArrayList<>();
for (CredentialRecord record : this.userCredentials.findByUserId(userEntity.getId())) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("label", record.getLabel());
entry.put("credentialId", record.getCredentialId().toBase64UrlString());
entry.put("signatureCount", record.getSignatureCount());
entry.put("uvInitialized", record.isUvInitialized());
entry.put("backupEligible", record.isBackupEligible());
entry.put("backupState", record.isBackupState());
entry.put("transports", record.getTransports().stream().map(AuthenticatorTransport::getValue).sorted().toList());
entry.put("attestationObjectBytes",
(record.getAttestationObject() != null) ? record.getAttestationObject().getBytes().length : null);
entry.put("created", String.valueOf(record.getCreated()));
entry.put("lastUsed", String.valueOf(record.getLastUsed()));
records.add(entry);
}
result.put("credentials", records);
return result;
}
@GetMapping("/diag/filters")
public Map<String, Object> filters() {
Map<String, Object> result = new LinkedHashMap<>();
List<Map<String, Object>> chains = new ArrayList<>();
for (SecurityFilterChain chain : this.filterChainProxy.getFilterChains()) {
List<String> names = new ArrayList<>();
int position = 0;
for (Filter filter : chain.getFilters()) {
names.add("%2d %s".formatted(++position, filter.getClass().getSimpleName()));
}
chains.add(Map.of("matcher", String.valueOf(chain), "filters", names));
}
result.put("chains", chains);
return result;
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.ott;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.ott.OneTimeToken;
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Delivers the one-time token by printing it, and by writing it to a file the demo scripts
* read.
*
* <p>In production this is where you send an email or an SMS. Spring Security deliberately has
* no default: {@code OneTimeTokenLoginConfigurer} throws at startup rather than guess a
* delivery channel, which is the correct decision and also the most common first error - see
* docs/passkeys/08-one-time-token-fallback.md.
*
* <p>The important behaviour here is what happens for a username that does not exist. The
* token is still generated, the file is still written, and the browser still lands on
* {@code /login/ott}. {@code InMemoryOneTimeTokenService} has no {@code UserDetailsService}
* and cannot tell; the failure surfaces later, in
* {@code OneTimeTokenAuthenticationProvider}, when the token is redeemed. The account
* enumeration oracle that a "no such user" response would create is closed by accident rather
* than by design, but it is closed.
*
* <p>{@code TOKEN_FILE} is a demo affordance. Deleting it is the first thing you should do
* with this class.
*/
@Component
public class ConsoleOneTimeTokenHandler implements OneTimeTokenGenerationSuccessHandler {
/** Where {@code scripts/ott-fallback.sh} picks the token up. */
public static final Path TOKEN_FILE = Path.of(System.getProperty("java.io.tmpdir"), "passkeys-demo-ott.txt");
private final OneTimeTokenGenerationSuccessHandler redirect = new RedirectOneTimeTokenGenerationSuccessHandler(
"/login/ott");
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken)
throws IOException, ServletException {
String link = UriComponentsBuilder.fromUriString(request.getRequestURL().toString())
.replacePath(request.getContextPath())
.replaceQuery(null)
.fragment(null)
.path("/login/ott")
.queryParam("token", oneTimeToken.getTokenValue())
.toUriString();
System.out.printf("%n[one-time-token] username=%s expires=%s%n[one-time-token] %s%n%n",
oneTimeToken.getUsername(), oneTimeToken.getExpiresAt(), link);
Files.writeString(TOKEN_FILE, oneTimeToken.getTokenValue(), StandardCharsets.UTF_8);
this.redirect.handle(request, response, oneTimeToken);
}
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.virtual;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
/**
* The smallest CBOR encoder that can produce a WebAuthn attestation object.
*
* <p>An authenticator emits CBOR for exactly two things: the COSE public key inside attested
* credential data, and the attestation object that wraps it. Both are fixed-shape maps of a
* handful of entries, so the code below is enough - there is no reason to pull a CBOR library
* onto the authenticator side, and writing the bytes by hand keeps the structure visible
* instead of hiding it behind a serializer.
*
* <p>Only the four major types WebAuthn needs are implemented: unsigned integers (major 0),
* negative integers (major 1), byte strings (major 2), text strings (major 3) and maps
* (major 5). Everything is written in the shortest form, which is what canonical CBOR
* requires anyway.
*
* @see <a href="https://www.rfc-editor.org/rfc/rfc8949">RFC 8949 - Concise Binary Object
* Representation</a>
* @see VirtualAuthenticator
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/04-virtual-authenticator.md">docs/passkeys/04-virtual-authenticator.md</a>
*/
public final class Cbor {
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
/** Writes the initial byte plus whatever length bytes the argument needs. */
private Cbor head(int major, long value) {
int m = major << 5;
if (value < 24) {
this.out.write(m | (int) value);
}
else if (value < 256) {
this.out.write(m | 24);
this.out.write((int) value);
}
else if (value < 65536) {
this.out.write(m | 25);
this.out.write((int) (value >> 8) & 0xFF);
this.out.write((int) value & 0xFF);
}
else {
this.out.write(m | 26);
this.out.write((int) (value >> 24) & 0xFF);
this.out.write((int) (value >> 16) & 0xFF);
this.out.write((int) (value >> 8) & 0xFF);
this.out.write((int) value & 0xFF);
}
return this;
}
/** Starts a definite-length map with {@code entries} key/value pairs. */
public Cbor map(int entries) {
return head(5, entries);
}
/** An integer key or value. Negative values use CBOR major type 1. */
public Cbor num(long value) {
return (value >= 0) ? head(0, value) : head(1, -1 - value);
}
/** A text string - the attestation object's keys are text, the COSE key's are integers. */
public Cbor text(String value) {
byte[] utf8 = value.getBytes(StandardCharsets.UTF_8);
head(3, utf8.length);
this.out.writeBytes(utf8);
return this;
}
/** A byte string - authenticator data, and the COSE key coordinates. */
public Cbor bytes(byte[] value) {
head(2, value.length);
this.out.writeBytes(value);
return this;
}
public byte[] toByteArray() {
return this.out.toByteArray();
}
}

View File

@@ -0,0 +1,291 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.virtual;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.Signature;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.util.Base64;
/**
* A software authenticator: everything a security key or a phone does, in about two hundred
* lines, with no browser and no hardware.
*
* <p>This exists so the ceremonies in this module can be executed rather than described.
* {@link #makeCredential} produces a real CBOR attestation object with a real COSE P-256
* public key inside it, and {@link #getAssertion} produces a real ECDSA-SHA256 signature over
* the concatenation the specification requires. Spring Security's
* {@code Webauthn4JRelyingPartyOperations} - and WebAuthn4J underneath it - verifies both
* without knowing or caring that no hardware was involved.
*
* <p>What it deliberately does <em>not</em> do is any of the things that make a real
* authenticator a security boundary: there is no user presence test, no user verification, no
* secure element, and the private key sits in the heap. It sets the UP, UV, BE and BS flags
* because it is asked to, not because anything happened. That is exactly why it is useful for
* showing which of those flags the relying party actually checks.
*
* <p>The signature counter is a field you control, which is the point of
* {@code scripts/clone-counter.sh}.
*
* @see <a href="https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data">WebAuthn Level 3,
* section 6.1 - Authenticator Data</a>
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/04-virtual-authenticator.md">docs/passkeys/04-virtual-authenticator.md</a>
*/
public final class VirtualAuthenticator {
/** User Present. Set when the authenticator believes a human touched it. */
public static final int FLAG_UP = 0x01;
/** User Verified. Set when a PIN or biometric was checked, not merely a touch. */
public static final int FLAG_UV = 0x04;
/** Backup Eligible. Set by syncable passkeys - a phone or a password manager. */
public static final int FLAG_BE = 0x08;
/** Backup State. Set when the credential is currently synced to a backup. */
public static final int FLAG_BS = 0x10;
/** Attested Credential Data included. Set during registration, never during assertion. */
public static final int FLAG_AT = 0x40;
private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder B64URL_DEC = Base64.getUrlDecoder();
private final KeyPair keyPair;
private final byte[] credentialId;
private final byte[] aaguid;
private int flags = FLAG_UP | FLAG_UV | FLAG_BE | FLAG_BS;
private long signCount;
public VirtualAuthenticator() {
this(randomBytes(16), new byte[16], 0);
}
/**
* @param credentialId the credential id this authenticator will hand out
* @param aaguid the authenticator model identifier; all-zero means "not disclosed", which
* is what platform authenticators send when attestation is {@code none}
* @param initialSignCount the starting value of the signature counter. Real platform
* authenticators (Touch ID, Windows Hello, iCloud Keychain) leave this at zero forever
*/
public VirtualAuthenticator(byte[] credentialId, byte[] aaguid, long initialSignCount) {
this.credentialId = credentialId;
this.aaguid = aaguid;
this.signCount = initialSignCount;
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
this.keyPair = generator.generateKeyPair();
}
catch (Exception ex) {
throw new IllegalStateException("cannot generate a P-256 key pair", ex);
}
}
/** Overrides the flag byte, so a caller can withhold UV or BE and see what breaks. */
public VirtualAuthenticator flags(int flags) {
this.flags = flags;
return this;
}
/** Sets the signature counter used by the next assertion. */
public VirtualAuthenticator signCount(long signCount) {
this.signCount = signCount;
return this;
}
public long signCount() {
return this.signCount;
}
public String credentialIdBase64Url() {
return B64URL.encodeToString(this.credentialId);
}
/**
* The registration half of the ceremony: {@code navigator.credentials.create()}.
* @param rpId the relying party id, hashed into authenticator data
* @param origin the origin that will appear in client data - the relying party compares
* it against its allowed origins, and a mismatch is the whole phishing defence
* @param challengeBase64Url the challenge from
* {@code POST /webauthn/register/options}, still base64url encoded
* @return the attestation object and client data, base64url encoded, ready to post
*/
public Registration makeCredential(String rpId, String origin, String challengeBase64Url) {
byte[] clientDataJson = clientData("webauthn.create", origin, challengeBase64Url);
byte[] authData = authenticatorData(rpId, this.flags | FLAG_AT, this.signCount, attestedCredentialData());
// "none" attestation: an empty statement. Platform authenticators send this, and it
// is what Spring Security asks for by default (AttestationConveyancePreference.NONE).
byte[] attestationObject = new Cbor().map(3)
.text("fmt")
.text("none")
.text("attStmt")
.map(0)
.text("authData")
.bytes(authData)
.toByteArray();
return new Registration(B64URL.encodeToString(this.credentialId), B64URL.encodeToString(attestationObject),
B64URL.encodeToString(clientDataJson));
}
/**
* The authentication half of the ceremony: {@code navigator.credentials.get()}.
*
* <p>The counter is incremented before signing, which is what a hardware key does. Pass an
* explicit value to {@link #signCount(long)} first to replay an old one.
* @param rpId the relying party id
* @param origin the origin placed in client data
* @param challengeBase64Url the challenge from {@code POST /webauthn/authenticate/options}
* @param userHandleBase64Url the user handle to return, or null to omit it
* @return authenticator data, client data and the assertion signature, base64url encoded
*/
public Assertion getAssertion(String rpId, String origin, String challengeBase64Url, String userHandleBase64Url) {
byte[] clientDataJson = clientData("webauthn.get", origin, challengeBase64Url);
// No FLAG_AT: attested credential data is registration-only.
byte[] authData = authenticatorData(rpId, this.flags, this.signCount, new byte[0]);
byte[] clientDataHash = sha256(clientDataJson);
byte[] signed = concat(authData, clientDataHash);
byte[] signature;
try {
Signature ecdsa = Signature.getInstance("SHA256withECDSA");
ecdsa.initSign(this.keyPair.getPrivate());
ecdsa.update(signed);
signature = ecdsa.sign();
}
catch (Exception ex) {
throw new IllegalStateException("cannot sign the assertion", ex);
}
return new Assertion(B64URL.encodeToString(this.credentialId), B64URL.encodeToString(authData),
B64URL.encodeToString(clientDataJson), B64URL.encodeToString(signature), userHandleBase64Url);
}
/** Increments the counter the way a hardware key does, and returns the new value. */
public long tick() {
return ++this.signCount;
}
/**
* {@code rpIdHash || flags || signCount || attestedCredentialData}. Fixed layout, big
* endian counter, no padding: this is the buffer the signature is computed over.
*/
private byte[] authenticatorData(String rpId, int flagByte, long counter, byte[] attested) {
byte[] rpIdHash = sha256(rpId.getBytes(StandardCharsets.UTF_8));
ByteBuffer buffer = ByteBuffer.allocate(32 + 1 + 4 + attested.length);
buffer.put(rpIdHash);
buffer.put((byte) flagByte);
buffer.putInt((int) counter);
buffer.put(attested);
return buffer.array();
}
/** {@code aaguid || credentialIdLength || credentialId || COSE public key}. */
private byte[] attestedCredentialData() {
byte[] cose = cosePublicKey();
ByteBuffer buffer = ByteBuffer.allocate(16 + 2 + this.credentialId.length + cose.length);
buffer.put(this.aaguid);
buffer.putShort((short) this.credentialId.length);
buffer.put(this.credentialId);
buffer.put(cose);
return buffer.array();
}
/**
* A COSE_Key for ES256: kty=EC2(2), alg=-7, crv=P-256(1), and the two 32-byte affine
* coordinates. The coordinates must be left-padded to exactly 32 bytes - a BigInteger
* whose top byte happens to be zero will otherwise serialise short and the relying party
* will reject the key.
*/
private byte[] cosePublicKey() {
ECPublicKey publicKey = (ECPublicKey) this.keyPair.getPublic();
byte[] x = fixedLength(publicKey.getW().getAffineX().toByteArray(), 32);
byte[] y = fixedLength(publicKey.getW().getAffineY().toByteArray(), 32);
return new Cbor().map(5)
.num(1)
.num(2) // kty: EC2
.num(3)
.num(-7) // alg: ES256
.num(-1)
.num(1) // crv: P-256
.num(-2)
.bytes(x)
.num(-3)
.bytes(y)
.toByteArray();
}
private static byte[] clientData(String type, String origin, String challengeBase64Url) {
String json = "{\"type\":\"" + type + "\",\"challenge\":\"" + challengeBase64Url + "\",\"origin\":\"" + origin
+ "\",\"crossOrigin\":false}";
return json.getBytes(StandardCharsets.UTF_8);
}
/**
* Strips a BigInteger's sign byte or left-pads a short magnitude, so the result is exactly
* {@code length} bytes.
*/
private static byte[] fixedLength(byte[] value, int length) {
if (value.length == length) {
return value;
}
byte[] result = new byte[length];
if (value.length > length) {
System.arraycopy(value, value.length - length, result, 0, length);
}
else {
System.arraycopy(value, 0, result, length - value.length, value.length);
}
return result;
}
private static byte[] sha256(byte[] input) {
try {
return MessageDigest.getInstance("SHA-256").digest(input);
}
catch (Exception ex) {
throw new IllegalStateException("SHA-256 is unavailable", ex);
}
}
private static byte[] concat(byte[] first, byte[] second) {
byte[] result = new byte[first.length + second.length];
System.arraycopy(first, 0, result, 0, first.length);
System.arraycopy(second, 0, result, first.length, second.length);
return result;
}
private static byte[] randomBytes(int length) {
byte[] bytes = new byte[length];
new java.security.SecureRandom().nextBytes(bytes);
return bytes;
}
public static byte[] decodeBase64Url(String value) {
return B64URL_DEC.decode(value);
}
public static String encodeBase64Url(byte[] value) {
return B64URL.encodeToString(value);
}
/** What {@code navigator.credentials.create()} hands back, already base64url encoded. */
public record Registration(String credentialId, String attestationObject, String clientDataJson) {
}
/** What {@code navigator.credentials.get()} hands back, already base64url encoded. */
public record Assertion(String credentialId, String authenticatorData, String clientDataJson, String signature,
String userHandle) {
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.web;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** Two endpoints: one anyone can reach, one that proves a login happened. */
@RestController
public class ApiControllers {
@GetMapping("/health")
public Map<String, String> health() {
return Map.of("status", "UP");
}
/**
* Reports which factors the current session actually carries. A passkey login adds
* {@code FACTOR_WEBAUTHN}; a one-time token login adds {@code FACTOR_OTT}; a password
* login adds {@code FACTOR_PASSWORD}. That is how Spring Security 7 expresses
* multi-factor state, and it is why the same endpoint can tell the three apart.
*/
/**
* Reachable only by a session that authenticated with a passkey. A one-time-token session
* is authenticated and still gets 403, which is the whole point of step-up.
*/
@GetMapping("/passkey-only")
public Map<String, String> passkeyOnly() {
return Map.of("ok", "this endpoint required FACTOR_WEBAUTHN");
}
@GetMapping("/me")
public Map<String, Object> me(Authentication authentication) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("name", authentication.getName());
result.put("authenticationType", authentication.getClass().getSimpleName());
result.put("authorities", authentication.getAuthorities().stream().map(Object::toString).sorted().toList());
return result;
}
}

View File

@@ -0,0 +1,7 @@
# Ask the authenticator for DIRECT attestation instead of the default NONE.
#
# Registration still succeeds against an authenticator that answers with fmt "none" and an
# all-zero AAGUID, because the default WebAuthnManager verifies no attestation statement at
# all. Asking is not checking.
demo:
attestation-direct: true

View File

@@ -0,0 +1,4 @@
# The relying party expects an origin the client will not send. This is the server side of a
# phishing attempt: same rpId, wrong origin, ceremony refused.
demo:
allowed-origin: https://passkeys.example.com

View File

@@ -0,0 +1,18 @@
# JDBC persistence for credentials and user entities.
#
# The two DDL files below are Spring Security's own. They ship inside spring-security-web,
# not spring-security-webauthn - the 7.0 artifact split moved the classes and left the SQL
# and the JavaScript behind. Nothing creates these tables for you.
spring:
autoconfigure:
exclude: []
datasource:
url: jdbc:h2:mem:passkeys;DB_CLOSE_DELAY=-1
username: sa
password:
sql:
init:
mode: always
schema-locations:
- classpath:org/springframework/security/user-entities-schema.sql
- classpath:org/springframework/security/user-credentials-schema.sql

View File

@@ -0,0 +1,10 @@
# Everything the WebAuthn and one-time-token machinery has to say.
#
# WebAuthnAuthenticationProvider collapses every failure into BadCredentialsException, so the
# HTTP response is a bare 401 with no body. The reason is only ever visible in the log, and
# only at DEBUG. Start here when a ceremony fails.
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.web.webauthn: TRACE
com.webauthn4j: DEBUG

View File

@@ -0,0 +1,3 @@
# User verification promoted from PREFERRED (the default) to REQUIRED, on both ceremonies.
demo:
user-verification-required: true

View File

@@ -0,0 +1,24 @@
# Defaults: rpId "localhost", origin http://localhost:8080, in-memory credentials.
#
# localhost is the only host a browser treats as a secure context without TLS, which is why
# every WebAuthn tutorial uses it and why every WebAuthn deployment then breaks on the first
# real hostname. See docs/passkeys/07-failure-modes.md.
server:
port: 8080
demo:
rp-id: localhost
allowed-origin: http://localhost:8080
spring:
application:
name: passkeys-demo
# No datasource is needed unless the jdbc profile is active; the H2 auto-configuration is
# switched off here so the default profile starts without one.
autoconfigure:
exclude:
- org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
logging:
level:
org.springframework.security.web.webauthn: INFO

View File

@@ -0,0 +1,246 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys;
import java.util.Set;
import com.ankurm.passkeys.virtual.VirtualAuthenticator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.web.webauthn.api.AuthenticatorAssertionResponse;
import org.springframework.security.web.webauthn.api.AuthenticatorAttestationResponse;
import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
import org.springframework.security.web.webauthn.api.Bytes;
import org.springframework.security.web.webauthn.api.CredentialRecord;
import org.springframework.security.web.webauthn.api.PublicKeyCredential;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialType;
import org.springframework.security.web.webauthn.api.ResidentKeyRequirement;
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
import org.springframework.security.web.webauthn.management.ImmutablePublicKeyCredentialCreationOptionsRequest;
import org.springframework.security.web.webauthn.management.ImmutablePublicKeyCredentialRequestOptionsRequest;
import org.springframework.security.web.webauthn.management.ImmutableRelyingPartyRegistrationRequest;
import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.MapUserCredentialRepository;
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.RelyingPartyAuthenticationRequest;
import org.springframework.security.web.webauthn.management.RelyingPartyPublicKey;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Contract tests for the relying party operations, driven by a software authenticator.
*
* <p>These pin behaviour rather than the happy path. Several of them assert that something
* <em>is not</em> checked, which is the only honest way to record a default: if a later Spring
* Security release starts enforcing it, the test fails and the article needs updating.
*
* <p>No HTTP, no browser and no Spring context - the ceremony is exercised straight through
* {@code Webauthn4JRelyingPartyOperations}, so a failure here is the framework's behaviour and
* not a filter-ordering accident.
*
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/05-defaults.md">docs/passkeys/05-defaults.md</a>
*/
class PasskeyContractTests {
private static final String RP_ID = "localhost";
private static final String ORIGIN = "http://localhost:8080";
private PublicKeyCredentialUserEntityRepository userEntities;
private UserCredentialRepository userCredentials;
private Webauthn4JRelyingPartyOperations operations;
private final Authentication authentication = UsernamePasswordAuthenticationToken.authenticated("user", null,
AuthorityUtils.createAuthorityList("ROLE_USER"));
@BeforeEach
void setUp() {
this.userEntities = new MapPublicKeyCredentialUserEntityRepository();
this.userCredentials = new MapUserCredentialRepository();
this.operations = newOperations();
}
private Webauthn4JRelyingPartyOperations newOperations() {
return new Webauthn4JRelyingPartyOperations(this.userEntities, this.userCredentials,
PublicKeyCredentialRpEntity.builder().id(RP_ID).name("test").build(), Set.of(ORIGIN));
}
@Test
@DisplayName("a software authenticator completes both ceremonies")
void bothCeremoniesSucceed() {
VirtualAuthenticator authenticator = new VirtualAuthenticator();
CredentialRecord record = register(authenticator, ORIGIN);
assertThat(record.getCredentialId().toBase64UrlString()).isEqualTo(authenticator.credentialIdBase64Url());
assertThat(record.getSignatureCount()).isZero();
assertThat(record.isUvInitialized()).isTrue();
authenticator.signCount(1);
assertThat(authenticate(authenticator, ORIGIN).getName()).isEqualTo("user");
}
@Test
@DisplayName("the signature counter is persisted on every assertion and compared against on none")
void signatureCounterIsStoredButNotVerified() {
VirtualAuthenticator authenticator = new VirtualAuthenticator();
CredentialRecord record = register(authenticator, ORIGIN);
Bytes credentialId = record.getCredentialId();
authenticator.signCount(1);
authenticate(authenticator, ORIGIN);
assertThat(this.userCredentials.findByCredentialId(credentialId).getSignatureCount()).isEqualTo(1);
authenticator.signCount(2);
authenticate(authenticator, ORIGIN);
assertThat(this.userCredentials.findByCredentialId(credentialId).getSignatureCount()).isEqualTo(2);
// A cloned authenticator would present a counter it had already used. The relying
// party has stored 2. It accepts 1 anyway, because authenticate() rebuilds the
// WebAuthn4J credential record from the stored attestation object, whose counter is
// frozen at its registration value of 0 - the persisted signatureCount above is never
// read back. If this assertion ever starts failing, clone detection has been fixed.
authenticator.signCount(1);
assertThatCode(() -> authenticate(authenticator, ORIGIN)).doesNotThrowAnyException();
assertThat(this.userCredentials.findByCredentialId(credentialId).getSignatureCount()).isEqualTo(1);
}
@Test
@DisplayName("user verification is not required by default, and the UV flag is recorded either way")
void userVerificationIsPreferredNotRequired() {
VirtualAuthenticator authenticator = new VirtualAuthenticator()
.flags(VirtualAuthenticator.FLAG_UP | VirtualAuthenticator.FLAG_BE | VirtualAuthenticator.FLAG_BS);
CredentialRecord record = register(authenticator, ORIGIN);
assertThat(record.isUvInitialized()).isFalse();
authenticator.signCount(1);
assertThatCode(() -> authenticate(authenticator, ORIGIN)).doesNotThrowAnyException();
}
@Test
@DisplayName("userVerification REQUIRED refuses a registration whose UV flag is clear")
void userVerificationRequiredIsEnforcedAtRegistration() {
this.operations = newOperations();
this.operations.setCustomizeCreationOptions((options) -> options
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
.userVerification(UserVerificationRequirement.REQUIRED)
.residentKey(ResidentKeyRequirement.REQUIRED)
.build()));
VirtualAuthenticator authenticator = new VirtualAuthenticator()
.flags(VirtualAuthenticator.FLAG_UP | VirtualAuthenticator.FLAG_BE | VirtualAuthenticator.FLAG_BS);
assertThatThrownBy(() -> register(authenticator, ORIGIN))
.isInstanceOf(com.webauthn4j.verifier.exception.UserNotVerifiedException.class)
.hasMessageContaining("UV flag in authenticatorData is not set");
}
@Test
@DisplayName("an origin the relying party did not allow is rejected in both ceremonies")
void originIsChecked() {
VirtualAuthenticator authenticator = new VirtualAuthenticator();
assertThatThrownBy(() -> register(authenticator, "http://evil.localhost:8080"))
.isInstanceOf(com.webauthn4j.verifier.exception.BadOriginException.class);
register(authenticator, ORIGIN);
authenticator.signCount(1);
assertThatThrownBy(() -> authenticate(authenticator, "http://evil.localhost:8080"))
.isInstanceOf(com.webauthn4j.verifier.exception.BadOriginException.class);
}
@Test
@DisplayName("asking for DIRECT attestation does not make anything verify attestation")
void directAttestationIsRequestedNotVerified() {
this.operations = newOperations();
this.operations.setCustomizeCreationOptions((options) -> options
.attestation(org.springframework.security.web.webauthn.api.AttestationConveyancePreference.DIRECT));
PublicKeyCredentialCreationOptions creationOptions = this.operations
.createPublicKeyCredentialCreationOptions(new ImmutablePublicKeyCredentialCreationOptionsRequest(
this.authentication));
assertThat(creationOptions.getAttestation().getValue()).isEqualTo("direct");
// The authenticator answers "none" with an all-zero AAGUID. It is registered anyway.
VirtualAuthenticator authenticator = new VirtualAuthenticator();
assertThatCode(() -> registerWith(creationOptions, authenticator, ORIGIN)).doesNotThrowAnyException();
}
@Test
@DisplayName("the same credential id cannot be registered twice")
void duplicateCredentialIdIsRejected() {
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, ORIGIN);
assertThatThrownBy(() -> register(authenticator, ORIGIN)).hasMessageContaining("already exists");
}
// ------------------------------------------------------------------ ceremony helpers
private CredentialRecord register(VirtualAuthenticator authenticator, String origin) {
PublicKeyCredentialCreationOptions creationOptions = this.operations
.createPublicKeyCredentialCreationOptions(new ImmutablePublicKeyCredentialCreationOptionsRequest(
this.authentication));
return registerWith(creationOptions, authenticator, origin);
}
private CredentialRecord registerWith(PublicKeyCredentialCreationOptions creationOptions,
VirtualAuthenticator authenticator, String origin) {
VirtualAuthenticator.Registration created = authenticator.makeCredential(RP_ID, origin,
creationOptions.getChallenge().toBase64UrlString());
AuthenticatorAttestationResponse response = AuthenticatorAttestationResponse.builder()
.attestationObject(Bytes.fromBase64(created.attestationObject()))
.clientDataJSON(Bytes.fromBase64(created.clientDataJson()))
.transports(AuthenticatorTransport.INTERNAL, AuthenticatorTransport.HYBRID)
.build();
PublicKeyCredential<AuthenticatorAttestationResponse> credential = PublicKeyCredential
.<AuthenticatorAttestationResponse>builder()
.id(created.credentialId())
.rawId(Bytes.fromBase64(created.credentialId()))
.type(PublicKeyCredentialType.PUBLIC_KEY)
.response(response)
.build();
return this.operations.registerCredential(new ImmutableRelyingPartyRegistrationRequest(creationOptions,
new RelyingPartyPublicKey(credential, "test-credential")));
}
private org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity authenticate(
VirtualAuthenticator authenticator, String origin) {
PublicKeyCredentialRequestOptions requestOptions = this.operations
.createCredentialRequestOptions(new ImmutablePublicKeyCredentialRequestOptionsRequest(null));
VirtualAuthenticator.Assertion assertion = authenticator.getAssertion(RP_ID, origin,
requestOptions.getChallenge().toBase64UrlString(), null);
AuthenticatorAssertionResponse response = AuthenticatorAssertionResponse.builder()
.authenticatorData(Bytes.fromBase64(assertion.authenticatorData()))
.clientDataJSON(Bytes.fromBase64(assertion.clientDataJson()))
.signature(Bytes.fromBase64(assertion.signature()))
.build();
PublicKeyCredential<AuthenticatorAssertionResponse> credential = PublicKeyCredential
.<AuthenticatorAssertionResponse>builder()
.id(assertion.credentialId())
.rawId(Bytes.fromBase64(assertion.credentialId()))
.type(PublicKeyCredentialType.PUBLIC_KEY)
.response(response)
.build();
return this.operations.authenticate(new RelyingPartyAuthenticationRequest(requestOptions, credential));
}
}

View File

@@ -0,0 +1,406 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.HttpCookie;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.ankurm.passkeys.virtual.VirtualAuthenticator;
/**
* Drives both WebAuthn ceremonies against a running instance, with no browser.
*
* <pre>
* java --class-path target/classes:target/deps/* tools/PasskeyCeremony.java [scenario]
* </pre>
*
* Scenarios:
* <ul>
* <li>{@code register-and-login} (default) - password login, register a passkey, log out, log
* back in with the passkey</li>
* <li>{@code clone-counter} - register, authenticate with an increasing counter, then replay a
* stale counter and see whether the relying party notices</li>
* <li>{@code no-uv} - register and authenticate with the UV flag clear</li>
* <li>{@code wrong-origin} - register with client data from an origin the relying party did
* not allow</li>
* <li>{@code wrong-origin-login} - assert from a disallowed origin, which fails differently
* from registering from one</li>
* <li>{@code duplicate} - register the same credential id twice</li>
* <li>{@code ott} - request a one-time token and redeem it, the fallback path</li>
* <li>{@code filters} - print the live security filter chain</li>
* <li>{@code bootstrap} - what happens when nobody is logged in yet</li>
* <li>{@code stepup} - an endpoint that only a passkey session can reach</li>
* </ul>
*
* Everything it prints is the real request and the real response. Nothing is transcribed.
*/
public final class PasskeyCeremony {
private static final String BASE = System.getProperty("demo.base", "http://localhost:8080");
private static final String ORIGIN = System.getProperty("demo.origin", BASE);
private static final String RP_ID = System.getProperty("demo.rpId", "localhost");
private static final Pattern CSRF = Pattern
.compile("name=\"_csrf\"[^>]*value=\"([^\"]+)\"|value=\"([^\"]+)\"[^>]*name=\"_csrf\"");
private final HttpClient http;
private final CookieManager cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
private PasskeyCeremony() {
CookieHandler.setDefault(this.cookies);
this.http = HttpClient.newBuilder()
.cookieHandler(this.cookies)
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(5))
.build();
}
public static void main(String[] args) throws Exception {
String scenario = (args.length > 0) ? args[0] : "register-and-login";
PasskeyCeremony ceremony = new PasskeyCeremony();
switch (scenario) {
case "register-and-login" -> ceremony.registerAndLogin();
case "clone-counter" -> ceremony.cloneCounter();
case "no-uv" -> ceremony.noUserVerification();
case "wrong-origin" -> ceremony.wrongOrigin();
case "wrong-origin-login" -> ceremony.wrongOriginLogin();
case "duplicate" -> ceremony.duplicateRegistration();
case "ott" -> ceremony.oneTimeToken();
case "filters" -> ceremony.filters();
case "bootstrap" -> ceremony.bootstrap();
case "stepup" -> ceremony.stepUp();
default -> throw new IllegalArgumentException("unknown scenario: " + scenario);
}
}
// ---------------------------------------------------------------- scenarios
private void registerAndLogin() throws Exception {
banner("Registration ceremony, then authentication ceremony, no browser involved");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "yubikey-on-my-desk");
print("GET /diag/credentials", get("/diag/credentials"));
logout();
banner("Session dropped. Authenticating with the passkey alone");
authenticate(authenticator, null);
print("GET /me", get("/me"));
}
private void cloneCounter() throws Exception {
banner("Signature counter: does the relying party detect a cloned authenticator?");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "counter-demo");
logout();
for (int i = 1; i <= 3; i++) {
authenticator.signCount(i);
System.out.printf("%n--- assertion %d, authenticator signCount = %d%n", i, authenticator.signCount());
authenticate(authenticator, null);
System.out.println("stored signatureCount now: " + storedSignatureCount("user", "password"));
logout();
}
banner("Replaying a stale counter. A cloned key would look exactly like this");
authenticator.signCount(1);
System.out.println("--- assertion 4, authenticator signCount = 1 (lower than the stored 3)");
HttpResponse<String> replay = authenticate(authenticator, "replay");
System.out.printf("%nreplayed a counter of 1 after the relying party had stored 3: HTTP %d%n",
replay.statusCode());
}
private void noUserVerification() throws Exception {
banner("A credential created and asserted with the UV flag clear");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator()
.flags(VirtualAuthenticator.FLAG_UP | VirtualAuthenticator.FLAG_BE | VirtualAuthenticator.FLAG_BS);
HttpResponse<String> registration = register(authenticator, "no-uv");
if (registration.statusCode() != 200) {
System.out.println("registration refused, which is what userVerification REQUIRED does");
return;
}
print("GET /diag/credentials (note uvInitialized)", get("/diag/credentials"));
logout();
authenticator.signCount(1);
HttpResponse<String> assertion = authenticate(authenticator, "no-uv");
System.out.printf("%nauthentication with UV clear: HTTP %d%n", assertion.statusCode());
}
private void wrongOrigin() throws Exception {
banner("Client data from an origin the relying party did not allow");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
String optionsJson = post("/webauthn/register/options", "").body();
String challenge = jsonString(optionsJson, "challenge");
VirtualAuthenticator.Registration credential = authenticator.makeCredential(RP_ID,
"http://evil.localhost:8080", challenge);
String body = registrationBody(credential, "phished");
HttpResponse<String> response = post("/webauthn/register", body);
System.out.println("origin sent by the client: http://evil.localhost:8080");
System.out.println("origin allowed by the relying party: " + ORIGIN);
print("POST /webauthn/register", response);
}
private void wrongOriginLogin() throws Exception {
banner("An assertion from a disallowed origin - the same mistake, one ceremony later");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "phishable");
logout();
HttpResponse<String> options = post("/webauthn/authenticate/options", "");
String challenge = jsonString(options.body(), "challenge");
VirtualAuthenticator.Assertion assertion = authenticator.getAssertion(RP_ID, "http://evil.localhost:8080",
challenge, null);
String body = """
{"id":"%s","rawId":"%s","response":{"authenticatorData":"%s","clientDataJSON":"%s","signature":"%s"},\
"clientExtensionResults":{},"type":"public-key","authenticatorAttachment":"platform"}"""
.formatted(assertion.credentialId(), assertion.credentialId(), assertion.authenticatorData(),
assertion.clientDataJson(), assertion.signature());
print("POST /login/webauthn (origin http://evil.localhost:8080)", post("/login/webauthn", body));
}
private void duplicateRegistration() throws Exception {
banner("Registering the same credential id twice");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "first");
System.out.println("\nsame authenticator, same credential id, second registration:");
HttpResponse<String> options = post("/webauthn/register/options", "");
System.out.println("excludeCredentials now: " + jsonArray(options.body(), "excludeCredentials"));
String challenge = jsonString(options.body(), "challenge");
VirtualAuthenticator.Registration credential = authenticator.makeCredential(RP_ID, ORIGIN, challenge);
print("POST /webauthn/register", post("/webauthn/register", registrationBody(credential, "second")));
}
private void oneTimeToken() throws Exception {
banner("One-time token: the way in when there is no passkey yet, and the way back");
String csrf = csrfToken("/login");
HttpResponse<String> generated = form("/ott/generate", "username=user", csrf);
System.out.printf("POST /ott/generate -> HTTP %d, Location: %s%n", generated.statusCode(),
generated.headers().firstValue("location").orElse("-"));
String token = java.nio.file.Files
.readString(java.nio.file.Path.of(System.getProperty("java.io.tmpdir"), "passkeys-demo-ott.txt"))
.trim();
System.out.println("token delivered out of band (the handler wrote it to a file): " + token);
String submitCsrf = csrfToken("/login/ott?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8));
HttpResponse<String> redeemed = form("/login/ott",
"token=" + URLEncoder.encode(token, StandardCharsets.UTF_8), submitCsrf);
System.out.printf("POST /login/ott -> HTTP %d, Location: %s%n", redeemed.statusCode(),
redeemed.headers().firstValue("location").orElse("-"));
print("GET /me", get("/me"));
banner("The same token, a second time");
String replayCsrf = csrfToken("/login");
HttpResponse<String> replay = form("/login/ott", "token=" + URLEncoder.encode(token, StandardCharsets.UTF_8),
replayCsrf);
System.out.printf("POST /login/ott -> HTTP %d, Location: %s%n", replay.statusCode(),
replay.headers().firstValue("location").orElse("-"));
}
private void bootstrap() throws Exception {
banner("Asking for registration options with nobody logged in");
print("POST /webauthn/register/options (anonymous)", post("/webauthn/register/options", ""));
banner("A one-time token for a username that does not exist");
String csrf = csrfToken("/login");
HttpResponse<String> generated = form("/ott/generate", "username=nosuchuser", csrf);
System.out.printf("POST /ott/generate -> HTTP %d, Location: %s%n", generated.statusCode(),
generated.headers().firstValue("location").orElse("-"));
String token = java.nio.file.Files
.readString(java.nio.file.Path.of(System.getProperty("java.io.tmpdir"), "passkeys-demo-ott.txt"))
.trim();
System.out.println("a token was still generated and delivered: " + token);
System.out.println("the response is byte-for-byte what a real username produces - no enumeration oracle");
String submitCsrf = csrfToken("/login");
HttpResponse<String> redeemed = form("/login/ott",
"token=" + URLEncoder.encode(token, StandardCharsets.UTF_8), submitCsrf);
System.out.printf("POST /login/ott -> HTTP %d, Location: %s (the failure lands here instead)%n",
redeemed.statusCode(), redeemed.headers().firstValue("location").orElse("-"));
}
private void stepUp() throws Exception {
banner("An endpoint guarded by hasAuthority(\"FACTOR_WEBAUTHN\")");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "step-up-demo");
HttpResponse<String> passwordAttempt = get("/passkey-only");
System.out.printf("%npassword session -> GET /passkey-only: HTTP %d, Location: %s%n",
passwordAttempt.statusCode(), passwordAttempt.headers().firstValue("location").orElse("-"));
logout();
oneTimeToken();
HttpResponse<String> ottAttempt = get("/passkey-only");
System.out.printf("%none-time-token session -> GET /passkey-only: HTTP %d, Location: %s%n",
ottAttempt.statusCode(), ottAttempt.headers().firstValue("location").orElse("-"));
logout();
authenticator.signCount(1);
authenticate(authenticator, "stepup");
print("passkey session -> GET /passkey-only", get("/passkey-only"));
}
private void filters() throws Exception {
passwordLogin("user", "password");
String body = get("/diag/filters").body();
Matcher matcher = Pattern.compile("\"( ?\\d+ [A-Za-z0-9]+)\"").matcher(body);
while (matcher.find()) {
System.out.println(matcher.group(1));
}
}
// ---------------------------------------------------------------- ceremony steps
private HttpResponse<String> register(VirtualAuthenticator authenticator, String label) throws Exception {
HttpResponse<String> options = post("/webauthn/register/options", "");
print("POST /webauthn/register/options", options);
String challenge = jsonString(options.body(), "challenge");
VirtualAuthenticator.Registration credential = authenticator.makeCredential(RP_ID, ORIGIN, challenge);
System.out.println("authenticator produced credentialId " + credential.credentialId() + " and a "
+ VirtualAuthenticator.decodeBase64Url(credential.attestationObject()).length
+ "-byte CBOR attestation object");
HttpResponse<String> response = post("/webauthn/register", registrationBody(credential, label));
print("POST /webauthn/register", response);
return response;
}
private HttpResponse<String> authenticate(VirtualAuthenticator authenticator, String note) throws Exception {
HttpResponse<String> options = post("/webauthn/authenticate/options", "");
if (note == null) {
print("POST /webauthn/authenticate/options", options);
}
String challenge = jsonString(options.body(), "challenge");
VirtualAuthenticator.Assertion assertion = authenticator.getAssertion(RP_ID, ORIGIN, challenge, null);
String body = """
{"id":"%s","rawId":"%s","response":{"authenticatorData":"%s","clientDataJSON":"%s","signature":"%s"},\
"clientExtensionResults":{},"type":"public-key","authenticatorAttachment":"platform"}"""
.formatted(assertion.credentialId(), assertion.credentialId(), assertion.authenticatorData(),
assertion.clientDataJson(), assertion.signature());
HttpResponse<String> response = post("/login/webauthn", body);
print("POST /login/webauthn", response);
return response;
}
private String registrationBody(VirtualAuthenticator.Registration credential, String label) {
return """
{"publicKey":{"credential":{"id":"%s","rawId":"%s","response":{"attestationObject":"%s",\
"clientDataJSON":"%s","transports":["internal","hybrid"]},"type":"public-key",\
"clientExtensionResults":{},"authenticatorAttachment":"platform"},"label":"%s"}}"""
.formatted(credential.credentialId(), credential.credentialId(), credential.attestationObject(),
credential.clientDataJson(), label);
}
private void passwordLogin(String username, String password) throws Exception {
String csrf = csrfToken("/login");
HttpResponse<String> response = form("/login", "username=" + username + "&password=" + password, csrf);
System.out.printf("POST /login (password) -> HTTP %d, Location: %s%n", response.statusCode(),
response.headers().firstValue("location").orElse("-"));
}
private void logout() throws Exception {
String csrf = csrfToken("/login");
form("/logout", "", csrf);
this.cookies.getCookieStore().removeAll();
System.out.println("logged out, cookie jar emptied");
}
private long storedSignatureCount(String username, String password) throws Exception {
CookieManager saved = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
List<HttpCookie> current = this.cookies.getCookieStore().getCookies();
current.forEach((c) -> saved.getCookieStore().add(null, c));
passwordLogin(username, password);
String body = get("/diag/credentials").body();
Matcher matcher = Pattern.compile("\"signatureCount\"\\s*:\\s*(\\d+)").matcher(body);
return matcher.find() ? Long.parseLong(matcher.group(1)) : -1;
}
// ---------------------------------------------------------------- plumbing
private String csrfToken(String path) throws Exception {
HttpResponse<String> page = get(path);
Matcher matcher = CSRF.matcher(page.body());
if (matcher.find()) {
return (matcher.group(1) != null) ? matcher.group(1) : matcher.group(2);
}
return this.cookies.getCookieStore()
.getCookies()
.stream()
.filter((c) -> "XSRF-TOKEN".equals(c.getName()))
.map(HttpCookie::getValue)
.findFirst()
.orElseThrow(() -> new IllegalStateException("no CSRF token found on " + path));
}
private HttpResponse<String> get(String path) throws Exception {
return this.http.send(HttpRequest.newBuilder(URI.create(BASE + path)).GET().build(),
HttpResponse.BodyHandlers.ofString());
}
/** JSON POST with the CSRF token in a header, which is how the WebAuthn endpoints work. */
private HttpResponse<String> post(String path, String json) throws Exception {
String csrf = csrfToken("/login");
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.header("X-CSRF-TOKEN", csrf)
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
return this.http.send(request, HttpResponse.BodyHandlers.ofString());
}
private HttpResponse<String> form(String path, String body, String csrf) throws Exception {
String payload = body.isEmpty() ? "_csrf=" + URLEncoder.encode(csrf, StandardCharsets.UTF_8)
: body + "&_csrf=" + URLEncoder.encode(csrf, StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
return this.http.send(request, HttpResponse.BodyHandlers.ofString());
}
private static String jsonArray(String json, String field) {
Matcher matcher = Pattern.compile("\"" + field + "\"\\s*:\\s*(\\[[^\\]]*\\])").matcher(json);
return matcher.find() ? matcher.group(1) : "?";
}
private static String jsonString(String json, String field) {
Matcher matcher = Pattern.compile("\"" + field + "\"\\s*:\\s*\"([^\"]+)\"").matcher(json);
if (!matcher.find()) {
throw new IllegalStateException("no \"" + field + "\" in: " + json);
}
return matcher.group(1);
}
private static void print(String what, HttpResponse<String> response) {
String body = response.body();
if (body.length() > 1200) {
body = body.substring(0, 1200) + "\n... (truncated)";
}
System.out.printf("%n$ %s%nHTTP %d%n%s%n", what, response.statusCode(), body.isBlank() ? "(empty body)" : body);
}
private static void banner(String text) {
System.out.printf("%n=== %s ===%n", text);
}
}