Kaynağa Gözat

Add Passkeys Support

Closes gh-13305
Rob Winch 10 ay önce
ebeveyn
işleme
b0e8730d70
100 değiştirilmiş dosya ile 13638 ekleme ve 4 silme
  1. 1 1
      build.gradle
  2. 1 0
      config/spring-security-config.gradle
  3. 26 0
      config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java
  4. 194 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java
  5. 31 0
      config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt
  6. 43 0
      config/src/main/kotlin/org/springframework/security/config/annotation/web/WebAuthnDsl.kt
  7. 1 0
      config/src/main/kotlin/org/springframework/security/config/annotation/web/X509Dsl.kt
  8. 83 0
      config/src/test/kotlin/org/springframework/security/config/annotation/web/WebAuthnDslTests.kt
  9. 1 0
      docs/modules/ROOT/nav.adoc
  10. 289 0
      docs/modules/ROOT/pages/servlet/authentication/passkeys.adoc
  11. 2 0
      gradle/libs.versions.toml
  12. 2 0
      javascript/.gitignore
  13. 3 0
      javascript/.prettierrc
  14. 30 0
      javascript/eslint.config.js
  15. 43 0
      javascript/lib/abort-controller.js
  16. 33 0
      javascript/lib/base64url.js
  17. 33 0
      javascript/lib/http.js
  18. 24 0
      javascript/lib/index.js
  19. 194 0
      javascript/lib/webauthn-core.js
  20. 47 0
      javascript/lib/webauthn-login.js
  21. 108 0
      javascript/lib/webauthn-registration.js
  22. 5465 0
      javascript/package-lock.json
  23. 51 0
      javascript/package.json
  24. 49 0
      javascript/spring-security-javascript.gradle
  25. 49 0
      javascript/test/abort-controller.test.js
  26. 76 0
      javascript/test/base64.test.js
  27. 22 0
      javascript/test/bootstrap.js
  28. 65 0
      javascript/test/http.test.js
  29. 697 0
      javascript/test/webauthn-core.test.js
  30. 106 0
      javascript/test/webauthn-login.test.js
  31. 279 0
      javascript/test/webauthn-registration.test.js
  32. 26 0
      web/spring-security-web.gradle
  33. 115 0
      web/src/main/java/org/springframework/security/web/authentication/HttpMessageConverterAuthenticationSuccessHandler.java
  34. 75 3
      web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java
  35. 103 0
      web/src/main/java/org/springframework/security/web/webauthn/api/AttestationConveyancePreference.java
  36. 48 0
      web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticationExtensionsClientInput.java
  37. 42 0
      web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticationExtensionsClientInputs.java
  38. 47 0
      web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticationExtensionsClientOutput.java
  39. 42 0
      web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticationExtensionsClientOutputs.java
  40. 205 0
      web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorAssertionResponse.java
  41. 88 0
      web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorAttachment.java
  42. 144 0
      web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorAttestationResponse.java
  43. 53 0
      web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorResponse.java
  44. 170 0
      web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorSelectionCriteria.java
  45. 118 0
      web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorTransport.java
  46. 103 0
      web/src/main/java/org/springframework/security/web/webauthn/api/Bytes.java
  47. 65 0
      web/src/main/java/org/springframework/security/web/webauthn/api/COSEAlgorithmIdentifier.java
  48. 73 0
      web/src/main/java/org/springframework/security/web/webauthn/api/CredProtectAuthenticationExtensionsClientInput.java
  49. 83 0
      web/src/main/java/org/springframework/security/web/webauthn/api/CredentialPropertiesOutput.java
  50. 136 0
      web/src/main/java/org/springframework/security/web/webauthn/api/CredentialRecord.java
  51. 59 0
      web/src/main/java/org/springframework/security/web/webauthn/api/ImmutableAuthenticationExtensionsClientInput.java
  52. 45 0
      web/src/main/java/org/springframework/security/web/webauthn/api/ImmutableAuthenticationExtensionsClientInputs.java
  53. 44 0
      web/src/main/java/org/springframework/security/web/webauthn/api/ImmutableAuthenticationExtensionsClientOutputs.java
  54. 285 0
      web/src/main/java/org/springframework/security/web/webauthn/api/ImmutableCredentialRecord.java
  55. 55 0
      web/src/main/java/org/springframework/security/web/webauthn/api/ImmutablePublicKeyCose.java
  56. 188 0
      web/src/main/java/org/springframework/security/web/webauthn/api/ImmutablePublicKeyCredentialUserEntity.java
  57. 34 0
      web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCose.java
  58. 223 0
      web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredential.java
  59. 332 0
      web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialCreationOptions.java
  60. 154 0
      web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialDescriptor.java
  61. 99 0
      web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialParameters.java
  62. 248 0
      web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialRequestOptions.java
  63. 115 0
      web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialRpEntity.java
  64. 55 0
      web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialType.java
  65. 60 0
      web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialUserEntity.java
  66. 80 0
      web/src/main/java/org/springframework/security/web/webauthn/api/ResidentKeyRequirement.java
  67. 69 0
      web/src/main/java/org/springframework/security/web/webauthn/api/UserVerificationRequirement.java
  68. 63 0
      web/src/main/java/org/springframework/security/web/webauthn/authentication/HttpSessionPublicKeyCredentialRequestOptionsRepository.java
  69. 127 0
      web/src/main/java/org/springframework/security/web/webauthn/authentication/PublicKeyCredentialRequestOptionsFilter.java
  70. 52 0
      web/src/main/java/org/springframework/security/web/webauthn/authentication/PublicKeyCredentialRequestOptionsRepository.java
  71. 66 0
      web/src/main/java/org/springframework/security/web/webauthn/authentication/WebAuthnAuthentication.java
  72. 136 0
      web/src/main/java/org/springframework/security/web/webauthn/authentication/WebAuthnAuthenticationFilter.java
  73. 80 0
      web/src/main/java/org/springframework/security/web/webauthn/authentication/WebAuthnAuthenticationProvider.java
  74. 70 0
      web/src/main/java/org/springframework/security/web/webauthn/authentication/WebAuthnAuthenticationRequestToken.java
  75. 32 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AttestationConveyancePreferenceMixin.java
  76. 45 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AttestationConveyancePreferenceSerializer.java
  77. 32 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputMixin.java
  78. 48 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputSerializer.java
  79. 32 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputsMixin.java
  80. 53 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputsSerializer.java
  81. 77 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientOutputsDeserializer.java
  82. 32 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientOutputsMixin.java
  83. 38 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAssertionResponseMixin.java
  84. 52 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentDeserializer.java
  85. 34 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentMixin.java
  86. 45 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentSerializer.java
  87. 48 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttestationResponseMixin.java
  88. 32 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorSelectionCriteriaMixin.java
  89. 52 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorTransportDeserializer.java
  90. 34 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorTransportMixin.java
  91. 41 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorTransportSerializer.java
  92. 41 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/BytesMixin.java
  93. 47 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/BytesSerializer.java
  94. 52 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierDeserializer.java
  95. 34 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierMixin.java
  96. 45 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierSerializer.java
  97. 24 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/CredProtectAuthenticationExtensionsClientInputMixin.java
  98. 63 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/CredProtectAuthenticationExtensionsClientInputSerializer.java
  99. 36 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/CredentialPropertiesOutputMixin.java
  100. 46 0
      web/src/main/java/org/springframework/security/web/webauthn/jackson/DurationSerializer.java

+ 1 - 1
build.gradle

@@ -106,7 +106,7 @@ develocity {
 }
 
 nohttp {
-	source.exclude "buildSrc/build/**"
+	source.exclude "buildSrc/build/**", "javascript/.gradle/**", "javascript/package-lock.json", "javascript/node_modules/**", "javascript/build/**", "javascript/dist/**"
 	source.builtBy(project(':spring-security-config').tasks.withType(RncToXsd))
 }
 

+ 1 - 0
config/spring-security-config.gradle

@@ -43,6 +43,7 @@ dependencies {
 	optional 'org.jetbrains.kotlin:kotlin-reflect'
 	optional 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
 	optional 'jakarta.annotation:jakarta.annotation-api'
+	optional libs.webauthn4j.core
 
 	provided 'jakarta.servlet:jakarta.servlet-api'
 

+ 26 - 0
config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java

@@ -67,6 +67,7 @@ import org.springframework.security.config.annotation.web.configurers.RequestCac
 import org.springframework.security.config.annotation.web.configurers.SecurityContextConfigurer;
 import org.springframework.security.config.annotation.web.configurers.ServletApiConfigurer;
 import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer;
+import org.springframework.security.config.annotation.web.configurers.WebAuthnConfigurer;
 import org.springframework.security.config.annotation.web.configurers.X509Configurer;
 import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2ClientConfigurer;
 import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer;
@@ -3674,6 +3675,31 @@ public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<Defaul
 		return this;
 	}
 
+	/**
+	 * Specifies webAuthn/passkeys based authentication.
+	 *
+	 * <pre>
+	 * 	&#064;Bean
+	 * 	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+	 * 		http
+	 * 			// ...
+	 * 			.webAuthn((webAuthn) -&gt; webAuthn
+	 * 				.rpName("Spring Security Relying Party")
+	 * 				.rpId("example.com")
+	 * 				.allowedOrigins("https://example.com")
+	 * 			);
+	 * 		return http.build();
+	 * 	}
+	 * </pre>
+	 * @param webAuthn the customizer to apply
+	 * @return the {@link HttpSecurity} for further customizations
+	 * @throws Exception
+	 */
+	public HttpSecurity webAuthn(Customizer<WebAuthnConfigurer<HttpSecurity>> webAuthn) throws Exception {
+		webAuthn.customize(getOrApply(new WebAuthnConfigurer<HttpSecurity>()));
+		return HttpSecurity.this;
+	}
+
 	private List<RequestMatcher> createAntMatchers(String... patterns) {
 		List<RequestMatcher> matchers = new ArrayList<>(patterns.length);
 		for (String pattern : patterns) {

+ 194 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java

@@ -0,0 +1,194 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.config.annotation.web.configurers;
+
+import java.lang.reflect.Constructor;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import org.springframework.beans.factory.NoSuchBeanDefinitionException;
+import org.springframework.context.ApplicationContext;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.security.authentication.ProviderManager;
+import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.access.intercept.AuthorizationFilter;
+import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
+import org.springframework.security.web.authentication.ui.DefaultResourcesFilter;
+import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
+import org.springframework.security.web.csrf.CsrfToken;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
+import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsFilter;
+import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter;
+import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationProvider;
+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;
+import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
+import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations;
+import org.springframework.security.web.webauthn.registration.DefaultWebAuthnRegistrationPageGeneratingFilter;
+import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsFilter;
+import org.springframework.security.web.webauthn.registration.WebAuthnRegistrationFilter;
+
+import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher;
+
+/**
+ * Configures WebAuthn for Spring Security applications
+ *
+ * @param <H> the type of builder
+ * @author Rob Winch
+ * @since 6.4
+ */
+public class WebAuthnConfigurer<H extends HttpSecurityBuilder<H>>
+		extends AbstractHttpConfigurer<WebAuthnConfigurer<H>, H> {
+
+	private String rpId;
+
+	private String rpName;
+
+	private Set<String> allowedOrigins = new HashSet<>();
+
+	/**
+	 * The Relying Party id.
+	 * @param rpId the relying party id
+	 * @return the {@link WebAuthnConfigurer} for further customization
+	 */
+	public WebAuthnConfigurer<H> rpId(String rpId) {
+		this.rpId = rpId;
+		return this;
+	}
+
+	/**
+	 * Sets the relying party name
+	 * @param rpName the relying party name
+	 * @return the {@link WebAuthnConfigurer} for further customization
+	 */
+	public WebAuthnConfigurer<H> rpName(String rpName) {
+		this.rpName = rpName;
+		return this;
+	}
+
+	/**
+	 * Convenience method for {@link #allowedOrigins(Set)}
+	 * @param allowedOrigins the allowed origins
+	 * @return the {@link WebAuthnConfigurer} for further customization
+	 * @see #allowedOrigins(Set)
+	 */
+	public WebAuthnConfigurer<H> allowedOrigins(String... allowedOrigins) {
+		return allowedOrigins(Set.of(allowedOrigins));
+	}
+
+	/**
+	 * Sets the allowed origins.
+	 * @param allowedOrigins the allowed origins
+	 * @return the {@link WebAuthnConfigurer} for further customization
+	 * @see #allowedOrigins(String...)
+	 */
+	public WebAuthnConfigurer<H> allowedOrigins(Set<String> allowedOrigins) {
+		this.allowedOrigins = allowedOrigins;
+		return this;
+	}
+
+	@Override
+	public void configure(H http) throws Exception {
+		UserDetailsService userDetailsService = getSharedOrBean(http, UserDetailsService.class).orElseGet(() -> {
+			throw new IllegalStateException("Missing UserDetailsService Bean");
+		});
+		PublicKeyCredentialUserEntityRepository userEntities = getSharedOrBean(http,
+				PublicKeyCredentialUserEntityRepository.class)
+			.orElse(userEntityRepository());
+		UserCredentialRepository userCredentials = getSharedOrBean(http, UserCredentialRepository.class)
+			.orElse(userCredentialRepository());
+		WebAuthnRelyingPartyOperations rpOperations = webAuthnRelyingPartyOperations(userEntities, userCredentials);
+		WebAuthnAuthenticationFilter webAuthnAuthnFilter = new WebAuthnAuthenticationFilter();
+		webAuthnAuthnFilter.setAuthenticationManager(
+				new ProviderManager(new WebAuthnAuthenticationProvider(rpOperations, userDetailsService)));
+		http.addFilterBefore(webAuthnAuthnFilter, BasicAuthenticationFilter.class);
+		http.addFilterAfter(new WebAuthnRegistrationFilter(userCredentials, rpOperations), AuthorizationFilter.class);
+		http.addFilterBefore(new PublicKeyCredentialCreationOptionsFilter(rpOperations), AuthorizationFilter.class);
+		http.addFilterAfter(new DefaultWebAuthnRegistrationPageGeneratingFilter(userEntities, userCredentials),
+				AuthorizationFilter.class);
+		http.addFilterBefore(new PublicKeyCredentialRequestOptionsFilter(rpOperations), AuthorizationFilter.class);
+		DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http
+			.getSharedObject(DefaultLoginPageGeneratingFilter.class);
+		if (loginPageGeneratingFilter != null) {
+			ClassPathResource webauthn = new ClassPathResource(
+					"org/springframework/security/spring-security-webauthn.js");
+			AntPathRequestMatcher matcher = antMatcher(HttpMethod.GET, "/login/webauthn.js");
+
+			Constructor<DefaultResourcesFilter> constructor = DefaultResourcesFilter.class
+				.getDeclaredConstructor(RequestMatcher.class, ClassPathResource.class, MediaType.class);
+			constructor.setAccessible(true);
+			DefaultResourcesFilter resourcesFilter = constructor.newInstance(matcher, webauthn,
+					MediaType.parseMediaType("text/javascript"));
+			http.addFilter(resourcesFilter);
+			DefaultLoginPageGeneratingFilter loginGeneratingFilter = http
+				.getSharedObject(DefaultLoginPageGeneratingFilter.class);
+			loginGeneratingFilter.setPasskeysEnabled(true);
+			loginGeneratingFilter.setResolveHeaders((request) -> {
+				CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
+				return Map.of(csrfToken.getHeaderName(), csrfToken.getToken());
+			});
+		}
+	}
+
+	private <C> Optional<C> getSharedOrBean(H http, Class<C> type) {
+		C shared = http.getSharedObject(type);
+		return Optional.ofNullable(shared).or(() -> getBeanOrNull(type));
+	}
+
+	private <T> Optional<T> getBeanOrNull(Class<T> type) {
+		ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class);
+		if (context == null) {
+			return Optional.empty();
+		}
+		try {
+			return Optional.of(context.getBean(type));
+		}
+		catch (NoSuchBeanDefinitionException ex) {
+			return Optional.empty();
+		}
+	}
+
+	private MapUserCredentialRepository userCredentialRepository() {
+		return new MapUserCredentialRepository();
+	}
+
+	private PublicKeyCredentialUserEntityRepository userEntityRepository() {
+		return new MapPublicKeyCredentialUserEntityRepository();
+	}
+
+	private WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations(
+			PublicKeyCredentialUserEntityRepository userEntities, UserCredentialRepository userCredentials) {
+		Optional<WebAuthnRelyingPartyOperations> webauthnOperationsBean = getBeanOrNull(
+				WebAuthnRelyingPartyOperations.class);
+		if (webauthnOperationsBean.isPresent()) {
+			return webauthnOperationsBean.get();
+		}
+		Webauthn4JRelyingPartyOperations result = new Webauthn4JRelyingPartyOperations(userEntities, userCredentials,
+				PublicKeyCredentialRpEntity.builder().id(this.rpId).name(this.rpName).build(), this.allowedOrigins);
+		return result;
+	}
+
+}

+ 31 - 0
config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt

@@ -1031,6 +1031,37 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu
         this.http.rememberMe(rememberMeCustomizer)
     }
 
+    /**
+     * Enable WebAuthn configuration.
+     *
+     * Example:
+     *
+     * ```
+     * @Configuration
+     * @EnableWebSecurity
+     * class SecurityConfig {
+     *
+     *     @Bean
+     *     fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
+     *         http {
+     *             webAuthn {
+     *                 loginPage = "/log-in"
+     *             }
+     *         }
+     *         return http.build()
+     *     }
+     * }
+     * ```
+     *
+     * @param webAuthnConfiguration custom configurations to be applied
+     * to the WebAuthn authentication
+     * @see [WebAuthnDsl]
+     */
+    fun webAuthn(webAuthnConfiguration: WebAuthnDsl.() -> Unit) {
+        val webAuthnCustomizer = WebAuthnDsl().apply(webAuthnConfiguration).get()
+        this.http.webAuthn(webAuthnCustomizer)
+    }
+
     /**
      * Adds the [Filter] at the location of the specified [Filter] class.
      *

+ 43 - 0
config/src/main/kotlin/org/springframework/security/config/annotation/web/WebAuthnDsl.kt

@@ -0,0 +1,43 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.config.annotation.web
+
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configurers.WebAuthnConfigurer
+
+/**
+ * A Kotlin DSL to configure [HttpSecurity] webauthn using idiomatic Kotlin code.
+ * @property rpName the relying party name
+ * @property rpId the relying party id
+ * @property the allowed origins
+ * @since 6.4
+ * @author Rob Winch
+ */
+@SecurityMarker
+class WebAuthnDsl {
+    var rpName: String? = null
+    var rpId: String? = null
+    var allowedOrigins: Set<String>? = null
+
+    internal fun get(): (WebAuthnConfigurer<HttpSecurity>) -> Unit {
+        return { webAuthn -> webAuthn
+                .rpId(rpId)
+                .rpName(rpName)
+                .allowedOrigins(allowedOrigins);
+        }
+    }
+}

+ 1 - 0
config/src/main/kotlin/org/springframework/security/config/annotation/web/X509Dsl.kt

@@ -53,6 +53,7 @@ class X509Dsl {
     var authenticationUserDetailsService: AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken>? = null
     var subjectPrincipalRegex: String? = null
 
+
     internal fun get(): (X509Configurer<HttpSecurity>) -> Unit {
         return { x509 ->
             x509AuthenticationFilter?.also { x509.x509AuthenticationFilter(x509AuthenticationFilter) }

+ 83 - 0
config/src/test/kotlin/org/springframework/security/config/annotation/web/WebAuthnDslTests.kt

@@ -0,0 +1,83 @@
+/*
+ * Copyright 2002-2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.config.annotation.web
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
+import org.springframework.security.config.test.SpringTestContext
+import org.springframework.security.config.test.SpringTestContextExtension
+import org.springframework.security.core.userdetails.User
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.provisioning.InMemoryUserDetailsManager
+import org.springframework.security.web.SecurityFilterChain
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.post
+
+/**
+ * Tests for [WebAuthnDsl]
+ *
+ * @author Rob Winch
+ */
+@ExtendWith(SpringTestContextExtension::class)
+class WebAuthnDslTests {
+    @JvmField
+    val spring = SpringTestContext(this)
+
+    @Autowired
+    lateinit var mockMvc: MockMvc
+
+    @Test
+    fun `default configuration`() {
+        this.spring.register(WebauthnConfig::class.java).autowire()
+
+        this.mockMvc.post("/test1")
+                .andExpect {
+                    status { isForbidden() }
+                }
+    }
+
+    @Configuration
+    @EnableWebSecurity
+    open class WebauthnConfig {
+        @Bean
+        open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
+            http {
+                webAuthn {
+                    rpName = "Spring Security Relying Party"
+                    rpId = "example.com"
+                    allowedOrigins = setOf("https://example.com")
+                }
+            }
+            return http.build()
+        }
+
+        @Bean
+        open fun userDetailsService(): UserDetailsService {
+            val userDetails = User.withDefaultPasswordEncoder()
+                .username("rod")
+                .password("password")
+                .roles("USER")
+                .build()
+            return InMemoryUserDetailsManager(userDetails)
+        }
+    }
+}

+ 1 - 0
docs/modules/ROOT/nav.adoc

@@ -45,6 +45,7 @@
 ***** xref:servlet/authentication/passwords/dao-authentication-provider.adoc[DaoAuthenticationProvider]
 ***** xref:servlet/authentication/passwords/ldap.adoc[LDAP]
 *** xref:servlet/authentication/persistence.adoc[Persistence]
+*** xref:servlet/authentication/passkeys.adoc[Passkeys]
 *** xref:servlet/authentication/onetimetoken.adoc[One-Time Token]
 *** xref:servlet/authentication/session-management.adoc[Session Management]
 *** xref:servlet/authentication/rememberme.adoc[Remember Me]

+ 289 - 0
docs/modules/ROOT/pages/servlet/authentication/passkeys.adoc

@@ -0,0 +1,289 @@
+[[passkeys]]
+= Passkeys
+
+Spring Security provides support for https://www.passkeys.com[passkeys].
+Passkeys are a more secure method of authenticating than passwords and are built using https://www.w3.org/TR/webauthn-3/[WebAuthn].
+
+In order to use a passkey to authenticate, a user must first xref:servlet/authentication/passkeys.adoc#passkeys-register[Register a New Credential].
+After the credential is registered, it can be used to authenticate by xref:servlet/authentication/passkeys.adoc#passkeys-verify[verifying an authentication assertion].
+
+[[passkeys-configuration]]
+== Configuration
+
+The following configuration enables passkey authentication.
+It provides a way to xref:./passkeys.adoc#passkeys-register[] at `/webauthn/register` and a default log in page that allows xref:./passkeys.adoc#passkeys-verify[authenticating with passkeys].
+
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@Bean
+SecurityFilterChain filterChain(HttpSecurity http) {
+	http
+		// ...
+		.formLogin(withDefaults())
+		.webAuthn((webAuthn) -> webAuthn
+			.rpName("Spring Security Relying Party")
+			.rpId("example.com")
+			.allowedOrigins("https://example.com")
+		);
+	return http.build();
+}
+
+@Bean
+UserDetailsService userDetailsService() {
+	UserDetails userDetails = User.withDefaultPasswordEncoder()
+		.username("user")
+		.password("password")
+		.roles("USER")
+		.build();
+
+	return new InMemoryUserDetailsManager(userDetails);
+}
+----
+
+Kotlin::
++
+[source,kotlin,role="secondary"]
+----
+@Bean
+open fun filterChain(http: HttpSecurity): SecurityFilterChain {
+	http {
+		webAuthn {
+			rpName = "Spring Security Relying Party"
+			rpId = "example.com"
+			allowedOrigins = setOf("https://example.com")
+		}
+	}
+}
+
+@Bean
+open fun userDetailsService(): UserDetailsService {
+	val userDetails = User.withDefaultPasswordEncoder()
+		.username("user")
+		.password("password")
+		.roles("USER")
+		.build()
+	return InMemoryUserDetailsManager(userDetails)
+}
+----
+======
+
+[[passkeys-register]]
+== Register a New Credential
+
+In order to use a passkey, a user must first https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential[Register a New Credential].
+
+Registering a new credential is composed of two steps:
+
+1. Requesting the Registration Options
+2. Registering the Credential
+
+[[passkeys-register-options]]
+=== Request the Registration Options
+
+The first step in registration of a new credential is to request the registration options.
+In Spring Security, a request for the registration options is typically done using JavaScript and looks like:
+
+[NOTE]
+====
+Spring Security provides a default registration page that can be used as a reference on how to register credentials.
+====
+
+.Request for Registration Options
+[source,http]
+----
+POST /webauthn/register/options
+X-CSRF-TOKEN: 4bfd1575-3ad1-4d21-96c7-4ef2d9f86721
+----
+
+The request above will obtain the registration options for the currently authenticated user.
+Since the challenge is persisted (state is changed) to be compared at the time of registration, the request must be a POST and include a CSRF token.
+
+.Response for Registration Options
+[source,json]
+----
+{
+  "rp": {
+    "name": "SimpleWebAuthn Example",
+    "id": "example.localhost"
+  },
+  "user": {
+    "name": "user@example.localhost",
+    "id": "oWJtkJ6vJ_m5b84LB4_K7QKTCTEwLIjCh4tFMCGHO4w",
+    "displayName": "user@example.localhost"
+  },
+  "challenge": "q7lCdd3SVQxdC-v8pnRAGEn1B2M-t7ZECWPwCAmhWvc",
+  "pubKeyCredParams": [
+    {
+      "type": "public-key",
+      "alg": -8
+    },
+    {
+      "type": "public-key",
+      "alg": -7
+    },
+    {
+      "type": "public-key",
+      "alg": -257
+    }
+  ],
+  "timeout": 300000,
+  "excludeCredentials": [],
+  "authenticatorSelection": {
+    "residentKey": "required",
+    "userVerification": "preferred"
+  },
+  "attestation": "direct",
+  "extensions": {
+    "credProps": true
+  }
+}
+----
+
+[[passkeys-register-create]]
+=== Registering the Credential
+
+After the registration options are obtained, they are used to create the credentials that are registered.
+To register a new credential, the application should pass the options to https://w3c.github.io/webappsec-credential-management/#dom-credentialscontainer-create[`navigator.credentials.create`] after base64url decoding the binary values such as `user.id`, `challenge`, and `excludeCredentials[].id`.
+
+The returned value can then be sent to the server as a JSON request.
+An example registration request can be found below:
+
+.Example Registration Request
+[source,http]
+----
+POST /webauthn/register
+X-CSRF-TOKEN: 4bfd1575-3ad1-4d21-96c7-4ef2d9f86721
+
+{
+  "publicKey": { // <1>
+    "credential": {
+      "id": "dYF7EGnRFFIXkpXi9XU2wg",
+      "rawId": "dYF7EGnRFFIXkpXi9XU2wg",
+      "response": {
+        "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViUy9GqwTRaMpzVDbXq1dyEAXVOxrou08k22ggRC45MKNhdAAAAALraVWanqkAfvZZFYZpVEg0AEHWBexBp0RRSF5KV4vV1NsKlAQIDJiABIVggQjmrekPGzyqtoKK9HPUH-8Z2FLpoqkklFpFPQVICQ3IiWCD6I9Jvmor685fOZOyGXqUd87tXfvJk8rxj9OhuZvUALA",
+        "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiSl9RTi10SFJYRWVKYjlNcUNrWmFPLUdOVmlibXpGVGVWMk43Z0ptQUdrQSIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5sb2NhbGhvc3Q6ODQ0MyIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
+        "transports": [
+          "internal",
+          "hybrid"
+        ]
+      },
+      "type": "public-key",
+      "clientExtensionResults": {},
+      "authenticatorAttachment": "platform"
+    },
+    "label": "1password" // <2>
+  }
+}
+----
+<1> The result of calling `navigator.credentials.create` with binary values base64url encoded.
+<2> A label that the user selects to have associated with this credential to help the user distinguish the credential.
+
+.Example Successful Registration Response
+[source,http]
+----
+HTTP/1.1 200 OK
+
+{
+  "success": true
+}
+----
+
+[[passkeys-verify]]
+== Verifying an Authentication Assertion
+
+After xref:./passkeys.adoc#passkeys-register[] the passkey can be https://www.w3.org/TR/webauthn-3/#sctn-verifying-assertion[verified] (authenticated).
+
+Verifying a credential is composed of two steps:
+
+1. Requesting the Verification Options
+2. Verifying the Credential
+
+[[passkeys-verify-options]]
+=== Request the Verification Options
+
+The first step in verification of a credential is to request the verification options.
+In Spring Security, a request for the verification options is typically done using JavaScript and looks like:
+
+[NOTE]
+====
+Spring Security provides a default log in page that can be used as a reference on how to verify credentials.
+====
+
+.Request for Verification Options
+[source,http]
+----
+POST /webauthn/authenticate/options
+X-CSRF-TOKEN: 4bfd1575-3ad1-4d21-96c7-4ef2d9f86721
+----
+
+The request above will obtain the verification options.
+Since the challenge is persisted (state is changed) to be compared at the time of authentication, the request must be a POST and include a CSRF token.
+
+The response will contain the options for obtaining a credential with binary values such as `challenge` base64url encoded.
+
+.Example Response for Verification Options
+[source,json]
+----
+{
+  "challenge": "cQfdGrj9zDg3zNBkOH3WPL954FTOShVy0-CoNgSewNM",
+  "timeout": 300000,
+  "rpId": "example.localhost",
+  "allowCredentials": [],
+  "userVerification": "preferred",
+  "extensions": {}
+}
+----
+
+[[passkeys-verify-get]]
+=== Verifying the Credential
+
+After the verification options are obtained, they are used to get a credential.
+To get a credential, the application should pass the options to https://w3c.github.io/webappsec-credential-management/#dom-credentialscontainer-create[`navigator.credentials.get`] after base64url decoding the binary values such as `challenge`.
+
+The returned value of `navigator.credentials.get` can then be sent to the server as a JSON request.
+Binary values such as `rawId` and `response.*` must be base64url encoded.
+An example authentication request can be found below:
+
+.Example Authentication Request
+[source,http]
+----
+POST /login/webauthn
+X-CSRF-TOKEN: 4bfd1575-3ad1-4d21-96c7-4ef2d9f86721
+
+{
+  "id": "dYF7EGnRFFIXkpXi9XU2wg",
+  "rawId": "dYF7EGnRFFIXkpXi9XU2wg",
+  "response": {
+    "authenticatorData": "y9GqwTRaMpzVDbXq1dyEAXVOxrou08k22ggRC45MKNgdAAAAAA",
+    "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiRFVsRzRDbU9naWhKMG1vdXZFcE9HdUk0ZVJ6MGRRWmxUQmFtbjdHQ1FTNCIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5sb2NhbGhvc3Q6ODQ0MyIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
+    "signature": "MEYCIQCW2BcUkRCAXDmGxwMi78jknenZ7_amWrUJEYoTkweldAIhAMD0EMp1rw2GfwhdrsFIeDsL7tfOXVPwOtfqJntjAo4z",
+    "userHandle": "Q3_0Xd64_HW0BlKRAJnVagJTpLKLgARCj8zjugpRnVo"
+  },
+  "clientExtensionResults": {},
+  "authenticatorAttachment": "platform"
+}
+----
+
+.Example Successful Authentication Response
+[source,http]
+----
+HTTP/1.1 200 OK
+
+{
+  "redirectUrl": "/", // <1>
+  "authenticated": true // <2>
+}
+----
+<1> The URL to redirect to
+<2> Indicates that the user is authenticated
+
+.Example Authentication Failure Response
+[source,http]
+----
+HTTP/1.1 401 OK
+
+----

+ 2 - 0
gradle/libs.versions.toml

@@ -107,6 +107,8 @@ org-jfrog-buildinfo-build-info-extractor-gradle = "org.jfrog.buildinfo:build-inf
 org-sonarsource-scanner-gradle-sonarqube-gradle-plugin = "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.8.0.1969"
 org-instancio-instancio-junit = "org.instancio:instancio-junit:3.7.1"
 
+webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.27.0.RELEASE'
+
 [plugins]
 
 org-gradle-wrapper-upgrade = "org.gradle.wrapper-upgrade:0.11.4"

+ 2 - 0
javascript/.gitignore

@@ -0,0 +1,2 @@
+node_modules/
+dist/

+ 3 - 0
javascript/.prettierrc

@@ -0,0 +1,3 @@
+{
+    "printWidth": 120
+}

+ 30 - 0
javascript/eslint.config.js

@@ -0,0 +1,30 @@
+import globals from "globals";
+import eslintConfigPrettier from "eslint-plugin-prettier/recommended";
+
+export default [
+  {
+    ignores: ["build/**/*"],
+  },
+  {
+    files: ["lib/**/*.js"],
+    languageOptions: {
+      sourceType: "module",
+      globals: {
+        ...globals.browser,
+        gobalThis: "readonly",
+      },
+    },
+  },
+  {
+    files: ["test/**/*.js"],
+    languageOptions: {
+      globals: {
+        ...globals.browser,
+        ...globals.mocha,
+        ...globals.chai,
+        ...globals.nodeBuiltin,
+      },
+    },
+  },
+  eslintConfigPrettier,
+];

+ 43 - 0
javascript/lib/abort-controller.js

@@ -0,0 +1,43 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use strict";
+
+const holder = {
+  controller: new AbortController(),
+};
+
+/**
+ * Returns a new AbortSignal to be used in the options for the registration and authentication ceremonies.
+ * Aborts the existing AbortController if it exists, cancelling any existing ceremony.
+ *
+ * The authentication ceremony, when triggered with conditional mediation, shows a non-modal
+ * interaction. If the user does not interact with the non-modal dialog, the existing ceremony MUST
+ * be cancelled before initiating a new one, hence the need for a singleton AbortController.
+ *
+ * @returns {AbortSignal} a new, non-aborted AbortSignal
+ */
+function newSignal() {
+  if (!!holder.controller) {
+    holder.controller.abort("Initiating new WebAuthN ceremony, cancelling current ceremony");
+  }
+  holder.controller = new AbortController();
+  return holder.controller.signal;
+}
+
+export default {
+  newSignal,
+};

+ 33 - 0
javascript/lib/base64url.js

@@ -0,0 +1,33 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use strict";
+
+export default {
+  encode: function (buffer) {
+    const base64 = window.btoa(String.fromCharCode(...new Uint8Array(buffer)));
+    return base64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
+  },
+  decode: function (base64url) {
+    const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
+    const binStr = window.atob(base64);
+    const bin = new Uint8Array(binStr.length);
+    for (let i = 0; i < binStr.length; i++) {
+      bin[i] = binStr.charCodeAt(i);
+    }
+    return bin.buffer;
+  },
+};

+ 33 - 0
javascript/lib/http.js

@@ -0,0 +1,33 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use strict";
+
+async function post(url, headers, body) {
+  const options = {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      ...headers,
+    },
+  };
+  if (body) {
+    options.body = JSON.stringify(body);
+  }
+  return fetch(url, options);
+}
+
+export default { post };

+ 24 - 0
javascript/lib/index.js

@@ -0,0 +1,24 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use strict";
+
+import { setupLogin } from "./webauthn-login.js";
+import { setupRegistration } from "./webauthn-registration.js";
+
+// Make "setup" available in the window domain, so it can be run with "setupLogin()"
+window.setupLogin = setupLogin;
+window.setupRegistration = setupRegistration;

+ 194 - 0
javascript/lib/webauthn-core.js

@@ -0,0 +1,194 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use strict";
+
+import base64url from "./base64url.js";
+import http from "./http.js";
+import abortController from "./abort-controller.js";
+
+async function isConditionalMediationAvailable() {
+  return !!(
+    window.PublicKeyCredential &&
+    window.PublicKeyCredential.isConditionalMediationAvailable &&
+    (await window.PublicKeyCredential.isConditionalMediationAvailable())
+  );
+}
+
+async function authenticate(headers, contextPath, useConditionalMediation) {
+  let options;
+  try {
+    const optionsResponse = await http.post(`${contextPath}/webauthn/authenticate/options`, headers);
+    if (!optionsResponse.ok) {
+      throw new Error(`HTTP ${optionsResponse.status}`);
+    }
+    options = await optionsResponse.json();
+  } catch (err) {
+    throw new Error(`Authentication failed. Could not fetch authentication options: ${err.message}`, { cause: err });
+  }
+
+  // FIXME: Use https://www.w3.org/TR/webauthn-3/#sctn-parseRequestOptionsFromJSON
+  const decodedOptions = {
+    ...options,
+    challenge: base64url.decode(options.challenge),
+  };
+
+  // Invoke the WebAuthn get() method.
+  const credentialOptions = {
+    publicKey: decodedOptions,
+    signal: abortController.newSignal(),
+  };
+  if (useConditionalMediation) {
+    // Request a conditional UI
+    credentialOptions.mediation = "conditional";
+  }
+
+  let cred;
+  try {
+    cred = await navigator.credentials.get(credentialOptions);
+  } catch (err) {
+    throw new Error(`Authentication failed. Call to navigator.credentials.get failed: ${err.message}`, { cause: err });
+  }
+
+  const { response, type: credType } = cred;
+  let userHandle;
+  if (response.userHandle) {
+    userHandle = base64url.encode(response.userHandle);
+  }
+  const body = {
+    id: cred.id,
+    rawId: base64url.encode(cred.rawId),
+    response: {
+      authenticatorData: base64url.encode(response.authenticatorData),
+      clientDataJSON: base64url.encode(response.clientDataJSON),
+      signature: base64url.encode(response.signature),
+      userHandle,
+    },
+    credType,
+    clientExtensionResults: cred.getClientExtensionResults(),
+    authenticatorAttachment: cred.authenticatorAttachment,
+  };
+
+  let authenticationResponse;
+  try {
+    const authenticationCallResponse = await http.post(`${contextPath}/login/webauthn`, headers, body);
+    if (!authenticationCallResponse.ok) {
+      throw new Error(`HTTP ${authenticationCallResponse.status}`);
+    }
+    authenticationResponse = await authenticationCallResponse.json();
+    //   if (authenticationResponse && authenticationResponse.authenticated) {
+  } catch (err) {
+    throw new Error(`Authentication failed. Could not process the authentication request: ${err.message}`, {
+      cause: err,
+    });
+  }
+
+  if (!(authenticationResponse && authenticationResponse.authenticated && authenticationResponse.redirectUrl)) {
+    throw new Error(
+      `Authentication failed. Expected {"authenticated": true, "redirectUrl": "..."}, server responded with: ${JSON.stringify(authenticationResponse)}`,
+    );
+  }
+
+  return authenticationResponse.redirectUrl;
+}
+
+async function register(headers, contextPath, label) {
+  if (!label) {
+    throw new Error("Error: Passkey Label is required");
+  }
+
+  let options;
+  try {
+    const optionsResponse = await http.post(`${contextPath}/webauthn/register/options`, headers);
+    if (!optionsResponse.ok) {
+      throw new Error(`Server responded with HTTP ${optionsResponse.status}`);
+    }
+    options = await optionsResponse.json();
+  } catch (e) {
+    throw new Error(`Registration failed. Could not fetch registration options: ${e.message}`, { cause: e });
+  }
+
+  // FIXME: Use https://www.w3.org/TR/webauthn-3/#sctn-parseCreationOptionsFromJSON
+  const decodedExcludeCredentials = !options.excludeCredentials
+    ? []
+    : options.excludeCredentials.map((cred) => ({
+        ...cred,
+        id: base64url.decode(cred.id),
+      }));
+
+  const decodedOptions = {
+    ...options,
+    user: {
+      ...options.user,
+      id: base64url.decode(options.user.id),
+    },
+    challenge: base64url.decode(options.challenge),
+    excludeCredentials: decodedExcludeCredentials,
+  };
+
+  let credentialsContainer;
+  try {
+    credentialsContainer = await navigator.credentials.create({
+      publicKey: decodedOptions,
+      signal: abortController.newSignal(),
+    });
+  } catch (e) {
+    throw new Error(`Registration failed. Call to navigator.credentials.create failed: ${e.message}`, { cause: e });
+  }
+
+  // FIXME: Let response be credential.response. If response is not an instance of AuthenticatorAttestationResponse, abort the ceremony with a user-visible error. https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
+  const { response } = credentialsContainer;
+  const credential = {
+    id: credentialsContainer.id,
+    rawId: base64url.encode(credentialsContainer.rawId),
+    response: {
+      attestationObject: base64url.encode(response.attestationObject),
+      clientDataJSON: base64url.encode(response.clientDataJSON),
+      transports: response.getTransports ? response.getTransports() : [],
+    },
+    type: credentialsContainer.type,
+    clientExtensionResults: credentialsContainer.getClientExtensionResults(),
+    authenticatorAttachment: credentialsContainer.authenticatorAttachment,
+  };
+
+  const registrationRequest = {
+    publicKey: {
+      credential: credential,
+      label: label,
+    },
+  };
+
+  let verificationJSON;
+  try {
+    const verificationResp = await http.post(`${contextPath}/webauthn/register`, headers, registrationRequest);
+    if (!verificationResp.ok) {
+      throw new Error(`HTTP ${verificationResp.status}`);
+    }
+    verificationJSON = await verificationResp.json();
+  } catch (e) {
+    throw new Error(`Registration failed. Could not process the registration request: ${e.message}`, { cause: e });
+  }
+
+  if (!(verificationJSON && verificationJSON.success)) {
+    throw new Error(`Registration failed. Server responded with: ${JSON.stringify(verificationJSON)}`);
+  }
+}
+
+export default {
+  authenticate,
+  register,
+  isConditionalMediationAvailable,
+};

+ 47 - 0
javascript/lib/webauthn-login.js

@@ -0,0 +1,47 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use strict";
+
+import webauthn from "./webauthn-core.js";
+
+async function authenticateOrError(headers, contextPath, useConditionalMediation) {
+  try {
+    const redirectUrl = await webauthn.authenticate(headers, contextPath, useConditionalMediation);
+    window.location.href = redirectUrl;
+  } catch (err) {
+    console.error(err);
+    window.location.href = `${contextPath}/login?error`;
+  }
+}
+
+async function conditionalMediation(headers, contextPath) {
+  const available = await webauthn.isConditionalMediationAvailable();
+  if (available) {
+    await authenticateOrError(headers, contextPath, true);
+  }
+  return available;
+}
+
+export async function setupLogin(headers, contextPath, signinButton) {
+  signinButton.addEventListener("click", async () => {
+    await authenticateOrError(headers, contextPath, false);
+  });
+
+  // FIXME: conditional mediation triggers browser crashes
+  // See: https://github.com/rwinch/spring-security-webauthn/issues/73
+  // await conditionalMediation(headers, contextPath);
+}

+ 108 - 0
javascript/lib/webauthn-registration.js

@@ -0,0 +1,108 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use strict";
+
+import webauthn from "./webauthn-core.js";
+
+function setVisibility(element, value) {
+  if (!element) {
+    return;
+  }
+  element.style.display = value ? "block" : "none";
+}
+
+function setError(ui, msg) {
+  resetPopups(ui);
+  const error = ui.getError();
+  if (!error) {
+    return;
+  }
+  error.textContent = msg;
+  setVisibility(error, true);
+}
+
+function setSuccess(ui) {
+  resetPopups(ui);
+  const success = ui.getSuccess();
+  if (!success) {
+    return;
+  }
+  setVisibility(success, true);
+}
+
+function resetPopups(ui) {
+  const success = ui.getSuccess();
+  const error = ui.getError();
+  setVisibility(success, false);
+  setVisibility(error, false);
+}
+
+async function submitDeleteForm(contextPath, form, headers) {
+  const options = {
+    method: "DELETE",
+    headers: {
+      "Content-Type": "application/json",
+      ...headers,
+    },
+  };
+  await fetch(form.action, options);
+}
+
+/**
+ *
+ * @param headers headers added to the credentials creation POST request, typically CSRF
+ * @param contextPath the contextPath from which the app is served
+ * @param ui contains getRegisterButton(), getSuccess(), getError(), getLabelInput(), getDeleteForms()
+ * @returns {Promise<void>}
+ */
+export async function setupRegistration(headers, contextPath, ui) {
+  resetPopups(ui);
+
+  if (!window.PublicKeyCredential) {
+    setError(ui, "WebAuthn is not supported");
+    return;
+  }
+
+  const queryString = new URLSearchParams(window.location.search);
+  if (queryString.has("success")) {
+    setSuccess(ui);
+  }
+
+  ui.getRegisterButton().addEventListener("click", async () => {
+    resetPopups(ui);
+    const label = ui.getLabelInput().value;
+    try {
+      await webauthn.register(headers, contextPath, label);
+      window.location.href = `${contextPath}/webauthn/register?success`;
+    } catch (err) {
+      setError(ui, err.message);
+      console.error(err);
+    }
+  });
+
+  ui.getDeleteForms().forEach((form) =>
+    form.addEventListener("submit", async function (e) {
+      e.preventDefault();
+      try {
+        await submitDeleteForm(contextPath, form, headers);
+        window.location.href = `${contextPath}/webauthn/register?success`;
+      } catch (err) {
+        setError(ui, err.message);
+      }
+    }),
+  );
+}

+ 5465 - 0
javascript/package-lock.json

@@ -0,0 +1,5465 @@
+{
+  "name": "@springprojects/spring-security-webauthn",
+  "version": "1.0.0-alpha.9",
+  "lockfileVersion": 2,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "@springprojects/spring-security-webauthn",
+      "version": "1.0.0-alpha.9",
+      "license": "ASL-2.0",
+      "devDependencies": {
+        "@eslint/js": "^9.6.0",
+        "@types/sinon": "^17.0.3",
+        "chai": "~4.3",
+        "esbuild": "^0.23.0",
+        "eslint": "^9.6.0",
+        "eslint-config-prettier": "^9.1.0",
+        "eslint-plugin-prettier": "^5.1.3",
+        "globals": "^15.8.0",
+        "mocha": "~10.2",
+        "prettier": "^3.3.2",
+        "prettier-eslint": "~15.0",
+        "sinon": "^18.0.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz",
+      "integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz",
+      "integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz",
+      "integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz",
+      "integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz",
+      "integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz",
+      "integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz",
+      "integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz",
+      "integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz",
+      "integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz",
+      "integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz",
+      "integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz",
+      "integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz",
+      "integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz",
+      "integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz",
+      "integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz",
+      "integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz",
+      "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz",
+      "integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz",
+      "integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz",
+      "integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz",
+      "integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz",
+      "integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz",
+      "integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz",
+      "integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+      "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+      "dev": true,
+      "dependencies": {
+        "eslint-visitor-keys": "^3.3.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+      }
+    },
+    "node_modules/@eslint-community/regexpp": {
+      "version": "4.11.0",
+      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz",
+      "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==",
+      "dev": true,
+      "engines": {
+        "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@eslint/config-array": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.0.tgz",
+      "integrity": "sha512-A68TBu6/1mHHuc5YJL0U0VVeGNiklLAL6rRmhTCP2B5XjWLMnrX+HkO+IAXyHvks5cyyY1jjK5ITPQ1HGS2EVA==",
+      "dev": true,
+      "dependencies": {
+        "@eslint/object-schema": "^2.1.4",
+        "debug": "^4.3.1",
+        "minimatch": "^3.1.2"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz",
+      "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^10.0.1",
+        "globals": "^14.0.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz",
+      "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==",
+      "dev": true,
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/espree": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz",
+      "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^8.12.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^4.0.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/globals": {
+      "version": "14.0.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+      "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@eslint/js": {
+      "version": "9.6.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.6.0.tgz",
+      "integrity": "sha512-D9B0/3vNg44ZeWbYMpBoXqNP4j6eQD5vNwIlGAuFRRzK/WtT/jvDQW3Bi9kkf3PMDMlM7Yi+73VLUsn5bJcl8A==",
+      "dev": true,
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/object-schema": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz",
+      "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==",
+      "dev": true,
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@humanwhocodes/config-array": {
+      "version": "0.11.14",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
+      "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
+      "deprecated": "Use @eslint/config-array instead",
+      "dev": true,
+      "dependencies": {
+        "@humanwhocodes/object-schema": "^2.0.2",
+        "debug": "^4.3.1",
+        "minimatch": "^3.0.5"
+      },
+      "engines": {
+        "node": ">=10.10.0"
+      }
+    },
+    "node_modules/@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true,
+      "engines": {
+        "node": ">=12.22"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@humanwhocodes/object-schema": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+      "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
+      "deprecated": "Use @eslint/object-schema instead",
+      "dev": true
+    },
+    "node_modules/@humanwhocodes/retry": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz",
+      "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==",
+      "dev": true,
+      "engines": {
+        "node": ">=18.18"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@pkgr/core": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
+      "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==",
+      "dev": true,
+      "engines": {
+        "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/unts"
+      }
+    },
+    "node_modules/@sinonjs/commons": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
+      "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
+      "dev": true,
+      "dependencies": {
+        "type-detect": "4.0.8"
+      }
+    },
+    "node_modules/@sinonjs/fake-timers": {
+      "version": "11.2.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz",
+      "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^3.0.0"
+      }
+    },
+    "node_modules/@sinonjs/samsam": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz",
+      "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^2.0.0",
+        "lodash.get": "^4.4.2",
+        "type-detect": "^4.0.8"
+      }
+    },
+    "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz",
+      "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==",
+      "dev": true,
+      "dependencies": {
+        "type-detect": "4.0.8"
+      }
+    },
+    "node_modules/@sinonjs/text-encoding": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz",
+      "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==",
+      "dev": true
+    },
+    "node_modules/@types/eslint": {
+      "version": "8.56.10",
+      "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz",
+      "integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==",
+      "dev": true,
+      "dependencies": {
+        "@types/estree": "*",
+        "@types/json-schema": "*"
+      }
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
+      "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
+      "dev": true
+    },
+    "node_modules/@types/json-schema": {
+      "version": "7.0.15",
+      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+      "dev": true
+    },
+    "node_modules/@types/prettier": {
+      "version": "2.7.3",
+      "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz",
+      "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==",
+      "dev": true
+    },
+    "node_modules/@types/sinon": {
+      "version": "17.0.3",
+      "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz",
+      "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==",
+      "dev": true,
+      "dependencies": {
+        "@types/sinonjs__fake-timers": "*"
+      }
+    },
+    "node_modules/@types/sinonjs__fake-timers": {
+      "version": "8.1.5",
+      "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz",
+      "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==",
+      "dev": true
+    },
+    "node_modules/@typescript-eslint/scope-manager": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
+      "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "5.62.0",
+        "@typescript-eslint/visitor-keys": "5.62.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/types": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
+      "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
+      "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "5.62.0",
+        "@typescript-eslint/visitor-keys": "5.62.0",
+        "debug": "^4.3.4",
+        "globby": "^11.1.0",
+        "is-glob": "^4.0.3",
+        "semver": "^7.3.7",
+        "tsutils": "^3.21.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+      "version": "7.6.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
+      "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@typescript-eslint/visitor-keys": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
+      "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "5.62.0",
+        "eslint-visitor-keys": "^3.3.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@ungap/structured-clone": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
+      "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+      "dev": true
+    },
+    "node_modules/acorn": {
+      "version": "8.12.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
+      "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
+      "dev": true,
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ansi-colors": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
+      "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz",
+      "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "dev": true,
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true
+    },
+    "node_modules/array-union": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+      "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/assertion-error": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+      "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/browser-stdout": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
+      "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
+      "dev": true
+    },
+    "node_modules/callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/chai": {
+      "version": "4.3.10",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz",
+      "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==",
+      "dev": true,
+      "dependencies": {
+        "assertion-error": "^1.1.0",
+        "check-error": "^1.0.3",
+        "deep-eql": "^4.1.3",
+        "get-func-name": "^2.0.2",
+        "loupe": "^2.3.6",
+        "pathval": "^1.1.1",
+        "type-detect": "^4.0.8"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/check-error": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
+      "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
+      "dev": true,
+      "dependencies": {
+        "get-func-name": "^2.0.2"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/chokidar": {
+      "version": "3.5.3",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+      "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ],
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/chokidar/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/cliui": {
+      "version": "7.0.4",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+      "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+      "dev": true,
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^7.0.0"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "node_modules/common-tags": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz",
+      "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "dev": true
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "dev": true,
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.3.5",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",
+      "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/deep-eql": {
+      "version": "4.1.4",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
+      "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
+      "dev": true,
+      "dependencies": {
+        "type-detect": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true
+    },
+    "node_modules/diff": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz",
+      "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.3.1"
+      }
+    },
+    "node_modules/dir-glob": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+      "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+      "dev": true,
+      "dependencies": {
+        "path-type": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dlv": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+      "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+      "dev": true
+    },
+    "node_modules/doctrine": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+      "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+      "dev": true,
+      "dependencies": {
+        "esutils": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true
+    },
+    "node_modules/esbuild": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz",
+      "integrity": "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.23.0",
+        "@esbuild/android-arm": "0.23.0",
+        "@esbuild/android-arm64": "0.23.0",
+        "@esbuild/android-x64": "0.23.0",
+        "@esbuild/darwin-arm64": "0.23.0",
+        "@esbuild/darwin-x64": "0.23.0",
+        "@esbuild/freebsd-arm64": "0.23.0",
+        "@esbuild/freebsd-x64": "0.23.0",
+        "@esbuild/linux-arm": "0.23.0",
+        "@esbuild/linux-arm64": "0.23.0",
+        "@esbuild/linux-ia32": "0.23.0",
+        "@esbuild/linux-loong64": "0.23.0",
+        "@esbuild/linux-mips64el": "0.23.0",
+        "@esbuild/linux-ppc64": "0.23.0",
+        "@esbuild/linux-riscv64": "0.23.0",
+        "@esbuild/linux-s390x": "0.23.0",
+        "@esbuild/linux-x64": "0.23.0",
+        "@esbuild/netbsd-x64": "0.23.0",
+        "@esbuild/openbsd-arm64": "0.23.0",
+        "@esbuild/openbsd-x64": "0.23.0",
+        "@esbuild/sunos-x64": "0.23.0",
+        "@esbuild/win32-arm64": "0.23.0",
+        "@esbuild/win32-ia32": "0.23.0",
+        "@esbuild/win32-x64": "0.23.0"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
+      "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint": {
+      "version": "9.6.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.6.0.tgz",
+      "integrity": "sha512-ElQkdLMEEqQNM9Njff+2Y4q2afHk7JpkPvrd7Xh7xefwgQynqPxwf55J7di9+MEibWUGdNjFF9ITG9Pck5M84w==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.2.0",
+        "@eslint-community/regexpp": "^4.6.1",
+        "@eslint/config-array": "^0.17.0",
+        "@eslint/eslintrc": "^3.1.0",
+        "@eslint/js": "9.6.0",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@humanwhocodes/retry": "^0.3.0",
+        "@nodelib/fs.walk": "^1.2.8",
+        "ajv": "^6.12.4",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.3.2",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^8.0.1",
+        "eslint-visitor-keys": "^4.0.0",
+        "espree": "^10.1.0",
+        "esquery": "^1.5.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^8.0.0",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "ignore": "^5.2.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "is-path-inside": "^3.0.3",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.3",
+        "strip-ansi": "^6.0.1",
+        "text-table": "^0.2.0"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      }
+    },
+    "node_modules/eslint-config-prettier": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
+      "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
+      "dev": true,
+      "bin": {
+        "eslint-config-prettier": "bin/cli.js"
+      },
+      "peerDependencies": {
+        "eslint": ">=7.0.0"
+      }
+    },
+    "node_modules/eslint-plugin-prettier": {
+      "version": "5.1.3",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz",
+      "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==",
+      "dev": true,
+      "dependencies": {
+        "prettier-linter-helpers": "^1.0.0",
+        "synckit": "^0.8.6"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint-plugin-prettier"
+      },
+      "peerDependencies": {
+        "@types/eslint": ">=8.0.0",
+        "eslint": ">=8.0.0",
+        "eslint-config-prettier": "*",
+        "prettier": ">=3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/eslint": {
+          "optional": true
+        },
+        "eslint-config-prettier": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "7.2.2",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+      "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+      "dev": true,
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint-visitor-keys": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint/node_modules/eslint-scope": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.1.tgz",
+      "integrity": "sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og==",
+      "dev": true,
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint/node_modules/eslint-visitor-keys": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz",
+      "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==",
+      "dev": true,
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint/node_modules/espree": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz",
+      "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^8.12.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^4.0.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/espree": {
+      "version": "9.6.1",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+      "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^8.9.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^3.4.1"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/esquery": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
+      "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
+    "node_modules/fast-diff": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
+      "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+      "dev": true
+    },
+    "node_modules/fast-glob": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
+      "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/fast-glob/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "node_modules/fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true
+    },
+    "node_modules/fastq": {
+      "version": "1.17.1",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
+      "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
+      "dev": true,
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/file-entry-cache": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+      "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+      "dev": true,
+      "dependencies": {
+        "flat-cache": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=16.0.0"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/flat": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
+      "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
+      "dev": true,
+      "bin": {
+        "flat": "cli.js"
+      }
+    },
+    "node_modules/flat-cache": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+      "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+      "dev": true,
+      "dependencies": {
+        "flatted": "^3.2.9",
+        "keyv": "^4.5.4"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
+      "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
+      "dev": true
+    },
+    "node_modules/fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+      "dev": true
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "dev": true,
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
+    "node_modules/get-func-name": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
+      "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/glob": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
+      "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
+      "deprecated": "Glob versions prior to v9 are no longer supported",
+      "dev": true,
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/globals": {
+      "version": "15.8.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-15.8.0.tgz",
+      "integrity": "sha512-VZAJ4cewHTExBWDHR6yptdIBlx9YSSZuwojj9Nt5mBRXQzrKakDsVKQ1J63sklLvzAJm0X5+RpO4i3Y2hcOnFw==",
+      "dev": true,
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/globby": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+      "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+      "dev": true,
+      "dependencies": {
+        "array-union": "^2.1.0",
+        "dir-glob": "^3.0.1",
+        "fast-glob": "^3.2.9",
+        "ignore": "^5.2.0",
+        "merge2": "^1.4.1",
+        "slash": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/graphemer": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+      "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+      "dev": true
+    },
+    "node_modules/has-ansi": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
+      "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/has-ansi/node_modules/ansi-regex": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+      "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true,
+      "bin": {
+        "he": "bin/he"
+      }
+    },
+    "node_modules/ignore": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
+      "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/import-fresh": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "dev": true,
+      "dependencies": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.19"
+      }
+    },
+    "node_modules/indent-string": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+      "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+      "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+      "dev": true,
+      "dependencies": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "dev": true
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/is-path-inside": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+      "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-plain-obj": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+      "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-unicode-supported": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
+      "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/json-buffer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+      "dev": true
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "node_modules/json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true
+    },
+    "node_modules/just-extend": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz",
+      "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==",
+      "dev": true
+    },
+    "node_modules/keyv": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+      "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+      "dev": true,
+      "dependencies": {
+        "json-buffer": "3.0.1"
+      }
+    },
+    "node_modules/levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "dev": true
+    },
+    "node_modules/lodash.get": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+      "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
+      "dev": true
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "node_modules/log-symbols": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
+      "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
+      "dev": true,
+      "dependencies": {
+        "chalk": "^4.1.0",
+        "is-unicode-supported": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/loglevel": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
+      "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.6.0"
+      },
+      "funding": {
+        "type": "tidelift",
+        "url": "https://tidelift.com/funding/github/npm/loglevel"
+      }
+    },
+    "node_modules/loglevel-colored-level-prefix": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/loglevel-colored-level-prefix/-/loglevel-colored-level-prefix-1.0.0.tgz",
+      "integrity": "sha512-u45Wcxxc+SdAlh4yeF/uKlC1SPUPCy0gullSNKXod5I4bmifzk+Q4lSLExNEVn19tGaJipbZ4V4jbFn79/6mVA==",
+      "dev": true,
+      "dependencies": {
+        "chalk": "^1.1.3",
+        "loglevel": "^1.4.1"
+      }
+    },
+    "node_modules/loglevel-colored-level-prefix/node_modules/ansi-regex": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+      "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/loglevel-colored-level-prefix/node_modules/ansi-styles": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+      "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/loglevel-colored-level-prefix/node_modules/chalk": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+      "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^2.2.1",
+        "escape-string-regexp": "^1.0.2",
+        "has-ansi": "^2.0.0",
+        "strip-ansi": "^3.0.0",
+        "supports-color": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/loglevel-colored-level-prefix/node_modules/escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/loglevel-colored-level-prefix/node_modules/strip-ansi": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+      "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/loglevel-colored-level-prefix/node_modules/supports-color": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+      "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/loupe": {
+      "version": "2.3.7",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
+      "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
+      "dev": true,
+      "dependencies": {
+        "get-func-name": "^2.0.1"
+      }
+    },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
+      "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
+      "dev": true,
+      "dependencies": {
+        "braces": "^3.0.3",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/mocha": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz",
+      "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==",
+      "dev": true,
+      "dependencies": {
+        "ansi-colors": "4.1.1",
+        "browser-stdout": "1.3.1",
+        "chokidar": "3.5.3",
+        "debug": "4.3.4",
+        "diff": "5.0.0",
+        "escape-string-regexp": "4.0.0",
+        "find-up": "5.0.0",
+        "glob": "7.2.0",
+        "he": "1.2.0",
+        "js-yaml": "4.1.0",
+        "log-symbols": "4.1.0",
+        "minimatch": "5.0.1",
+        "ms": "2.1.3",
+        "nanoid": "3.3.3",
+        "serialize-javascript": "6.0.0",
+        "strip-json-comments": "3.1.1",
+        "supports-color": "8.1.1",
+        "workerpool": "6.2.1",
+        "yargs": "16.2.0",
+        "yargs-parser": "20.2.4",
+        "yargs-unparser": "2.0.0"
+      },
+      "bin": {
+        "_mocha": "bin/_mocha",
+        "mocha": "bin/mocha.js"
+      },
+      "engines": {
+        "node": ">= 14.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mochajs"
+      }
+    },
+    "node_modules/mocha/node_modules/brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/mocha/node_modules/debug": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+      "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/mocha/node_modules/debug/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "node_modules/mocha/node_modules/minimatch": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz",
+      "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/mocha/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true
+    },
+    "node_modules/mocha/node_modules/supports-color": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz",
+      "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==",
+      "dev": true,
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true
+    },
+    "node_modules/nise": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/nise/-/nise-6.0.0.tgz",
+      "integrity": "sha512-K8ePqo9BFvN31HXwEtTNGzgrPpmvgciDsFz8aztFjt4LqKO/JeFD8tBOeuDiCMXrIl/m1YvfH8auSpxfaD09wg==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^3.0.0",
+        "@sinonjs/fake-timers": "^11.2.2",
+        "@sinonjs/text-encoding": "^0.7.2",
+        "just-extend": "^6.2.0",
+        "path-to-regexp": "^6.2.1"
+      }
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "dev": true,
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/optionator": {
+      "version": "0.9.4",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+      "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+      "dev": true,
+      "dependencies": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.5"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "dependencies": {
+        "yocto-queue": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "dependencies": {
+        "callsites": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz",
+      "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==",
+      "dev": true
+    },
+    "node_modules/path-type": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+      "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/pathval": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+      "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/prettier": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz",
+      "integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==",
+      "dev": true,
+      "bin": {
+        "prettier": "bin/prettier.cjs"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/prettier/prettier?sponsor=1"
+      }
+    },
+    "node_modules/prettier-eslint": {
+      "version": "15.0.1",
+      "resolved": "https://registry.npmjs.org/prettier-eslint/-/prettier-eslint-15.0.1.tgz",
+      "integrity": "sha512-mGOWVHixSvpZWARqSDXbdtTL54mMBxc5oQYQ6RAqy8jecuNJBgN3t9E5a81G66F8x8fsKNiR1HWaBV66MJDOpg==",
+      "dev": true,
+      "dependencies": {
+        "@types/eslint": "^8.4.2",
+        "@types/prettier": "^2.6.0",
+        "@typescript-eslint/parser": "^5.10.0",
+        "common-tags": "^1.4.0",
+        "dlv": "^1.1.0",
+        "eslint": "^8.7.0",
+        "indent-string": "^4.0.0",
+        "lodash.merge": "^4.6.0",
+        "loglevel-colored-level-prefix": "^1.0.0",
+        "prettier": "^2.5.1",
+        "pretty-format": "^23.0.1",
+        "require-relative": "^0.8.7",
+        "typescript": "^4.5.4",
+        "vue-eslint-parser": "^8.0.1"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/prettier-eslint/node_modules/@eslint/eslintrc": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+      "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^9.6.0",
+        "globals": "^13.19.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/prettier-eslint/node_modules/@eslint/js": {
+      "version": "8.57.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
+      "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/prettier-eslint/node_modules/@typescript-eslint/parser": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
+      "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/scope-manager": "5.62.0",
+        "@typescript-eslint/types": "5.62.0",
+        "@typescript-eslint/typescript-estree": "5.62.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/prettier-eslint/node_modules/eslint": {
+      "version": "8.57.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
+      "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.2.0",
+        "@eslint-community/regexpp": "^4.6.1",
+        "@eslint/eslintrc": "^2.1.4",
+        "@eslint/js": "8.57.0",
+        "@humanwhocodes/config-array": "^0.11.14",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@nodelib/fs.walk": "^1.2.8",
+        "@ungap/structured-clone": "^1.2.0",
+        "ajv": "^6.12.4",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.3.2",
+        "doctrine": "^3.0.0",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^7.2.2",
+        "eslint-visitor-keys": "^3.4.3",
+        "espree": "^9.6.1",
+        "esquery": "^1.4.2",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "globals": "^13.19.0",
+        "graphemer": "^1.4.0",
+        "ignore": "^5.2.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "is-path-inside": "^3.0.3",
+        "js-yaml": "^4.1.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.3",
+        "strip-ansi": "^6.0.1",
+        "text-table": "^0.2.0"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/prettier-eslint/node_modules/file-entry-cache": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+      "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+      "dev": true,
+      "dependencies": {
+        "flat-cache": "^3.0.4"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/prettier-eslint/node_modules/flat-cache": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
+      "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+      "dev": true,
+      "dependencies": {
+        "flatted": "^3.2.9",
+        "keyv": "^4.5.3",
+        "rimraf": "^3.0.2"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/prettier-eslint/node_modules/globals": {
+      "version": "13.24.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+      "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+      "dev": true,
+      "dependencies": {
+        "type-fest": "^0.20.2"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/prettier-eslint/node_modules/prettier": {
+      "version": "2.8.8",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
+      "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
+      "dev": true,
+      "bin": {
+        "prettier": "bin-prettier.js"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      },
+      "funding": {
+        "url": "https://github.com/prettier/prettier?sponsor=1"
+      }
+    },
+    "node_modules/prettier-linter-helpers": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
+      "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
+      "dev": true,
+      "dependencies": {
+        "fast-diff": "^1.1.2"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/pretty-format": {
+      "version": "23.6.0",
+      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz",
+      "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^3.0.0",
+        "ansi-styles": "^3.2.0"
+      }
+    },
+    "node_modules/pretty-format/node_modules/ansi-styles": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^1.9.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pretty-format/node_modules/color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "1.1.3"
+      }
+    },
+    "node_modules/pretty-format/node_modules/color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+      "dev": true
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/randombytes": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+      "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+      "dev": true,
+      "dependencies": {
+        "safe-buffer": "^5.1.0"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/require-relative": {
+      "version": "0.8.7",
+      "resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz",
+      "integrity": "sha512-AKGr4qvHiryxRb19m3PsLRGuKVAbJLUD7E6eOaHkfKhwc+vSgVOCY5xNvm9EkolBKTOf0GrQAZKLimOCz81Khg==",
+      "dev": true
+    },
+    "node_modules/resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true,
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "deprecated": "Rimraf versions prior to v4 are no longer supported",
+      "dev": true,
+      "dependencies": {
+        "glob": "^7.1.3"
+      },
+      "bin": {
+        "rimraf": "bin.js"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/serialize-javascript": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
+      "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
+      "dev": true,
+      "dependencies": {
+        "randombytes": "^2.1.0"
+      }
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/sinon": {
+      "version": "18.0.0",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.0.tgz",
+      "integrity": "sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^3.0.1",
+        "@sinonjs/fake-timers": "^11.2.2",
+        "@sinonjs/samsam": "^8.0.0",
+        "diff": "^5.2.0",
+        "nise": "^6.0.0",
+        "supports-color": "^7"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/sinon"
+      }
+    },
+    "node_modules/sinon/node_modules/diff": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
+      "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.3.1"
+      }
+    },
+    "node_modules/slash": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+      "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/synckit": {
+      "version": "0.8.8",
+      "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
+      "integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==",
+      "dev": true,
+      "dependencies": {
+        "@pkgr/core": "^0.1.0",
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/unts"
+      }
+    },
+    "node_modules/synckit/node_modules/tslib": {
+      "version": "2.6.3",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
+      "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
+      "dev": true
+    },
+    "node_modules/text-table": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+      "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+      "dev": true
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+      "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+      "dev": true
+    },
+    "node_modules/tsutils": {
+      "version": "3.21.0",
+      "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
+      "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
+      "dev": true,
+      "dependencies": {
+        "tslib": "^1.8.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      },
+      "peerDependencies": {
+        "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
+      }
+    },
+    "node_modules/type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/type-detect": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/type-fest": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "4.9.5",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
+      "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
+      "dev": true,
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=4.2.0"
+      }
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/vue-eslint-parser": {
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-8.3.0.tgz",
+      "integrity": "sha512-dzHGG3+sYwSf6zFBa0Gi9ZDshD7+ad14DGOdTLjruRVgZXe2J+DcZ9iUhyR48z5g1PqRa20yt3Njna/veLJL/g==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^4.3.2",
+        "eslint-scope": "^7.0.0",
+        "eslint-visitor-keys": "^3.1.0",
+        "espree": "^9.0.0",
+        "esquery": "^1.4.0",
+        "lodash": "^4.17.21",
+        "semver": "^7.3.5"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mysticatea"
+      },
+      "peerDependencies": {
+        "eslint": ">=6.0.0"
+      }
+    },
+    "node_modules/vue-eslint-parser/node_modules/semver": {
+      "version": "7.6.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
+      "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/word-wrap": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/workerpool": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz",
+      "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==",
+      "dev": true
+    },
+    "node_modules/wrap-ansi": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "dev": true
+    },
+    "node_modules/y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs": {
+      "version": "16.2.0",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
+      "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
+      "dev": true,
+      "dependencies": {
+        "cliui": "^7.0.2",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.0",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^20.2.2"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "20.2.4",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz",
+      "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs-unparser": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz",
+      "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==",
+      "dev": true,
+      "dependencies": {
+        "camelcase": "^6.0.0",
+        "decamelize": "^4.0.0",
+        "flat": "^5.0.2",
+        "is-plain-obj": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs-unparser/node_modules/camelcase": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+      "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/yargs-unparser/node_modules/decamelize": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz",
+      "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    }
+  },
+  "dependencies": {
+    "@esbuild/aix-ppc64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz",
+      "integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/android-arm": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz",
+      "integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/android-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz",
+      "integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/android-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz",
+      "integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/darwin-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz",
+      "integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/darwin-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz",
+      "integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/freebsd-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz",
+      "integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/freebsd-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz",
+      "integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-arm": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz",
+      "integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz",
+      "integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-ia32": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz",
+      "integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-loong64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz",
+      "integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-mips64el": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz",
+      "integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-ppc64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz",
+      "integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-riscv64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz",
+      "integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-s390x": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz",
+      "integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz",
+      "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/netbsd-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz",
+      "integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/openbsd-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz",
+      "integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/openbsd-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz",
+      "integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/sunos-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz",
+      "integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/win32-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz",
+      "integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/win32-ia32": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz",
+      "integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/win32-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz",
+      "integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==",
+      "dev": true,
+      "optional": true
+    },
+    "@eslint-community/eslint-utils": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+      "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+      "dev": true,
+      "requires": {
+        "eslint-visitor-keys": "^3.3.0"
+      }
+    },
+    "@eslint-community/regexpp": {
+      "version": "4.11.0",
+      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz",
+      "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==",
+      "dev": true
+    },
+    "@eslint/config-array": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.0.tgz",
+      "integrity": "sha512-A68TBu6/1mHHuc5YJL0U0VVeGNiklLAL6rRmhTCP2B5XjWLMnrX+HkO+IAXyHvks5cyyY1jjK5ITPQ1HGS2EVA==",
+      "dev": true,
+      "requires": {
+        "@eslint/object-schema": "^2.1.4",
+        "debug": "^4.3.1",
+        "minimatch": "^3.1.2"
+      }
+    },
+    "@eslint/eslintrc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz",
+      "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==",
+      "dev": true,
+      "requires": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^10.0.1",
+        "globals": "^14.0.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      },
+      "dependencies": {
+        "eslint-visitor-keys": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz",
+          "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==",
+          "dev": true
+        },
+        "espree": {
+          "version": "10.1.0",
+          "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz",
+          "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==",
+          "dev": true,
+          "requires": {
+            "acorn": "^8.12.0",
+            "acorn-jsx": "^5.3.2",
+            "eslint-visitor-keys": "^4.0.0"
+          }
+        },
+        "globals": {
+          "version": "14.0.0",
+          "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+          "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+          "dev": true
+        }
+      }
+    },
+    "@eslint/js": {
+      "version": "9.6.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.6.0.tgz",
+      "integrity": "sha512-D9B0/3vNg44ZeWbYMpBoXqNP4j6eQD5vNwIlGAuFRRzK/WtT/jvDQW3Bi9kkf3PMDMlM7Yi+73VLUsn5bJcl8A==",
+      "dev": true
+    },
+    "@eslint/object-schema": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz",
+      "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==",
+      "dev": true
+    },
+    "@humanwhocodes/config-array": {
+      "version": "0.11.14",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
+      "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
+      "dev": true,
+      "requires": {
+        "@humanwhocodes/object-schema": "^2.0.2",
+        "debug": "^4.3.1",
+        "minimatch": "^3.0.5"
+      }
+    },
+    "@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true
+    },
+    "@humanwhocodes/object-schema": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+      "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
+      "dev": true
+    },
+    "@humanwhocodes/retry": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz",
+      "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==",
+      "dev": true
+    },
+    "@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      }
+    },
+    "@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true
+    },
+    "@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      }
+    },
+    "@pkgr/core": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
+      "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==",
+      "dev": true
+    },
+    "@sinonjs/commons": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
+      "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
+      "dev": true,
+      "requires": {
+        "type-detect": "4.0.8"
+      }
+    },
+    "@sinonjs/fake-timers": {
+      "version": "11.2.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz",
+      "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^3.0.0"
+      }
+    },
+    "@sinonjs/samsam": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz",
+      "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^2.0.0",
+        "lodash.get": "^4.4.2",
+        "type-detect": "^4.0.8"
+      },
+      "dependencies": {
+        "@sinonjs/commons": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz",
+          "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==",
+          "dev": true,
+          "requires": {
+            "type-detect": "4.0.8"
+          }
+        }
+      }
+    },
+    "@sinonjs/text-encoding": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz",
+      "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==",
+      "dev": true
+    },
+    "@types/eslint": {
+      "version": "8.56.10",
+      "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz",
+      "integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==",
+      "dev": true,
+      "requires": {
+        "@types/estree": "*",
+        "@types/json-schema": "*"
+      }
+    },
+    "@types/estree": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
+      "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
+      "dev": true
+    },
+    "@types/json-schema": {
+      "version": "7.0.15",
+      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+      "dev": true
+    },
+    "@types/prettier": {
+      "version": "2.7.3",
+      "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz",
+      "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==",
+      "dev": true
+    },
+    "@types/sinon": {
+      "version": "17.0.3",
+      "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz",
+      "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==",
+      "dev": true,
+      "requires": {
+        "@types/sinonjs__fake-timers": "*"
+      }
+    },
+    "@types/sinonjs__fake-timers": {
+      "version": "8.1.5",
+      "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz",
+      "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==",
+      "dev": true
+    },
+    "@typescript-eslint/scope-manager": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
+      "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/types": "5.62.0",
+        "@typescript-eslint/visitor-keys": "5.62.0"
+      }
+    },
+    "@typescript-eslint/types": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
+      "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
+      "dev": true
+    },
+    "@typescript-eslint/typescript-estree": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
+      "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/types": "5.62.0",
+        "@typescript-eslint/visitor-keys": "5.62.0",
+        "debug": "^4.3.4",
+        "globby": "^11.1.0",
+        "is-glob": "^4.0.3",
+        "semver": "^7.3.7",
+        "tsutils": "^3.21.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "7.6.2",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
+          "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
+          "dev": true
+        }
+      }
+    },
+    "@typescript-eslint/visitor-keys": {
+      "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
+      "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/types": "5.62.0",
+        "eslint-visitor-keys": "^3.3.0"
+      }
+    },
+    "@ungap/structured-clone": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
+      "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+      "dev": true
+    },
+    "acorn": {
+      "version": "8.12.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
+      "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
+      "dev": true
+    },
+    "acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "requires": {}
+    },
+    "ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "requires": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      }
+    },
+    "ansi-colors": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
+      "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==",
+      "dev": true
+    },
+    "ansi-regex": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz",
+      "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==",
+      "dev": true
+    },
+    "ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "requires": {
+        "color-convert": "^2.0.1"
+      }
+    },
+    "anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "dev": true,
+      "requires": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      }
+    },
+    "argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true
+    },
+    "array-union": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+      "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+      "dev": true
+    },
+    "assertion-error": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+      "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+      "dev": true
+    },
+    "balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true
+    },
+    "binary-extensions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+      "dev": true
+    },
+    "brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dev": true,
+      "requires": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "requires": {
+        "fill-range": "^7.1.1"
+      }
+    },
+    "browser-stdout": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
+      "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
+      "dev": true
+    },
+    "callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true
+    },
+    "chai": {
+      "version": "4.3.10",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz",
+      "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==",
+      "dev": true,
+      "requires": {
+        "assertion-error": "^1.1.0",
+        "check-error": "^1.0.3",
+        "deep-eql": "^4.1.3",
+        "get-func-name": "^2.0.2",
+        "loupe": "^2.3.6",
+        "pathval": "^1.1.1",
+        "type-detect": "^4.0.8"
+      }
+    },
+    "chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      }
+    },
+    "check-error": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
+      "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
+      "dev": true,
+      "requires": {
+        "get-func-name": "^2.0.2"
+      }
+    },
+    "chokidar": {
+      "version": "3.5.3",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+      "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+      "dev": true,
+      "requires": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "fsevents": "~2.3.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "dependencies": {
+        "glob-parent": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+          "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+          "dev": true,
+          "requires": {
+            "is-glob": "^4.0.1"
+          }
+        }
+      }
+    },
+    "cliui": {
+      "version": "7.0.4",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+      "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+      "dev": true,
+      "requires": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^7.0.0"
+      }
+    },
+    "color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "requires": {
+        "color-name": "~1.1.4"
+      }
+    },
+    "color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "common-tags": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz",
+      "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==",
+      "dev": true
+    },
+    "concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "dev": true
+    },
+    "cross-spawn": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "dev": true,
+      "requires": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      }
+    },
+    "debug": {
+      "version": "4.3.5",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",
+      "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==",
+      "dev": true,
+      "requires": {
+        "ms": "2.1.2"
+      }
+    },
+    "deep-eql": {
+      "version": "4.1.4",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
+      "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
+      "dev": true,
+      "requires": {
+        "type-detect": "^4.0.0"
+      }
+    },
+    "deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true
+    },
+    "diff": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz",
+      "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==",
+      "dev": true
+    },
+    "dir-glob": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+      "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+      "dev": true,
+      "requires": {
+        "path-type": "^4.0.0"
+      }
+    },
+    "dlv": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+      "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+      "dev": true
+    },
+    "doctrine": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+      "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+      "dev": true,
+      "requires": {
+        "esutils": "^2.0.2"
+      }
+    },
+    "emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true
+    },
+    "esbuild": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz",
+      "integrity": "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==",
+      "dev": true,
+      "requires": {
+        "@esbuild/aix-ppc64": "0.23.0",
+        "@esbuild/android-arm": "0.23.0",
+        "@esbuild/android-arm64": "0.23.0",
+        "@esbuild/android-x64": "0.23.0",
+        "@esbuild/darwin-arm64": "0.23.0",
+        "@esbuild/darwin-x64": "0.23.0",
+        "@esbuild/freebsd-arm64": "0.23.0",
+        "@esbuild/freebsd-x64": "0.23.0",
+        "@esbuild/linux-arm": "0.23.0",
+        "@esbuild/linux-arm64": "0.23.0",
+        "@esbuild/linux-ia32": "0.23.0",
+        "@esbuild/linux-loong64": "0.23.0",
+        "@esbuild/linux-mips64el": "0.23.0",
+        "@esbuild/linux-ppc64": "0.23.0",
+        "@esbuild/linux-riscv64": "0.23.0",
+        "@esbuild/linux-s390x": "0.23.0",
+        "@esbuild/linux-x64": "0.23.0",
+        "@esbuild/netbsd-x64": "0.23.0",
+        "@esbuild/openbsd-arm64": "0.23.0",
+        "@esbuild/openbsd-x64": "0.23.0",
+        "@esbuild/sunos-x64": "0.23.0",
+        "@esbuild/win32-arm64": "0.23.0",
+        "@esbuild/win32-ia32": "0.23.0",
+        "@esbuild/win32-x64": "0.23.0"
+      }
+    },
+    "escalade": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
+      "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
+      "dev": true
+    },
+    "escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true
+    },
+    "eslint": {
+      "version": "9.6.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.6.0.tgz",
+      "integrity": "sha512-ElQkdLMEEqQNM9Njff+2Y4q2afHk7JpkPvrd7Xh7xefwgQynqPxwf55J7di9+MEibWUGdNjFF9ITG9Pck5M84w==",
+      "dev": true,
+      "requires": {
+        "@eslint-community/eslint-utils": "^4.2.0",
+        "@eslint-community/regexpp": "^4.6.1",
+        "@eslint/config-array": "^0.17.0",
+        "@eslint/eslintrc": "^3.1.0",
+        "@eslint/js": "9.6.0",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@humanwhocodes/retry": "^0.3.0",
+        "@nodelib/fs.walk": "^1.2.8",
+        "ajv": "^6.12.4",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.3.2",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^8.0.1",
+        "eslint-visitor-keys": "^4.0.0",
+        "espree": "^10.1.0",
+        "esquery": "^1.5.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^8.0.0",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "ignore": "^5.2.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "is-path-inside": "^3.0.3",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.3",
+        "strip-ansi": "^6.0.1",
+        "text-table": "^0.2.0"
+      },
+      "dependencies": {
+        "eslint-scope": {
+          "version": "8.0.1",
+          "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.1.tgz",
+          "integrity": "sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og==",
+          "dev": true,
+          "requires": {
+            "esrecurse": "^4.3.0",
+            "estraverse": "^5.2.0"
+          }
+        },
+        "eslint-visitor-keys": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz",
+          "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==",
+          "dev": true
+        },
+        "espree": {
+          "version": "10.1.0",
+          "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz",
+          "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==",
+          "dev": true,
+          "requires": {
+            "acorn": "^8.12.0",
+            "acorn-jsx": "^5.3.2",
+            "eslint-visitor-keys": "^4.0.0"
+          }
+        }
+      }
+    },
+    "eslint-config-prettier": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
+      "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
+      "dev": true,
+      "requires": {}
+    },
+    "eslint-plugin-prettier": {
+      "version": "5.1.3",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz",
+      "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==",
+      "dev": true,
+      "requires": {
+        "prettier-linter-helpers": "^1.0.0",
+        "synckit": "^0.8.6"
+      }
+    },
+    "eslint-scope": {
+      "version": "7.2.2",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+      "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+      "dev": true,
+      "requires": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      }
+    },
+    "eslint-visitor-keys": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+      "dev": true
+    },
+    "espree": {
+      "version": "9.6.1",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+      "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+      "dev": true,
+      "requires": {
+        "acorn": "^8.9.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^3.4.1"
+      }
+    },
+    "esquery": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
+      "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+      "dev": true,
+      "requires": {
+        "estraverse": "^5.1.0"
+      }
+    },
+    "esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "requires": {
+        "estraverse": "^5.2.0"
+      }
+    },
+    "estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true
+    },
+    "esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true
+    },
+    "fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
+    "fast-diff": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
+      "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+      "dev": true
+    },
+    "fast-glob": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
+      "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.4"
+      },
+      "dependencies": {
+        "glob-parent": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+          "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+          "dev": true,
+          "requires": {
+            "is-glob": "^4.0.1"
+          }
+        }
+      }
+    },
+    "fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true
+    },
+    "fastq": {
+      "version": "1.17.1",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
+      "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
+      "dev": true,
+      "requires": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "file-entry-cache": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+      "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+      "dev": true,
+      "requires": {
+        "flat-cache": "^4.0.0"
+      }
+    },
+    "fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "requires": {
+        "to-regex-range": "^5.0.1"
+      }
+    },
+    "find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "requires": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      }
+    },
+    "flat": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
+      "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
+      "dev": true
+    },
+    "flat-cache": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+      "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+      "dev": true,
+      "requires": {
+        "flatted": "^3.2.9",
+        "keyv": "^4.5.4"
+      }
+    },
+    "flatted": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
+      "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
+      "dev": true
+    },
+    "fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+      "dev": true
+    },
+    "fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "optional": true
+    },
+    "get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "dev": true
+    },
+    "get-func-name": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
+      "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
+      "dev": true
+    },
+    "glob": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
+      "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
+      "dev": true,
+      "requires": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      }
+    },
+    "glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "requires": {
+        "is-glob": "^4.0.3"
+      }
+    },
+    "globals": {
+      "version": "15.8.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-15.8.0.tgz",
+      "integrity": "sha512-VZAJ4cewHTExBWDHR6yptdIBlx9YSSZuwojj9Nt5mBRXQzrKakDsVKQ1J63sklLvzAJm0X5+RpO4i3Y2hcOnFw==",
+      "dev": true
+    },
+    "globby": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+      "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+      "dev": true,
+      "requires": {
+        "array-union": "^2.1.0",
+        "dir-glob": "^3.0.1",
+        "fast-glob": "^3.2.9",
+        "ignore": "^5.2.0",
+        "merge2": "^1.4.1",
+        "slash": "^3.0.0"
+      }
+    },
+    "graphemer": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+      "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+      "dev": true
+    },
+    "has-ansi": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
+      "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==",
+      "dev": true,
+      "requires": {
+        "ansi-regex": "^2.0.0"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+          "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+          "dev": true
+        }
+      }
+    },
+    "has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true
+    },
+    "he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true
+    },
+    "ignore": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
+      "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
+      "dev": true
+    },
+    "import-fresh": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "dev": true,
+      "requires": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      }
+    },
+    "imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true
+    },
+    "indent-string": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+      "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+      "dev": true
+    },
+    "inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+      "dev": true,
+      "requires": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "dev": true
+    },
+    "is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "requires": {
+        "binary-extensions": "^2.0.0"
+      }
+    },
+    "is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true
+    },
+    "is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true
+    },
+    "is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "requires": {
+        "is-extglob": "^2.1.1"
+      }
+    },
+    "is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true
+    },
+    "is-path-inside": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+      "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+      "dev": true
+    },
+    "is-plain-obj": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+      "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+      "dev": true
+    },
+    "is-unicode-supported": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
+      "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
+      "dev": true
+    },
+    "isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true
+    },
+    "js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
+      "requires": {
+        "argparse": "^2.0.1"
+      }
+    },
+    "json-buffer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+      "dev": true
+    },
+    "json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true
+    },
+    "just-extend": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz",
+      "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==",
+      "dev": true
+    },
+    "keyv": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+      "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+      "dev": true,
+      "requires": {
+        "json-buffer": "3.0.1"
+      }
+    },
+    "levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "requires": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      }
+    },
+    "locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "requires": {
+        "p-locate": "^5.0.0"
+      }
+    },
+    "lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "dev": true
+    },
+    "lodash.get": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+      "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
+      "dev": true
+    },
+    "lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "log-symbols": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
+      "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
+      "dev": true,
+      "requires": {
+        "chalk": "^4.1.0",
+        "is-unicode-supported": "^0.1.0"
+      }
+    },
+    "loglevel": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
+      "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
+      "dev": true
+    },
+    "loglevel-colored-level-prefix": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/loglevel-colored-level-prefix/-/loglevel-colored-level-prefix-1.0.0.tgz",
+      "integrity": "sha512-u45Wcxxc+SdAlh4yeF/uKlC1SPUPCy0gullSNKXod5I4bmifzk+Q4lSLExNEVn19tGaJipbZ4V4jbFn79/6mVA==",
+      "dev": true,
+      "requires": {
+        "chalk": "^1.1.3",
+        "loglevel": "^1.4.1"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+          "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+          "dev": true
+        },
+        "ansi-styles": {
+          "version": "2.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+          "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==",
+          "dev": true
+        },
+        "chalk": {
+          "version": "1.1.3",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+          "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^2.2.1",
+            "escape-string-regexp": "^1.0.2",
+            "has-ansi": "^2.0.0",
+            "strip-ansi": "^3.0.0",
+            "supports-color": "^2.0.0"
+          }
+        },
+        "escape-string-regexp": {
+          "version": "1.0.5",
+          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+          "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+          "dev": true
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "^2.0.0"
+          }
+        },
+        "supports-color": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+          "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==",
+          "dev": true
+        }
+      }
+    },
+    "loupe": {
+      "version": "2.3.7",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
+      "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
+      "dev": true,
+      "requires": {
+        "get-func-name": "^2.0.1"
+      }
+    },
+    "merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true
+    },
+    "micromatch": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
+      "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
+      "dev": true,
+      "requires": {
+        "braces": "^3.0.3",
+        "picomatch": "^2.3.1"
+      }
+    },
+    "minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "requires": {
+        "brace-expansion": "^1.1.7"
+      }
+    },
+    "mocha": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz",
+      "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==",
+      "dev": true,
+      "requires": {
+        "ansi-colors": "4.1.1",
+        "browser-stdout": "1.3.1",
+        "chokidar": "3.5.3",
+        "debug": "4.3.4",
+        "diff": "5.0.0",
+        "escape-string-regexp": "4.0.0",
+        "find-up": "5.0.0",
+        "glob": "7.2.0",
+        "he": "1.2.0",
+        "js-yaml": "4.1.0",
+        "log-symbols": "4.1.0",
+        "minimatch": "5.0.1",
+        "ms": "2.1.3",
+        "nanoid": "3.3.3",
+        "serialize-javascript": "6.0.0",
+        "strip-json-comments": "3.1.1",
+        "supports-color": "8.1.1",
+        "workerpool": "6.2.1",
+        "yargs": "16.2.0",
+        "yargs-parser": "20.2.4",
+        "yargs-unparser": "2.0.0"
+      },
+      "dependencies": {
+        "brace-expansion": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+          "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+          "dev": true,
+          "requires": {
+            "balanced-match": "^1.0.0"
+          }
+        },
+        "debug": {
+          "version": "4.3.4",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+          "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+          "dev": true,
+          "requires": {
+            "ms": "2.1.2"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.1.2",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+              "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+              "dev": true
+            }
+          }
+        },
+        "minimatch": {
+          "version": "5.0.1",
+          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz",
+          "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==",
+          "dev": true,
+          "requires": {
+            "brace-expansion": "^2.0.1"
+          }
+        },
+        "ms": {
+          "version": "2.1.3",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+          "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+          "dev": true
+        },
+        "supports-color": {
+          "version": "8.1.1",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+          "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
+    "ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "nanoid": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz",
+      "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==",
+      "dev": true
+    },
+    "natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true
+    },
+    "nise": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/nise/-/nise-6.0.0.tgz",
+      "integrity": "sha512-K8ePqo9BFvN31HXwEtTNGzgrPpmvgciDsFz8aztFjt4LqKO/JeFD8tBOeuDiCMXrIl/m1YvfH8auSpxfaD09wg==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^3.0.0",
+        "@sinonjs/fake-timers": "^11.2.2",
+        "@sinonjs/text-encoding": "^0.7.2",
+        "just-extend": "^6.2.0",
+        "path-to-regexp": "^6.2.1"
+      }
+    },
+    "normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true
+    },
+    "once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "dev": true,
+      "requires": {
+        "wrappy": "1"
+      }
+    },
+    "optionator": {
+      "version": "0.9.4",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+      "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+      "dev": true,
+      "requires": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.5"
+      }
+    },
+    "p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "requires": {
+        "yocto-queue": "^0.1.0"
+      }
+    },
+    "p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "requires": {
+        "p-limit": "^3.0.2"
+      }
+    },
+    "parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "requires": {
+        "callsites": "^3.0.0"
+      }
+    },
+    "path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true
+    },
+    "path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+      "dev": true
+    },
+    "path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true
+    },
+    "path-to-regexp": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz",
+      "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==",
+      "dev": true
+    },
+    "path-type": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+      "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+      "dev": true
+    },
+    "pathval": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+      "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+      "dev": true
+    },
+    "picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true
+    },
+    "prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true
+    },
+    "prettier": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz",
+      "integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==",
+      "dev": true
+    },
+    "prettier-eslint": {
+      "version": "15.0.1",
+      "resolved": "https://registry.npmjs.org/prettier-eslint/-/prettier-eslint-15.0.1.tgz",
+      "integrity": "sha512-mGOWVHixSvpZWARqSDXbdtTL54mMBxc5oQYQ6RAqy8jecuNJBgN3t9E5a81G66F8x8fsKNiR1HWaBV66MJDOpg==",
+      "dev": true,
+      "requires": {
+        "@types/eslint": "^8.4.2",
+        "@types/prettier": "^2.6.0",
+        "@typescript-eslint/parser": "^5.10.0",
+        "common-tags": "^1.4.0",
+        "dlv": "^1.1.0",
+        "eslint": "^8.7.0",
+        "indent-string": "^4.0.0",
+        "lodash.merge": "^4.6.0",
+        "loglevel-colored-level-prefix": "^1.0.0",
+        "prettier": "^2.5.1",
+        "pretty-format": "^23.0.1",
+        "require-relative": "^0.8.7",
+        "typescript": "^4.5.4",
+        "vue-eslint-parser": "^8.0.1"
+      },
+      "dependencies": {
+        "@eslint/eslintrc": {
+          "version": "2.1.4",
+          "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+          "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+          "dev": true,
+          "requires": {
+            "ajv": "^6.12.4",
+            "debug": "^4.3.2",
+            "espree": "^9.6.0",
+            "globals": "^13.19.0",
+            "ignore": "^5.2.0",
+            "import-fresh": "^3.2.1",
+            "js-yaml": "^4.1.0",
+            "minimatch": "^3.1.2",
+            "strip-json-comments": "^3.1.1"
+          }
+        },
+        "@eslint/js": {
+          "version": "8.57.0",
+          "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
+          "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
+          "dev": true
+        },
+        "@typescript-eslint/parser": {
+          "version": "5.62.0",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
+          "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
+          "dev": true,
+          "requires": {
+            "@typescript-eslint/scope-manager": "5.62.0",
+            "@typescript-eslint/types": "5.62.0",
+            "@typescript-eslint/typescript-estree": "5.62.0",
+            "debug": "^4.3.4"
+          }
+        },
+        "eslint": {
+          "version": "8.57.0",
+          "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
+          "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
+          "dev": true,
+          "requires": {
+            "@eslint-community/eslint-utils": "^4.2.0",
+            "@eslint-community/regexpp": "^4.6.1",
+            "@eslint/eslintrc": "^2.1.4",
+            "@eslint/js": "8.57.0",
+            "@humanwhocodes/config-array": "^0.11.14",
+            "@humanwhocodes/module-importer": "^1.0.1",
+            "@nodelib/fs.walk": "^1.2.8",
+            "@ungap/structured-clone": "^1.2.0",
+            "ajv": "^6.12.4",
+            "chalk": "^4.0.0",
+            "cross-spawn": "^7.0.2",
+            "debug": "^4.3.2",
+            "doctrine": "^3.0.0",
+            "escape-string-regexp": "^4.0.0",
+            "eslint-scope": "^7.2.2",
+            "eslint-visitor-keys": "^3.4.3",
+            "espree": "^9.6.1",
+            "esquery": "^1.4.2",
+            "esutils": "^2.0.2",
+            "fast-deep-equal": "^3.1.3",
+            "file-entry-cache": "^6.0.1",
+            "find-up": "^5.0.0",
+            "glob-parent": "^6.0.2",
+            "globals": "^13.19.0",
+            "graphemer": "^1.4.0",
+            "ignore": "^5.2.0",
+            "imurmurhash": "^0.1.4",
+            "is-glob": "^4.0.0",
+            "is-path-inside": "^3.0.3",
+            "js-yaml": "^4.1.0",
+            "json-stable-stringify-without-jsonify": "^1.0.1",
+            "levn": "^0.4.1",
+            "lodash.merge": "^4.6.2",
+            "minimatch": "^3.1.2",
+            "natural-compare": "^1.4.0",
+            "optionator": "^0.9.3",
+            "strip-ansi": "^6.0.1",
+            "text-table": "^0.2.0"
+          }
+        },
+        "file-entry-cache": {
+          "version": "6.0.1",
+          "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+          "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+          "dev": true,
+          "requires": {
+            "flat-cache": "^3.0.4"
+          }
+        },
+        "flat-cache": {
+          "version": "3.2.0",
+          "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
+          "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+          "dev": true,
+          "requires": {
+            "flatted": "^3.2.9",
+            "keyv": "^4.5.3",
+            "rimraf": "^3.0.2"
+          }
+        },
+        "globals": {
+          "version": "13.24.0",
+          "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+          "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+          "dev": true,
+          "requires": {
+            "type-fest": "^0.20.2"
+          }
+        },
+        "prettier": {
+          "version": "2.8.8",
+          "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
+          "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
+          "dev": true
+        }
+      }
+    },
+    "prettier-linter-helpers": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
+      "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
+      "dev": true,
+      "requires": {
+        "fast-diff": "^1.1.2"
+      }
+    },
+    "pretty-format": {
+      "version": "23.6.0",
+      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz",
+      "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==",
+      "dev": true,
+      "requires": {
+        "ansi-regex": "^3.0.0",
+        "ansi-styles": "^3.2.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "3.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+          "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^1.9.0"
+          }
+        },
+        "color-convert": {
+          "version": "1.9.3",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+          "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+          "dev": true,
+          "requires": {
+            "color-name": "1.1.3"
+          }
+        },
+        "color-name": {
+          "version": "1.1.3",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+          "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+          "dev": true
+        }
+      }
+    },
+    "punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true
+    },
+    "queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true
+    },
+    "randombytes": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+      "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+      "dev": true,
+      "requires": {
+        "safe-buffer": "^5.1.0"
+      }
+    },
+    "readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "requires": {
+        "picomatch": "^2.2.1"
+      }
+    },
+    "require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+      "dev": true
+    },
+    "require-relative": {
+      "version": "0.8.7",
+      "resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz",
+      "integrity": "sha512-AKGr4qvHiryxRb19m3PsLRGuKVAbJLUD7E6eOaHkfKhwc+vSgVOCY5xNvm9EkolBKTOf0GrQAZKLimOCz81Khg==",
+      "dev": true
+    },
+    "resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true
+    },
+    "reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true
+    },
+    "rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "dev": true,
+      "requires": {
+        "glob": "^7.1.3"
+      }
+    },
+    "run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "requires": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "dev": true
+    },
+    "serialize-javascript": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
+      "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
+      "dev": true,
+      "requires": {
+        "randombytes": "^2.1.0"
+      }
+    },
+    "shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "requires": {
+        "shebang-regex": "^3.0.0"
+      }
+    },
+    "shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true
+    },
+    "sinon": {
+      "version": "18.0.0",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.0.tgz",
+      "integrity": "sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^3.0.1",
+        "@sinonjs/fake-timers": "^11.2.2",
+        "@sinonjs/samsam": "^8.0.0",
+        "diff": "^5.2.0",
+        "nise": "^6.0.0",
+        "supports-color": "^7"
+      },
+      "dependencies": {
+        "diff": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
+          "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
+          "dev": true
+        }
+      }
+    },
+    "slash": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+      "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+      "dev": true
+    },
+    "string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "requires": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      }
+    },
+    "strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "requires": {
+        "ansi-regex": "^5.0.1"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "5.0.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+          "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+          "dev": true
+        }
+      }
+    },
+    "strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true
+    },
+    "supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "requires": {
+        "has-flag": "^4.0.0"
+      }
+    },
+    "synckit": {
+      "version": "0.8.8",
+      "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
+      "integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==",
+      "dev": true,
+      "requires": {
+        "@pkgr/core": "^0.1.0",
+        "tslib": "^2.6.2"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.6.3",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
+          "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
+          "dev": true
+        }
+      }
+    },
+    "text-table": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+      "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+      "dev": true
+    },
+    "to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "requires": {
+        "is-number": "^7.0.0"
+      }
+    },
+    "tslib": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+      "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+      "dev": true
+    },
+    "tsutils": {
+      "version": "3.21.0",
+      "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
+      "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
+      "dev": true,
+      "requires": {
+        "tslib": "^1.8.1"
+      }
+    },
+    "type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "requires": {
+        "prelude-ls": "^1.2.1"
+      }
+    },
+    "type-detect": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+      "dev": true
+    },
+    "type-fest": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+      "dev": true
+    },
+    "typescript": {
+      "version": "4.9.5",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
+      "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
+      "dev": true
+    },
+    "uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "requires": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "vue-eslint-parser": {
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-8.3.0.tgz",
+      "integrity": "sha512-dzHGG3+sYwSf6zFBa0Gi9ZDshD7+ad14DGOdTLjruRVgZXe2J+DcZ9iUhyR48z5g1PqRa20yt3Njna/veLJL/g==",
+      "dev": true,
+      "requires": {
+        "debug": "^4.3.2",
+        "eslint-scope": "^7.0.0",
+        "eslint-visitor-keys": "^3.1.0",
+        "espree": "^9.0.0",
+        "esquery": "^1.4.0",
+        "lodash": "^4.17.21",
+        "semver": "^7.3.5"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "7.6.2",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
+          "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
+          "dev": true
+        }
+      }
+    },
+    "which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "requires": {
+        "isexe": "^2.0.0"
+      }
+    },
+    "word-wrap": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+      "dev": true
+    },
+    "workerpool": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz",
+      "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==",
+      "dev": true
+    },
+    "wrap-ansi": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      }
+    },
+    "wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "dev": true
+    },
+    "y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "dev": true
+    },
+    "yargs": {
+      "version": "16.2.0",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
+      "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
+      "dev": true,
+      "requires": {
+        "cliui": "^7.0.2",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.0",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^20.2.2"
+      }
+    },
+    "yargs-parser": {
+      "version": "20.2.4",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz",
+      "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==",
+      "dev": true
+    },
+    "yargs-unparser": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz",
+      "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==",
+      "dev": true,
+      "requires": {
+        "camelcase": "^6.0.0",
+        "decamelize": "^4.0.0",
+        "flat": "^5.0.2",
+        "is-plain-obj": "^2.1.0"
+      },
+      "dependencies": {
+        "camelcase": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+          "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+          "dev": true
+        },
+        "decamelize": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz",
+          "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==",
+          "dev": true
+        }
+      }
+    },
+    "yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true
+    }
+  }
+}

+ 51 - 0
javascript/package.json

@@ -0,0 +1,51 @@
+{
+  "name": "@springprojects/spring-security-webauthn",
+  "version": "1.0.0-alpha.9",
+  "description": "WebAuthN JS library for Spring Security",
+  "license": "ASL-2.0",
+  "author": "????",
+  "contributors": [
+    "Rob Winch <rwinch@users.noreply.github.com>",
+    "Daniel Garnier-Moiroux <git@garnier.wf>"
+  ],
+  "repository": "github:spring-projects/spring-security",
+  "bugs": {
+    "url": "https://github.com/spring-projects/spring-security/issues"
+  },
+  "engines": {
+    "node": ">=20.0.0"
+  },
+  "scripts": {
+    "test": "mocha",
+    "check": "npm test && npm run lint",
+    "test:watch": "mocha --watch --parallel",
+    "assemble": "esbuild lib/index.js --bundle --outfile=build/dist/spring-security-webauthn.js",
+    "build": "npm run check && npm run assemble",
+    "lint": "eslint",
+    "format": "npm run lint -- --fix"
+  },
+  "main": "lib/index.js",
+  "files": [
+    "lib"
+  ],
+  "keywords": [
+    "Spring Security",
+    "WebAuthn",
+    "passkeys"
+  ],
+  "devDependencies": {
+    "@eslint/js": "^9.6.0",
+    "@types/sinon": "^17.0.3",
+    "chai": "~4.3",
+    "esbuild": "^0.23.0",
+    "eslint": "^9.6.0",
+    "eslint-config-prettier": "^9.1.0",
+    "eslint-plugin-prettier": "^5.1.3",
+    "globals": "^15.8.0",
+    "mocha": "~10.2",
+    "prettier": "^3.3.2",
+    "prettier-eslint": "~15.0",
+    "sinon": "^18.0.0"
+  },
+  "type": "module"
+}

+ 49 - 0
javascript/spring-security-javascript.gradle

@@ -0,0 +1,49 @@
+
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+    id 'base'
+    id 'com.github.node-gradle.node' version '7.1.0'
+}
+
+node {
+    download = true
+    version = '20.17.0'
+}
+
+tasks.named('check') {
+    dependsOn 'npm_run_check'
+}
+
+tasks.register('dist', Zip) {
+    dependsOn 'npm_run_assemble'
+    from 'build/dist/spring-security.js'
+    into 'org/springframework/security'
+}
+
+configurations {
+    javascript {
+        canBeConsumed = true
+        canBeResolved = false
+    }
+}
+
+artifacts {
+    javascript(project.layout.buildDirectory.dir('dist')) {
+        builtBy(npm_run_assemble)
+    }
+}

+ 49 - 0
javascript/test/abort-controller.test.js

@@ -0,0 +1,49 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use strict";
+
+import "./bootstrap.js";
+import abortController from "../lib/abort-controller.js";
+import { expect } from "chai";
+
+describe("abort-controller", () => {
+  describe("newSignal", () => {
+    it("returns an AbortSignal", () => {
+      const signal = abortController.newSignal();
+
+      expect(signal).to.be.instanceof(AbortSignal);
+      expect(signal.aborted).to.be.false;
+    });
+
+    it("returns a new signal every time", () => {
+      const initialSignal = abortController.newSignal();
+
+      const newSignal = abortController.newSignal();
+
+      expect(initialSignal).to.not.equal(newSignal);
+    });
+
+    it("aborts the existing signal", () => {
+      const signal = abortController.newSignal();
+
+      abortController.newSignal();
+
+      expect(signal.aborted).to.be.true;
+      expect(signal.reason).to.equal("Initiating new WebAuthN ceremony, cancelling current ceremony");
+    });
+  });
+});

+ 76 - 0
javascript/test/base64.test.js

@@ -0,0 +1,76 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use strict";
+
+import { expect } from "chai";
+import base64url from "../lib/base64url.js";
+
+describe("base64url", () => {
+  before(() => {
+    // Emulate the atob / btoa base64 encoding/decoding from the browser
+    global.window = {
+      btoa: (str) => Buffer.from(str, "binary").toString("base64"),
+      atob: (b64) => Buffer.from(b64, "base64").toString("binary"),
+    };
+  });
+
+  after(() => {
+    // Reset window object
+    global.window = {};
+  });
+
+  it("decodes", () => {
+    // "Zm9vYmFy" is "foobar" in base 64, i.e. f:102 o:111 o:111 b:98 a:97 r:114
+    const decoded = base64url.decode("Zm9vYmFy");
+
+    expect(new Uint8Array(decoded)).to.be.deep.equal(new Uint8Array([102, 111, 111, 98, 97, 114]));
+  });
+
+  it("decodes special characters", () => {
+    // Wrap the decode function for easy testing
+    const decode = (str) => {
+      const decoded = new Uint8Array(base64url.decode(str));
+      return Array.from(decoded);
+    };
+
+    // "Pz8/" is "???" in base64, i.e. ?:63 three times
+    expect(decode("Pz8/")).to.be.deep.equal(decode("Pz8_"));
+    expect(decode("Pz8_")).to.be.deep.equal([63, 63, 63]);
+    // "Pj4+" is ">>>" in base64, ie >:62 three times
+    expect(decode("Pj4+")).to.be.deep.equal(decode("Pj4-"));
+    expect(decode("Pj4-")).to.be.deep.equal([62, 62, 62]);
+  });
+
+  it("encodes", () => {
+    const encoded = base64url.encode(Buffer.from("foobar"));
+
+    expect(encoded).to.be.equal("Zm9vYmFy");
+  });
+
+  it("encodes special +/ characters", () => {
+    const encode = (str) => base64url.encode(Buffer.from(str));
+
+    expect(encode("???")).to.be.equal("Pz8_");
+    expect(encode(">>>")).to.be.equal("Pj4-");
+  });
+
+  it("is stable", () => {
+    const base = "tyRDnKxdj7uWOT5jrchXu54lo6nf3bWOUvMQnGOXk7g";
+
+    expect(base64url.encode(base64url.decode(base))).to.be.equal(base);
+  });
+});

+ 22 - 0
javascript/test/bootstrap.js

@@ -0,0 +1,22 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import chai from "chai";
+
+// Show full diffs when there is an equality difference an assertion.
+// By default, chai truncates at 40 characters, making it difficult to
+// compare e.g. error messages
+chai.config.truncateThreshold = 0;

+ 65 - 0
javascript/test/http.test.js

@@ -0,0 +1,65 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use strict";
+
+import http from "../lib/http.js";
+import { expect } from "chai";
+import { fake, assert } from "sinon";
+
+describe("http", () => {
+  beforeEach(() => {
+    global.fetch = fake.resolves({ ok: true });
+  });
+
+  afterEach(() => {
+    delete global.fetch;
+  });
+
+  describe("post", () => {
+    it("calls fetch with headers", async () => {
+      const url = "https://example.com/some/path";
+      const headers = { "x-custom": "some-value" };
+
+      const resp = await http.post(url, headers);
+
+      expect(resp.ok).to.be.true;
+      assert.calledOnceWithExactly(global.fetch, url, {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+          ...headers,
+        },
+      });
+    });
+
+    it("sends the body as a JSON string", async () => {
+      const body = { foo: "bar", baz: 42 };
+      const url = "https://example.com/some/path";
+
+      const resp = await http.post(url, {}, body);
+
+      expect(resp.ok).to.be.true;
+      assert.calledOnceWithExactly(global.fetch, url, {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: `{"foo":"bar","baz":42}`,
+      });
+    });
+  });
+});

+ 697 - 0
javascript/test/webauthn-core.test.js

@@ -0,0 +1,697 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use strict";
+
+import "./bootstrap.js";
+import { expect } from "chai";
+import { assert, fake, match, stub } from "sinon";
+import http from "../lib/http.js";
+import webauthn from "../lib/webauthn-core.js";
+import base64url from "../lib/base64url.js";
+
+describe("webauthn-core", () => {
+  beforeEach(() => {
+    global.window = {
+      btoa: (str) => Buffer.from(str, "binary").toString("base64"),
+      atob: (b64) => Buffer.from(b64, "base64").toString("binary"),
+    };
+  });
+
+  afterEach(() => {
+    delete global.window;
+  });
+
+  describe("isConditionalMediationAvailable", () => {
+    afterEach(() => {
+      delete global.window.PublicKeyCredential;
+    });
+
+    it("is available", async () => {
+      global.window = {
+        PublicKeyCredential: {
+          isConditionalMediationAvailable: fake.resolves(true),
+        },
+      };
+
+      const result = await webauthn.isConditionalMediationAvailable();
+
+      expect(result).to.be.true;
+    });
+
+    describe("is not available", async () => {
+      it("PublicKeyCredential does not exist", async () => {
+        global.window = {};
+        const result = await webauthn.isConditionalMediationAvailable();
+        expect(result).to.be.false;
+      });
+      it("PublicKeyCredential.isConditionalMediationAvailable undefined", async () => {
+        global.window = {
+          PublicKeyCredential: {},
+        };
+        const result = await webauthn.isConditionalMediationAvailable();
+        expect(result).to.be.false;
+      });
+      it("PublicKeyCredential.isConditionalMediationAvailable false", async () => {
+        global.window = {
+          PublicKeyCredential: {
+            isConditionalMediationAvailable: fake.resolves(false),
+          },
+        };
+        const result = await webauthn.isConditionalMediationAvailable();
+        expect(result).to.be.false;
+      });
+    });
+  });
+
+  describe("authenticate", () => {
+    let httpPostStub;
+    const contextPath = "/some/path";
+
+    const credentialsGetOptions = {
+      challenge: "nRbOrtNKTfJ1JaxfUDKs8j3B-JFqyGQw8DO4u6eV3JA",
+      timeout: 300000,
+      rpId: "localhost",
+      allowCredentials: [],
+      userVerification: "preferred",
+      extensions: {},
+    };
+
+    // This is kind of a self-fulfilling prophecy type of test: we produce array buffers by calling
+    // base64url.decode ; they will then be re-encoded to the same string in the production code.
+    // The ArrayBuffer API is not super friendly.
+    beforeEach(() => {
+      httpPostStub = stub(http, "post");
+      httpPostStub.withArgs(contextPath + "/webauthn/authenticate/options", match.any).resolves({
+        ok: true,
+        status: 200,
+        json: fake.resolves(credentialsGetOptions),
+      });
+      httpPostStub.withArgs(`${contextPath}/login/webauthn`, match.any, match.any).resolves({
+        ok: true,
+        status: 200,
+        json: fake.resolves({
+          authenticated: true,
+          redirectUrl: "/success",
+        }),
+      });
+
+      const validAuthenticatorResponse = {
+        id: "UgghgP5QKozwsSUK1twCj8mpgZs",
+        rawId: base64url.decode("UgghgP5QKozwsSUK1twCj8mpgZs"),
+        response: {
+          authenticatorData: base64url.decode("y9GqwTRaMpzVDbXq1dyEAXVOxrou08k22ggRC45MKNgdAAAAAA"),
+          clientDataJSON: base64url.decode(
+            "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiUTdlR0NkNUw2cG9fa01meWNIQnBWRlR5dmd3RklCV0QxZWg5OUktRFhnWSIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5sb2NhbGhvc3Q6ODQ0MyJ9",
+          ),
+          signature: base64url.decode(
+            "MEUCIGT9PAWfU3lMicOXFMpHGcl033dY-sNSJvehlXvvoivyAiEA_D_yOsChERlXX2rFcK6Qx5BaAbx5qdU2hgYDVN6W770",
+          ),
+          userHandle: base64url.decode("tyRDnKxdj7uWOT5jrchXu54lo6nf3bWOUvMQnGOXk7g"),
+        },
+        getClientExtensionResults: () => ({}),
+        authenticatorAttachment: "platform",
+        type: "public-key",
+      };
+      global.navigator = {
+        credentials: {
+          get: fake.resolves(validAuthenticatorResponse),
+        },
+      };
+    });
+
+    afterEach(() => {
+      http.post.restore();
+      delete global.navigator;
+    });
+
+    it("succeeds", async () => {
+      const redirectUrl = await webauthn.authenticate({ "x-custom": "some-value" }, contextPath, false);
+
+      expect(redirectUrl).to.equal("/success");
+      assert.calledWith(
+        httpPostStub.lastCall,
+        `${contextPath}/login/webauthn`,
+        { "x-custom": "some-value" },
+        {
+          id: "UgghgP5QKozwsSUK1twCj8mpgZs",
+          rawId: "UgghgP5QKozwsSUK1twCj8mpgZs",
+          credType: "public-key",
+          response: {
+            authenticatorData: "y9GqwTRaMpzVDbXq1dyEAXVOxrou08k22ggRC45MKNgdAAAAAA",
+            clientDataJSON:
+              "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiUTdlR0NkNUw2cG9fa01meWNIQnBWRlR5dmd3RklCV0QxZWg5OUktRFhnWSIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5sb2NhbGhvc3Q6ODQ0MyJ9",
+            signature:
+              "MEUCIGT9PAWfU3lMicOXFMpHGcl033dY-sNSJvehlXvvoivyAiEA_D_yOsChERlXX2rFcK6Qx5BaAbx5qdU2hgYDVN6W770",
+            userHandle: "tyRDnKxdj7uWOT5jrchXu54lo6nf3bWOUvMQnGOXk7g",
+          },
+          clientExtensionResults: {},
+          authenticatorAttachment: "platform",
+        },
+      );
+    });
+
+    it("calls the authenticator with the correct options", async () => {
+      await webauthn.authenticate({}, contextPath, false);
+
+      assert.calledOnceWithMatch(global.navigator.credentials.get, {
+        publicKey: {
+          challenge: base64url.decode("nRbOrtNKTfJ1JaxfUDKs8j3B-JFqyGQw8DO4u6eV3JA"),
+          timeout: 300000,
+          rpId: "localhost",
+          allowCredentials: [],
+          userVerification: "preferred",
+          extensions: {},
+        },
+        signal: match.any,
+      });
+    });
+
+    describe("authentication failures", () => {
+      it("when authentication options call", async () => {
+        httpPostStub
+          .withArgs(`${contextPath}/webauthn/authenticate/options`, match.any)
+          .rejects(new Error("Connection refused"));
+
+        try {
+          await webauthn.authenticate({}, contextPath, false);
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal(
+            "Authentication failed. Could not fetch authentication options: Connection refused",
+          );
+          return;
+        }
+        expect.fail("authenticate should throw");
+      });
+
+      it("when authentication options call returns does not return HTTP 200 OK", async () => {
+        httpPostStub.withArgs(`${contextPath}/webauthn/authenticate/options`, match.any).resolves({
+          ok: false,
+          status: 400,
+        });
+
+        try {
+          await webauthn.authenticate({}, contextPath, false);
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal("Authentication failed. Could not fetch authentication options: HTTP 400");
+          return;
+        }
+        expect.fail("authenticate should throw");
+      });
+
+      it("when authentication options are not valid json", async () => {
+        httpPostStub.withArgs(`${contextPath}/webauthn/authenticate/options`, match.any).resolves({
+          ok: true,
+          status: 200,
+          json: fake.rejects(new Error("Not valid JSON")),
+        });
+
+        try {
+          await webauthn.authenticate({}, contextPath, false);
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal("Authentication failed. Could not fetch authentication options: Not valid JSON");
+          return;
+        }
+        expect.fail("authenticate should throw");
+      });
+
+      it("when navigator.credentials.get fails", async () => {
+        global.navigator.credentials.get = fake.rejects(new Error("Operation was aborted"));
+        try {
+          await webauthn.authenticate({}, contextPath, false);
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal(
+            "Authentication failed. Call to navigator.credentials.get failed: Operation was aborted",
+          );
+          return;
+        }
+        expect.fail("authenticate should throw");
+      });
+
+      it("when authentication call fails", async () => {
+        httpPostStub
+          .withArgs(`${contextPath}/login/webauthn`, match.any, match.any)
+          .rejects(new Error("Connection refused"));
+        try {
+          await webauthn.authenticate({}, contextPath, false);
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal(
+            "Authentication failed. Could not process the authentication request: Connection refused",
+          );
+          return;
+        }
+        expect.fail("authenticate should throw");
+      });
+
+      it("when authentication call does not return HTTP 200 OK", async () => {
+        httpPostStub.withArgs(`${contextPath}/login/webauthn`, match.any, match.any).resolves({
+          ok: false,
+          status: 400,
+        });
+        try {
+          await webauthn.authenticate({}, contextPath, false);
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal("Authentication failed. Could not process the authentication request: HTTP 400");
+          return;
+        }
+        expect.fail("authenticate should throw");
+      });
+
+      it("when authentication call does not return JSON", async () => {
+        httpPostStub.withArgs(`${contextPath}/login/webauthn`, match.any, match.any).resolves({
+          ok: true,
+          status: 200,
+          json: fake.rejects(new Error("Not valid JSON")),
+        });
+        try {
+          await webauthn.authenticate({}, contextPath, false);
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal(
+            "Authentication failed. Could not process the authentication request: Not valid JSON",
+          );
+          return;
+        }
+        expect.fail("authenticate should throw");
+      });
+
+      it("when authentication call returns null", async () => {
+        httpPostStub.withArgs(`${contextPath}/login/webauthn`, match.any, match.any).resolves({
+          ok: true,
+          status: 200,
+          json: fake.resolves(null),
+        });
+        try {
+          await webauthn.authenticate({}, contextPath, false);
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal(
+            'Authentication failed. Expected {"authenticated": true, "redirectUrl": "..."}, server responded with: null',
+          );
+          return;
+        }
+        expect.fail("authenticate should throw");
+      });
+
+      it('when authentication call returns {"authenticated":false}', async () => {
+        httpPostStub.withArgs(`${contextPath}/login/webauthn`, match.any, match.any).resolves({
+          ok: true,
+          status: 200,
+          json: fake.resolves({
+            authenticated: false,
+          }),
+        });
+        try {
+          await webauthn.authenticate({}, contextPath, false);
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal(
+            'Authentication failed. Expected {"authenticated": true, "redirectUrl": "..."}, server responded with: {"authenticated":false}',
+          );
+          return;
+        }
+        expect.fail("authenticate should throw");
+      });
+
+      it("when authentication call returns no redirectUrl", async () => {
+        httpPostStub.withArgs(`${contextPath}/login/webauthn`, match.any, match.any).resolves({
+          ok: true,
+          status: 200,
+          json: fake.resolves({
+            authenticated: true,
+          }),
+        });
+        try {
+          await webauthn.authenticate({}, contextPath, false);
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal(
+            'Authentication failed. Expected {"authenticated": true, "redirectUrl": "..."}, server responded with: {"authenticated":true}',
+          );
+          return;
+        }
+        expect.fail("authenticate should throw");
+      });
+    });
+  });
+
+  describe("register", () => {
+    let httpPostStub;
+    const contextPath = "/some/path";
+
+    beforeEach(() => {
+      const credentialsCreateOptions = {
+        rp: {
+          name: "Spring Security Relying Party",
+          id: "example.localhost",
+        },
+        user: {
+          name: "user",
+          id: "eatPy60xmXG_58JrIiIBa5wq8Y76c7MD6mnY5vW8yP8",
+          displayName: "user",
+        },
+        challenge: "s0hBOfkSaVLXdsbyD8jii6t2IjUd-eiTP1Cmeuo1qUo",
+        pubKeyCredParams: [
+          {
+            type: "public-key",
+            alg: -8,
+          },
+          {
+            type: "public-key",
+            alg: -7,
+          },
+          {
+            type: "public-key",
+            alg: -257,
+          },
+        ],
+        timeout: 300000,
+        excludeCredentials: [
+          {
+            id: "nOsjw8eaaqSwVdTBBYE1FqfGdHs",
+            type: "public-key",
+            transports: [],
+          },
+        ],
+        authenticatorSelection: {
+          residentKey: "required",
+          userVerification: "preferred",
+        },
+        attestation: "direct",
+        extensions: { credProps: true },
+      };
+      const validAuthenticatorResponse = {
+        authenticatorAttachment: "platform",
+        id: "9wAuex_025BgEQrs7fOypo5SGBA",
+        rawId: base64url.decode("9wAuex_025BgEQrs7fOypo5SGBA"),
+        response: {
+          attestationObject: base64url.decode(
+            "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYy9GqwTRaMpzVDbXq1dyEAXVOxrou08k22ggRC45MKNhdAAAAAPv8MAcVTk7MjAtuAgVX170AFPcALnsf9NuQYBEK7O3zsqaOUhgQpQECAyYgASFYIMB9pM2BeSeEG83fAKFVSLKIfvDBBVoyGgMoiGxE-6WgIlggazAojM5sduQy2M7rz1do55nVaNLGXh8k4xBHz-Oy91E",
+          ),
+          getAuthenticatorData: () =>
+            base64url.decode(
+              "y9GqwTRaMpzVDbXq1dyEAXVOxrou08k22ggRC45MKNhdAAAAAPv8MAcVTk7MjAtuAgVX170AFPcALnsf9NuQYBEK7O3zsqaOUhgQpQECAyYgASFYIMB9pM2BeSeEG83fAKFVSLKIfvDBBVoyGgMoiGxE-6WgIlggazAojM5sduQy2M7rz1do55nVaNLGXh8k4xBHz-Oy91E",
+            ),
+          clientDataJSON: base64url.decode(
+            "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiUVdwd3lUcXJpYVlqbVdnOWFvZ0FxUlRKNVFYMFBGV2JWR2xNeGNsVjZhcyIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5sb2NhbGhvc3Q6ODQ0MyJ9",
+          ),
+          getPublicKey: () =>
+            base64url.decode(
+              "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwH2kzYF5J4Qbzd8AoVVIsoh-8MEFWjIaAyiIbET7paBrMCiMzmx25DLYzuvPV2jnmdVo0sZeHyTjEEfP47L3UQ",
+            ),
+          getPublicKeyAlgorithm: () => -7,
+          getTransports: () => ["internal"],
+        },
+        type: "public-key",
+        getClientExtensionResults: () => ({}),
+      };
+      global.navigator = {
+        credentials: {
+          create: fake.resolves(validAuthenticatorResponse),
+        },
+      };
+      httpPostStub = stub(http, "post");
+      httpPostStub.withArgs(contextPath + "/webauthn/register/options", match.any).resolves({
+        ok: true,
+        status: 200,
+        json: fake.resolves(credentialsCreateOptions),
+      });
+      httpPostStub.withArgs(`${contextPath}/webauthn/register`, match.any, match.any).resolves({
+        ok: true,
+        json: fake.resolves({
+          success: true,
+        }),
+      });
+    });
+
+    afterEach(() => {
+      httpPostStub.restore();
+      delete global.navigator;
+    });
+
+    it("succeeds", async () => {
+      const contextPath = "/some/path";
+      const headers = { _csrf: "csrf-value" };
+
+      await webauthn.register(headers, contextPath, "my passkey");
+      assert.calledWithExactly(
+        httpPostStub.lastCall,
+        `${contextPath}/webauthn/register`,
+        headers,
+        match({
+          publicKey: {
+            credential: {
+              id: "9wAuex_025BgEQrs7fOypo5SGBA",
+              rawId: "9wAuex_025BgEQrs7fOypo5SGBA",
+              response: {
+                attestationObject:
+                  "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYy9GqwTRaMpzVDbXq1dyEAXVOxrou08k22ggRC45MKNhdAAAAAPv8MAcVTk7MjAtuAgVX170AFPcALnsf9NuQYBEK7O3zsqaOUhgQpQECAyYgASFYIMB9pM2BeSeEG83fAKFVSLKIfvDBBVoyGgMoiGxE-6WgIlggazAojM5sduQy2M7rz1do55nVaNLGXh8k4xBHz-Oy91E",
+                clientDataJSON:
+                  "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiUVdwd3lUcXJpYVlqbVdnOWFvZ0FxUlRKNVFYMFBGV2JWR2xNeGNsVjZhcyIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5sb2NhbGhvc3Q6ODQ0MyJ9",
+                transports: ["internal"],
+              },
+              type: "public-key",
+              clientExtensionResults: {},
+              authenticatorAttachment: "platform",
+            },
+            label: "my passkey",
+          },
+        }),
+      );
+    });
+
+    it("calls the authenticator with the correct options", async () => {
+      await webauthn.register({}, contextPath, "my passkey");
+
+      assert.calledOnceWithExactly(
+        global.navigator.credentials.create,
+        match({
+          publicKey: {
+            rp: {
+              name: "Spring Security Relying Party",
+              id: "example.localhost",
+            },
+            user: {
+              name: "user",
+              id: base64url.decode("eatPy60xmXG_58JrIiIBa5wq8Y76c7MD6mnY5vW8yP8"),
+              displayName: "user",
+            },
+            challenge: base64url.decode("s0hBOfkSaVLXdsbyD8jii6t2IjUd-eiTP1Cmeuo1qUo"),
+            pubKeyCredParams: [
+              {
+                type: "public-key",
+                alg: -8,
+              },
+              {
+                type: "public-key",
+                alg: -7,
+              },
+              {
+                type: "public-key",
+                alg: -257,
+              },
+            ],
+            timeout: 300000,
+            excludeCredentials: [
+              {
+                id: base64url.decode("nOsjw8eaaqSwVdTBBYE1FqfGdHs"),
+                type: "public-key",
+                transports: [],
+              },
+            ],
+            authenticatorSelection: {
+              residentKey: "required",
+              userVerification: "preferred",
+            },
+            attestation: "direct",
+            extensions: { credProps: true },
+          },
+          signal: match.any,
+        }),
+      );
+    });
+
+    describe("registration failures", () => {
+      it("when label is missing", async () => {
+        try {
+          await webauthn.register({}, "/", "");
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal("Error: Passkey Label is required");
+          return;
+        }
+        expect.fail("register should throw");
+      });
+
+      it("when cannot get the registration options", async () => {
+        httpPostStub.withArgs(match.any, match.any).rejects(new Error("Server threw an error"));
+        try {
+          await webauthn.register({}, "/", "my passkey");
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal(
+            "Registration failed. Could not fetch registration options: Server threw an error",
+          );
+          return;
+        }
+        expect.fail("register should throw");
+      });
+
+      it("when registration options call does not return HTTP 200 OK", async () => {
+        httpPostStub.withArgs(match.any, match.any).resolves({
+          ok: false,
+          status: 400,
+        });
+        try {
+          await webauthn.register({}, "/", "my passkey");
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal(
+            "Registration failed. Could not fetch registration options: Server responded with HTTP 400",
+          );
+          return;
+        }
+        expect.fail("register should throw");
+      });
+
+      it("when registration options are not valid JSON", async () => {
+        httpPostStub.withArgs(match.any, match.any).resolves({
+          ok: true,
+          status: 200,
+          json: fake.rejects(new Error("Not a JSON response")),
+        });
+        try {
+          await webauthn.register({}, "/", "my passkey");
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal(
+            "Registration failed. Could not fetch registration options: Not a JSON response",
+          );
+          return;
+        }
+        expect.fail("register should throw");
+      });
+
+      it("when navigator.credentials.create fails", async () => {
+        global.navigator = {
+          credentials: {
+            create: fake.rejects(new Error("authenticator threw an error")),
+          },
+        };
+        try {
+          await webauthn.register({}, contextPath, "my passkey");
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal(
+            "Registration failed. Call to navigator.credentials.create failed: authenticator threw an error",
+          );
+          expect(err.cause).to.deep.equal(new Error("authenticator threw an error"));
+          return;
+        }
+        expect.fail("register should throw");
+      });
+
+      it("when registration call fails", async () => {
+        httpPostStub
+          .withArgs(`${contextPath}/webauthn/register`, match.any, match.any)
+          .rejects(new Error("Connection refused"));
+        try {
+          await webauthn.register({}, contextPath, "my passkey");
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal(
+            "Registration failed. Could not process the registration request: Connection refused",
+          );
+          expect(err.cause).to.deep.equal(new Error("Connection refused"));
+          return;
+        }
+        expect.fail("register should throw");
+      });
+
+      it("when registration call does not return HTTP 200 OK", async () => {
+        httpPostStub.withArgs(`${contextPath}/webauthn/register`, match.any, match.any).resolves({
+          ok: false,
+          status: 400,
+        });
+        try {
+          await webauthn.register({}, contextPath, "my passkey");
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal("Registration failed. Could not process the registration request: HTTP 400");
+          return;
+        }
+        expect.fail("register should throw");
+      });
+
+      it("when registration call does not return JSON", async () => {
+        httpPostStub.withArgs(`${contextPath}/webauthn/register`, match.any, match.any).resolves({
+          ok: true,
+          status: 200,
+          json: fake.rejects(new Error("Not valid JSON")),
+        });
+        try {
+          await webauthn.register({}, contextPath, "my passkey");
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal(
+            "Registration failed. Could not process the registration request: Not valid JSON",
+          );
+          expect(err.cause).to.deep.equal(new Error("Not valid JSON"));
+          return;
+        }
+        expect.fail("register should throw");
+      });
+
+      it("when registration call returns null", async () => {
+        httpPostStub.withArgs(`${contextPath}/webauthn/register`, match.any, match.any).resolves({
+          ok: true,
+          status: 200,
+          json: fake.resolves(null),
+        });
+        try {
+          await webauthn.register({}, contextPath, "my passkey");
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal("Registration failed. Server responded with: null");
+          return;
+        }
+        expect.fail("register should throw");
+      });
+
+      it('when registration call returns {"success":false}', async () => {
+        httpPostStub.withArgs(`${contextPath}/webauthn/register`, match.any, match.any).resolves({
+          ok: true,
+          status: 200,
+          json: fake.resolves({ success: false }),
+        });
+        try {
+          await webauthn.register({}, contextPath, "my passkey");
+        } catch (err) {
+          expect(err).to.be.an("error");
+          expect(err.message).to.equal('Registration failed. Server responded with: {"success":false}');
+          return;
+        }
+        expect.fail("register should throw");
+      });
+    });
+  });
+});

+ 106 - 0
javascript/test/webauthn-login.test.js

@@ -0,0 +1,106 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use strict";
+
+import "./bootstrap.js";
+import { expect } from "chai";
+import { setupLogin } from "../lib/webauthn-login.js";
+import webauthn from "../lib/webauthn-core.js";
+import { assert, fake, match, stub } from "sinon";
+
+describe("webauthn-login", () => {
+  describe("bootstrap", () => {
+    let authenticateStub;
+    let isConditionalMediationAvailableStub;
+    let signinButton;
+
+    beforeEach(() => {
+      isConditionalMediationAvailableStub = stub(webauthn, "isConditionalMediationAvailable").resolves(false);
+      authenticateStub = stub(webauthn, "authenticate").resolves("/success");
+      signinButton = {
+        addEventListener: fake(),
+      };
+
+      global.console = {
+        error: stub(),
+      };
+      global.window = {
+        location: {
+          href: {},
+        },
+      };
+    });
+
+    afterEach(() => {
+      authenticateStub.restore();
+      isConditionalMediationAvailableStub.restore();
+    });
+
+    it("sets up a click event listener on the signin button", async () => {
+      await setupLogin({}, "/some/path", signinButton);
+
+      assert.calledOnceWithMatch(signinButton.addEventListener, "click", match.typeOf("function"));
+    });
+
+    // FIXME: conditional mediation triggers browser crashes
+    // See: https://github.com/rwinch/spring-security-webauthn/issues/73
+    xit("uses conditional mediation when available", async () => {
+      isConditionalMediationAvailableStub.resolves(true);
+
+      const headers = { "x-header": "value" };
+      const contextPath = "/some/path";
+
+      await setupLogin(headers, contextPath, signinButton);
+
+      assert.calledOnceWithExactly(authenticateStub, headers, contextPath, true);
+      expect(global.window.location.href).to.equal("/success");
+    });
+
+    it("does not call authenticate when conditional mediation is not available", async () => {
+      await setupLogin({}, "/", signinButton);
+
+      assert.notCalled(authenticateStub);
+    });
+
+    it("calls authenticate when the signin button is clicked", async () => {
+      const headers = { "x-header": "value" };
+      const contextPath = "/some/path";
+
+      await setupLogin(headers, contextPath, signinButton);
+
+      // Call the event listener
+      await signinButton.addEventListener.firstCall.lastArg();
+
+      assert.calledOnceWithExactly(authenticateStub, headers, contextPath, false);
+      expect(global.window.location.href).to.equal("/success");
+    });
+
+    it("handles authentication errors", async () => {
+      authenticateStub.rejects(new Error("Authentication failed"));
+      await setupLogin({}, "/some/path", signinButton);
+
+      // Call the event listener
+      await signinButton.addEventListener.firstCall.lastArg();
+
+      expect(global.window.location.href).to.equal(`/some/path/login?error`);
+      assert.calledOnceWithMatch(
+        global.console.error,
+        match.instanceOf(Error).and(match.has("message", "Authentication failed")),
+      );
+    });
+  });
+});

+ 279 - 0
javascript/test/webauthn-registration.test.js

@@ -0,0 +1,279 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use strict";
+
+import "./bootstrap.js";
+import { expect, util, Assertion } from "chai";
+import { setupRegistration } from "../lib/webauthn-registration.js";
+import webauthn from "../lib/webauthn-core.js";
+import { assert, fake, match, stub } from "sinon";
+
+describe("webauthn-registration", () => {
+  before(() => {
+    Assertion.addProperty("visible", function () {
+      const obj = util.flag(this, "object");
+      new Assertion(obj).to.have.nested.property("style.display", "block");
+    });
+    Assertion.addProperty("hidden", function () {
+      const obj = util.flag(this, "object");
+      new Assertion(obj).to.have.nested.property("style.display", "none");
+    });
+  });
+
+  describe("bootstrap", () => {
+    let registerStub;
+    let registerButton;
+    let labelField;
+    let errorPopup;
+    let successPopup;
+    let deleteForms;
+    let ui;
+
+    beforeEach(() => {
+      registerStub = stub(webauthn, "register").resolves(undefined);
+      errorPopup = {
+        style: {
+          display: undefined,
+        },
+        textContent: undefined,
+      };
+      successPopup = {
+        style: {
+          display: undefined,
+        },
+        textContent: undefined,
+      };
+      registerButton = {
+        addEventListener: fake(),
+      };
+      labelField = {
+        value: undefined,
+      };
+      deleteForms = [];
+      ui = {
+        getSuccess: function () {
+          return successPopup;
+        },
+        getError: function () {
+          return errorPopup;
+        },
+        getRegisterButton: function () {
+          return registerButton;
+        },
+        getLabelInput: function () {
+          return labelField;
+        },
+        getDeleteForms: function () {
+          return deleteForms;
+        },
+      };
+      global.window = {
+        location: {
+          href: {},
+        },
+      };
+      global.console = {
+        error: stub(),
+      };
+    });
+
+    afterEach(() => {
+      registerStub.restore();
+      delete global.window;
+    });
+
+    describe("when webauthn is not supported", () => {
+      beforeEach(() => {
+        delete global.window.PublicKeyCredential;
+      });
+
+      it("does not set up a click event listener", async () => {
+        await setupRegistration({}, "/", ui);
+
+        assert.notCalled(registerButton.addEventListener);
+      });
+
+      it("shows an error popup", async () => {
+        await setupRegistration({}, "/", ui);
+
+        expect(errorPopup).to.be.visible;
+        expect(errorPopup.textContent).to.equal("WebAuthn is not supported");
+        expect(successPopup).to.be.hidden;
+      });
+    });
+
+    describe("when webauthn is supported", () => {
+      beforeEach(() => {
+        global.window.PublicKeyCredential = fake();
+      });
+
+      it("hides the popups", async () => {
+        await setupRegistration({}, "/", ui);
+
+        expect(successPopup).to.be.hidden;
+        expect(errorPopup).to.be.hidden;
+      });
+
+      it("sets up a click event listener on the register button", async () => {
+        await setupRegistration({}, "/some/path", ui);
+
+        assert.calledOnceWithMatch(registerButton.addEventListener, "click", match.typeOf("function"));
+      });
+
+      describe(`when the query string contains "success"`, () => {
+        beforeEach(() => {
+          global.window.location.search = "?success&continue=true";
+        });
+
+        it("shows the success popup", async () => {
+          await setupRegistration({}, "/", ui);
+
+          expect(successPopup).to.be.visible;
+          expect(errorPopup).to.be.hidden;
+        });
+      });
+
+      describe("when the register button is clicked", () => {
+        const headers = { "x-header": "value" };
+        const contextPath = "/some/path";
+
+        beforeEach(async () => {
+          await setupRegistration(headers, contextPath, ui);
+        });
+
+        it("hides all the popups", async () => {
+          successPopup.textContent = "dummy-content";
+          successPopup.style.display = "block";
+          errorPopup.textContent = "dummy-content";
+          errorPopup.style.display = "block";
+
+          await registerButton.addEventListener.firstCall.lastArg();
+
+          expect(successPopup).to.be.hidden;
+          expect(errorPopup).to.be.hidden;
+        });
+
+        it("calls register", async () => {
+          labelField.value = "passkey name";
+
+          await registerButton.addEventListener.firstCall.lastArg();
+
+          assert.calledOnceWithExactly(registerStub, headers, contextPath, labelField.value);
+        });
+
+        it("navigates to success page", async () => {
+          labelField.value = "passkey name";
+
+          await registerButton.addEventListener.firstCall.lastArg();
+
+          expect(global.window.location.href).to.equal(`${contextPath}/webauthn/register?success`);
+        });
+
+        it("handles errors", async () => {
+          registerStub.rejects(new Error("The registration failed"));
+
+          await registerButton.addEventListener.firstCall.lastArg();
+
+          expect(errorPopup.textContent).to.equal("The registration failed");
+          expect(errorPopup).to.be.visible;
+          expect(successPopup).to.be.hidden;
+          assert.calledOnceWithMatch(
+            global.console.error,
+            match.instanceOf(Error).and(match.has("message", "The registration failed")),
+          );
+        });
+      });
+
+      describe("delete", () => {
+        beforeEach(() => {
+          global.fetch = fake.resolves({ ok: true });
+        });
+
+        afterEach(() => {
+          delete global.fetch;
+        });
+
+        it("no errors when no forms", async () => {
+          await setupRegistration({}, "/some/path", ui);
+        });
+
+        it("sets up forms for fetch", async () => {
+          const deleteFormOne = {
+            addEventListener: fake(),
+          };
+          const deleteFormTwo = {
+            addEventListener: fake(),
+          };
+          deleteForms = [deleteFormOne, deleteFormTwo];
+
+          await setupRegistration({}, "", ui);
+
+          assert.calledOnceWithMatch(deleteFormOne.addEventListener, "submit", match.typeOf("function"));
+          assert.calledOnceWithMatch(deleteFormTwo.addEventListener, "submit", match.typeOf("function"));
+        });
+
+        describe("when the delete button is clicked", () => {
+          it("calls POST to the form action", async () => {
+            const contextPath = "/some/path";
+            const deleteForm = {
+              addEventListener: fake(),
+              action: `${contextPath}/webauthn/1234`,
+            };
+            deleteForms = [deleteForm];
+            const headers = {
+              "X-CSRF-TOKEN": "token",
+            };
+
+            await setupRegistration(headers, contextPath, ui);
+
+            const clickEvent = {
+              preventDefault: fake(),
+            };
+            await deleteForm.addEventListener.firstCall.lastArg(clickEvent);
+            assert.calledOnce(clickEvent.preventDefault);
+            assert.calledOnceWithExactly(global.fetch, `/some/path/webauthn/1234`, {
+              method: "DELETE",
+              headers: {
+                "Content-Type": "application/json",
+                ...headers,
+              },
+            });
+            expect(global.window.location.href).to.equal(`/some/path/webauthn/register?success`);
+          });
+        });
+
+        it("handles errors", async () => {
+          global.fetch = fake.rejects("Server threw an error");
+          global.window.location.href = "/initial/location";
+          const deleteForm = {
+            addEventListener: fake(),
+          };
+          deleteForms = [deleteForm];
+
+          await setupRegistration({}, "", ui);
+          const clickEvent = { preventDefault: fake() };
+          await deleteForm.addEventListener.firstCall.lastArg(clickEvent);
+
+          expect(errorPopup).to.be.visible;
+          expect(errorPopup.textContent).to.equal("Server threw an error");
+          // URL does not change
+          expect(global.window.location.href).to.equal("/initial/location");
+        });
+      });
+    });
+  });
+});

+ 26 - 0
web/spring-security-web.gradle

@@ -1,6 +1,31 @@
 apply plugin: 'io.spring.convention.spring-module'
 
+configurations {
+	javascript {
+		canBeConsumed = false
+	}
+}
+
+def syncJavascript = tasks.register('syncJavascript', Sync) {
+	group = 'Build'
+	description = 'Syncs the Javascript from the javascript configuration'
+	into project.layout.buildDirectory.dir('spring-security-javascript')
+	from(configurations.javascript) {
+		into 'org/springframework/security'
+	}
+}
+
+
+sourceSets {
+	main {
+		resources {
+			srcDirs(syncJavascript)
+		}
+	}
+}
+
 dependencies {
+	javascript project(path: ':spring-security-javascript', configuration: 'javascript')
 	management platform(project(":spring-security-dependencies"))
 	api project(':spring-security-core')
 	api 'org.springframework:spring-core'
@@ -16,6 +41,7 @@ dependencies {
 	optional 'org.springframework:spring-tx'
 	optional 'org.springframework:spring-webflux'
 	optional 'org.springframework:spring-webmvc'
+	optional libs.webauthn4j.core
 
 	provided 'jakarta.servlet:jakarta.servlet-api'
 

+ 115 - 0
web/src/main/java/org/springframework/security/web/authentication/HttpMessageConverterAuthenticationSuccessHandler.java

@@ -0,0 +1,115 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.authentication;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.http.server.ServletServerHttpResponse;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
+import org.springframework.security.web.savedrequest.RequestCache;
+import org.springframework.security.web.savedrequest.SavedRequest;
+import org.springframework.security.web.webauthn.jackson.WebauthnJackson2Module;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link AuthenticationSuccessHandler} that writes a JSON response with the redirect
+ * URL and an authenticated status similar to:
+ *
+ * <code>
+ *     {
+ *         "redirectUrl": "/user/profile",
+ *         "authenticated": true
+ *     }
+ * </code>
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class HttpMessageConverterAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
+
+	private HttpMessageConverter<Object> converter = new MappingJackson2HttpMessageConverter(
+			JsonMapper.builder().addModule(new WebauthnJackson2Module()).build());
+
+	private RequestCache requestCache = new HttpSessionRequestCache();
+
+	/**
+	 * Sets the {@link GenericHttpMessageConverter} to write to the response. The default
+	 * is {@link MappingJackson2HttpMessageConverter}.
+	 * @param converter the {@link GenericHttpMessageConverter} to use. Cannot be null.
+	 */
+	public void setConverter(HttpMessageConverter<Object> converter) {
+		Assert.notNull(converter, "converter cannot be null");
+		this.converter = converter;
+	}
+
+	/**
+	 * Sets the {@link RequestCache} to use. The default is
+	 * {@link HttpSessionRequestCache}.
+	 * @param requestCache the {@link RequestCache} to use. Cannot be null
+	 */
+	public void setRequestCache(RequestCache requestCache) {
+		Assert.notNull(requestCache, "requestCache cannot be null");
+		this.requestCache = requestCache;
+	}
+
+	@Override
+	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
+			Authentication authentication) throws IOException, ServletException {
+		final SavedRequest savedRequest = this.requestCache.getRequest(request, response);
+		final String redirectUrl = (savedRequest != null) ? savedRequest.getRedirectUrl()
+				: request.getContextPath() + "/";
+		this.requestCache.removeRequest(request, response);
+		this.converter.write(new AuthenticationSuccess(redirectUrl), MediaType.APPLICATION_JSON,
+				new ServletServerHttpResponse(response));
+	}
+
+	/**
+	 * A response object used to write the JSON response for successful authentication.
+	 *
+	 * NOTE: We should be careful about writing {@link Authentication} or
+	 * {@link Authentication#getPrincipal()} to the response since it contains
+	 * credentials.
+	 */
+	public static final class AuthenticationSuccess {
+
+		private final String redirectUrl;
+
+		private AuthenticationSuccess(String redirectUrl) {
+			this.redirectUrl = redirectUrl;
+		}
+
+		public String getRedirectUrl() {
+			return this.redirectUrl;
+		}
+
+		public boolean isAuthenticated() {
+			return true;
+		}
+
+	}
+
+}

+ 75 - 3
web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java

@@ -67,6 +67,8 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 
 	private boolean saml2LoginEnabled;
 
+	private boolean passkeysEnabled;
+
 	private boolean oneTimeTokenEnabled;
 
 	private String authenticationUrl;
@@ -85,6 +87,8 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 
 	private Function<HttpServletRequest, Map<String, String>> resolveHiddenInputs = (request) -> Collections.emptyMap();
 
+	private Function<HttpServletRequest, Map<String, String>> resolveHeaders = (request) -> Collections.emptyMap();
+
 	public DefaultLoginPageGeneratingFilter() {
 	}
 
@@ -117,6 +121,17 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 		this.resolveHiddenInputs = resolveHiddenInputs;
 	}
 
+	/**
+	 * Sets a Function used to resolve a Map of the HTTP headers where the key is the name
+	 * of the header and the value is the value of the header. Typically, this is used to
+	 * resolve the CSRF token.
+	 * @param resolveHeaders the function to resolve the headers
+	 */
+	public void setResolveHeaders(Function<HttpServletRequest, Map<String, String>> resolveHeaders) {
+		Assert.notNull(resolveHeaders, "resolveHeaders cannot be null");
+		this.resolveHeaders = resolveHeaders;
+	}
+
 	public boolean isEnabled() {
 		return this.formLoginEnabled || this.oauth2LoginEnabled || this.saml2LoginEnabled;
 	}
@@ -153,6 +168,10 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 		this.saml2LoginEnabled = saml2LoginEnabled;
 	}
 
+	public void setPasskeysEnabled(boolean passkeysEnabled) {
+		this.passkeysEnabled = passkeysEnabled;
+	}
+
 	public void setAuthenticationUrl(String authenticationUrl) {
 		this.authenticationUrl = authenticationUrl;
 	}
@@ -207,14 +226,46 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 
 		return HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE)
 			.withRawHtml("contextPath", contextPath)
+			.withRawHtml("javaScript", renderJavaScript(request, contextPath))
 			.withRawHtml("formLogin", renderFormLogin(request, loginError, logoutSuccess, contextPath, errorMsg))
 			.withRawHtml("oneTimeTokenLogin",
 					renderOneTimeTokenLogin(request, loginError, logoutSuccess, contextPath, errorMsg))
 			.withRawHtml("oauth2Login", renderOAuth2Login(loginError, logoutSuccess, errorMsg, contextPath))
 			.withRawHtml("saml2Login", renderSaml2Login(loginError, logoutSuccess, errorMsg, contextPath))
+			.withRawHtml("passkeyLogin", renderPasskeyLogin())
 			.render();
 	}
 
+	private String renderJavaScript(HttpServletRequest request, String contextPath) {
+		if (this.passkeysEnabled) {
+			return HtmlTemplates.fromTemplate(PASSKEY_SCRIPT_TEMPLATE)
+				.withValue("loginPageUrl", this.loginPageUrl)
+				.withValue("contextPath", contextPath)
+				.withRawHtml("csrfHeaders", renderHeaders(request))
+				.render();
+		}
+		return "";
+	}
+
+	private String renderPasskeyLogin() {
+		if (this.passkeysEnabled) {
+			return PASSKEY_FORM_TEMPLATE;
+		}
+		return "";
+	}
+
+	private String renderHeaders(HttpServletRequest request) {
+		StringBuffer javascriptHeadersEntries = new StringBuffer();
+		Map<String, String> headers = this.resolveHeaders.apply(request);
+		for (Map.Entry<String, String> header : headers.entrySet()) {
+			javascriptHeadersEntries.append(HtmlTemplates.fromTemplate(CSRF_HEADERS)
+				.withValue("headerName", header.getKey())
+				.withValue("headerValue", header.getValue())
+				.render());
+		}
+		return javascriptHeadersEntries.toString();
+	}
+
 	private String renderFormLogin(HttpServletRequest request, boolean loginError, boolean logoutSuccess,
 			String contextPath, String errorMsg) {
 		if (!this.formLoginEnabled) {
@@ -235,6 +286,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 			.withValue("passwordParameter", this.passwordParameter)
 			.withRawHtml("rememberMeInput", renderRememberMe(this.rememberMeParameter))
 			.withRawHtml("hiddenInputs", hiddenInputs)
+			.withValue("autocomplete", this.passkeysEnabled ? "autocomplete=\"password webauthn\"" : "")
 			.render();
 	}
 
@@ -383,6 +435,26 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 		return uri.equals(request.getContextPath() + url);
 	}
 
+	private static final String CSRF_HEADERS = """
+			{"{{headerName}}" : "{{headerValue}}"}""";
+
+	private static final String PASSKEY_SCRIPT_TEMPLATE = """
+				<script type="text/javascript" src="{{contextPath}}/login/webauthn.js"></script>
+				<script type="text/javascript">
+				<!--
+					document.addEventListener("DOMContentLoaded",() => setupLogin({{csrfHeaders}}, "{{contextPath}}", document.getElementById('passkey-signin')));
+
+				//-->
+				</script>
+			""";
+
+	private static final String PASSKEY_FORM_TEMPLATE = """
+			<div class="login-form">
+			<h2>Login with Passkeys</h2>
+			<button id="passkey-signin" type="submit" class="primary">Sign in with a passkey</button>
+			</form>
+			""";
+
 	private static final String LOGIN_PAGE_TEMPLATE = """
 			<!DOCTYPE html>
 			<html lang="en">
@@ -392,12 +464,12 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 			    <meta name="description" content="">
 			    <meta name="author" content="">
 			    <title>Please sign in</title>
-			    <link href="{{contextPath}}/default-ui.css" rel="stylesheet" />
+			    <link href="{{contextPath}}/default-ui.css" rel="stylesheet" />{{javaScript}}
 			  </head>
 			  <body>
 			    <div class="content">
 			{{formLogin}}
-			{{oneTimeTokenLogin}}
+			{{oneTimeTokenLogin}}{{passkeyLogin}}
 			{{oauth2Login}}
 			{{saml2Login}}
 			    </div>
@@ -414,7 +486,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 			        </p>
 			        <p>
 			          <label for="password" class="screenreader">Password</label>
-			          <input type="password" id="password" name="{{passwordParameter}}" placeholder="Password" required>
+			          <input type="password" id="password" name="{{passwordParameter}}" placeholder="Password" {{autocomplete}}required>
 			        </p>
 			{{rememberMeInput}}
 			{{hiddenInputs}}

+ 103 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/AttestationConveyancePreference.java

@@ -0,0 +1,103 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * <a href="https://www.w3.org/TR/webauthn-3/#webauthn-relying-party">WebAuthn Relying
+ * Parties</a> may use <a href=
+ * "https://www.w3.org/TR/webauthn-3/#enumdef-attestationconveyancepreference">AttestationConveyancePreference</a>
+ * to specify their preference regarding attestation conveyance during credential
+ * generation.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class AttestationConveyancePreference {
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-attestationconveyancepreference-none">none</a>
+	 * preference indicates that the Relying Party is not interested in
+	 * <a href="https://www.w3.org/TR/webauthn-3/#authenticator">authenticator</a>
+	 * <a href="https://www.w3.org/TR/webauthn-3/#attestation">attestation</a>.
+	 */
+	public static final AttestationConveyancePreference NONE = new AttestationConveyancePreference("none");
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-attestationconveyancepreference-indirect">indirect</a>
+	 * preference indicates that the Relying Party wants to receive a verifiable
+	 * <a href="https://www.w3.org/TR/webauthn-3/#attestation-statement">attestation
+	 * statement</a>, but allows the client to decide how to obtain such an attestation
+	 * statement.
+	 */
+	public static final AttestationConveyancePreference INDIRECT = new AttestationConveyancePreference("indirect");
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-attestationconveyancepreference-direct">direct</a>
+	 * preference indicates that the Relying Party wants to receive the
+	 * <a href="https://www.w3.org/TR/webauthn-3/#attestation-statement">attestation
+	 * statement</a> as generated by the
+	 * <a href="https://www.w3.org/TR/webauthn-3/#authenticator">authenticator</a>.
+	 */
+	public static final AttestationConveyancePreference DIRECT = new AttestationConveyancePreference("direct");
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-attestationconveyancepreference-enterprise">enterprise</a>
+	 * preference indicates that the Relying Party wants to receive an
+	 * <a href="https://www.w3.org/TR/webauthn-3/#attestation-statement">attestation
+	 * statement</a> that may include uniquely identifying information.
+	 */
+	public static final AttestationConveyancePreference ENTERPRISE = new AttestationConveyancePreference("enterprise");
+
+	private final String value;
+
+	AttestationConveyancePreference(String value) {
+		this.value = value;
+	}
+
+	/**
+	 * Gets the String value of the preference.
+	 * @return the String value of the preference.
+	 */
+	public String getValue() {
+		return this.value;
+	}
+
+	/**
+	 * Gets an instance of {@link AttestationConveyancePreference}
+	 * @param value the {@link #getValue()}
+	 * @return an {@link AttestationConveyancePreference}
+	 */
+	public static AttestationConveyancePreference valueOf(String value) {
+		switch (value) {
+			case "none":
+				return NONE;
+			case "indirect":
+				return INDIRECT;
+			case "direct":
+				return DIRECT;
+			case "enterprise":
+				return ENTERPRISE;
+			default:
+				return new AttestationConveyancePreference(value);
+		}
+	}
+
+}

+ 48 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticationExtensionsClientInput.java

@@ -0,0 +1,48 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * A <a href="https://www.w3.org/TR/webauthn-3/#client-extension-input">client extension
+ * input</a> entry in the {@link AuthenticationExtensionsClientInputs}.
+ *
+ * @param <T>
+ * @author Rob Winch
+ * @since 6.4
+ * @see ImmutableAuthenticationExtensionsClientInput
+ */
+public interface AuthenticationExtensionsClientInput<T> {
+
+	/**
+	 * Gets the <a href="https://www.w3.org/TR/webauthn-3/#extension-identifier">extension
+	 * identifier</a>.
+	 * @return the
+	 * <a href="https://www.w3.org/TR/webauthn-3/#extension-identifier">extension
+	 * identifier</a>.
+	 */
+	String getExtensionId();
+
+	/**
+	 * Gets the <a href="https://www.w3.org/TR/webauthn-3/#client-extension-input">client
+	 * extension</a>.
+	 * @return the
+	 * <a href="https://www.w3.org/TR/webauthn-3/#client-extension-input">client
+	 * extension</a>.
+	 */
+	T getInput();
+
+}

+ 42 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticationExtensionsClientInputs.java

@@ -0,0 +1,42 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+import java.util.List;
+
+/**
+ * <a href=
+ * "https://www.w3.org/TR/webauthn-3/#iface-authentication-extensions-client-inputs">AuthenticationExtensionsClientInputs</a>
+ * is a dictionary containing the
+ * <a href="https://www.w3.org/TR/webauthn-3/#client-extension-input">client extension
+ * input</a> values for zero or more
+ * <a href="https://www.w3.org/TR/webauthn-3/#webauthn-extensions">WebAuthn
+ * Extensions</a>.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ * @see PublicKeyCredentialCreationOptions#getExtensions()
+ */
+public interface AuthenticationExtensionsClientInputs {
+
+	/**
+	 * Gets all of the {@link AuthenticationExtensionsClientInput}.
+	 * @return a non-null {@link List} of {@link AuthenticationExtensionsClientInput}.
+	 */
+	List<AuthenticationExtensionsClientInput> getInputs();
+
+}

+ 47 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticationExtensionsClientOutput.java

@@ -0,0 +1,47 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * A <a href="https://www.w3.org/TR/webauthn-3/#client-extension-output">client extension
+ * output</a> entry in {@link AuthenticationExtensionsClientOutputs}.
+ *
+ * @param <T>
+ * @see AuthenticationExtensionsClientOutputs#getOutputs()
+ * @see CredentialPropertiesOutput
+ */
+public interface AuthenticationExtensionsClientOutput<T> {
+
+	/**
+	 * Gets the <a href="https://www.w3.org/TR/webauthn-3/#extension-identifier">extension
+	 * identifier</a>.
+	 * @return the
+	 * <a href="https://www.w3.org/TR/webauthn-3/#extension-identifier">extension
+	 * identifier</a>.
+	 */
+	String getExtensionId();
+
+	/**
+	 * The <a href="https://www.w3.org/TR/webauthn-3/#client-extension-output">client
+	 * extension output</a>.
+	 * @return the
+	 * <a href="https://www.w3.org/TR/webauthn-3/#client-extension-output">client
+	 * extension output</a>.
+	 */
+	T getOutput();
+
+}

+ 42 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticationExtensionsClientOutputs.java

@@ -0,0 +1,42 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+import java.util.List;
+
+/**
+ * <a href=
+ * "https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsclientoutputs">AuthenticationExtensionsClientOutputs</a>
+ * is a dictionary containing the
+ * <a href="https://www.w3.org/TR/webauthn-3/#client-extension-output">client extension
+ * output</a> values for zero or more
+ * <a href="https://www.w3.org/TR/webauthn-3/#webauthn-extensions">WebAuthn
+ * Extensions</a>.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ * @see PublicKeyCredential#getClientExtensionResults()
+ */
+public interface AuthenticationExtensionsClientOutputs {
+
+	/**
+	 * Gets all of the {@link AuthenticationExtensionsClientOutput}.
+	 * @return a non-null {@link List} of {@link AuthenticationExtensionsClientOutput}.
+	 */
+	List<AuthenticationExtensionsClientOutput<?>> getOutputs();
+
+}

+ 205 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorAssertionResponse.java

@@ -0,0 +1,205 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * The <a href=
+ * "https://www.w3.org/TR/webauthn-3/#authenticatorassertionresponse">AuthenticatorAssertionResponse</a>
+ * interface represents an
+ * <a href="https://www.w3.org/TR/webauthn-3/#authenticator">authenticator</a>'s response
+ * to a client’s request for generation of a new
+ * <a href="https://www.w3.org/TR/webauthn-3/#authentication-assertion">authentication
+ * assertion</a> given the
+ * <a href="https://www.w3.org/TR/webauthn-3/#webauthn-relying-party">WebAuthn Relying
+ * Party</a>'s challenge and OPTIONAL list of credentials it is aware of. This response
+ * contains a cryptographic signature proving possession of the
+ * <a href="https://www.w3.org/TR/webauthn-3/#credential-private-key">credential private
+ * key</a>, and optionally evidence of
+ * <a href="https://www.w3.org/TR/webauthn-3/#user-consent">user consent</a> to a specific
+ * transaction.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ * @see PublicKeyCredential#getResponse()
+ */
+public final class AuthenticatorAssertionResponse extends AuthenticatorResponse {
+
+	private final Bytes authenticatorData;
+
+	private final Bytes signature;
+
+	private final Bytes userHandle;
+
+	private final Bytes attestationObject;
+
+	private AuthenticatorAssertionResponse(Bytes clientDataJSON, Bytes authenticatorData, Bytes signature,
+			Bytes userHandle, Bytes attestationObject) {
+		super(clientDataJSON);
+		this.authenticatorData = authenticatorData;
+		this.signature = signature;
+		this.userHandle = userHandle;
+		this.attestationObject = attestationObject;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatorassertionresponse-authenticatordata">authenticatorData</a>
+	 * contains the
+	 * <a href="https://www.w3.org/TR/webauthn-3/#authenticator-data">authenticator
+	 * data</a> returned by the authenticator. See
+	 * <a href="https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data">6.1
+	 * Authenticator Data.</a>.
+	 * @return the {@code authenticatorData}
+	 */
+	public Bytes getAuthenticatorData() {
+		return this.authenticatorData;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatorassertionresponse-signature">signature</a>
+	 * contains the raw signature returned from the authenticator. See
+	 * <a href="https://www.w3.org/TR/webauthn-3/#sctn-op-get-assertion">6.3.3 The
+	 * authenticatorGetAssertion Operation</a>.
+	 * @return the {@code signature}
+	 */
+	public Bytes getSignature() {
+		return this.signature;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatorassertionresponse-userhandle">userHandle</a>
+	 * is the <a href="https://www.w3.org/TR/webauthn-3/#user-handle">user handle</a>
+	 * which is returned from the authenticator, or null if the authenticator did not
+	 * return a user handle. See
+	 * <a href="https://www.w3.org/TR/webauthn-3/#sctn-op-get-assertion">6.3.3 The
+	 * authenticatorGetAssertion Operation</a>. The authenticator MUST always return a
+	 * user handle if the <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-allowcredentials">allowCredentials</a>
+	 * option used in the
+	 * <a href="https://www.w3.org/TR/webauthn-3/#authentication-ceremony">authentication
+	 * ceremony</a> is empty, and MAY return one otherwise.
+	 * @return the <a href="https://www.w3.org/TR/webauthn-3/#user-handle">user handle</a>
+	 */
+	public Bytes getUserHandle() {
+		return this.userHandle;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatorattestationresponse-attestationobject">attestationObject</a>
+	 * is an OPTIONAL attribute contains an
+	 * <a href="https://www.w3.org/TR/webauthn-3/#attestation-object">attestation
+	 * object</a>, if the authenticator supports attestation in assertions.
+	 * @return the {@code attestationObject}
+	 */
+	public Bytes getAttestationObject() {
+		return this.attestationObject;
+	}
+
+	/**
+	 * Creates a new {@link AuthenticatorAssertionResponseBuilder}
+	 * @return the {@link AuthenticatorAssertionResponseBuilder}
+	 */
+	public static AuthenticatorAssertionResponseBuilder builder() {
+		return new AuthenticatorAssertionResponseBuilder();
+	}
+
+	/**
+	 * Builds a {@link AuthenticatorAssertionResponse}.
+	 *
+	 * @author Rob Winch
+	 * @since 6.4
+	 */
+	public static final class AuthenticatorAssertionResponseBuilder {
+
+		private Bytes authenticatorData;
+
+		private Bytes signature;
+
+		private Bytes userHandle;
+
+		private Bytes attestationObject;
+
+		private Bytes clientDataJSON;
+
+		private AuthenticatorAssertionResponseBuilder() {
+		}
+
+		/**
+		 * Set the {@link #getAuthenticatorData()} property
+		 * @param authenticatorData the authenticator data.
+		 * @return the {@link AuthenticatorAssertionResponseBuilder}
+		 */
+		public AuthenticatorAssertionResponseBuilder authenticatorData(Bytes authenticatorData) {
+			this.authenticatorData = authenticatorData;
+			return this;
+		}
+
+		/**
+		 * Set the {@link #getSignature()} property
+		 * @param signature the signature
+		 * @return the {@link AuthenticatorAssertionResponseBuilder}
+		 */
+		public AuthenticatorAssertionResponseBuilder signature(Bytes signature) {
+			this.signature = signature;
+			return this;
+		}
+
+		/**
+		 * Set the {@link #getUserHandle()} property
+		 * @param userHandle the user handle
+		 * @return the {@link AuthenticatorAssertionResponseBuilder}
+		 */
+		public AuthenticatorAssertionResponseBuilder userHandle(Bytes userHandle) {
+			this.userHandle = userHandle;
+			return this;
+		}
+
+		/**
+		 * Set the {@link #attestationObject} property
+		 * @param attestationObject the attestation object
+		 * @return the {@link AuthenticatorAssertionResponseBuilder}
+		 */
+		public AuthenticatorAssertionResponseBuilder attestationObject(Bytes attestationObject) {
+			this.attestationObject = attestationObject;
+			return this;
+		}
+
+		/**
+		 * Set the {@link #getClientDataJSON()} property
+		 * @param clientDataJSON the client data JSON
+		 * @return the {@link AuthenticatorAssertionResponseBuilder}
+		 */
+		public AuthenticatorAssertionResponseBuilder clientDataJSON(Bytes clientDataJSON) {
+			this.clientDataJSON = clientDataJSON;
+			return this;
+		}
+
+		/**
+		 * Builds the {@link AuthenticatorAssertionResponse}
+		 * @return the {@link AuthenticatorAssertionResponse}
+		 */
+		public AuthenticatorAssertionResponse build() {
+			return new AuthenticatorAssertionResponse(this.clientDataJSON, this.authenticatorData, this.signature,
+					this.userHandle, this.attestationObject);
+		}
+
+	}
+
+}

+ 88 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorAttachment.java

@@ -0,0 +1,88 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * The <a href=
+ * "https://www.w3.org/TR/webauthn-3/#enumdef-authenticatorattachment">AuthenticatorAttachment</a>.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class AuthenticatorAttachment {
+
+	/**
+	 * Indicates <a href=
+	 * "https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#cross-platform-attachment">cross-platform
+	 * attachment</a>.
+	 *
+	 * <p>
+	 * Authenticators of this class are removable from, and can "roam" among, client
+	 * platforms.
+	 */
+	public static final AuthenticatorAttachment CROSS_PLATFORM = new AuthenticatorAttachment("cross-platform");
+
+	/**
+	 * Indicates <a href=
+	 * "https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#platform-attachment">platform
+	 * attachment</a>.
+	 *
+	 * <p>
+	 * Usually, authenticators of this class are not removable from the platform.
+	 */
+	public static final AuthenticatorAttachment PLATFORM = new AuthenticatorAttachment("platform");
+
+	private final String value;
+
+	AuthenticatorAttachment(String value) {
+		this.value = value;
+	}
+
+	/**
+	 * Gets the value.
+	 * @return the value.
+	 */
+	public String getValue() {
+		return this.value;
+	}
+
+	@Override
+	public String toString() {
+		return "AuthenticatorAttachment [" + this.value + "]";
+	}
+
+	/**
+	 * Gets an instance of {@link AuthenticatorAttachment} based upon the value passed in.
+	 * @param value the value to obtain the {@link AuthenticatorAttachment}
+	 * @return the {@link AuthenticatorAttachment}
+	 */
+	public static AuthenticatorAttachment valueOf(String value) {
+		switch (value) {
+			case "cross-platform":
+				return CROSS_PLATFORM;
+			case "platform":
+				return PLATFORM;
+			default:
+				return new AuthenticatorAttachment(value);
+		}
+	}
+
+	public static AuthenticatorAttachment[] values() {
+		return new AuthenticatorAttachment[] { CROSS_PLATFORM, PLATFORM };
+	}
+
+}

+ 144 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorAttestationResponse.java

@@ -0,0 +1,144 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * <a href=
+ * "https://www.w3.org/TR/webauthn-3/#authenticatorattestationresponse">AuthenticatorAttestationResponse</a>
+ * represents the
+ * <a href="https://www.w3.org/TR/webauthn-3/#authenticator">authenticator</a>'s response
+ * to a client’s request for the creation of a new
+ * <a href="https://www.w3.org/TR/webauthn-3/#public-key-credential">public key
+ * credential</a>.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ * @see PublicKeyCredential#getResponse()
+ */
+public final class AuthenticatorAttestationResponse extends AuthenticatorResponse {
+
+	private final Bytes attestationObject;
+
+	private final List<AuthenticatorTransport> transports;
+
+	private AuthenticatorAttestationResponse(Bytes clientDataJSON, Bytes attestationObject,
+			List<AuthenticatorTransport> transports) {
+		super(clientDataJSON);
+		this.attestationObject = attestationObject;
+		this.transports = transports;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatorattestationresponse-attestationobject">attestationObject</a>
+	 * attribute contains an attestation object, which is opaque to, and cryptographically
+	 * protected against tampering by, the client.
+	 * @return the attestationObject
+	 */
+	public Bytes getAttestationObject() {
+		return this.attestationObject;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatorattestationresponse-gettransports">transports</a>
+	 * returns the <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatorattestationresponse-transports-slot">transports</a>
+	 * @return the transports
+	 */
+	public List<AuthenticatorTransport> getTransports() {
+		return this.transports;
+	}
+
+	/**
+	 * Creates a new instance.
+	 * @return the {@link AuthenticatorAttestationResponseBuilder}
+	 */
+	public static AuthenticatorAttestationResponseBuilder builder() {
+		return new AuthenticatorAttestationResponseBuilder();
+	}
+
+	/**
+	 * Builds {@link AuthenticatorAssertionResponse}.
+	 *
+	 * @author Rob Winch
+	 * @since 6.4
+	 */
+	public static final class AuthenticatorAttestationResponseBuilder {
+
+		private Bytes attestationObject;
+
+		private List<AuthenticatorTransport> transports;
+
+		private Bytes clientDataJSON;
+
+		private AuthenticatorAttestationResponseBuilder() {
+		}
+
+		/**
+		 * Sets the {@link #getAttestationObject()} property.
+		 * @param attestationObject the attestation object.
+		 * @return the {@link AuthenticatorAttestationResponseBuilder}
+		 */
+		public AuthenticatorAttestationResponseBuilder attestationObject(Bytes attestationObject) {
+			this.attestationObject = attestationObject;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getTransports()} property.
+		 * @param transports the transports
+		 * @return the {@link AuthenticatorAttestationResponseBuilder}
+		 */
+		public AuthenticatorAttestationResponseBuilder transports(AuthenticatorTransport... transports) {
+			return transports(Arrays.asList(transports));
+		}
+
+		/**
+		 * Sets the {@link #getTransports()} property.
+		 * @param transports the transports
+		 * @return the {@link AuthenticatorAttestationResponseBuilder}
+		 */
+		public AuthenticatorAttestationResponseBuilder transports(List<AuthenticatorTransport> transports) {
+			this.transports = transports;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getClientDataJSON()} property.
+		 * @param clientDataJSON the client data JSON.
+		 * @return the {@link AuthenticatorAttestationResponseBuilder}
+		 */
+		public AuthenticatorAttestationResponseBuilder clientDataJSON(Bytes clientDataJSON) {
+			this.clientDataJSON = clientDataJSON;
+			return this;
+		}
+
+		/**
+		 * Builds a {@link AuthenticatorAssertionResponse}.
+		 * @return the {@link AuthenticatorAttestationResponseBuilder}
+		 */
+		public AuthenticatorAttestationResponse build() {
+			return new AuthenticatorAttestationResponse(this.clientDataJSON, this.attestationObject, this.transports);
+		}
+
+	}
+
+}

+ 53 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorResponse.java

@@ -0,0 +1,53 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * The <a href=
+ * "https://www.w3.org/TR/webauthn-3/#iface-authenticatorresponse">AuthenticatorResponse</a>
+ * represents <a href="https://www.w3.org/TR/webauthn-3/#authenticator">Authenticators</a>
+ * respond to <a href="https://www.w3.org/TR/webauthn-3/#relying-party">Relying Party</a>
+ * requests.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public abstract class AuthenticatorResponse {
+
+	private final Bytes clientDataJSON;
+
+	/**
+	 * Creates a new instance
+	 * @param clientDataJSON the {@link #getClientDataJSON()}
+	 */
+	AuthenticatorResponse(Bytes clientDataJSON) {
+		this.clientDataJSON = clientDataJSON;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatorresponse-clientdatajson">clientDataJSON</a>
+	 * contains a JSON-compatible serialization of the client data, the hash of which is
+	 * passed to the authenticator by the client in its call to either create() or get()
+	 * (i.e., the client data itself is not sent to the authenticator).
+	 * @return the client data JSON
+	 */
+	public Bytes getClientDataJSON() {
+		return this.clientDataJSON;
+	}
+
+}

+ 170 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorSelectionCriteria.java

@@ -0,0 +1,170 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * <a href=
+ * "https://www.w3.org/TR/webauthn-3/#dictdef-authenticatorselectioncriteria">AuthenticatorAttachment</a>
+ * can be used by
+ * <a href="https://www.w3.org/TR/webauthn-3/#webauthn-relying-party">WebAuthn Relying
+ * Parties</a> to specify their requirements regarding authenticator attributes.
+ *
+ * There is no <a href=
+ * "https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-requireresidentkey">requireResidentKey</a>
+ * property because it is only for backwards compatability with WebAuthn Level 1.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ * @see PublicKeyCredentialCreationOptions#getAuthenticatorSelection()
+ */
+public final class AuthenticatorSelectionCriteria {
+
+	private final AuthenticatorAttachment authenticatorAttachment;
+
+	private final ResidentKeyRequirement residentKey;
+
+	private final UserVerificationRequirement userVerification;
+
+	// NOTE: There is no requireResidentKey property because it is only for backward
+	// compatability with WebAuthn Level 1
+
+	/**
+	 * Creates a new instance
+	 * @param authenticatorAttachment the authenticator attachment
+	 * @param residentKey the resident key requirement
+	 * @param userVerification the user verification
+	 */
+	private AuthenticatorSelectionCriteria(AuthenticatorAttachment authenticatorAttachment,
+			ResidentKeyRequirement residentKey, UserVerificationRequirement userVerification) {
+		this.authenticatorAttachment = authenticatorAttachment;
+		this.residentKey = residentKey;
+		this.userVerification = userVerification;
+	}
+
+	/**
+	 * If <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-authenticatorattachment">
+	 * authenticatorAttachment</a> is present, eligible
+	 * <a href="https://www.w3.org/TR/webauthn-3/#authenticator">authenticators</a> are
+	 * filtered to be only those authenticators attached with the specified
+	 * <a href="https://www.w3.org/TR/webauthn-3/#enum-attachment">authenticator
+	 * attachment modality</a> (see also <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#sctn-authenticator-attachment-modality">6.2.1
+	 * Authenticator Attachment Modality</a>).
+	 * @return the authenticator attachment
+	 */
+	public AuthenticatorAttachment getAuthenticatorAttachment() {
+		return this.authenticatorAttachment;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-residentkey">residentKey</a>
+	 * specifies the extent to which the
+	 * <a href="https://www.w3.org/TR/webauthn-3/#relying-party">Relying Party</a> desires
+	 * to create a <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#client-side-discoverable-credential">client-side
+	 * discoverable credential</a>.
+	 * @return the residenty key requirement
+	 */
+	public ResidentKeyRequirement getResidentKey() {
+		return this.residentKey;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-userverification">userVerification</a>
+	 * specifies the <a href="https://www.w3.org/TR/webauthn-3/#relying-party">Relying
+	 * Party</a>'s requirements regarding
+	 * <a href="https://www.w3.org/TR/webauthn-3/#user-verification">user verification</a>
+	 * for the <a href=
+	 * "https://w3c.github.io/webappsec-credential-management/#dom-credentialscontainer-create">create()</a>
+	 * operation.
+	 * @return the user verification requirement
+	 */
+	public UserVerificationRequirement getUserVerification() {
+		return this.userVerification;
+	}
+
+	/**
+	 * Creates a new {@link AuthenticatorSelectionCriteriaBuilder}
+	 * @return a new {@link AuthenticatorSelectionCriteriaBuilder}
+	 */
+	public static AuthenticatorSelectionCriteriaBuilder builder() {
+		return new AuthenticatorSelectionCriteriaBuilder();
+	}
+
+	/**
+	 * Creates a {@link AuthenticatorSelectionCriteria}
+	 *
+	 * @author Rob Winch
+	 * @since 6.4
+	 */
+	public static final class AuthenticatorSelectionCriteriaBuilder {
+
+		private AuthenticatorAttachment authenticatorAttachment;
+
+		private ResidentKeyRequirement residentKey;
+
+		private UserVerificationRequirement userVerification;
+
+		private AuthenticatorSelectionCriteriaBuilder() {
+		}
+
+		/**
+		 * Sets the {@link #getAuthenticatorAttachment()} property.
+		 * @param authenticatorAttachment the authenticator attachment
+		 * @return the {@link AuthenticatorSelectionCriteriaBuilder}
+		 */
+		public AuthenticatorSelectionCriteriaBuilder authenticatorAttachment(
+				AuthenticatorAttachment authenticatorAttachment) {
+			this.authenticatorAttachment = authenticatorAttachment;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getResidentKey()} property.
+		 * @param residentKey the resident key
+		 * @return the {@link AuthenticatorSelectionCriteriaBuilder}
+		 */
+		public AuthenticatorSelectionCriteriaBuilder residentKey(ResidentKeyRequirement residentKey) {
+			this.residentKey = residentKey;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getUserVerification()} property.
+		 * @param userVerification the user verification requirement
+		 * @return the {@link AuthenticatorSelectionCriteriaBuilder}
+		 */
+		public AuthenticatorSelectionCriteriaBuilder userVerification(UserVerificationRequirement userVerification) {
+			this.userVerification = userVerification;
+			return this;
+		}
+
+		/**
+		 * Builds a {@link AuthenticatorSelectionCriteria}
+		 * @return a new {@link AuthenticatorSelectionCriteria}
+		 */
+		public AuthenticatorSelectionCriteria build() {
+			return new AuthenticatorSelectionCriteria(this.authenticatorAttachment, this.residentKey,
+					this.userVerification);
+		}
+
+	}
+
+}

+ 118 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/AuthenticatorTransport.java

@@ -0,0 +1,118 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * <a href=
+ * "https://www.w3.org/TR/webauthn-3/#enumdef-authenticatortransport">AuthenticatorTransport</a>
+ * defines hints as to how clients might communicate with a particular authenticator in
+ * order to obtain an assertion for a specific credential.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class AuthenticatorTransport {
+
+	/**
+	 * <a href="https://www.w3.org/TR/webauthn-3/#dom-authenticatortransport-usb">usbc</a>
+	 * indicates the respective authenticator can be contacted over removable USB.
+	 */
+	public static final AuthenticatorTransport USB = new AuthenticatorTransport("usb");
+
+	/**
+	 * <a href="https://www.w3.org/TR/webauthn-3/#dom-authenticatortransport-nfc">nfc</a>
+	 * indicates the respective authenticator can be contacted over Near Field
+	 * Communication (NFC).
+	 */
+	public static final AuthenticatorTransport NFC = new AuthenticatorTransport("nfc");
+
+	/**
+	 * <a href="https://www.w3.org/TR/webauthn-3/#dom-authenticatortransport-ble">ble</a>
+	 * Indicates the respective authenticator can be contacted over Bluetooth Smart
+	 * (Bluetooth Low Energy / BLE).
+	 */
+	public static final AuthenticatorTransport BLE = new AuthenticatorTransport("ble");
+
+	/**
+	 * <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatortransport-smart-card">smart-card</a>
+	 * indicates the respective authenticator can be contacted over ISO/IEC 7816 smart
+	 * card with contacts.
+	 */
+	public static final AuthenticatorTransport SMART_CARD = new AuthenticatorTransport("smart-card");
+
+	/**
+	 * <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatortransport-hybrid">hybrid</a>
+	 * indicates the respective authenticator can be contacted using a combination of
+	 * (often separate) data-transport and proximity mechanisms. This supports, for
+	 * example, authentication on a desktop computer using a smartphone.
+	 */
+	public static final AuthenticatorTransport HYBRID = new AuthenticatorTransport("hybrid");
+
+	/**
+	 * <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-authenticatortransport-internal">internal</a>
+	 * indicates the respective authenticator is contacted using a client device-specific
+	 * transport, i.e., it is a platform authenticator. These authenticators are not
+	 * removable from the client device.
+	 */
+	public static final AuthenticatorTransport INTERNAL = new AuthenticatorTransport("internal");
+
+	private final String value;
+
+	AuthenticatorTransport(String value) {
+		this.value = value;
+	}
+
+	/**
+	 * Get's the value.
+	 * @return the value.
+	 */
+	public String getValue() {
+		return this.value;
+	}
+
+	/**
+	 * Gets an instance of {@link AuthenticatorTransport}.
+	 * @param value the value of the {@link AuthenticatorTransport}
+	 * @return the {@link AuthenticatorTransport}
+	 */
+	public static AuthenticatorTransport valueOf(String value) {
+		switch (value) {
+			case "usb":
+				return USB;
+			case "nfc":
+				return NFC;
+			case "ble":
+				return BLE;
+			case "smart-card":
+				return SMART_CARD;
+			case "hybrid":
+				return HYBRID;
+			case "internal":
+				return INTERNAL;
+			default:
+				return new AuthenticatorTransport(value);
+		}
+	}
+
+	public static AuthenticatorTransport[] values() {
+		return new AuthenticatorTransport[] { USB, NFC, BLE, HYBRID, INTERNAL };
+	}
+
+}

+ 103 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/Bytes.java

@@ -0,0 +1,103 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Base64;
+
+import org.springframework.util.Assert;
+
+/**
+ * An object representation of byte[].
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class Bytes {
+
+	private static final SecureRandom RANDOM = new SecureRandom();
+
+	private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
+
+	private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
+
+	private final byte[] bytes;
+
+	/**
+	 * Creates a new instance
+	 * @param bytes the raw base64UrlString that will be encoded.
+	 */
+	public Bytes(byte[] bytes) {
+		Assert.notNull(bytes, "bytes cannot be null");
+		this.bytes = bytes;
+	}
+
+	/**
+	 * Gets the raw bytes.
+	 * @return the bytes
+	 */
+	public byte[] getBytes() {
+		return Arrays.copyOf(this.bytes, this.bytes.length);
+	}
+
+	/**
+	 * Gets the bytes as Base64 URL encoded String.
+	 * @return
+	 */
+	public String toBase64UrlString() {
+		return ENCODER.encodeToString(getBytes());
+	}
+
+	@Override
+	public boolean equals(Object obj) {
+		if (obj instanceof Bytes that) {
+			return that.toBase64UrlString().equals(toBase64UrlString());
+		}
+		return false;
+	}
+
+	@Override
+	public int hashCode() {
+		return toBase64UrlString().hashCode();
+	}
+
+	public String toString() {
+		return "Bytes[" + toBase64UrlString() + "]";
+	}
+
+	/**
+	 * Creates a secure random {@link Bytes} with random bytes and sufficient entropy.
+	 * @return a new secure random generated {@link Bytes}
+	 */
+	public static Bytes random() {
+		byte[] bytes = new byte[32];
+		RANDOM.nextBytes(bytes);
+		return new Bytes(bytes);
+	}
+
+	/**
+	 * Creates a new instance from a base64 url string.
+	 * @param base64UrlString the base64 url string
+	 * @return the {@link Bytes}
+	 */
+	public static Bytes fromBase64(String base64UrlString) {
+		byte[] bytes = DECODER.decode(base64UrlString);
+		return new Bytes(bytes);
+	}
+
+}

+ 65 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/COSEAlgorithmIdentifier.java

@@ -0,0 +1,65 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * <a href=
+ * "https://www.w3.org/TR/webauthn-3/#sctn-alg-identifier">COSEAlgorithmIdentifier</a> is
+ * used to identify a cryptographic algorithm.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ * @see PublicKeyCredentialParameters#getAlg()
+ */
+public final class COSEAlgorithmIdentifier {
+
+	public static final COSEAlgorithmIdentifier EdDSA = new COSEAlgorithmIdentifier(-8);
+
+	public static final COSEAlgorithmIdentifier ES256 = new COSEAlgorithmIdentifier(-7);
+
+	public static final COSEAlgorithmIdentifier ES384 = new COSEAlgorithmIdentifier(-35);
+
+	public static final COSEAlgorithmIdentifier ES512 = new COSEAlgorithmIdentifier(-36);
+
+	public static final COSEAlgorithmIdentifier RS256 = new COSEAlgorithmIdentifier(-257);
+
+	public static final COSEAlgorithmIdentifier RS384 = new COSEAlgorithmIdentifier(-258);
+
+	public static final COSEAlgorithmIdentifier RS512 = new COSEAlgorithmIdentifier(-259);
+
+	public static final COSEAlgorithmIdentifier RS1 = new COSEAlgorithmIdentifier(-65535);
+
+	private final long value;
+
+	private COSEAlgorithmIdentifier(long value) {
+		this.value = value;
+	}
+
+	public long getValue() {
+		return this.value;
+	}
+
+	@Override
+	public String toString() {
+		return String.valueOf(this.value);
+	}
+
+	public static COSEAlgorithmIdentifier[] values() {
+		return new COSEAlgorithmIdentifier[] { EdDSA, ES256, ES384, ES512, RS256, RS384, RS512, RS1 };
+	}
+
+}

+ 73 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/CredProtectAuthenticationExtensionsClientInput.java

@@ -0,0 +1,73 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * Implements <a href=
+ * "https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-credProtect-extension">
+ * Credential Protection (credProtect)</a>.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public class CredProtectAuthenticationExtensionsClientInput
+		implements AuthenticationExtensionsClientInput<CredProtectAuthenticationExtensionsClientInput.CredProtect> {
+
+	private final CredProtect input;
+
+	public CredProtectAuthenticationExtensionsClientInput(CredProtect input) {
+		this.input = input;
+	}
+
+	@Override
+	public String getExtensionId() {
+		return "credProtect";
+	}
+
+	@Override
+	public CredProtect getInput() {
+		return this.input;
+	}
+
+	public static class CredProtect {
+
+		private final ProtectionPolicy credProtectionPolicy;
+
+		private final boolean enforceCredentialProtectionPolicy;
+
+		public CredProtect(ProtectionPolicy credProtectionPolicy, boolean enforceCredentialProtectionPolicy) {
+			this.enforceCredentialProtectionPolicy = enforceCredentialProtectionPolicy;
+			this.credProtectionPolicy = credProtectionPolicy;
+		}
+
+		public boolean isEnforceCredentialProtectionPolicy() {
+			return this.enforceCredentialProtectionPolicy;
+		}
+
+		public ProtectionPolicy getCredProtectionPolicy() {
+			return this.credProtectionPolicy;
+		}
+
+		public enum ProtectionPolicy {
+
+			USER_VERIFICATION_OPTIONAL, USER_VERIFICATION_OPTIONAL_WITH_CREDENTIAL_ID_LIST, USER_VERIFICATION_REQUIRED
+
+		}
+
+	}
+
+}

+ 83 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/CredentialPropertiesOutput.java

@@ -0,0 +1,83 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * <a href=
+ * "https://www.w3.org/TR/webauthn-3/#dictdef-credentialpropertiesoutput">CredentialPropertiesOutput</a>
+ * is the Client extension output.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public class CredentialPropertiesOutput
+		implements AuthenticationExtensionsClientOutput<CredentialPropertiesOutput.ExtensionOutput> {
+
+	/**
+	 * The extension id.
+	 */
+	public static final String EXTENSION_ID = "credProps";
+
+	private final ExtensionOutput output;
+
+	/**
+	 * Creates a new instance.
+	 * @param rk is the resident key is discoverable
+	 */
+	public CredentialPropertiesOutput(boolean rk) {
+		this.output = new ExtensionOutput(rk);
+	}
+
+	@Override
+	public String getExtensionId() {
+		return EXTENSION_ID;
+	}
+
+	@Override
+	public ExtensionOutput getOutput() {
+		return this.output;
+	}
+
+	/**
+	 * The output for {@link CredentialPropertiesOutput}
+	 *
+	 * @author Rob Winch
+	 * @since 6.4
+	 * @see #getOutput()
+	 */
+	public static final class ExtensionOutput {
+
+		private final boolean rk;
+
+		private ExtensionOutput(boolean rk) {
+			this.rk = rk;
+		}
+
+		/**
+		 * This OPTIONAL property, known abstractly as the resident key credential
+		 * property (i.e., client-side discoverable credential property), is a Boolean
+		 * value indicating whether the PublicKeyCredential returned as a result of a
+		 * registration ceremony is a client-side discoverable credential.
+		 * @return is resident key credential property
+		 */
+		public boolean isRk() {
+			return this.rk;
+		}
+
+	}
+
+}

+ 136 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/CredentialRecord.java

@@ -0,0 +1,136 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+import java.time.Instant;
+import java.util.Set;
+
+/**
+ * Represents a <a href="https://www.w3.org/TR/webauthn-3/#credential-record">Credential
+ * Record</a> that is stored by the Relying Party
+ * <a href="https://www.w3.org/TR/webauthn-3/#reg-ceremony-store-credential-record">after
+ * successful registration</a>.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public interface CredentialRecord {
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-type">credential.type</a>
+	 * @return
+	 */
+	PublicKeyCredentialType getCredentialType();
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-id">credential.id</a>.
+	 * @return
+	 */
+	Bytes getCredentialId();
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-publickey">publicKey</a>
+	 * @return
+	 */
+	PublicKeyCose getPublicKey();
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-signcount">authData.signCount</a>
+	 * @return
+	 */
+	long getSignatureCount();
+
+	/**
+	 * <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-uvinitialized">uvInitialized</a>
+	 * is the value of the UV (user verified) flag in authData and indicates whether any
+	 * credential from this public key credential source has had the UV flag set.
+	 * @return
+	 */
+	boolean isUvInitialized();
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-transports">transpots</a>
+	 * is the value returned from {@code response.getTransports()}.
+	 * @return
+	 */
+	Set<AuthenticatorTransport> getTransports();
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-backupeligible">backupElgible</a>
+	 * flag is the same as the BE flag in authData.
+	 * @return
+	 */
+	boolean isBackupEligible();
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-backupstate">backupState</a>
+	 * flag is the same as the BS flag in authData.
+	 * @return
+	 */
+	boolean isBackupState();
+
+	/**
+	 * A reference to the associated {@link PublicKeyCredentialUserEntity#getId()}
+	 * @return
+	 */
+	Bytes getUserEntityUserId();
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-attestationobject">attestationObject</a>
+	 * is the value of the attestationObject attribute when the public key credential
+	 * source was registered.
+	 * @return the attestationObject
+	 */
+	Bytes getAttestationObject();
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#abstract-opdef-credential-record-attestationclientdatajson">attestationClientDataJSON</a>
+	 * is the value of the attestationObject attribute when the public key credential
+	 * source was registered.
+	 * @return
+	 */
+	Bytes getAttestationClientDataJSON();
+
+	/**
+	 * A human-readable label for this {@link CredentialRecord} assigned by the user.
+	 * @return a label
+	 */
+	String getLabel();
+
+	/**
+	 * The last time this {@link CredentialRecord} was used.
+	 * @return the last time this {@link CredentialRecord} was used.
+	 */
+	Instant getLastUsed();
+
+	/**
+	 * When this {@link CredentialRecord} was created.
+	 * @return When this {@link CredentialRecord} was created.
+	 */
+	Instant getCreated();
+
+}

+ 59 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/ImmutableAuthenticationExtensionsClientInput.java

@@ -0,0 +1,59 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * An immutable {@link AuthenticationExtensionsClientInput}.
+ *
+ * @param <T> the input type
+ * @author Rob Winch
+ * @since 6.4
+ * @see AuthenticationExtensionsClientInputs
+ */
+public class ImmutableAuthenticationExtensionsClientInput<T> implements AuthenticationExtensionsClientInput<T> {
+
+	/**
+	 * https://www.w3.org/TR/webauthn-3/#sctn-authenticator-credential-properties-extension
+	 */
+	public static final AuthenticationExtensionsClientInput<Boolean> credProps = new ImmutableAuthenticationExtensionsClientInput<>(
+			"credProps", true);
+
+	private final String extensionId;
+
+	private final T input;
+
+	/**
+	 * Creates a new instance
+	 * @param extensionId the extension id.
+	 * @param input the input.
+	 */
+	public ImmutableAuthenticationExtensionsClientInput(String extensionId, T input) {
+		this.extensionId = extensionId;
+		this.input = input;
+	}
+
+	@Override
+	public String getExtensionId() {
+		return this.extensionId;
+	}
+
+	@Override
+	public T getInput() {
+		return this.input;
+	}
+
+}

+ 45 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/ImmutableAuthenticationExtensionsClientInputs.java

@@ -0,0 +1,45 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * An immutable implementation of {@link AuthenticationExtensionsClientInputs}.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public class ImmutableAuthenticationExtensionsClientInputs implements AuthenticationExtensionsClientInputs {
+
+	private final List<AuthenticationExtensionsClientInput> inputs;
+
+	public ImmutableAuthenticationExtensionsClientInputs(List<AuthenticationExtensionsClientInput> inputs) {
+		this.inputs = inputs;
+	}
+
+	public ImmutableAuthenticationExtensionsClientInputs(AuthenticationExtensionsClientInput... inputs) {
+		this(Arrays.asList(inputs));
+	}
+
+	@Override
+	public List<AuthenticationExtensionsClientInput> getInputs() {
+		return this.inputs;
+	}
+
+}

+ 44 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/ImmutableAuthenticationExtensionsClientOutputs.java

@@ -0,0 +1,44 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * An immutable implementation of {@link AuthenticationExtensionsClientOutputs}.
+ *
+ * @author Rob Winch
+ */
+public class ImmutableAuthenticationExtensionsClientOutputs implements AuthenticationExtensionsClientOutputs {
+
+	private final List<AuthenticationExtensionsClientOutput<?>> outputs;
+
+	public ImmutableAuthenticationExtensionsClientOutputs(List<AuthenticationExtensionsClientOutput<?>> outputs) {
+		this.outputs = outputs;
+	}
+
+	public ImmutableAuthenticationExtensionsClientOutputs(AuthenticationExtensionsClientOutput<?>... outputs) {
+		this(Arrays.asList(outputs));
+	}
+
+	@Override
+	public List<AuthenticationExtensionsClientOutput<?>> getOutputs() {
+		return this.outputs;
+	}
+
+}

+ 285 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/ImmutableCredentialRecord.java

@@ -0,0 +1,285 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+import java.time.Instant;
+import java.util.Set;
+
+/**
+ * An immutable {@link CredentialRecord}.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class ImmutableCredentialRecord implements CredentialRecord {
+
+	private final PublicKeyCredentialType credentialType;
+
+	private final Bytes credentialId;
+
+	private final Bytes userEntityUserId;
+
+	private final PublicKeyCose publicKey;
+
+	private final long signatureCount;
+
+	private final boolean uvInitialized;
+
+	private final Set<AuthenticatorTransport> transports;
+
+	private final boolean backupEligible;
+
+	private final boolean backupState;
+
+	private final Bytes attestationObject;
+
+	private final Bytes attestationClientDataJSON;
+
+	private final Instant created;
+
+	private final Instant lastUsed;
+
+	private final String label;
+
+	private ImmutableCredentialRecord(PublicKeyCredentialType credentialType, Bytes credentialId,
+			Bytes userEntityUserId, PublicKeyCose publicKey, long signatureCount, boolean uvInitialized,
+			Set<AuthenticatorTransport> transports, boolean backupEligible, boolean backupState,
+			Bytes attestationObject, Bytes attestationClientDataJSON, Instant created, Instant lastUsed, String label) {
+		this.credentialType = credentialType;
+		this.credentialId = credentialId;
+		this.userEntityUserId = userEntityUserId;
+		this.publicKey = publicKey;
+		this.signatureCount = signatureCount;
+		this.uvInitialized = uvInitialized;
+		this.transports = transports;
+		this.backupEligible = backupEligible;
+		this.backupState = backupState;
+		this.attestationObject = attestationObject;
+		this.attestationClientDataJSON = attestationClientDataJSON;
+		this.created = created;
+		this.lastUsed = lastUsed;
+		this.label = label;
+	}
+
+	@Override
+	public PublicKeyCredentialType getCredentialType() {
+		return this.credentialType;
+	}
+
+	@Override
+	public Bytes getCredentialId() {
+		return this.credentialId;
+	}
+
+	@Override
+	public Bytes getUserEntityUserId() {
+		return this.userEntityUserId;
+	}
+
+	@Override
+	public PublicKeyCose getPublicKey() {
+		return this.publicKey;
+	}
+
+	@Override
+	public long getSignatureCount() {
+		return this.signatureCount;
+	}
+
+	@Override
+	public boolean isUvInitialized() {
+		return this.uvInitialized;
+	}
+
+	@Override
+	public Set<AuthenticatorTransport> getTransports() {
+		return this.transports;
+	}
+
+	@Override
+	public boolean isBackupEligible() {
+		return this.backupEligible;
+	}
+
+	@Override
+	public boolean isBackupState() {
+		return this.backupState;
+	}
+
+	@Override
+	public Bytes getAttestationObject() {
+		return this.attestationObject;
+	}
+
+	@Override
+	public Bytes getAttestationClientDataJSON() {
+		return this.attestationClientDataJSON;
+	}
+
+	@Override
+	public Instant getCreated() {
+		return this.created;
+	}
+
+	@Override
+	public Instant getLastUsed() {
+		return this.lastUsed;
+	}
+
+	@Override
+	public String getLabel() {
+		return this.label;
+	}
+
+	public static ImmutableCredentialRecordBuilder builder() {
+		return new ImmutableCredentialRecordBuilder();
+	}
+
+	public static ImmutableCredentialRecordBuilder fromCredentialRecord(CredentialRecord credentialRecord) {
+		return new ImmutableCredentialRecordBuilder(credentialRecord);
+	}
+
+	public static final class ImmutableCredentialRecordBuilder {
+
+		private PublicKeyCredentialType credentialType;
+
+		private Bytes credentialId;
+
+		private Bytes userEntityUserId;
+
+		private PublicKeyCose publicKey;
+
+		private long signatureCount;
+
+		private boolean uvInitialized;
+
+		private Set<AuthenticatorTransport> transports;
+
+		private boolean backupEligible;
+
+		private boolean backupState;
+
+		private Bytes attestationObject;
+
+		private Bytes attestationClientDataJSON;
+
+		private Instant created = Instant.now();
+
+		private Instant lastUsed = this.created;
+
+		private String label;
+
+		private ImmutableCredentialRecordBuilder() {
+		}
+
+		private ImmutableCredentialRecordBuilder(CredentialRecord other) {
+			this.credentialType = other.getCredentialType();
+			this.credentialId = other.getCredentialId();
+			this.userEntityUserId = other.getUserEntityUserId();
+			this.publicKey = other.getPublicKey();
+			this.signatureCount = other.getSignatureCount();
+			this.uvInitialized = other.isUvInitialized();
+			this.transports = other.getTransports();
+			this.backupEligible = other.isBackupEligible();
+			this.backupState = other.isBackupState();
+			this.attestationObject = other.getAttestationObject();
+			this.attestationClientDataJSON = other.getAttestationClientDataJSON();
+			this.created = other.getCreated();
+			this.lastUsed = other.getLastUsed();
+			this.label = other.getLabel();
+		}
+
+		public ImmutableCredentialRecordBuilder credentialType(PublicKeyCredentialType credentialType) {
+			this.credentialType = credentialType;
+			return this;
+		}
+
+		public ImmutableCredentialRecordBuilder credentialId(Bytes credentialId) {
+			this.credentialId = credentialId;
+			return this;
+		}
+
+		public ImmutableCredentialRecordBuilder userEntityUserId(Bytes userEntityUserId) {
+			this.userEntityUserId = userEntityUserId;
+			return this;
+		}
+
+		public ImmutableCredentialRecordBuilder publicKey(PublicKeyCose publicKey) {
+			this.publicKey = publicKey;
+			return this;
+		}
+
+		public ImmutableCredentialRecordBuilder signatureCount(long signatureCount) {
+			this.signatureCount = signatureCount;
+			return this;
+		}
+
+		public ImmutableCredentialRecordBuilder uvInitialized(boolean uvInitialized) {
+			this.uvInitialized = uvInitialized;
+			return this;
+		}
+
+		public ImmutableCredentialRecordBuilder transports(Set<AuthenticatorTransport> transports) {
+			this.transports = transports;
+			return this;
+		}
+
+		public ImmutableCredentialRecordBuilder backupEligible(boolean backupEligible) {
+			this.backupEligible = backupEligible;
+			return this;
+		}
+
+		public ImmutableCredentialRecordBuilder backupState(boolean backupState) {
+			this.backupState = backupState;
+			return this;
+		}
+
+		public ImmutableCredentialRecordBuilder attestationObject(Bytes attestationObject) {
+			this.attestationObject = attestationObject;
+			return this;
+		}
+
+		public ImmutableCredentialRecordBuilder attestationClientDataJSON(Bytes attestationClientDataJSON) {
+			this.attestationClientDataJSON = attestationClientDataJSON;
+			return this;
+		}
+
+		public ImmutableCredentialRecordBuilder created(Instant created) {
+			this.created = created;
+			return this;
+		}
+
+		public ImmutableCredentialRecordBuilder lastUsed(Instant lastUsed) {
+			this.lastUsed = lastUsed;
+			return this;
+		}
+
+		public ImmutableCredentialRecordBuilder label(String label) {
+			this.label = label;
+			return this;
+		}
+
+		public ImmutableCredentialRecord build() {
+			return new ImmutableCredentialRecord(this.credentialType, this.credentialId, this.userEntityUserId,
+					this.publicKey, this.signatureCount, this.uvInitialized, this.transports, this.backupEligible,
+					this.backupState, this.attestationObject, this.attestationClientDataJSON, this.created,
+					this.lastUsed, this.label);
+		}
+
+	}
+
+}

+ 55 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/ImmutablePublicKeyCose.java

@@ -0,0 +1,55 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+import java.util.Arrays;
+import java.util.Base64;
+
+/**
+ * An immutable {@link PublicKeyCose}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public class ImmutablePublicKeyCose implements PublicKeyCose {
+
+	private final byte[] bytes;
+
+	/**
+	 * Creates a new instance.
+	 * @param bytes the raw bytes of the public key.
+	 */
+	public ImmutablePublicKeyCose(byte[] bytes) {
+		this.bytes = Arrays.copyOf(bytes, bytes.length);
+	}
+
+	@Override
+	public byte[] getBytes() {
+		return Arrays.copyOf(this.bytes, this.bytes.length);
+	}
+
+	/**
+	 * Creates a new instance form a Base64 URL encoded String
+	 * @param base64EncodedString the base64EncodedString encoded String
+	 * @return
+	 */
+	public static ImmutablePublicKeyCose fromBase64(String base64EncodedString) {
+		byte[] decode = Base64.getUrlDecoder().decode(base64EncodedString);
+		return new ImmutablePublicKeyCose(decode);
+	}
+
+}

+ 188 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/ImmutablePublicKeyCredentialUserEntity.java

@@ -0,0 +1,188 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * <a href=
+ * "https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialuserentity">PublicKeyCredentialUserEntity</a>
+ * is used to supply additional
+ * <a href="https://www.w3.org/TR/webauthn-3/#user-account">user account</a> attributes
+ * when creating a new credential.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class ImmutablePublicKeyCredentialUserEntity implements PublicKeyCredentialUserEntity {
+
+	/**
+	 * When inherited by PublicKeyCredentialUserEntity, it is a human-palatable identifier
+	 * for a user account. It is intended only for display, i.e., aiding the user in
+	 * determining the difference between user accounts with similar displayNames. For
+	 * example, "alexm", "alex.mueller@example.com" or "+14255551234".
+	 *
+	 * The Relying Party MAY let the user choose this value. The Relying Party SHOULD
+	 * perform enforcement, as prescribed in Section 3.4.3 of [RFC8265] for the
+	 * UsernameCasePreserved Profile of the PRECIS IdentifierClass [RFC8264], when setting
+	 * name's value, or displaying the value to the user.
+	 *
+	 * This string MAY contain language and direction metadata. Relying Parties SHOULD
+	 * consider providing this information. See § 6.4.2 Language and Direction Encoding
+	 * about how this metadata is encoded.
+	 *
+	 * Clients SHOULD perform enforcement, as prescribed in Section 3.4.3 of [RFC8265] for
+	 * the UsernameCasePreserved Profile of the PRECIS IdentifierClass [RFC8264], on
+	 * name's value prior to displaying the value to the user or including the value as a
+	 * parameter of the authenticatorMakeCredential operation.
+	 */
+	private final String name;
+
+	/**
+	 * The user handle of the user account entity. A user handle is an opaque byte
+	 * sequence with a maximum size of 64 bytes, and is not meant to be displayed to the
+	 * user.
+	 *
+	 * To ensure secure operation, authentication and authorization decisions MUST be made
+	 * on the basis of this id member, not the displayName nor name members. See Section
+	 * 6.1 of [RFC8266].
+	 *
+	 * The user handle MUST NOT contain personally identifying information about the user,
+	 * such as a username or e-mail address; see § 14.6.1 User Handle Contents for
+	 * details. The user handle MUST NOT be empty, though it MAY be null.
+	 *
+	 * Note: the user handle ought not be a constant value across different accounts, even
+	 * for non-discoverable credentials, because some authenticators always create
+	 * discoverable credentials. Thus a constant user handle would prevent a user from
+	 * using such an authenticator with more than one account at the Relying Party.
+	 */
+	private final Bytes id;
+
+	/**
+	 * A human-palatable name for the user account, intended only for display. For
+	 * example, "Alex Müller" or "田中倫". The Relying Party SHOULD let the user choose this,
+	 * and SHOULD NOT restrict the choice more than necessary.
+	 *
+	 * Relying Parties SHOULD perform enforcement, as prescribed in Section 2.3 of
+	 * [RFC8266] for the Nickname Profile of the PRECIS FreeformClass [RFC8264], when
+	 * setting displayName's value, or displaying the value to the user.
+	 *
+	 * This string MAY contain language and direction metadata. Relying Parties SHOULD
+	 * consider providing this information. See § 6.4.2 Language and Direction Encoding
+	 * about how this metadata is encoded.
+	 *
+	 * Clients SHOULD perform enforcement, as prescribed in Section 2.3 of [RFC8266] for
+	 * the Nickname Profile of the PRECIS FreeformClass [RFC8264], on displayName's value
+	 * prior to displaying the value to the user or including the value as a parameter of
+	 * the authenticatorMakeCredential operation.
+	 *
+	 * When clients, client platforms, or authenticators display a displayName's value,
+	 * they should always use UI elements to provide a clear boundary around the displayed
+	 * value, and not allow overflow into other elements [css-overflow-3].
+	 *
+	 * Authenticators MUST accept and store a 64-byte minimum length for a displayName
+	 * member’s value. Authenticators MAY truncate a displayName member’s value so that it
+	 * fits within 64 bytes. See § 6.4.1 String Truncation about truncation and other
+	 * considerations.
+	 */
+	private final String displayName;
+
+	private ImmutablePublicKeyCredentialUserEntity(String name, Bytes id, String displayName) {
+		this.name = name;
+		this.id = id;
+		this.displayName = displayName;
+	}
+
+	@Override
+	public String getName() {
+		return this.name;
+	}
+
+	@Override
+	public Bytes getId() {
+		return this.id;
+	}
+
+	@Override
+	public String getDisplayName() {
+		return this.displayName;
+	}
+
+	/**
+	 * Create a new {@link PublicKeyCredentialUserEntityBuilder}
+	 * @return a new {@link PublicKeyCredentialUserEntityBuilder}
+	 */
+	public static PublicKeyCredentialUserEntityBuilder builder() {
+		return new PublicKeyCredentialUserEntityBuilder();
+	}
+
+	/**
+	 * Used to build {@link PublicKeyCredentialUserEntity}.
+	 *
+	 * @author Rob Winch
+	 * @since 6.4
+	 */
+	public static final class PublicKeyCredentialUserEntityBuilder {
+
+		private String name;
+
+		private Bytes id;
+
+		private String displayName;
+
+		private PublicKeyCredentialUserEntityBuilder() {
+		}
+
+		/**
+		 * Sets the {@link #getName()} property.
+		 * @param name the name
+		 * @return the {@link PublicKeyCredentialUserEntityBuilder}
+		 */
+		public PublicKeyCredentialUserEntityBuilder name(String name) {
+			this.name = name;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getId()} property.
+		 * @param id the id
+		 * @return the {@link PublicKeyCredentialUserEntityBuilder}
+		 */
+		public PublicKeyCredentialUserEntityBuilder id(Bytes id) {
+			this.id = id;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getDisplayName()} property.
+		 * @param displayName the display name
+		 * @return the {@link PublicKeyCredentialUserEntityBuilder}
+		 */
+		public PublicKeyCredentialUserEntityBuilder displayName(String displayName) {
+			this.displayName = displayName;
+			return this;
+		}
+
+		/**
+		 * Builds a new {@link PublicKeyCredentialUserEntity}
+		 * @return a new {@link PublicKeyCredentialUserEntity}
+		 */
+		public PublicKeyCredentialUserEntity build() {
+			return new ImmutablePublicKeyCredentialUserEntity(this.name, this.id, this.displayName);
+		}
+
+	}
+
+}

+ 34 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCose.java

@@ -0,0 +1,34 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * A <a href="https://www.w3.org/TR/webauthn-3/#sctn-encoded-credPubKey-examples">COSE
+ * encoded public key</a>.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public interface PublicKeyCose {
+
+	/**
+	 * The byes of a COSE encoded public key.
+	 * @return the bytes of a COSE encoded public key.
+	 */
+	byte[] getBytes();
+
+}

+ 223 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredential.java

@@ -0,0 +1,223 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * <a href="https://www.w3.org/TR/webauthn-3/#iface-pkcredential">PublicKeyCredential</a>
+ * contains the attributes that are returned to the caller when a new credential is
+ * created, or a new assertion is requested.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class PublicKeyCredential<R extends AuthenticatorResponse> {
+
+	private final String id;
+
+	private final PublicKeyCredentialType type;
+
+	private final Bytes rawId;
+
+	private final R response;
+
+	private final AuthenticatorAttachment authenticatorAttachment;
+
+	private final AuthenticationExtensionsClientOutputs clientExtensionResults;
+
+	private PublicKeyCredential(String id, PublicKeyCredentialType type, Bytes rawId, R response,
+			AuthenticatorAttachment authenticatorAttachment,
+			AuthenticationExtensionsClientOutputs clientExtensionResults) {
+		this.id = id;
+		this.type = type;
+		this.rawId = rawId;
+		this.response = response;
+		this.authenticatorAttachment = authenticatorAttachment;
+		this.clientExtensionResults = clientExtensionResults;
+	}
+
+	/**
+	 * The
+	 * <a href="https://www.w3.org/TR/credential-management-1/#dom-credential-id">id</a>
+	 * attribute is inherited from Credential, though PublicKeyCredential overrides
+	 * Credential's getter, instead returning the base64url encoding of the data contained
+	 * in the object’s [[identifier]] internal slot.
+	 */
+	public String getId() {
+		return this.id;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/credential-management-1/#dom-credential-type">type</a>
+	 * attribute returns the value of the object’s interface object's [[type]] slot, which
+	 * specifies the credential type represented by this object.
+	 * @return the credential type
+	 */
+	public PublicKeyCredentialType getType() {
+		return this.type;
+	}
+
+	/**
+	 * The
+	 * <a href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-rawid">rawId</a>
+	 * returns the raw identifier.
+	 * @return the raw id
+	 */
+	public Bytes getRawId() {
+		return this.rawId;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-response">response</a>
+	 * to the client's request to either create a public key credential, or generate an
+	 * authentication assertion.
+	 * @return the response
+	 */
+	public R getResponse() {
+		return this.response;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-authenticatorattachment">authenticatorAttachment</a>
+	 * reports the <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#authenticator-attachment-modality">authenticator
+	 * attachment modality</a> in effect at the time the navigator.credentials.create() or
+	 * navigator.credentials.get() methods successfully complete.
+	 * @return the authenticator attachment
+	 */
+	public AuthenticatorAttachment getAuthenticatorAttachment() {
+		return this.authenticatorAttachment;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-getclientextensionresults">clientExtensionsResults</a>
+	 * is a mapping of extension identifier to client extension output.
+	 * @return the extension results
+	 */
+	public AuthenticationExtensionsClientOutputs getClientExtensionResults() {
+		return this.clientExtensionResults;
+	}
+
+	/**
+	 * Creates a new {@link PublicKeyCredentialBuilder}
+	 * @param <T> the response type
+	 * @return the {@link PublicKeyCredentialBuilder}
+	 */
+	public static <T extends AuthenticatorResponse> PublicKeyCredentialBuilder<T> builder() {
+		return new PublicKeyCredentialBuilder<T>();
+	}
+
+	/**
+	 * The {@link PublicKeyCredentialBuilder}
+	 *
+	 * @param <R> the response type
+	 * @author Rob Winch
+	 * @since 6.4
+	 */
+	public static final class PublicKeyCredentialBuilder<R extends AuthenticatorResponse> {
+
+		private String id;
+
+		private PublicKeyCredentialType type;
+
+		private Bytes rawId;
+
+		private R response;
+
+		private AuthenticatorAttachment authenticatorAttachment;
+
+		private AuthenticationExtensionsClientOutputs clientExtensionResults;
+
+		private PublicKeyCredentialBuilder() {
+		}
+
+		/**
+		 * Sets the {@link #getId()} property
+		 * @param id the id
+		 * @return the PublicKeyCredentialBuilder
+		 */
+		public PublicKeyCredentialBuilder id(String id) {
+			this.id = id;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getType()} property.
+		 * @param type the type
+		 * @return the PublicKeyCredentialBuilder
+		 */
+		public PublicKeyCredentialBuilder type(PublicKeyCredentialType type) {
+			this.type = type;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getRawId()} property.
+		 * @param rawId the raw id
+		 * @return the PublicKeyCredentialBuilder
+		 */
+		public PublicKeyCredentialBuilder rawId(Bytes rawId) {
+			this.rawId = rawId;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getResponse()} property.
+		 * @param response the response
+		 * @return the PublicKeyCredentialBuilder
+		 */
+		public PublicKeyCredentialBuilder response(R response) {
+			this.response = response;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getAuthenticatorAttachment()} property.
+		 * @param authenticatorAttachment the authenticator attachement
+		 * @return the PublicKeyCredentialBuilder
+		 */
+		public PublicKeyCredentialBuilder authenticatorAttachment(AuthenticatorAttachment authenticatorAttachment) {
+			this.authenticatorAttachment = authenticatorAttachment;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getClientExtensionResults()} property.
+		 * @param clientExtensionResults the client extension results
+		 * @return the PublicKeyCredentialBuilder
+		 */
+		public PublicKeyCredentialBuilder clientExtensionResults(
+				AuthenticationExtensionsClientOutputs clientExtensionResults) {
+			this.clientExtensionResults = clientExtensionResults;
+			return this;
+		}
+
+		/**
+		 * Creates a new {@link PublicKeyCredential}
+		 * @return a new {@link PublicKeyCredential}
+		 */
+		public PublicKeyCredential<R> build() {
+			return new PublicKeyCredential(this.id, this.type, this.rawId, this.response, this.authenticatorAttachment,
+					this.clientExtensionResults);
+		}
+
+	}
+
+}

+ 332 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialCreationOptions.java

@@ -0,0 +1,332 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Represents the <a href=
+ * "https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialcreationoptions">PublicKeyCredentialCreationOptions</a>
+ * which is an argument to <a href=
+ * "https://w3c.github.io/webappsec-credential-management/#dom-credentialscontainer-create">creating</a>
+ * a new credential.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class PublicKeyCredentialCreationOptions {
+
+	private final PublicKeyCredentialRpEntity rp;
+
+	private final PublicKeyCredentialUserEntity user;
+
+	private final Bytes challenge;
+
+	private final List<PublicKeyCredentialParameters> pubKeyCredParams;
+
+	private final Duration timeout;
+
+	private final List<PublicKeyCredentialDescriptor> excludeCredentials;
+
+	private final AuthenticatorSelectionCriteria authenticatorSelection;
+
+	private final AttestationConveyancePreference attestation;
+
+	private final AuthenticationExtensionsClientInputs extensions;
+
+	private PublicKeyCredentialCreationOptions(PublicKeyCredentialRpEntity rp, PublicKeyCredentialUserEntity user,
+			Bytes challenge, List<PublicKeyCredentialParameters> pubKeyCredParams, Duration timeout,
+			List<PublicKeyCredentialDescriptor> excludeCredentials,
+			AuthenticatorSelectionCriteria authenticatorSelection, AttestationConveyancePreference attestation,
+			AuthenticationExtensionsClientInputs extensions) {
+		this.rp = rp;
+		this.user = user;
+		this.challenge = challenge;
+		this.pubKeyCredParams = pubKeyCredParams;
+		this.timeout = timeout;
+		this.excludeCredentials = excludeCredentials;
+		this.authenticatorSelection = authenticatorSelection;
+		this.attestation = attestation;
+		this.extensions = extensions;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-rp">rp</a>
+	 * property contains data about the Relying Party responsible for the request.
+	 * @return the relying party
+	 */
+	public PublicKeyCredentialRpEntity getRp() {
+		return this.rp;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-user">user</a>
+	 * contains names and an identifier for the user account performing the registration.
+	 * @return the user
+	 */
+	public PublicKeyCredentialUserEntity getUser() {
+		return this.user;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-challenge">challenge</a>
+	 * specifies the challenge that the authenticator signs, along with other data, when
+	 * producing an attestation object for the newly created credential.
+	 * @return the challenge
+	 */
+	public Bytes getChallenge() {
+		return this.challenge;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-pubkeycredparams">publicKeyCredParams</a>
+	 * params lisst the key types and signature algorithms the Relying Party Supports,
+	 * ordered from most preferred to least preferred.
+	 * @return the public key credential parameters
+	 */
+	public List<PublicKeyCredentialParameters> getPubKeyCredParams() {
+		return this.pubKeyCredParams;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-timeout">timeout</a>
+	 * property specifies a time, in milliseconds, that the Relying Party is willing to
+	 * wait for the call to complete.
+	 * @return the timeout
+	 */
+	public Duration getTimeout() {
+		return this.timeout;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-excludecredentials">excludeCredentials</a>
+	 * property is the OPTIONAL member used by the Relying Party to list any existing
+	 * credentials mapped to this user account (as identified by user.id).
+	 * @return exclude credentials
+	 */
+	public List<PublicKeyCredentialDescriptor> getExcludeCredentials() {
+		return this.excludeCredentials;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-authenticatorselection">authenticatorSelection</a>
+	 * property is an OPTIONAL member used by the Relying Party to list any existing
+	 * credentials mapped to this user account (as identified by user.id).
+	 * @return the authenticatorSelection
+	 */
+	public AuthenticatorSelectionCriteria getAuthenticatorSelection() {
+		return this.authenticatorSelection;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-attestation">attestation</a>
+	 * property is an OPTIONAL member used by the Relying Party to specify a preference
+	 * regarding attestation conveyance.
+	 * @return the attestation preference
+	 */
+	public AttestationConveyancePreference getAttestation() {
+		return this.attestation;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-extensions">extensions</a>
+	 * property is an OPTIONAL member used by the Relying Party to provide client
+	 * extension inputs requesting additional processing by the client and authenticator.
+	 * @return the extensions
+	 */
+	public AuthenticationExtensionsClientInputs getExtensions() {
+		return this.extensions;
+	}
+
+	/**
+	 * Creates a new {@link PublicKeyCredentialCreationOptions}
+	 * @return a new {@link PublicKeyCredentialCreationOptions}
+	 */
+	public static PublicKeyCredentialCreationOptionsBuilder builder() {
+		return new PublicKeyCredentialCreationOptionsBuilder();
+	}
+
+	/**
+	 * Used to build {@link PublicKeyCredentialCreationOptions}.
+	 *
+	 * @author Rob Winch
+	 * @since 6.4
+	 */
+	public static final class PublicKeyCredentialCreationOptionsBuilder {
+
+		private PublicKeyCredentialRpEntity rp;
+
+		private PublicKeyCredentialUserEntity user;
+
+		private Bytes challenge;
+
+		private List<PublicKeyCredentialParameters> pubKeyCredParams = new ArrayList<>();
+
+		private Duration timeout;
+
+		private List<PublicKeyCredentialDescriptor> excludeCredentials = new ArrayList<>();
+
+		private AuthenticatorSelectionCriteria authenticatorSelection;
+
+		private AttestationConveyancePreference attestation;
+
+		private AuthenticationExtensionsClientInputs extensions;
+
+		private PublicKeyCredentialCreationOptionsBuilder() {
+		}
+
+		/**
+		 * Sets the {@link #getRp()} property.
+		 * @param rp the relying party
+		 * @return the PublicKeyCredentialCreationOptionsBuilder
+		 */
+		public PublicKeyCredentialCreationOptionsBuilder rp(PublicKeyCredentialRpEntity rp) {
+			this.rp = rp;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getUser()} property.
+		 * @param user the user entity
+		 * @return the PublicKeyCredentialCreationOptionsBuilder
+		 */
+		public PublicKeyCredentialCreationOptionsBuilder user(PublicKeyCredentialUserEntity user) {
+			this.user = user;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getChallenge()} property.
+		 * @param challenge the challenge
+		 * @return the PublicKeyCredentialCreationOptionsBuilder
+		 */
+		public PublicKeyCredentialCreationOptionsBuilder challenge(Bytes challenge) {
+			this.challenge = challenge;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getPubKeyCredParams()} property.
+		 * @param pubKeyCredParams the public key credential parameters
+		 * @return the PublicKeyCredentialCreationOptionsBuilder
+		 */
+		public PublicKeyCredentialCreationOptionsBuilder pubKeyCredParams(
+				PublicKeyCredentialParameters... pubKeyCredParams) {
+			return pubKeyCredParams(Arrays.asList(pubKeyCredParams));
+		}
+
+		/**
+		 * Sets the {@link #getPubKeyCredParams()} property.
+		 * @param pubKeyCredParams the public key credential parameters
+		 * @return the PublicKeyCredentialCreationOptionsBuilder
+		 */
+		public PublicKeyCredentialCreationOptionsBuilder pubKeyCredParams(
+				List<PublicKeyCredentialParameters> pubKeyCredParams) {
+			this.pubKeyCredParams = pubKeyCredParams;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getTimeout()} property.
+		 * @param timeout the timeout
+		 * @return the PublicKeyCredentialCreationOptionsBuilder
+		 */
+		public PublicKeyCredentialCreationOptionsBuilder timeout(Duration timeout) {
+			this.timeout = timeout;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getExcludeCredentials()} property.
+		 * @param excludeCredentials the excluded credentials.
+		 * @return the PublicKeyCredentialCreationOptionsBuilder
+		 */
+		public PublicKeyCredentialCreationOptionsBuilder excludeCredentials(
+				List<PublicKeyCredentialDescriptor> excludeCredentials) {
+			this.excludeCredentials = excludeCredentials;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getAuthenticatorSelection()} property.
+		 * @param authenticatorSelection the authenticator selection
+		 * @return the PublicKeyCredentialCreationOptionsBuilder
+		 */
+		public PublicKeyCredentialCreationOptionsBuilder authenticatorSelection(
+				AuthenticatorSelectionCriteria authenticatorSelection) {
+			this.authenticatorSelection = authenticatorSelection;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getAttestation()} property.
+		 * @param attestation the attestation
+		 * @return the PublicKeyCredentialCreationOptionsBuilder
+		 */
+		public PublicKeyCredentialCreationOptionsBuilder attestation(AttestationConveyancePreference attestation) {
+			this.attestation = attestation;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getExtensions()} property.
+		 * @param extensions the extensions
+		 * @return the PublicKeyCredentialCreationOptionsBuilder
+		 */
+		public PublicKeyCredentialCreationOptionsBuilder extensions(AuthenticationExtensionsClientInputs extensions) {
+			this.extensions = extensions;
+			return this;
+		}
+
+		/**
+		 * Allows customizing the builder using the {@link Consumer} that is passed in.
+		 * @param customizer the {@link Consumer} that can be used to customize the
+		 * {@link PublicKeyCredentialCreationOptionsBuilder}
+		 * @return the PublicKeyCredentialCreationOptionsBuilder
+		 */
+		public PublicKeyCredentialCreationOptionsBuilder customize(
+				Consumer<PublicKeyCredentialCreationOptionsBuilder> customizer) {
+			customizer.accept(this);
+			return this;
+		}
+
+		/**
+		 * Builds a new {@link PublicKeyCredentialCreationOptions}
+		 * @return the new {@link PublicKeyCredentialCreationOptions}
+		 */
+		public PublicKeyCredentialCreationOptions build() {
+			return new PublicKeyCredentialCreationOptions(this.rp, this.user, this.challenge, this.pubKeyCredParams,
+					this.timeout, this.excludeCredentials, this.authenticatorSelection, this.attestation,
+					this.extensions);
+		}
+
+	}
+
+}

+ 154 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialDescriptor.java

@@ -0,0 +1,154 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+import java.util.Set;
+
+/**
+ * <a href=
+ * "https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialdescriptor">PublicKeyCredentialDescriptor</a>
+ * identifies a specific public key credential. It is used in create() to prevent creating
+ * duplicate credentials on the same authenticator, and in get() to determine if and how
+ * the credential can currently be reached by the client. It mirrors some fields of the
+ * PublicKeyCredential object returned by create() and get().
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class PublicKeyCredentialDescriptor {
+
+	private final PublicKeyCredentialType type;
+
+	private final Bytes id;
+
+	private final Set<AuthenticatorTransport> transports;
+
+	private PublicKeyCredentialDescriptor(PublicKeyCredentialType type, Bytes id,
+			Set<AuthenticatorTransport> transports) {
+		this.type = type;
+		this.id = id;
+		this.transports = transports;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialdescriptor-type">type</a>
+	 * property contains the type of the public key credential the caller is referring to.
+	 * @return the type
+	 */
+	public PublicKeyCredentialType getType() {
+		return this.type;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialdescriptor-id">id</a>
+	 * property contains the credential ID of the public key credential the caller is
+	 * referring to.
+	 * @return the id
+	 */
+	public Bytes getId() {
+		return this.id;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialdescriptor-transports">transports</a>
+	 * property is an OPTIONAL member that contains a hint as to how the client might
+	 * communicate with the managing authenticator of the public key credential the caller
+	 * is referring to.
+	 * @return the transports
+	 */
+	public Set<AuthenticatorTransport> getTransports() {
+		return this.transports;
+	}
+
+	/**
+	 * Creates a new {@link PublicKeyCredentialDescriptorBuilder}
+	 * @return a new {@link PublicKeyCredentialDescriptorBuilder}
+	 */
+	public static PublicKeyCredentialDescriptorBuilder builder() {
+		return new PublicKeyCredentialDescriptorBuilder();
+	}
+
+	/**
+	 * Used to create {@link PublicKeyCredentialDescriptor}
+	 *
+	 * @author Rob Winch
+	 * @since 6.4
+	 */
+	public static final class PublicKeyCredentialDescriptorBuilder {
+
+		private PublicKeyCredentialType type = PublicKeyCredentialType.PUBLIC_KEY;
+
+		private Bytes id;
+
+		private Set<AuthenticatorTransport> transports;
+
+		private PublicKeyCredentialDescriptorBuilder() {
+		}
+
+		/**
+		 * Sets the {@link #getType()} property.
+		 * @param type the type
+		 * @return the {@link PublicKeyCredentialDescriptorBuilder}
+		 */
+		public PublicKeyCredentialDescriptorBuilder type(PublicKeyCredentialType type) {
+			this.type = type;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getId()} property.
+		 * @param id the id
+		 * @return the {@link PublicKeyCredentialDescriptorBuilder}
+		 */
+		public PublicKeyCredentialDescriptorBuilder id(Bytes id) {
+			this.id = id;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getTransports()} property.
+		 * @param transports the transports
+		 * @return the {@link PublicKeyCredentialDescriptorBuilder}
+		 */
+		public PublicKeyCredentialDescriptorBuilder transports(Set<AuthenticatorTransport> transports) {
+			this.transports = transports;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getTransports()} property.
+		 * @param transports the transports
+		 * @return the {@link PublicKeyCredentialDescriptorBuilder}
+		 */
+		public PublicKeyCredentialDescriptorBuilder transports(AuthenticatorTransport... transports) {
+			return transports(Set.of(transports));
+		}
+
+		/**
+		 * Create a new {@link PublicKeyCredentialDescriptor}
+		 * @return a new {@link PublicKeyCredentialDescriptor}
+		 */
+		public PublicKeyCredentialDescriptor build() {
+			return new PublicKeyCredentialDescriptor(this.type, this.id, this.transports);
+		}
+
+	}
+
+}

+ 99 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialParameters.java

@@ -0,0 +1,99 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * The <a href=
+ * "https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialparameters">PublicKeyCredentialParameters</a>
+ * is used to supply additional parameters when creating a new credential.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ * @see PublicKeyCredentialCreationOptions#getPubKeyCredParams()
+ */
+public final class PublicKeyCredentialParameters {
+
+	public static final PublicKeyCredentialParameters EdDSA = new PublicKeyCredentialParameters(
+			COSEAlgorithmIdentifier.EdDSA);
+
+	public static final PublicKeyCredentialParameters ES256 = new PublicKeyCredentialParameters(
+			COSEAlgorithmIdentifier.ES256);
+
+	public static final PublicKeyCredentialParameters ES384 = new PublicKeyCredentialParameters(
+			COSEAlgorithmIdentifier.ES384);
+
+	public static final PublicKeyCredentialParameters ES512 = new PublicKeyCredentialParameters(
+			COSEAlgorithmIdentifier.ES512);
+
+	public static final PublicKeyCredentialParameters RS256 = new PublicKeyCredentialParameters(
+			COSEAlgorithmIdentifier.RS256);
+
+	public static final PublicKeyCredentialParameters RS384 = new PublicKeyCredentialParameters(
+			COSEAlgorithmIdentifier.RS384);
+
+	public static final PublicKeyCredentialParameters RS512 = new PublicKeyCredentialParameters(
+			COSEAlgorithmIdentifier.RS512);
+
+	public static final PublicKeyCredentialParameters RS1 = new PublicKeyCredentialParameters(
+			COSEAlgorithmIdentifier.RS1);
+
+	/**
+	 * This member specifies the type of credential to be created. The value SHOULD be a
+	 * member of PublicKeyCredentialType but client platforms MUST ignore unknown values,
+	 * ignoring any PublicKeyCredentialParameters with an unknown type.
+	 */
+	private final PublicKeyCredentialType type;
+
+	/**
+	 * This member specifies the cryptographic signature algorithm with which the newly
+	 * generated credential will be used, and thus also the type of asymmetric key pair to
+	 * be generated, e.g., RSA or Elliptic Curve.
+	 */
+	private final COSEAlgorithmIdentifier alg;
+
+	private PublicKeyCredentialParameters(COSEAlgorithmIdentifier alg) {
+		this(PublicKeyCredentialType.PUBLIC_KEY, alg);
+	}
+
+	private PublicKeyCredentialParameters(PublicKeyCredentialType type, COSEAlgorithmIdentifier alg) {
+		this.type = type;
+		this.alg = alg;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialparameters-type">type</a>
+	 * property member specifies the type of credential to be created.
+	 * @return the type
+	 */
+	public PublicKeyCredentialType getType() {
+		return this.type;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialparameters-alg">alg</a>
+	 * member specifies the cryptographic signature algorithm with which the newly
+	 * generated credential will be used, and thus also the type of asymmetric key pair to
+	 * be generated, e.g., RSA or Elliptic Curve.
+	 * @return the algorithm
+	 */
+	public COSEAlgorithmIdentifier getAlg() {
+		return this.alg;
+	}
+
+}

+ 248 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialRequestOptions.java

@@ -0,0 +1,248 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Consumer;
+
+import org.springframework.util.Assert;
+
+/**
+ * <a href=
+ * "https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions">PublicKeyCredentialRequestOptions</a>
+ * contains the information to create an assertion used for authentication.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class PublicKeyCredentialRequestOptions {
+
+	private final Bytes challenge;
+
+	private final Duration timeout;
+
+	private final String rpId;
+
+	private final List<PublicKeyCredentialDescriptor> allowCredentials;
+
+	private final UserVerificationRequirement userVerification;
+
+	private final AuthenticationExtensionsClientInputs extensions;
+
+	private PublicKeyCredentialRequestOptions(Bytes challenge, Duration timeout, String rpId,
+			List<PublicKeyCredentialDescriptor> allowCredentials, UserVerificationRequirement userVerification,
+			AuthenticationExtensionsClientInputs extensions) {
+		Assert.notNull(challenge, "challenge cannot be null");
+		Assert.hasText(rpId, "rpId cannot be empty");
+		this.challenge = challenge;
+		this.timeout = timeout;
+		this.rpId = rpId;
+		this.allowCredentials = allowCredentials;
+		this.userVerification = userVerification;
+		this.extensions = extensions;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-challenge">challenge</a>
+	 * property specifies a challenge that the authenticator signs, along with other data,
+	 * when producing an authentication assertion.
+	 * @return the challenge
+	 */
+	public Bytes getChallenge() {
+		return this.challenge;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-timeout">timeout</a>
+	 * property is an OPTIONAL member specifies a time, in milliseconds, that the Relying
+	 * Party is willing to wait for the call to complete.
+	 * @return the timeout
+	 */
+	public Duration getTimeout() {
+		return this.timeout;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-rpid">rpId</a>
+	 * is an OPTIONAL member specifies the RP ID claimed by the Relying Party. The client
+	 * MUST verify that the Relying Party's origin matches the scope of this RP ID.
+	 * @return the relying party id
+	 */
+	public String getRpId() {
+		return this.rpId;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-allowcredentials">allowCredentials</a>
+	 * property is an OPTIONAL member is used by the client to find authenticators
+	 * eligible for this authentication ceremony.
+	 * @return the allowCredentials property
+	 */
+	public List<PublicKeyCredentialDescriptor> getAllowCredentials() {
+		return this.allowCredentials;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-userverification">userVerification</a>
+	 * property is an OPTIONAL member specifies the Relying Party's requirements regarding
+	 * user verification for the get() operation.
+	 * @return the user verification
+	 */
+	public UserVerificationRequirement getUserVerification() {
+		return this.userVerification;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-extensions">extensions</a>
+	 * is an OPTIONAL property used by the Relying Party to provide client extension
+	 * inputs requesting additional processing by the client and authenticator.
+	 * @return the extensions
+	 */
+	public AuthenticationExtensionsClientInputs getExtensions() {
+		return this.extensions;
+	}
+
+	/**
+	 * Creates a {@link PublicKeyCredentialRequestOptionsBuilder}
+	 * @return the {@link PublicKeyCredentialRequestOptionsBuilder}
+	 */
+	public static PublicKeyCredentialRequestOptionsBuilder builder() {
+		return new PublicKeyCredentialRequestOptionsBuilder();
+	}
+
+	/**
+	 * Used to build a {@link PublicKeyCredentialCreationOptions}.
+	 *
+	 * @author Rob Winch
+	 * @since 6.4
+	 */
+	public static final class PublicKeyCredentialRequestOptionsBuilder {
+
+		private Bytes challenge;
+
+		private Duration timeout = Duration.ofMinutes(5);
+
+		private String rpId;
+
+		private List<PublicKeyCredentialDescriptor> allowCredentials = Collections.emptyList();
+
+		private UserVerificationRequirement userVerification;
+
+		private AuthenticationExtensionsClientInputs extensions = new ImmutableAuthenticationExtensionsClientInputs(
+				new ArrayList<>());
+
+		private PublicKeyCredentialRequestOptionsBuilder() {
+		}
+
+		/**
+		 * Sets the {@link #getChallenge()} property.
+		 * @param challenge the challenge
+		 * @return the {@link PublicKeyCredentialRequestOptionsBuilder}
+		 */
+		public PublicKeyCredentialRequestOptionsBuilder challenge(Bytes challenge) {
+			this.challenge = challenge;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getTimeout()} property.
+		 * @param timeout the timeout
+		 * @return the {@link PublicKeyCredentialRequestOptionsBuilder}
+		 */
+		public PublicKeyCredentialRequestOptionsBuilder timeout(Duration timeout) {
+			Assert.notNull(timeout, "timeout cannot be null");
+			this.timeout = timeout;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getRpId()} property.
+		 * @param rpId the rpId property
+		 * @return the {@link PublicKeyCredentialRequestOptionsBuilder}
+		 */
+		public PublicKeyCredentialRequestOptionsBuilder rpId(String rpId) {
+			this.rpId = rpId;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getAllowCredentials()} property
+		 * @param allowCredentials the allowed credentials
+		 * @return the {@link PublicKeyCredentialRequestOptionsBuilder}
+		 */
+		public PublicKeyCredentialRequestOptionsBuilder allowCredentials(
+				List<PublicKeyCredentialDescriptor> allowCredentials) {
+			Assert.notNull(allowCredentials, "allowCredentials cannot be null");
+			this.allowCredentials = allowCredentials;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getUserVerification()} property.
+		 * @param userVerification the user verification
+		 * @return the {@link PublicKeyCredentialRequestOptionsBuilder}
+		 */
+		public PublicKeyCredentialRequestOptionsBuilder userVerification(UserVerificationRequirement userVerification) {
+			this.userVerification = userVerification;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getExtensions()} property
+		 * @param extensions the extensions
+		 * @return the {@link PublicKeyCredentialRequestOptionsBuilder}
+		 */
+		public PublicKeyCredentialRequestOptionsBuilder extensions(AuthenticationExtensionsClientInputs extensions) {
+			this.extensions = extensions;
+			return this;
+		}
+
+		/**
+		 * Allows customizing the {@link PublicKeyCredentialRequestOptionsBuilder}
+		 * @param customizer the {@link Consumer} used to customize the builder
+		 * @return the {@link PublicKeyCredentialRequestOptionsBuilder}
+		 */
+		public PublicKeyCredentialRequestOptionsBuilder customize(
+				Consumer<PublicKeyCredentialRequestOptionsBuilder> customizer) {
+			customizer.accept(this);
+			return this;
+		}
+
+		/**
+		 * Builds a new {@link PublicKeyCredentialRequestOptions}
+		 * @return a new {@link PublicKeyCredentialRequestOptions}
+		 */
+		public PublicKeyCredentialRequestOptions build() {
+			if (this.challenge == null) {
+				this.challenge = Bytes.random();
+			}
+			return new PublicKeyCredentialRequestOptions(this.challenge, this.timeout, this.rpId, this.allowCredentials,
+					this.userVerification, this.extensions);
+		}
+
+	}
+
+}

+ 115 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialRpEntity.java

@@ -0,0 +1,115 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * The <a href=
+ * "https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrpentity">PublicKeyCredentialRpEntity</a>
+ * dictionary is used to supply additional Relying Party attributes when creating a new
+ * credential.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class PublicKeyCredentialRpEntity {
+
+	private final String name;
+
+	private final String id;
+
+	private PublicKeyCredentialRpEntity(String name, String id) {
+		this.name = name;
+		this.id = id;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialentity-name">name</a>
+	 * property is a human-palatable name for the entity. Its function depends on what the
+	 * PublicKeyCredentialEntity represents for the Relying Party, intended only for
+	 * display.
+	 * @return the name
+	 */
+	public String getName() {
+		return this.name;
+	}
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrpentity-id">id</a>
+	 * property is a unique identifier for the Relying Party entity, which sets the
+	 * <a href="https://www.w3.org/TR/webauthn-3/#rp-id">RP ID</a>.
+	 * @return the relying party id
+	 */
+	public String getId() {
+		return this.id;
+	}
+
+	/**
+	 * Creates a new {@link PublicKeyCredentialRpEntityBuilder}
+	 * @return a new {@link PublicKeyCredentialRpEntityBuilder}
+	 */
+	public static PublicKeyCredentialRpEntityBuilder builder() {
+		return new PublicKeyCredentialRpEntityBuilder();
+	}
+
+	/**
+	 * Used to create a {@link PublicKeyCredentialRpEntity}.
+	 *
+	 * @author Rob Winch
+	 * @since 6.4
+	 */
+	public static final class PublicKeyCredentialRpEntityBuilder {
+
+		private String name;
+
+		private String id;
+
+		private PublicKeyCredentialRpEntityBuilder() {
+		}
+
+		/**
+		 * Sets the {@link #getName()} property.
+		 * @param name the name property
+		 * @return the {@link PublicKeyCredentialRpEntityBuilder}
+		 */
+		public PublicKeyCredentialRpEntityBuilder name(String name) {
+			this.name = name;
+			return this;
+		}
+
+		/**
+		 * Sets the {@link #getId()} property.
+		 * @param id the id
+		 * @return the {@link PublicKeyCredentialRpEntityBuilder}
+		 */
+		public PublicKeyCredentialRpEntityBuilder id(String id) {
+			this.id = id;
+			return this;
+		}
+
+		/**
+		 * Creates a new {@link PublicKeyCredentialRpEntity}.
+		 * @return a new {@link PublicKeyCredentialRpEntity}.
+		 */
+		public PublicKeyCredentialRpEntity build() {
+			return new PublicKeyCredentialRpEntity(this.name, this.id);
+		}
+
+	}
+
+}

+ 55 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialType.java

@@ -0,0 +1,55 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * The <a href=
+ * "https://www.w3.org/TR/webauthn-3/#enum-credentialType">PublicKeyCredentialType</a>
+ * defines the credential types.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class PublicKeyCredentialType {
+
+	/**
+	 * The only credential type that currently exists.
+	 */
+	public static final PublicKeyCredentialType PUBLIC_KEY = new PublicKeyCredentialType("public-key");
+
+	private final String value;
+
+	private PublicKeyCredentialType(String value) {
+		this.value = value;
+	}
+
+	/**
+	 * Gets the value.
+	 * @return the value
+	 */
+	public String getValue() {
+		return this.value;
+	}
+
+	public static PublicKeyCredentialType valueOf(String value) {
+		if (PUBLIC_KEY.getValue().equals(value)) {
+			return PUBLIC_KEY;
+		}
+		return new PublicKeyCredentialType(value);
+	}
+
+}

+ 60 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/PublicKeyCredentialUserEntity.java

@@ -0,0 +1,60 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+import org.springframework.security.web.webauthn.management.RelyingPartyAuthenticationRequest;
+import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
+
+/**
+ * <a href=
+ * "https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialuserentity">PublicKeyCredentialUserEntity</a>
+ * is used to supply additional
+ * <a href="https://www.w3.org/TR/webauthn-3/#user-account">user account</a> attributes
+ * when creating a new credential.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ * @see WebAuthnRelyingPartyOperations#authenticate(RelyingPartyAuthenticationRequest)
+ */
+public interface PublicKeyCredentialUserEntity {
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialentity-name">name</a>
+	 * property is a human-palatable identifier for a user account.
+	 * @return the name
+	 */
+	String getName();
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialuserentity-id">id</a> is
+	 * the user handle of the user account. A user handle is an opaque byte sequence with
+	 * a maximum size of 64 bytes, and is not meant to be displayed to the user.
+	 * @return the user handle of the user account
+	 */
+	Bytes getId();
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialuserentity-displayname">displayName</a>
+	 * is a human-palatable name for the user account, intended only for display.
+	 * @return the display name
+	 */
+	String getDisplayName();
+
+}

+ 80 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/ResidentKeyRequirement.java

@@ -0,0 +1,80 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * The <a href=
+ * "https://www.w3.org/TR/webauthn-3/#enumdef-residentkeyrequirement">ResidentKeyRequirement</a>
+ * describes the Relying Partys requirements for client-side discoverable credentials.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class ResidentKeyRequirement {
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-residentkeyrequirement-discouraged">discouraged</a>
+	 * requirement indicates that the Relying Party prefers creating a server-side
+	 * credential, but will accept a client-side discoverable credential.
+	 */
+	public static final ResidentKeyRequirement DISCOURAGED = new ResidentKeyRequirement("discouraged");
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-residentkeyrequirement-preferred">preferred</a>
+	 * requirement indicates that the Relying Party strongly prefers creating a
+	 * client-side discoverable credential, but will accept a server-side credential.
+	 */
+	public static final ResidentKeyRequirement PREFERRED = new ResidentKeyRequirement("preferred");
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-residentkeyrequirement-required">required</a>
+	 * value indicates that the Relying Party requires a client-side discoverable
+	 * credential.
+	 */
+	public static final ResidentKeyRequirement REQUIRED = new ResidentKeyRequirement("required");
+
+	private final String value;
+
+	private ResidentKeyRequirement(String value) {
+		this.value = value;
+	}
+
+	/**
+	 * Gets the value.
+	 * @return the value
+	 */
+	public String getValue() {
+		return this.value;
+	}
+
+	public static ResidentKeyRequirement valueOf(String value) {
+		if (DISCOURAGED.getValue().equals(value)) {
+			return DISCOURAGED;
+		}
+		if (PREFERRED.getValue().equals(value)) {
+			return PREFERRED;
+		}
+		if (REQUIRED.getValue().equals(value)) {
+			return REQUIRED;
+		}
+		return new ResidentKeyRequirement(value);
+	}
+
+}

+ 69 - 0
web/src/main/java/org/springframework/security/web/webauthn/api/UserVerificationRequirement.java

@@ -0,0 +1,69 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.api;
+
+/**
+ * <a href=
+ * "https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement">UserVerificationRequirement</a>
+ * is used by the Relying Party to indicate if user verification is needed.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public final class UserVerificationRequirement {
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-userverificationrequirement-discouraged">discouraged</a>
+	 * value indicates that the Relying Party does not want user verification employed
+	 * during the operation (e.g., in the interest of minimizing disruption to the user
+	 * interaction flow).
+	 */
+	public static final UserVerificationRequirement DISCOURAGED = new UserVerificationRequirement("discouraged");
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-userverificationrequirement-preferred">preferred</a>
+	 * value indicates that the Relying Party prefers user verification for the operation
+	 * if possible, but will not fail the operation if the response does not have the UV
+	 * flag set.
+	 */
+	public static final UserVerificationRequirement PREFERRED = new UserVerificationRequirement("preferred");
+
+	/**
+	 * The <a href=
+	 * "https://www.w3.org/TR/webauthn-3/#dom-userverificationrequirement-required">required</a>
+	 * value indicates that the Relying Party requires user verification for the operation
+	 * and will fail the overall ceremony if the response does not have the UV flag set.
+	 */
+	public static final UserVerificationRequirement REQUIRED = new UserVerificationRequirement("required");
+
+	private final String value;
+
+	UserVerificationRequirement(String value) {
+		this.value = value;
+	}
+
+	/**
+	 * Gets the value
+	 * @return the value
+	 */
+	public String getValue() {
+		return this.value;
+	}
+
+}

+ 63 - 0
web/src/main/java/org/springframework/security/web/webauthn/authentication/HttpSessionPublicKeyCredentialRequestOptionsRepository.java

@@ -0,0 +1,63 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.authentication;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link PublicKeyCredentialRequestOptionsRepository} that stores the
+ * {@link PublicKeyCredentialRequestOptions} in the
+ * {@link jakarta.servlet.http.HttpSession}.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public class HttpSessionPublicKeyCredentialRequestOptionsRepository
+		implements PublicKeyCredentialRequestOptionsRepository {
+
+	static final String DEFAULT_ATTR_NAME = PublicKeyCredentialRequestOptionsRepository.class.getName()
+		.concat(".ATTR_NAME");
+
+	private String attrName = DEFAULT_ATTR_NAME;
+
+	@Override
+	public void save(HttpServletRequest request, HttpServletResponse response,
+			PublicKeyCredentialRequestOptions options) {
+		HttpSession session = request.getSession();
+		session.setAttribute(this.attrName, options);
+	}
+
+	@Override
+	public PublicKeyCredentialRequestOptions load(HttpServletRequest request) {
+		HttpSession session = request.getSession(false);
+		if (session == null) {
+			return null;
+		}
+		return (PublicKeyCredentialRequestOptions) session.getAttribute(this.attrName);
+	}
+
+	public void setAttrName(String attrName) {
+		Assert.notNull(attrName, "attrName cannot be null");
+		this.attrName = attrName;
+	}
+
+}

+ 127 - 0
web/src/main/java/org/springframework/security/web/webauthn/authentication/PublicKeyCredentialRequestOptionsFilter.java

@@ -0,0 +1,127 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.authentication;
+
+import java.io.IOException;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.http.server.ServletServerHttpResponse;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextHolderStrategy;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
+import org.springframework.security.web.webauthn.jackson.WebauthnJackson2Module;
+import org.springframework.security.web.webauthn.management.ImmutablePublicKeyCredentialRequestOptionsRequest;
+import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
+import org.springframework.util.Assert;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher;
+
+/**
+ * A {@link jakarta.servlet.Filter} that renders the
+ * {@link PublicKeyCredentialRequestOptions} in order to <a href=
+ * "https://w3c.github.io/webappsec-credential-management/#dom-credentialscontainer-get">get</a>
+ * a credential.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public class PublicKeyCredentialRequestOptionsFilter extends OncePerRequestFilter {
+
+	private RequestMatcher matcher = antMatcher(HttpMethod.POST, "/webauthn/authenticate/options");
+
+	private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
+		.getContextHolderStrategy();
+
+	private final WebAuthnRelyingPartyOperations rpOptions;
+
+	private PublicKeyCredentialRequestOptionsRepository requestOptionsRepository = new HttpSessionPublicKeyCredentialRequestOptionsRepository();
+
+	private HttpMessageConverter<Object> converter = new MappingJackson2HttpMessageConverter(
+			Jackson2ObjectMapperBuilder.json().modules(new WebauthnJackson2Module()).build());
+
+	/**
+	 * Creates a new instance with the provided {@link WebAuthnRelyingPartyOperations}.
+	 * @param rpOptions the {@link WebAuthnRelyingPartyOperations} to use. Cannot be null.
+	 */
+	public PublicKeyCredentialRequestOptionsFilter(WebAuthnRelyingPartyOperations rpOptions) {
+		Assert.notNull(rpOptions, "rpOperations cannot be null");
+		this.rpOptions = rpOptions;
+	}
+
+	@Override
+	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+			throws ServletException, IOException {
+		if (!this.matcher.matches(request)) {
+			filterChain.doFilter(request, response);
+			return;
+		}
+
+		SecurityContext context = this.securityContextHolderStrategy.getContext();
+		ImmutablePublicKeyCredentialRequestOptionsRequest optionsRequest = new ImmutablePublicKeyCredentialRequestOptionsRequest(
+				context.getAuthentication());
+		PublicKeyCredentialRequestOptions credentialRequestOptions = this.rpOptions
+			.createCredentialRequestOptions(optionsRequest);
+		this.requestOptionsRepository.save(request, response, credentialRequestOptions);
+		response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
+		this.converter.write(credentialRequestOptions, MediaType.APPLICATION_JSON,
+				new ServletServerHttpResponse(response));
+
+	}
+
+	/**
+	 * Sets the {@link PublicKeyCredentialRequestOptionsRepository} to use.
+	 * @param requestOptionsRepository the
+	 * {@link PublicKeyCredentialRequestOptionsRepository} to use. Cannot be null.
+	 */
+	public void setRequestOptionsRepository(PublicKeyCredentialRequestOptionsRepository requestOptionsRepository) {
+		Assert.notNull(requestOptionsRepository, "requestOptionsRepository cannot be null");
+		this.requestOptionsRepository = requestOptionsRepository;
+	}
+
+	/**
+	 * Sets the {@link HttpMessageConverter} to use.
+	 * @param converter the {@link HttpMessageConverter} to use. Cannot be null.
+	 */
+	public void setConverter(HttpMessageConverter<Object> converter) {
+		Assert.notNull(converter, "converter cannot be null");
+		this.converter = converter;
+	}
+
+	/**
+	 * Sets the {@link SecurityContextHolderStrategy} to use.
+	 * @param securityContextHolderStrategy the {@link SecurityContextHolderStrategy} to
+	 * use. Cannot be null.
+	 */
+	public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) {
+		Assert.notNull(securityContextHolderStrategy, "securityContextHolderStrategy cannot be null");
+		this.securityContextHolderStrategy = securityContextHolderStrategy;
+	}
+
+}

+ 52 - 0
web/src/main/java/org/springframework/security/web/webauthn/authentication/PublicKeyCredentialRequestOptionsRepository.java

@@ -0,0 +1,52 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.authentication;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
+
+/**
+ * Saves {@link PublicKeyCredentialRequestOptions} between a request to generate an
+ * assertion and the validation of the assertion.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public interface PublicKeyCredentialRequestOptionsRepository {
+
+	/**
+	 * Saves the provided {@link PublicKeyCredentialRequestOptions} or clears an existing
+	 * {@link PublicKeyCredentialRequestOptions} if {@code options} is null.
+	 * @param request the {@link HttpServletRequest}
+	 * @param response the {@link HttpServletResponse}
+	 * @param options the {@link PublicKeyCredentialRequestOptions} to save or null if an
+	 * existing {@link PublicKeyCredentialRequestOptions} should be removed.
+	 */
+	void save(HttpServletRequest request, HttpServletResponse response, PublicKeyCredentialRequestOptions options);
+
+	/**
+	 * Gets a saved {@link PublicKeyCredentialRequestOptions} if it exists, otherwise
+	 * null.
+	 * @param request the {@link HttpServletRequest}
+	 * @return the {@link PublicKeyCredentialRequestOptions} that was saved, otherwise
+	 * null.
+	 */
+	PublicKeyCredentialRequestOptions load(HttpServletRequest request);
+
+}

+ 66 - 0
web/src/main/java/org/springframework/security/web/webauthn/authentication/WebAuthnAuthentication.java

@@ -0,0 +1,66 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.authentication;
+
+import java.util.Collection;
+
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link WebAuthnAuthentication} is used to represent successful authentication with
+ * WebAuthn.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ * @see WebAuthnAuthenticationRequestToken
+ */
+public class WebAuthnAuthentication extends AbstractAuthenticationToken {
+
+	private final PublicKeyCredentialUserEntity principal;
+
+	public WebAuthnAuthentication(PublicKeyCredentialUserEntity principal,
+			Collection<? extends GrantedAuthority> authorities) {
+		super(authorities);
+		this.principal = principal;
+		super.setAuthenticated(true);
+	}
+
+	@Override
+	public void setAuthenticated(boolean authenticated) {
+		Assert.isTrue(!authenticated, "Cannot set this token to trusted");
+		super.setAuthenticated(authenticated);
+	}
+
+	@Override
+	public Object getCredentials() {
+		return null;
+	}
+
+	@Override
+	public PublicKeyCredentialUserEntity getPrincipal() {
+		return this.principal;
+	}
+
+	@Override
+	public String getName() {
+		return this.principal.getName();
+	}
+
+}

+ 136 - 0
web/src/main/java/org/springframework/security/web/webauthn/authentication/WebAuthnAuthenticationFilter.java

@@ -0,0 +1,136 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.authentication;
+
+import java.io.IOException;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.springframework.core.ResolvableType;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.http.server.ServletServerHttpRequest;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
+import org.springframework.security.web.authentication.AuthenticationEntryPointFailureHandler;
+import org.springframework.security.web.authentication.HttpMessageConverterAuthenticationSuccessHandler;
+import org.springframework.security.web.authentication.HttpStatusEntryPoint;
+import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
+import org.springframework.security.web.webauthn.api.AuthenticatorAssertionResponse;
+import org.springframework.security.web.webauthn.api.PublicKeyCredential;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
+import org.springframework.security.web.webauthn.jackson.WebauthnJackson2Module;
+import org.springframework.security.web.webauthn.management.RelyingPartyAuthenticationRequest;
+import org.springframework.util.Assert;
+
+import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher;
+
+/**
+ * Authenticates {@code PublicKeyCredential<AuthenticatorAssertionResponse>} that is
+ * parsed from the body of the {@link HttpServletRequest} using the
+ * {@link #setConverter(GenericHttpMessageConverter)}. An example request is provided
+ * below:
+ *
+ * <pre>
+ * {
+ * 	"id": "dYF7EGnRFFIXkpXi9XU2wg",
+ * 	"rawId": "dYF7EGnRFFIXkpXi9XU2wg",
+ * 	"response": {
+ * 		"authenticatorData": "y9GqwTRaMpzVDbXq1dyEAXVOxrou08k22ggRC45MKNgdAAAAAA",
+ * 		"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiRFVsRzRDbU9naWhKMG1vdXZFcE9HdUk0ZVJ6MGRRWmxUQmFtbjdHQ1FTNCIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5sb2NhbGhvc3Q6ODQ0MyIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
+ * 		"signature": "MEYCIQCW2BcUkRCAXDmGxwMi78jknenZ7_amWrUJEYoTkweldAIhAMD0EMp1rw2GfwhdrsFIeDsL7tfOXVPwOtfqJntjAo4z",
+ * 		"userHandle": "Q3_0Xd64_HW0BlKRAJnVagJTpLKLgARCj8zjugpRnVo"
+ * 	    },
+ * 	"clientExtensionResults": {},
+ * 	"authenticatorAttachment": "platform"
+ * }
+ * </pre>
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public class WebAuthnAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
+
+	private GenericHttpMessageConverter<Object> converter = new MappingJackson2HttpMessageConverter(
+			Jackson2ObjectMapperBuilder.json().modules(new WebauthnJackson2Module()).build());
+
+	private PublicKeyCredentialRequestOptionsRepository requestOptionsRepository = new HttpSessionPublicKeyCredentialRequestOptionsRepository();
+
+	public WebAuthnAuthenticationFilter() {
+		super(antMatcher(HttpMethod.POST, "/login/webauthn"));
+		setSecurityContextRepository(new HttpSessionSecurityContextRepository());
+		setAuthenticationFailureHandler(
+				new AuthenticationEntryPointFailureHandler(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));
+		setAuthenticationSuccessHandler(new HttpMessageConverterAuthenticationSuccessHandler());
+	}
+
+	@Override
+	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
+			throws AuthenticationException, IOException, ServletException {
+		ServletServerHttpRequest httpRequest = new ServletServerHttpRequest(request);
+		ResolvableType resolvableType = ResolvableType.forClassWithGenerics(PublicKeyCredential.class,
+				AuthenticatorAssertionResponse.class);
+		PublicKeyCredential<AuthenticatorAssertionResponse> publicKeyCredential = null;
+		try {
+			publicKeyCredential = (PublicKeyCredential<AuthenticatorAssertionResponse>) this.converter
+				.read(resolvableType.getType(), getClass(), httpRequest);
+		}
+		catch (Exception ex) {
+			throw new BadCredentialsException("Unable to authenticate the PublicKeyCredential", ex);
+		}
+		PublicKeyCredentialRequestOptions requestOptions = this.requestOptionsRepository.load(request);
+		if (requestOptions == null) {
+			throw new BadCredentialsException(
+					"Unable to authenticate the PublicKeyCredential. No PublicKeyCredentialRequestOptions found.");
+		}
+		this.requestOptionsRepository.save(request, response, null);
+		RelyingPartyAuthenticationRequest authenticationRequest = new RelyingPartyAuthenticationRequest(requestOptions,
+				publicKeyCredential);
+		WebAuthnAuthenticationRequestToken token = new WebAuthnAuthenticationRequestToken(authenticationRequest);
+		return getAuthenticationManager().authenticate(token);
+	}
+
+	/**
+	 * Sets the {@link GenericHttpMessageConverter} to use for writing
+	 * {@code PublicKeyCredential<AuthenticatorAssertionResponse>} to the response. The
+	 * default is @{code MappingJackson2HttpMessageConverter}
+	 * @param converter the {@link GenericHttpMessageConverter} to use. Cannot be null.
+	 */
+	public void setConverter(GenericHttpMessageConverter<Object> converter) {
+		Assert.notNull(converter, "converter cannot be null");
+		this.converter = converter;
+	}
+
+	/**
+	 * Sets the {@link PublicKeyCredentialRequestOptionsRepository} to use. The default is
+	 * {@link HttpSessionPublicKeyCredentialRequestOptionsRepository}.
+	 * @param requestOptionsRepository the
+	 * {@link PublicKeyCredentialRequestOptionsRepository} to use. Cannot be null.
+	 */
+	public void setRequestOptionsRepository(PublicKeyCredentialRequestOptionsRepository requestOptionsRepository) {
+		Assert.notNull(requestOptionsRepository, "requestOptionsRepository cannot be null");
+		this.requestOptionsRepository = requestOptionsRepository;
+	}
+
+}

+ 80 - 0
web/src/main/java/org/springframework/security/web/webauthn/authentication/WebAuthnAuthenticationProvider.java

@@ -0,0 +1,80 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.authentication;
+
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.management.RelyingPartyAuthenticationRequest;
+import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link AuthenticationProvider} that uses {@link WebAuthnRelyingPartyOperations} for
+ * authentication using an {@link WebAuthnAuthenticationRequestToken}. First
+ * {@link WebAuthnRelyingPartyOperations#authenticate(RelyingPartyAuthenticationRequest)}
+ * is invoked. The result is a username passed into {@link UserDetailsService}. The
+ * {@link UserDetails} is used to create an {@link Authentication}.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public class WebAuthnAuthenticationProvider implements AuthenticationProvider {
+
+	private final WebAuthnRelyingPartyOperations relyingPartyOperations;
+
+	private final UserDetailsService userDetailsService;
+
+	/**
+	 * Creates a new instance.
+	 * @param relyingPartyOperations the {@link WebAuthnRelyingPartyOperations} to use.
+	 * Cannot be null.
+	 * @param userDetailsService the {@link UserDetailsService} to use. Cannot be null.
+	 */
+	public WebAuthnAuthenticationProvider(WebAuthnRelyingPartyOperations relyingPartyOperations,
+			UserDetailsService userDetailsService) {
+		Assert.notNull(relyingPartyOperations, "relyingPartyOperations cannot be null");
+		Assert.notNull(userDetailsService, "userDetailsService cannot be null");
+		this.relyingPartyOperations = relyingPartyOperations;
+		this.userDetailsService = userDetailsService;
+	}
+
+	@Override
+	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
+		WebAuthnAuthenticationRequestToken webAuthnRequest = (WebAuthnAuthenticationRequestToken) authentication;
+		try {
+			PublicKeyCredentialUserEntity userEntity = this.relyingPartyOperations
+				.authenticate(webAuthnRequest.getWebAuthnRequest());
+			String username = userEntity.getName();
+			UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
+			return new WebAuthnAuthentication(userEntity, userDetails.getAuthorities());
+		}
+		catch (RuntimeException ex) {
+			throw new BadCredentialsException(ex.getMessage(), ex);
+		}
+	}
+
+	@Override
+	public boolean supports(Class<?> authentication) {
+		return WebAuthnAuthenticationRequestToken.class.isAssignableFrom(authentication);
+	}
+
+}

+ 70 - 0
web/src/main/java/org/springframework/security/web/webauthn/authentication/WebAuthnAuthenticationRequestToken.java

@@ -0,0 +1,70 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.authentication;
+
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.web.webauthn.management.RelyingPartyAuthenticationRequest;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link org.springframework.security.core.Authentication} used in
+ * {@link WebAuthnAuthenticationProvider} for authenticating via WebAuthn.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+public class WebAuthnAuthenticationRequestToken extends AbstractAuthenticationToken {
+
+	private final RelyingPartyAuthenticationRequest webAuthnRequest;
+
+	/**
+	 * Creates a new instance.
+	 * @param webAuthnRequest the {@link RelyingPartyAuthenticationRequest} to use for
+	 * authentication. Cannot be null.
+	 */
+	public WebAuthnAuthenticationRequestToken(RelyingPartyAuthenticationRequest webAuthnRequest) {
+		super(AuthorityUtils.NO_AUTHORITIES);
+		Assert.notNull(webAuthnRequest, "webAuthnRequest cannot be null");
+		this.webAuthnRequest = webAuthnRequest;
+	}
+
+	/**
+	 * Gets the {@link RelyingPartyAuthenticationRequest}
+	 * @return the {@link RelyingPartyAuthenticationRequest}
+	 */
+	public RelyingPartyAuthenticationRequest getWebAuthnRequest() {
+		return this.webAuthnRequest;
+	}
+
+	@Override
+	public void setAuthenticated(boolean authenticated) {
+		Assert.isTrue(!authenticated, "Cannot set this token to trusted");
+		super.setAuthenticated(authenticated);
+	}
+
+	@Override
+	public Object getCredentials() {
+		return this.webAuthnRequest.getPublicKey();
+	}
+
+	@Override
+	public Object getPrincipal() {
+		return this.webAuthnRequest.getPublicKey().getResponse().getUserHandle();
+	}
+
+}

+ 32 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AttestationConveyancePreferenceMixin.java

@@ -0,0 +1,32 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import org.springframework.security.web.webauthn.api.AttestationConveyancePreference;
+
+/**
+ * Jackson mixin for {@link AttestationConveyancePreference}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+@JsonSerialize(using = AttestationConveyancePreferenceSerializer.class)
+class AttestationConveyancePreferenceMixin {
+
+}

+ 45 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AttestationConveyancePreferenceSerializer.java

@@ -0,0 +1,45 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+
+import org.springframework.security.web.webauthn.api.AttestationConveyancePreference;
+
+/**
+ * Jackson serializer for {@link AttestationConveyancePreference}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+class AttestationConveyancePreferenceSerializer extends StdSerializer<AttestationConveyancePreference> {
+
+	AttestationConveyancePreferenceSerializer() {
+		super(AttestationConveyancePreference.class);
+	}
+
+	@Override
+	public void serialize(AttestationConveyancePreference preference, JsonGenerator jgen, SerializerProvider provider)
+			throws IOException {
+		jgen.writeString(preference.getValue());
+	}
+
+}

+ 32 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputMixin.java

@@ -0,0 +1,32 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import org.springframework.security.web.webauthn.api.AuthenticationExtensionsClientInputs;
+
+/**
+ * Jackson mixin for {@link AuthenticationExtensionsClientInputs}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+@JsonSerialize(using = AuthenticationExtensionsClientInputSerializer.class)
+class AuthenticationExtensionsClientInputMixin {
+
+}

+ 48 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputSerializer.java

@@ -0,0 +1,48 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+
+import org.springframework.security.web.webauthn.api.AuthenticationExtensionsClientInput;
+
+/**
+ * Provides Jackson serialization of {@link AuthenticationExtensionsClientInput}.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+class AuthenticationExtensionsClientInputSerializer extends StdSerializer<AuthenticationExtensionsClientInput> {
+
+	/**
+	 * Creates a new instance.
+	 */
+	AuthenticationExtensionsClientInputSerializer() {
+		super(AuthenticationExtensionsClientInput.class);
+	}
+
+	@Override
+	public void serialize(AuthenticationExtensionsClientInput input, JsonGenerator jgen, SerializerProvider provider)
+			throws IOException {
+		jgen.writeObjectField(input.getExtensionId(), input.getInput());
+	}
+
+}

+ 32 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputsMixin.java

@@ -0,0 +1,32 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import org.springframework.security.web.webauthn.api.AuthenticationExtensionsClientInputs;
+
+/**
+ * Jackson mixin for {@link AuthenticationExtensionsClientInputs}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+@JsonSerialize(using = AuthenticationExtensionsClientInputsSerializer.class)
+class AuthenticationExtensionsClientInputsMixin {
+
+}

+ 53 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientInputsSerializer.java

@@ -0,0 +1,53 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+
+import org.springframework.security.web.webauthn.api.AuthenticationExtensionsClientInput;
+import org.springframework.security.web.webauthn.api.AuthenticationExtensionsClientInputs;
+
+/**
+ * Provides Jackson serialization of {@link AuthenticationExtensionsClientInputs}.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+class AuthenticationExtensionsClientInputsSerializer extends StdSerializer<AuthenticationExtensionsClientInputs> {
+
+	/**
+	 * Creates a new instance.
+	 */
+	AuthenticationExtensionsClientInputsSerializer() {
+		super(AuthenticationExtensionsClientInputs.class);
+	}
+
+	@Override
+	public void serialize(AuthenticationExtensionsClientInputs inputs, JsonGenerator jgen, SerializerProvider provider)
+			throws IOException {
+		jgen.writeStartObject();
+		for (AuthenticationExtensionsClientInput input : inputs.getInputs()) {
+			jgen.writeObject(input);
+		}
+		jgen.writeEndObject();
+	}
+
+}

+ 77 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientOutputsDeserializer.java

@@ -0,0 +1,77 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import com.fasterxml.jackson.core.JacksonException;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.security.web.webauthn.api.AuthenticationExtensionsClientOutput;
+import org.springframework.security.web.webauthn.api.AuthenticationExtensionsClientOutputs;
+import org.springframework.security.web.webauthn.api.CredentialPropertiesOutput;
+import org.springframework.security.web.webauthn.api.ImmutableAuthenticationExtensionsClientOutputs;
+
+/**
+ * Provides Jackson deserialization of {@link AuthenticationExtensionsClientOutputs}.
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+class AuthenticationExtensionsClientOutputsDeserializer extends StdDeserializer<AuthenticationExtensionsClientOutputs> {
+
+	private static final Log logger = LogFactory.getLog(AuthenticationExtensionsClientOutputsDeserializer.class);
+
+	/**
+	 * Creates a new instance.
+	 */
+	AuthenticationExtensionsClientOutputsDeserializer() {
+		super(AuthenticationExtensionsClientOutputs.class);
+	}
+
+	@Override
+	public AuthenticationExtensionsClientOutputs deserialize(JsonParser parser, DeserializationContext ctxt)
+			throws IOException, JacksonException {
+		List<AuthenticationExtensionsClientOutput<?>> outputs = new ArrayList<>();
+		for (String key = parser.nextFieldName(); key != null; key = parser.nextFieldName()) {
+			JsonToken startObject = parser.nextValue();
+			if (startObject != JsonToken.START_OBJECT) {
+				break;
+			}
+			if (CredentialPropertiesOutput.EXTENSION_ID.equals(key)) {
+				CredentialPropertiesOutput output = parser.readValueAs(CredentialPropertiesOutput.class);
+				outputs.add(output);
+			}
+			else {
+				if (logger.isDebugEnabled()) {
+					logger.debug("Skipping unknown extension with id " + key);
+				}
+				parser.nextValue();
+			}
+		}
+
+		return new ImmutableAuthenticationExtensionsClientOutputs(outputs);
+	}
+
+}

+ 32 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticationExtensionsClientOutputsMixin.java

@@ -0,0 +1,32 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+
+import org.springframework.security.web.webauthn.api.AuthenticationExtensionsClientOutputs;
+
+/**
+ * Jackson mixin for {@link AuthenticationExtensionsClientOutputs}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+@JsonDeserialize(using = AuthenticationExtensionsClientOutputsDeserializer.class)
+class AuthenticationExtensionsClientOutputsMixin {
+
+}

+ 38 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAssertionResponseMixin.java

@@ -0,0 +1,38 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+
+import org.springframework.security.web.webauthn.api.AuthenticatorAssertionResponse;
+
+/**
+ * Jackson mixin for {@link AuthenticatorAssertionResponse}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+@JsonDeserialize(builder = AuthenticatorAssertionResponse.AuthenticatorAssertionResponseBuilder.class)
+class AuthenticatorAssertionResponseMixin {
+
+	@JsonPOJOBuilder(withPrefix = "")
+	abstract class AuthenticatorAssertionResponseBuilderMixin {
+
+	}
+
+}

+ 52 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentDeserializer.java

@@ -0,0 +1,52 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JacksonException;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+
+import org.springframework.security.web.webauthn.api.AuthenticatorAttachment;
+
+/**
+ * Jackson deserializer for {@link AuthenticatorAttachment}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+class AuthenticatorAttachmentDeserializer extends StdDeserializer<AuthenticatorAttachment> {
+
+	AuthenticatorAttachmentDeserializer() {
+		super(AuthenticatorAttachment.class);
+	}
+
+	@Override
+	public AuthenticatorAttachment deserialize(JsonParser parser, DeserializationContext ctxt)
+			throws IOException, JacksonException {
+		String type = parser.readValueAs(String.class);
+		for (AuthenticatorAttachment publicKeyCredentialType : AuthenticatorAttachment.values()) {
+			if (publicKeyCredentialType.getValue().equals(type)) {
+				return publicKeyCredentialType;
+			}
+		}
+		return null;
+	}
+
+}

+ 34 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentMixin.java

@@ -0,0 +1,34 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import org.springframework.security.web.webauthn.api.AuthenticatorAttachment;
+
+/**
+ * Jackson mixin for {@link AuthenticatorAttachment}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+@JsonDeserialize(using = AuthenticatorAttachmentDeserializer.class)
+@JsonSerialize(using = AuthenticatorAttachmentSerializer.class)
+class AuthenticatorAttachmentMixin {
+
+}

+ 45 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttachmentSerializer.java

@@ -0,0 +1,45 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+
+import org.springframework.security.web.webauthn.api.AuthenticatorAttachment;
+
+/**
+ * Jackson serializer for {@link AuthenticatorAttachment}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+class AuthenticatorAttachmentSerializer extends StdSerializer<AuthenticatorAttachment> {
+
+	AuthenticatorAttachmentSerializer() {
+		super(AuthenticatorAttachment.class);
+	}
+
+	@Override
+	public void serialize(AuthenticatorAttachment attachment, JsonGenerator jgen, SerializerProvider provider)
+			throws IOException {
+		jgen.writeString(attachment.getValue());
+	}
+
+}

+ 48 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorAttestationResponseMixin.java

@@ -0,0 +1,48 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.util.List;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonSetter;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+
+import org.springframework.security.web.webauthn.api.AuthenticatorAttestationResponse;
+import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
+
+/**
+ * Jackson mixin for {@link AuthenticatorAttestationResponse}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+@JsonDeserialize(builder = AuthenticatorAttestationResponse.AuthenticatorAttestationResponseBuilder.class)
+class AuthenticatorAttestationResponseMixin {
+
+	@JsonPOJOBuilder(withPrefix = "")
+	@JsonIgnoreProperties(ignoreUnknown = true)
+	abstract class AuthenticatorAttestationResponseBuilderMixin {
+
+		@JsonSetter
+		abstract AuthenticatorAttestationResponse.AuthenticatorAttestationResponseBuilder transports(
+				List<AuthenticatorTransport> transports);
+
+	}
+
+}

+ 32 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorSelectionCriteriaMixin.java

@@ -0,0 +1,32 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
+
+/**
+ * Jackson mixin for {@link AuthenticatorSelectionCriteria}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+abstract class AuthenticatorSelectionCriteriaMixin {
+
+}

+ 52 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorTransportDeserializer.java

@@ -0,0 +1,52 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JacksonException;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+
+import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
+
+/**
+ * Jackson deserializer for {@link AuthenticatorTransport}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+class AuthenticatorTransportDeserializer extends StdDeserializer<AuthenticatorTransport> {
+
+	AuthenticatorTransportDeserializer() {
+		super(AuthenticatorTransport.class);
+	}
+
+	@Override
+	public AuthenticatorTransport deserialize(JsonParser parser, DeserializationContext ctxt)
+			throws IOException, JacksonException {
+		String transportValue = parser.readValueAs(String.class);
+		for (AuthenticatorTransport transport : AuthenticatorTransport.values()) {
+			if (transport.getValue().equals(transportValue)) {
+				return transport;
+			}
+		}
+		return null;
+	}
+
+}

+ 34 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorTransportMixin.java

@@ -0,0 +1,34 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
+
+/**
+ * Jackson mixin for {@link AuthenticatorTransport}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+@JsonDeserialize(using = AuthenticatorTransportDeserializer.class)
+@JsonSerialize(using = AuthenticatorTransportSerializer.class)
+class AuthenticatorTransportMixin {
+
+}

+ 41 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/AuthenticatorTransportSerializer.java

@@ -0,0 +1,41 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+
+import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
+
+/**
+ * Jackson serializer for {@link AuthenticatorTransport}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+class AuthenticatorTransportSerializer extends JsonSerializer<AuthenticatorTransport> {
+
+	@Override
+	public void serialize(AuthenticatorTransport transport, JsonGenerator jgen, SerializerProvider provider)
+			throws IOException {
+		jgen.writeString(transport.getValue());
+	}
+
+}

+ 41 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/BytesMixin.java

@@ -0,0 +1,41 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import org.springframework.security.web.webauthn.api.Bytes;
+
+/**
+ * Jackson mixin for {@link Bytes}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+@JsonSerialize(using = BytesSerializer.class)
+final class BytesMixin {
+
+	@JsonCreator
+	static Bytes fromBase64(String value) {
+		return Bytes.fromBase64(value);
+	}
+
+	private BytesMixin() {
+	}
+
+}

+ 47 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/BytesSerializer.java

@@ -0,0 +1,47 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+
+import org.springframework.security.web.webauthn.api.Bytes;
+
+/**
+ * Jackson serializer for {@link Bytes}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+class BytesSerializer extends StdSerializer<Bytes> {
+
+	/**
+	 * Creates a new instance.
+	 */
+	BytesSerializer() {
+		super(Bytes.class);
+	}
+
+	@Override
+	public void serialize(Bytes bytes, JsonGenerator jgen, SerializerProvider provider) throws IOException {
+		jgen.writeString(bytes.toBase64UrlString());
+	}
+
+}

+ 52 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierDeserializer.java

@@ -0,0 +1,52 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JacksonException;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+
+import org.springframework.security.web.webauthn.api.COSEAlgorithmIdentifier;
+
+/**
+ * Jackson serializer for {@link COSEAlgorithmIdentifier}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+class COSEAlgorithmIdentifierDeserializer extends StdDeserializer<COSEAlgorithmIdentifier> {
+
+	COSEAlgorithmIdentifierDeserializer() {
+		super(COSEAlgorithmIdentifier.class);
+	}
+
+	@Override
+	public COSEAlgorithmIdentifier deserialize(JsonParser parser, DeserializationContext ctxt)
+			throws IOException, JacksonException {
+		Long transportValue = parser.readValueAs(Long.class);
+		for (COSEAlgorithmIdentifier identifier : COSEAlgorithmIdentifier.values()) {
+			if (identifier.getValue() == transportValue.longValue()) {
+				return identifier;
+			}
+		}
+		return null;
+	}
+
+}

+ 34 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierMixin.java

@@ -0,0 +1,34 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import org.springframework.security.web.webauthn.api.COSEAlgorithmIdentifier;
+
+/**
+ * Jackson mixin for {@link COSEAlgorithmIdentifier}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+@JsonSerialize(using = COSEAlgorithmIdentifierSerializer.class)
+@JsonDeserialize(using = COSEAlgorithmIdentifierDeserializer.class)
+abstract class COSEAlgorithmIdentifierMixin {
+
+}

+ 45 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/COSEAlgorithmIdentifierSerializer.java

@@ -0,0 +1,45 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+
+import org.springframework.security.web.webauthn.api.COSEAlgorithmIdentifier;
+
+/**
+ * Jackson serializer for {@link COSEAlgorithmIdentifier}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+class COSEAlgorithmIdentifierSerializer extends StdSerializer<COSEAlgorithmIdentifier> {
+
+	COSEAlgorithmIdentifierSerializer() {
+		super(COSEAlgorithmIdentifier.class);
+	}
+
+	@Override
+	public void serialize(COSEAlgorithmIdentifier identifier, JsonGenerator jgen, SerializerProvider provider)
+			throws IOException {
+		jgen.writeNumber(identifier.getValue());
+	}
+
+}

+ 24 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/CredProtectAuthenticationExtensionsClientInputMixin.java

@@ -0,0 +1,24 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+@JsonSerialize(using = CredProtectAuthenticationExtensionsClientInputSerializer.class)
+class CredProtectAuthenticationExtensionsClientInputMixin {
+
+}

+ 63 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/CredProtectAuthenticationExtensionsClientInputSerializer.java

@@ -0,0 +1,63 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+
+import org.springframework.security.web.webauthn.api.CredProtectAuthenticationExtensionsClientInput;
+
+/**
+ * Serializes <a href=
+ * "https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-credProtect-extension">credProtect
+ * extension</a>.
+ *
+ * @author Rob Winch
+ */
+class CredProtectAuthenticationExtensionsClientInputSerializer
+		extends StdSerializer<CredProtectAuthenticationExtensionsClientInput> {
+
+	protected CredProtectAuthenticationExtensionsClientInputSerializer() {
+		super(CredProtectAuthenticationExtensionsClientInput.class);
+	}
+
+	@Override
+	public void serialize(CredProtectAuthenticationExtensionsClientInput input, JsonGenerator jgen,
+			SerializerProvider provider) throws IOException {
+		CredProtectAuthenticationExtensionsClientInput.CredProtect credProtect = input.getInput();
+		String policy = toString(credProtect.getCredProtectionPolicy());
+		jgen.writeObjectField("credentialProtectionPolicy", policy);
+		jgen.writeObjectField("enforceCredentialProtectionPolicy", credProtect.isEnforceCredentialProtectionPolicy());
+	}
+
+	private static String toString(CredProtectAuthenticationExtensionsClientInput.CredProtect.ProtectionPolicy policy) {
+		switch (policy) {
+			case USER_VERIFICATION_OPTIONAL:
+				return "userVerificationOptional";
+			case USER_VERIFICATION_OPTIONAL_WITH_CREDENTIAL_ID_LIST:
+				return "userVerificationOptionalWithCredentialIdList";
+			case USER_VERIFICATION_REQUIRED:
+				return "userVerificationRequired";
+			default:
+				throw new IllegalArgumentException("Unsupported ProtectionPolicy " + policy);
+		}
+	}
+
+}

+ 36 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/CredentialPropertiesOutputMixin.java

@@ -0,0 +1,36 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import org.springframework.security.web.webauthn.api.CredentialPropertiesOutput;
+
+/**
+ * Jackson mixin for {@link CredentialPropertiesOutput}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+abstract class CredentialPropertiesOutputMixin {
+
+	CredentialPropertiesOutputMixin(@JsonProperty("rk") boolean rk) {
+	}
+
+}

+ 46 - 0
web/src/main/java/org/springframework/security/web/webauthn/jackson/DurationSerializer.java

@@ -0,0 +1,46 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.webauthn.jackson;
+
+import java.io.IOException;
+import java.time.Duration;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+
+/**
+ * Jackson serializer for {@link Duration}
+ *
+ * @author Rob Winch
+ * @since 6.4
+ */
+class DurationSerializer extends StdSerializer<Duration> {
+
+	/**
+	 * Creates an instance.
+	 */
+	DurationSerializer() {
+		super(Duration.class);
+	}
+
+	@Override
+	public void serialize(Duration duration, JsonGenerator jgen, SerializerProvider provider) throws IOException {
+		jgen.writeNumber(duration.toMillis());
+	}
+
+}

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor