enableLoginEndpoint();
}
diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java
index d803512ea5a34..a489f5b892f92 100644
--- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java
+++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java
@@ -1,23 +1,80 @@
package io.quarkus.security.webauthn;
+import java.io.IOException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
import java.security.Principal;
+import java.security.cert.CertificateException;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
+import com.webauthn4j.async.WebAuthnAsyncManager;
+import com.webauthn4j.async.anchor.KeyStoreTrustAnchorAsyncRepository;
+import com.webauthn4j.async.anchor.TrustAnchorAsyncRepository;
+import com.webauthn4j.async.metadata.FidoMDS3MetadataBLOBAsyncProvider;
+import com.webauthn4j.async.metadata.HttpAsyncClient;
+import com.webauthn4j.async.metadata.anchor.MetadataBLOBBasedTrustAnchorAsyncRepository;
+import com.webauthn4j.async.verifier.attestation.statement.androidkey.AndroidKeyAttestationStatementAsyncVerifier;
+import com.webauthn4j.async.verifier.attestation.statement.androidsafetynet.AndroidSafetyNetAttestationStatementAsyncVerifier;
+import com.webauthn4j.async.verifier.attestation.statement.apple.AppleAnonymousAttestationStatementAsyncVerifier;
+import com.webauthn4j.async.verifier.attestation.statement.packed.PackedAttestationStatementAsyncVerifier;
+import com.webauthn4j.async.verifier.attestation.statement.tpm.TPMAttestationStatementAsyncVerifier;
+import com.webauthn4j.async.verifier.attestation.statement.u2f.FIDOU2FAttestationStatementAsyncVerifier;
+import com.webauthn4j.async.verifier.attestation.trustworthiness.certpath.DefaultCertPathTrustworthinessAsyncVerifier;
+import com.webauthn4j.async.verifier.attestation.trustworthiness.self.DefaultSelfAttestationTrustworthinessAsyncVerifier;
+import com.webauthn4j.converter.util.ObjectConverter;
+import com.webauthn4j.data.AuthenticationParameters;
+import com.webauthn4j.data.AuthenticatorSelectionCriteria;
+import com.webauthn4j.data.PublicKeyCredentialCreationOptions;
+import com.webauthn4j.data.PublicKeyCredentialDescriptor;
+import com.webauthn4j.data.PublicKeyCredentialParameters;
+import com.webauthn4j.data.PublicKeyCredentialRequestOptions;
+import com.webauthn4j.data.PublicKeyCredentialRpEntity;
+import com.webauthn4j.data.PublicKeyCredentialType;
+import com.webauthn4j.data.PublicKeyCredentialUserEntity;
+import com.webauthn4j.data.RegistrationParameters;
+import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier;
+import com.webauthn4j.data.client.Origin;
+import com.webauthn4j.data.client.challenge.DefaultChallenge;
+import com.webauthn4j.data.extension.client.AuthenticationExtensionsClientInputs;
+import com.webauthn4j.server.ServerProperty;
+import com.webauthn4j.util.Base64UrlUtil;
+
import io.quarkus.security.runtime.QuarkusPrincipal;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
+import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.Attestation;
+import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.AuthenticatorAttachment;
+import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.COSEAlgorithm;
+import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.ResidentKey;
+import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.UserVerification;
+import io.quarkus.security.webauthn.impl.VertxHttpAsyncClient;
+import io.quarkus.tls.TlsConfiguration;
+import io.quarkus.tls.TlsConfigurationRegistry;
import io.quarkus.vertx.http.runtime.security.PersistentLoginManager.RestoreResult;
import io.smallrye.mutiny.Uni;
import io.vertx.core.Vertx;
+import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.Cookie;
-import io.vertx.ext.auth.webauthn.Authenticator;
-import io.vertx.ext.auth.webauthn.RelyingParty;
-import io.vertx.ext.auth.webauthn.WebAuthn;
-import io.vertx.ext.auth.webauthn.WebAuthnCredentials;
-import io.vertx.ext.auth.webauthn.WebAuthnOptions;
+import io.vertx.core.json.JsonObject;
+import io.vertx.ext.auth.impl.CertificateHelper;
+import io.vertx.ext.auth.impl.CertificateHelper.CertInfo;
+import io.vertx.ext.auth.impl.jose.JWS;
+import io.vertx.ext.auth.prng.VertxContextPRNG;
import io.vertx.ext.web.RoutingContext;
-import io.vertx.ext.web.impl.Origin;
/**
* Utility class that allows users to manually login or register users using WebAuthn
@@ -25,138 +82,530 @@
@ApplicationScoped
public class WebAuthnSecurity {
- private WebAuthn webAuthn;
- private String origin;
- private String domain;
+ /*
+ * Android Keystore Root is not published anywhere.
+ * This certificate was extracted from one of the attestations
+ * The last certificate in x5c must match this certificate
+ * This needs to be checked to ensure that malicious party won't generate fake attestations
+ */
+ private static final String ANDROID_KEYSTORE_ROOT = "MIICizCCAjKgAwIBAgIJAKIFntEOQ1tXMAoGCCqGSM49BAMCMIGYMQswCQYDVQQG" +
+ "EwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNTW91bnRhaW4gVmll" +
+ "dzEVMBMGA1UECgwMR29vZ2xlLCBJbmMuMRAwDgYDVQQLDAdBbmRyb2lkMTMwMQYD" +
+ "VQQDDCpBbmRyb2lkIEtleXN0b3JlIFNvZnR3YXJlIEF0dGVzdGF0aW9uIFJvb3Qw" +
+ "HhcNMTYwMTExMDA0MzUwWhcNMzYwMTA2MDA0MzUwWjCBmDELMAkGA1UEBhMCVVMx" +
+ "EzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFTAT" +
+ "BgNVBAoMDEdvb2dsZSwgSW5jLjEQMA4GA1UECwwHQW5kcm9pZDEzMDEGA1UEAwwq" +
+ "QW5kcm9pZCBLZXlzdG9yZSBTb2Z0d2FyZSBBdHRlc3RhdGlvbiBSb290MFkwEwYH" +
+ "KoZIzj0CAQYIKoZIzj0DAQcDQgAE7l1ex+HA220Dpn7mthvsTWpdamguD/9/SQ59" +
+ "dx9EIm29sa/6FsvHrcV30lacqrewLVQBXT5DKyqO107sSHVBpKNjMGEwHQYDVR0O" +
+ "BBYEFMit6XdMRcOjzw0WEOR5QzohWjDPMB8GA1UdIwQYMBaAFMit6XdMRcOjzw0W" +
+ "EOR5QzohWjDPMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgKEMAoGCCqG" +
+ "SM49BAMCA0cAMEQCIDUho++LNEYenNVg8x1YiSBq3KNlQfYNns6KGYxmSGB7AiBN" +
+ "C/NR2TB8fVvaNTQdqEcbY6WFZTytTySn502vQX3xvw==";
+
+ // https://aboutssl.org/globalsign-root-certificates-licensing-and-use/
+ // Name gsr1
+ // Thumbprint: b1:bc:96:8b:d4:f4:9d:62:2a:a8:9a:81:f2:15:01:52:a4:1d:82:9c
+ // Valid Until 28 January 2028
+ private static final String GSR1 = "MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG\n" +
+ "A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv\n" +
+ "b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw\n" +
+ "MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i\n" +
+ "YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT\n" +
+ "aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ\n" +
+ "jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp\n" +
+ "xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp\n" +
+ "1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG\n" +
+ "snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ\n" +
+ "U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8\n" +
+ "9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E\n" +
+ "BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B\n" +
+ "AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz\n" +
+ "yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE\n" +
+ "38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP\n" +
+ "AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad\n" +
+ "DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME\n" +
+ "HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==";
+
+ /**
+ * Apple WebAuthn Root CA PEM
+ *
+ * Downloaded from https://www.apple.com/certificateauthority/Apple_WebAuthn_Root_CA.pem
+ *
+ * Valid until 03/14/2045 @ 5:00 PM PST
+ */
+ private static final String APPLE_WEBAUTHN_ROOT_CA = "MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w" +
+ "HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ" +
+ "bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx" +
+ "NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG" +
+ "A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49" +
+ "AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k" +
+ "xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/" +
+ "pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk" +
+ "2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA" +
+ "MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3" +
+ "jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B" +
+ "1bWeT0vT";
+
+ /**
+ * Default FIDO2 MDS3 ROOT Certificate
+ *
+ * Downloaded from https://valid.r3.roots.globalsign.com/
+ *
+ * Valid until 18 March 2029
+ */
+ private static final String FIDO_MDS3_ROOT_CERTIFICATE = "MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4G"
+ +
+ "A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNp" +
+ "Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4" +
+ "MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEG" +
+ "A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI" +
+ "hvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8" +
+ "RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsT" +
+ "gHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmm" +
+ "KPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zd" +
+ "QQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZ" +
+ "XriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAw" +
+ "DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+o" +
+ "LkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZU" +
+ "RUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMp" +
+ "jjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK" +
+ "6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQX" +
+ "mcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecs" +
+ "Mx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpH" +
+ "WD9f";
+
+ @Inject
+ TlsConfigurationRegistry certificates;
@Inject
WebAuthnAuthenticationMechanism authMech;
+
+ @Inject
+ WebAuthnAuthenticatorStorage storage;
+
+ private ObjectConverter objectConverter = new ObjectConverter();
+ private WebAuthnAsyncManager webAuthn;
+ private VertxContextPRNG random;
+
private String challengeCookie;
- private String challengeUsernameCookie;
+
+ private List origins;
+ private String rpId;
+ private String rpName;
+
+ private UserVerification userVerification;
+ private Boolean userPresenceRequired;
+ private List pubKeyCredParams;
+ private ResidentKey residentKey;
+
+ private Duration timeout;
+ private int challengeLength;
+ private AuthenticatorAttachment authenticatorAttachment;
+
+ private Attestation attestation;
public WebAuthnSecurity(WebAuthnRunTimeConfig config, Vertx vertx, WebAuthnAuthenticatorStorage database) {
- // create the webauthn security object
- WebAuthnOptions options = new WebAuthnOptions();
- RelyingParty relyingParty = new RelyingParty();
- if (config.relyingParty().id().isPresent()) {
- relyingParty.setId(config.relyingParty().id().get());
- }
- // this is required
- relyingParty.setName(config.relyingParty().name());
- options.setRelyingParty(relyingParty);
- if (config.attestation().isPresent()) {
- options.setAttestation(config.attestation().get());
- }
- if (config.authenticatorAttachment().isPresent()) {
- options.setAuthenticatorAttachment(config.authenticatorAttachment().get());
- }
- if (config.challengeLength().isPresent()) {
- options.setChallengeLength(config.challengeLength().getAsInt());
+ // apply config defaults
+ this.rpId = config.relyingParty().id().orElse(null);
+ this.rpName = config.relyingParty().name();
+ this.origins = config.origins().orElse(Collections.emptyList());
+ this.challengeCookie = config.challengeCookieName();
+ this.challengeLength = config.challengeLength().orElse(64);
+ this.userPresenceRequired = config.userPresenceRequired().orElse(true);
+ this.timeout = config.timeout().orElse(Duration.ofMinutes(5));
+ if (config.publicKeyCredentialParameters().isPresent()) {
+ this.pubKeyCredParams = new ArrayList<>(config.publicKeyCredentialParameters().get().size());
+ for (COSEAlgorithm publicKeyCredential : config.publicKeyCredentialParameters().get()) {
+ this.pubKeyCredParams.add(new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY,
+ COSEAlgorithmIdentifier.create(publicKeyCredential.coseId())));
+ }
+ } else {
+ this.pubKeyCredParams = new ArrayList<>(2);
+ this.pubKeyCredParams
+ .add(new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256));
+ this.pubKeyCredParams
+ .add(new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.RS256));
}
- if (config.pubKeyCredParams().isPresent()) {
- options.setPubKeyCredParams(config.pubKeyCredParams().get());
+ this.authenticatorAttachment = config.authenticatorAttachment().orElse(null);
+ this.userVerification = config.userVerification().orElse(UserVerification.REQUIRED);
+ this.residentKey = config.residentKey().orElse(ResidentKey.REQUIRED);
+ this.attestation = config.attestation().orElse(Attestation.NONE);
+ // create the webauthn4j manager
+ this.webAuthn = makeWebAuthn(vertx, config);
+ this.random = VertxContextPRNG.current(vertx);
+ }
+
+ private String randomBase64URLBuffer() {
+ final byte[] buff = new byte[challengeLength];
+ random.nextBytes(buff);
+ return Base64UrlUtil.encodeToString(buff);
+ }
+
+ private WebAuthnAsyncManager makeWebAuthn(Vertx vertx, WebAuthnRunTimeConfig config) {
+ if (config.attestation().isPresent()
+ && config.attestation().get() != WebAuthnRunTimeConfig.Attestation.NONE) {
+ TrustAnchorAsyncRepository something;
+ // FIXME: make config name configurable?
+ Optional webauthnTlsConfiguration = certificates.get("webauthn");
+ KeyStore trustStore;
+ if (webauthnTlsConfiguration.isPresent()) {
+ trustStore = webauthnTlsConfiguration.get().getTrustStore();
+ } else {
+ try {
+ trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ trustStore.load(null, null);
+ addCert(trustStore, ANDROID_KEYSTORE_ROOT);
+ addCert(trustStore, APPLE_WEBAUTHN_ROOT_CA);
+ addCert(trustStore, FIDO_MDS3_ROOT_CERTIFICATE);
+ addCert(trustStore, GSR1);
+ } catch (CertificateException | KeyStoreException | NoSuchAlgorithmException | IOException e) {
+ throw new RuntimeException("Failed to configure default WebAuthn certificates", e);
+ }
+ }
+ Set trustAnchors = new HashSet<>();
+ try {
+ Enumeration aliases = trustStore.aliases();
+ while (aliases.hasMoreElements()) {
+ trustAnchors.add(new TrustAnchor((X509Certificate) trustStore.getCertificate(aliases.nextElement()), null));
+ }
+ } catch (KeyStoreException e) {
+ throw new RuntimeException("Failed to configure WebAuthn trust store", e);
+ }
+ // FIXME CLRs are not supported yet
+ something = new KeyStoreTrustAnchorAsyncRepository(trustStore);
+ if (config.loadMetadata().orElse(false)) {
+ HttpAsyncClient httpClient = new VertxHttpAsyncClient(vertx);
+ FidoMDS3MetadataBLOBAsyncProvider blobAsyncProvider = new FidoMDS3MetadataBLOBAsyncProvider(objectConverter,
+ FidoMDS3MetadataBLOBAsyncProvider.DEFAULT_BLOB_ENDPOINT, httpClient, trustAnchors);
+ something = new MetadataBLOBBasedTrustAnchorAsyncRepository(blobAsyncProvider);
+ }
+
+ return new WebAuthnAsyncManager(
+ Arrays.asList(
+ new FIDOU2FAttestationStatementAsyncVerifier(),
+ new PackedAttestationStatementAsyncVerifier(),
+ new TPMAttestationStatementAsyncVerifier(),
+ new AndroidKeyAttestationStatementAsyncVerifier(),
+ new AndroidSafetyNetAttestationStatementAsyncVerifier(),
+ new AppleAnonymousAttestationStatementAsyncVerifier()),
+ new DefaultCertPathTrustworthinessAsyncVerifier(something),
+ new DefaultSelfAttestationTrustworthinessAsyncVerifier(),
+ objectConverter);
+
+ } else {
+ return WebAuthnAsyncManager.createNonStrictWebAuthnAsyncManager(objectConverter);
}
- if (config.requireResidentKey().isPresent()) {
- options.setRequireResidentKey(config.requireResidentKey().get());
+ }
+
+ private void addCert(KeyStore keyStore, String pemCertificate) throws CertificateException, KeyStoreException {
+ X509Certificate cert = JWS.parseX5c(pemCertificate);
+ CertInfo info = CertificateHelper.getCertInfo(cert);
+ keyStore.setCertificateEntry(info.subject("CN"), cert);
+ }
+
+ private static byte[] uUIDBytes(UUID uuid) {
+ Buffer buffer = Buffer.buffer(16);
+ buffer.setLong(0, uuid.getMostSignificantBits());
+ buffer.setLong(8, uuid.getLeastSignificantBits());
+ return buffer.getBytes();
+ }
+
+ /**
+ * Obtains a registration challenge for the given required userName and displayName. This will also
+ * create and save a challenge in a session cookie.
+ *
+ * @param userName the userName for the registration
+ * @param displayName the displayName for the registration
+ * @param ctx the Vert.x context
+ * @return the registration challenge.
+ */
+ @SuppressWarnings("unused")
+ public Uni getRegisterChallenge(String userName, String displayName,
+ RoutingContext ctx) {
+ if (userName == null || userName.isEmpty()) {
+ return Uni.createFrom().failure(new IllegalArgumentException("Username is required"));
}
- if (config.timeout().isPresent()) {
- options.setTimeoutInMilliseconds(config.timeout().get().toMillis());
+ // default displayName to userName, but it's required really
+ if (displayName == null || displayName.isEmpty()) {
+ displayName = userName;
}
- if (config.transports().isPresent()) {
- options.setTransports(config.transports().get());
+ String finalDisplayName = displayName;
+ String challenge = getOrCreateChallenge(ctx);
+ Origin origin = Origin.create(!this.origins.isEmpty() ? this.origins.get(0) : ctx.request().absoluteURI());
+ String rpId = this.rpId != null ? this.rpId : origin.getHost();
+
+ return storage.findByUserName(userName)
+ .map(credentials -> {
+ List excluded;
+ // See https://github.com/quarkusio/quarkus/issues/44292 for why this is currently disabled
+ if (false) {
+ excluded = new ArrayList<>(credentials.size());
+ for (WebAuthnCredentialRecord credential : credentials) {
+ excluded.add(new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PUBLIC_KEY,
+ credential.getAttestedCredentialData().getCredentialId(),
+ credential.getTransports()));
+ }
+ } else {
+ excluded = Collections.emptyList();
+ }
+ PublicKeyCredentialCreationOptions publicKeyCredentialCreationOptions = new PublicKeyCredentialCreationOptions(
+ new PublicKeyCredentialRpEntity(
+ rpId,
+ rpName),
+ new PublicKeyCredentialUserEntity(
+ uUIDBytes(UUID.randomUUID()),
+ userName,
+ finalDisplayName),
+ new DefaultChallenge(challenge),
+ pubKeyCredParams,
+ timeout.getSeconds() * 1000,
+ excluded,
+ new AuthenticatorSelectionCriteria(
+ authenticatorAttachment != null ? authenticatorAttachment.toWebAuthn4J() : null,
+ residentKey == ResidentKey.REQUIRED,
+ residentKey.toWebAuthn4J(),
+ userVerification.toWebAuthn4J()),
+ attestation.toWebAuthn4J(),
+ new AuthenticationExtensionsClientInputs<>());
+
+ // save challenge to the session
+ authMech.getLoginManager().save(challenge, ctx, challengeCookie, null,
+ ctx.request().isSSL());
+
+ return publicKeyCredentialCreationOptions;
+ });
+
+ }
+
+ /**
+ * Obtains a login challenge for the given optional userName. This will also
+ * create and save a challenge in a session cookie.
+ *
+ * @param userName the optional userName for the login
+ * @param ctx the Vert.x context
+ * @return the login challenge.
+ */
+ @SuppressWarnings("unused")
+ public Uni getLoginChallenge(String userName, RoutingContext ctx) {
+ // Username is not required with passkeys
+ if (userName == null) {
+ userName = "";
}
- if (config.userVerification().isPresent()) {
- options.setUserVerification(config.userVerification().get());
+ String finalUserName = userName;
+ String challenge = getOrCreateChallenge(ctx);
+ Origin origin = Origin.create(!this.origins.isEmpty() ? this.origins.get(0) : ctx.request().absoluteURI());
+ String rpId = this.rpId != null ? this.rpId : origin.getHost();
+
+ // do not attempt to look users up if there's no user name
+ Uni> credentialsUni;
+ if (userName.isEmpty()) {
+ credentialsUni = Uni.createFrom().item(Collections.emptyList());
+ } else {
+ credentialsUni = storage.findByUserName(userName);
}
- webAuthn = WebAuthn.create(vertx, options)
- // where to load/update authenticators data
- .authenticatorFetcher(database::fetcher)
- .authenticatorUpdater(database::updater);
- origin = config.origin().orElse(null);
- if (origin != null) {
- Origin o = Origin.parse(origin);
- domain = o.host();
+ return credentialsUni
+ .map(credentials -> {
+ List allowedCredentials;
+ // See https://github.com/quarkusio/quarkus/issues/44292 for why this is currently disabled
+ if (false) {
+
+ if (credentials.isEmpty()) {
+ throw new RuntimeException("No credentials found for " + finalUserName);
+ }
+ allowedCredentials = new ArrayList<>(credentials.size());
+ for (WebAuthnCredentialRecord credential : credentials) {
+ allowedCredentials.add(new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PUBLIC_KEY,
+ credential.getAttestedCredentialData().getCredentialId(),
+ credential.getTransports()));
+ }
+ } else {
+ allowedCredentials = Collections.emptyList();
+ }
+ PublicKeyCredentialRequestOptions publicKeyCredentialRequestOptions = new PublicKeyCredentialRequestOptions(
+ new DefaultChallenge(challenge),
+ timeout.getSeconds() * 1000,
+ rpId,
+ allowedCredentials,
+ userVerification.toWebAuthn4J(),
+ null);
+
+ // save challenge to the session
+ authMech.getLoginManager().save(challenge, ctx, challengeCookie, null,
+ ctx.request().isSSL());
+
+ return publicKeyCredentialRequestOptions;
+ });
+ }
+
+ private String getOrCreateChallenge(RoutingContext ctx) {
+ RestoreResult challengeRestoreResult = authMech.getLoginManager().restore(ctx, challengeCookie);
+ String challenge;
+ if (challengeRestoreResult == null || challengeRestoreResult.getPrincipal() == null
+ || challengeRestoreResult.getPrincipal().isEmpty()) {
+ challenge = randomBase64URLBuffer();
+ } else {
+ challenge = challengeRestoreResult.getPrincipal();
}
- this.challengeCookie = config.challengeCookieName();
- this.challengeUsernameCookie = config.challengeUsernameCookieName();
+ return challenge;
}
/**
- * Registers a new WebAuthn credentials
+ * Registers a new WebAuthn credentials. This will check it, clear the challenge cookie and return it in case of
+ * success, but not invoke {@link WebAuthnUserProvider#store(WebAuthnCredentialRecord)}, you have to do
+ * it manually in case of success. This will also not set a login cookie, you have to do it manually using
+ * {@link #rememberUser(String, RoutingContext)}
+ * or using any other way.
*
+ * @param the username to register credentials for
* @param response the Webauthn registration info
* @param ctx the current request
* @return the newly created credentials
*/
- public Uni register(WebAuthnRegisterResponse response, RoutingContext ctx) {
- // validation of the response is done before
+ public Uni register(String username, WebAuthnRegisterResponse response, RoutingContext ctx) {
+ return register(username, response.toJsonObject(), ctx);
+ }
+
+ /**
+ * Registers a new WebAuthn credentials. This will check it, clear the challenge cookie and return it in case of
+ * success, but not invoke {@link WebAuthnUserProvider#store(WebAuthnCredentialRecord)}, you have to do
+ * it manually in case of success. This will also not set a login cookie, you have to do it manually using
+ * {@link #rememberUser(String, RoutingContext)}
+ * or using any other way.
+ *
+ * @param the username to register credentials for
+ * @param response the Webauthn registration info
+ * @param ctx the current request
+ * @return the newly created credentials
+ */
+ public Uni register(String username, JsonObject response, RoutingContext ctx) {
RestoreResult challenge = authMech.getLoginManager().restore(ctx, challengeCookie);
- RestoreResult username = authMech.getLoginManager().restore(ctx, challengeUsernameCookie);
- if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty()
- || username == null || username.getPrincipal() == null || username.getPrincipal().isEmpty()) {
- return Uni.createFrom().failure(new RuntimeException("Missing challenge or username"));
- }
-
- return Uni.createFrom().emitter(emitter -> {
- webAuthn.authenticate(
- // authInfo
- new WebAuthnCredentials()
- .setOrigin(origin)
- .setDomain(domain)
- .setChallenge(challenge.getPrincipal())
- .setUsername(username.getPrincipal())
- .setWebauthn(response.toJsonObject()),
- authenticate -> {
- removeCookie(ctx, challengeCookie);
- removeCookie(ctx, challengeUsernameCookie);
- if (authenticate.succeeded()) {
- // this is registration, so the caller will want to store the created Authenticator,
- // let's recreate it
- emitter.complete(new Authenticator(authenticate.result().principal()));
- } else {
- emitter.fail(authenticate.cause());
- }
- });
- });
+ if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty()) {
+ return Uni.createFrom().failure(new RuntimeException("Missing challenge"));
+ }
+ if (username == null || username.isEmpty()) {
+ return Uni.createFrom().failure(new RuntimeException("Missing username"));
+ }
+
+ // input validation
+ if (response == null ||
+ !containsRequiredString(response, "id") ||
+ !containsRequiredString(response, "rawId") ||
+ !containsRequiredObject(response, "response") ||
+ !containsOptionalString(response.getJsonObject("response"), "userHandle") ||
+ !containsRequiredString(response, "type") ||
+ !"public-key".equals(response.getString("type"))) {
+
+ return Uni.createFrom().failure(new IllegalArgumentException(
+ "Response missing one or more of id/rawId/response[.userHandle]/type fields, or type is not public-key"));
+ }
+ String registrationResponseJSON = response.encode();
+
+ ServerProperty serverProperty = makeServerProperty(challenge, ctx);
+ RegistrationParameters registrationParameters = new RegistrationParameters(serverProperty, pubKeyCredParams,
+ userVerification == UserVerification.REQUIRED, userPresenceRequired);
+
+ return Uni.createFrom()
+ .completionStage(webAuthn.verifyRegistrationResponseJSON(registrationResponseJSON, registrationParameters))
+ .eventually(() -> {
+ removeCookie(ctx, challengeCookie);
+ }).map(registrationData -> new WebAuthnCredentialRecord(
+ username,
+ registrationData.getAttestationObject(),
+ registrationData.getCollectedClientData(),
+ registrationData.getClientExtensions(),
+ registrationData.getTransports()));
+ }
+
+ private ServerProperty makeServerProperty(RestoreResult challenge, RoutingContext ctx) {
+ Set origins = new HashSet<>();
+ Origin firstOrigin = null;
+ if (this.origins.isEmpty()) {
+ firstOrigin = Origin.create(ctx.request().absoluteURI());
+ origins.add(firstOrigin);
+ } else {
+ for (String origin : this.origins) {
+ Origin newOrigin = Origin.create(origin);
+ if (firstOrigin == null) {
+ firstOrigin = newOrigin;
+ origins.add(newOrigin);
+ }
+ }
+ }
+ String rpId = this.rpId != null ? this.rpId : firstOrigin.getHost();
+ DefaultChallenge challengeObject = new DefaultChallenge(challenge.getPrincipal());
+ return new ServerProperty(origins, rpId, challengeObject, /* this is deprecated in Level 3, so ignore it */ null);
+ }
+
+ /**
+ * Logs an existing WebAuthn user in. This will check it, clear the challenge cookie and return the updated credentials in
+ * case of
+ * success, but not invoke {@link WebAuthnUserProvider#update(String, long)}, you have to do
+ * it manually in case of success. This will also not set a login cookie, you have to do it manually using
+ * {@link #rememberUser(String, RoutingContext)}
+ * or using any other way.
+ *
+ * @param response the Webauthn login info
+ * @param ctx the current request
+ * @return the updated credentials
+ */
+ public Uni login(WebAuthnLoginResponse response, RoutingContext ctx) {
+ return login(response.toJsonObject(), ctx);
}
/**
- * Logs an existing WebAuthn user in
+ * Logs an existing WebAuthn user in. This will check it, clear the challenge cookie and return the updated credentials in
+ * case of
+ * success, but not invoke {@link WebAuthnUserProvider#update(String, long)}, you have to do
+ * it manually in case of success. This will also not set a login cookie, you have to do it manually using
+ * {@link #rememberUser(String, RoutingContext)}
+ * or using any other way.
*
* @param response the Webauthn login info
* @param ctx the current request
* @return the updated credentials
*/
- public Uni login(WebAuthnLoginResponse response, RoutingContext ctx) {
- // validation of the response is done before
+ public Uni login(JsonObject response, RoutingContext ctx) {
RestoreResult challenge = authMech.getLoginManager().restore(ctx, challengeCookie);
- RestoreResult username = authMech.getLoginManager().restore(ctx, challengeUsernameCookie);
if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty()
- || username == null || username.getPrincipal() == null || username.getPrincipal().isEmpty()) {
- return Uni.createFrom().failure(new RuntimeException("Missing challenge or username"));
- }
-
- return Uni.createFrom().emitter(emitter -> {
- webAuthn.authenticate(
- // authInfo
- new WebAuthnCredentials()
- .setOrigin(origin)
- .setDomain(domain)
- .setChallenge(challenge.getPrincipal())
- .setUsername(username.getPrincipal())
- .setWebauthn(response.toJsonObject()),
- authenticate -> {
- removeCookie(ctx, challengeCookie);
- removeCookie(ctx, challengeUsernameCookie);
- if (authenticate.succeeded()) {
- // this is login, so the user will want to bump the counter
- // FIXME: do we need the auth here? likely the user will know it and will just ++ on the DB-stored counter, no?
- emitter.complete(new Authenticator(authenticate.result().principal()));
- } else {
- emitter.fail(authenticate.cause());
- }
- });
- });
+ // although login can be empty, we should still have a cookie for it
+ ) {
+ return Uni.createFrom().failure(new RuntimeException("Missing challenge"));
+ }
+
+ // input validation
+ if (response == null ||
+ !containsRequiredString(response, "id") ||
+ !containsRequiredString(response, "rawId") ||
+ !containsRequiredObject(response, "response") ||
+ !containsOptionalString(response.getJsonObject("response"), "userHandle") ||
+ !containsRequiredString(response, "type") ||
+ !"public-key".equals(response.getString("type"))) {
+
+ return Uni.createFrom().failure(new IllegalArgumentException(
+ "Response missing one or more of id/rawId/response[.userHandle]/type fields, or type is not public-key"));
+ }
+
+ String authenticationResponseJSON = response.encode();
+ // validated
+ String rawId = response.getString("rawId");
+
+ ServerProperty serverProperty = makeServerProperty(challenge, ctx);
+
+ return storage.findByCredID(rawId)
+ .chain(credentialRecord -> {
+ List allowCredentials = List.of(Base64UrlUtil.decode(rawId));
+ AuthenticationParameters authenticationParameters = new AuthenticationParameters(serverProperty,
+ credentialRecord, allowCredentials,
+ userVerification == UserVerification.REQUIRED, userPresenceRequired);
+
+ return Uni.createFrom()
+ .completionStage(webAuthn.verifyAuthenticationResponseJSON(authenticationResponseJSON,
+ authenticationParameters))
+ .eventually(() -> {
+ removeCookie(ctx, challengeCookie);
+ }).map(authenticationData -> credentialRecord);
+ });
}
static void removeCookie(RoutingContext ctx, String name) {
@@ -170,11 +619,11 @@ static void removeCookie(RoutingContext ctx, String name) {
}
/**
- * Returns the underlying Vert.x WebAuthn authenticator
+ * Returns the underlying WebAuthn4J authenticator
*
- * @return the underlying Vert.x WebAuthn authenticator
+ * @return the underlying WebAuthn4J authenticator
*/
- public WebAuthn getWebAuthn() {
+ public WebAuthnAsyncManager getWebAuthn4J() {
return webAuthn;
}
@@ -198,4 +647,72 @@ public void rememberUser(String userID, RoutingContext ctx) {
public void logout(RoutingContext ctx) {
authMech.getLoginManager().clear(ctx);
}
+
+ static boolean containsRequiredString(JsonObject json, String key) {
+ try {
+ if (json == null) {
+ return false;
+ }
+ if (!json.containsKey(key)) {
+ return false;
+ }
+ Object s = json.getValue(key);
+ return (s instanceof String) && !"".equals(s);
+ } catch (ClassCastException e) {
+ return false;
+ }
+ }
+
+ private static boolean containsOptionalString(JsonObject json, String key) {
+ try {
+ if (json == null) {
+ return true;
+ }
+ if (!json.containsKey(key)) {
+ return true;
+ }
+ Object s = json.getValue(key);
+ return (s instanceof String);
+ } catch (ClassCastException e) {
+ return false;
+ }
+ }
+
+ private static boolean containsRequiredObject(JsonObject json, String key) {
+ try {
+ if (json == null) {
+ return false;
+ }
+ if (!json.containsKey(key)) {
+ return false;
+ }
+ JsonObject s = json.getJsonObject(key);
+ return s != null;
+ } catch (ClassCastException e) {
+ return false;
+ }
+ }
+
+ public String toJsonString(PublicKeyCredentialCreationOptions challenge) {
+ return objectConverter.getJsonConverter().writeValueAsString(challenge);
+ }
+
+ public String toJsonString(PublicKeyCredentialRequestOptions challenge) {
+ return objectConverter.getJsonConverter().writeValueAsString(challenge);
+ }
+
+ /**
+ * Returns the list of allowed origins, or defaults to the current request's origin if unconfigured.
+ */
+ public List getAllowedOrigins(RoutingContext ctx) {
+ if (this.origins.isEmpty()) {
+ return List.of(Origin.create(ctx.request().absoluteURI()).toString());
+ } else {
+ return this.origins;
+ }
+ }
+
+ WebAuthnAuthenticatorStorage storage() {
+ return storage;
+ }
}
diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnUserProvider.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnUserProvider.java
index b74c45363eb50..03b58ce4924b3 100644
--- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnUserProvider.java
+++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnUserProvider.java
@@ -5,7 +5,6 @@
import java.util.Set;
import io.smallrye.mutiny.Uni;
-import io.vertx.ext.auth.webauthn.Authenticator;
/**
* Implement this interface in order to tell Quarkus WebAuthn how to look up
@@ -14,29 +13,66 @@
*/
public interface WebAuthnUserProvider {
/**
- * Look up a WebAuthn credential by username
+ * Look up a WebAuthn credential by username. This should return an empty list Uni if the user name is not found.
*
* @param userName the username
- * @return a list of credentials for this username
+ * @return a list of credentials for this username, or an empty list if there are no credentials or if the user name is
+ * not found.
*/
- public Uni> findWebAuthnCredentialsByUserName(String userName);
+ public Uni> findByUserName(String userName);
/**
- * Look up a WebAuthn credential by credential ID
+ * Look up a WebAuthn credential by credential ID, this should return an exception Uni rather than return a null-item Uni
+ * in case the credential is not found.
*
* @param credentialId the credential ID
- * @returna list of credentials for this credential ID.
+ * @return a credentials for this credential ID.
+ * @throws an exception Uni if the credential ID is unknown
*/
- public Uni> findWebAuthnCredentialsByCredID(String credentialId);
+ public Uni findByCredentialId(String credentialId);
/**
- * If this credential's combination of user and credential ID does not exist,
- * then store the new credential. If it already exists, then only update its counter
+ * Update an existing WebAuthn credential's counter. This is only used by the default login endpoint, which
+ * is disabled by default and can be enabled via the quarkus.webauthn.enable-login-endpoint
.
+ * You don't have to implement this method
+ * if you handle logins manually via {@link WebAuthnSecurity#login(WebAuthnLoginResponse, io.vertx.ext.web.RoutingContext)}.
*
- * @param authenticator the new credential if it does not exist, or the credential to update
+ * The default behaviour is to not do anything.
+ *
+ * @param credentialId the credential ID
* @return a uni completion object
*/
- public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator);
+ public default Uni update(String credentialId, long counter) {
+ return Uni.createFrom().voidItem();
+ }
+
+ /**
+ * Store a new WebAuthn credential. This is only used by the default registration endpoint, which
+ * is disabled by default and can be enabled via the quarkus.webauthn.enable-registration-endpoint
.
+ * You don't have to implement this method if you handle registration manually via
+ * {@link WebAuthnSecurity#register(WebAuthnRegisterResponse, io.vertx.ext.web.RoutingContext)}
+ *
+ * Make sure that you never allow creating
+ * new credentials for a `userName` that already exists. Otherwise you risk allowing third-parties to impersonate existing
+ * users by letting them add their own credentials to existing accounts. If you want to allow existing users to register
+ * more than one WebAuthn credential, you must make sure that the user is currently logged
+ * in under the same userName
to which you want to add new credentials. In every other case, make sure to
+ * return a failed
+ * {@link Uni} from this method.
+ *
+ * The default behaviour is to not do anything.
+ *
+ * @param userName the userName's credentials
+ * @param credentialRecord the new credentials to store
+ * @return a uni completion object
+ * @throws Exception a failed {@link Uni} if the credentialId
already exists, or the userName
+ * already
+ * has a credential and you disallow having more, or if trying to add credentials to other users than the current
+ * user.
+ */
+ public default Uni store(WebAuthnCredentialRecord credentialRecord) {
+ return Uni.createFrom().voidItem();
+ }
/**
* Returns the set of roles for the given username
diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/impl/VertxHttpAsyncClient.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/impl/VertxHttpAsyncClient.java
new file mode 100644
index 0000000000000..755b9810b216b
--- /dev/null
+++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/impl/VertxHttpAsyncClient.java
@@ -0,0 +1,41 @@
+package io.quarkus.security.webauthn.impl;
+
+import java.io.ByteArrayInputStream;
+import java.util.concurrent.CompletionStage;
+
+import com.webauthn4j.async.metadata.HttpAsyncClient;
+import com.webauthn4j.metadata.HttpClient.Response;
+import com.webauthn4j.metadata.exception.MDSException;
+
+import io.vertx.core.Vertx;
+import io.vertx.core.buffer.Buffer;
+import io.vertx.core.http.HttpClientOptions;
+import io.vertx.core.http.HttpMethod;
+import io.vertx.ext.auth.impl.http.SimpleHttpClient;
+
+public class VertxHttpAsyncClient implements HttpAsyncClient {
+
+ private static final byte[] NO_BYTES = new byte[0];
+ private SimpleHttpClient httpClient;
+
+ public VertxHttpAsyncClient(Vertx vertx) {
+ this.httpClient = new SimpleHttpClient(vertx, "vertx-auth", new HttpClientOptions());
+ }
+
+ @Override
+ public CompletionStage fetch(String uri) throws MDSException {
+ return httpClient
+ .fetch(HttpMethod.GET, uri, null, null)
+ .map(res -> {
+ Buffer body = res.body();
+ byte[] bytes;
+ if (body != null) {
+ bytes = body.getBytes();
+ } else {
+ bytes = NO_BYTES;
+ }
+ return new Response(res.statusCode(), new ByteArrayInputStream(bytes));
+ }).toCompletionStage();
+ }
+
+}
diff --git a/extensions/security-webauthn/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/security-webauthn/runtime/src/main/resources/META-INF/quarkus-extension.yaml
index b13ed8dc74128..8e56c89d705c1 100644
--- a/extensions/security-webauthn/runtime/src/main/resources/META-INF/quarkus-extension.yaml
+++ b/extensions/security-webauthn/runtime/src/main/resources/META-INF/quarkus-extension.yaml
@@ -8,6 +8,6 @@ metadata:
guide: "https://quarkus.io/guides/security-webauthn"
categories:
- "security"
- status: "preview"
+ status: "experimental"
config:
- "quarkus.webauthn."
diff --git a/extensions/security-webauthn/runtime/src/main/resources/webauthn.js b/extensions/security-webauthn/runtime/src/main/resources/webauthn.js
index e38b2982fc23e..cc91864350831 100644
--- a/extensions/security-webauthn/runtime/src/main/resources/webauthn.js
+++ b/extensions/security-webauthn/runtime/src/main/resources/webauthn.js
@@ -94,24 +94,33 @@
* Licensed under the Apache 2 license.
*/
- function WebAuthn(options) {
- this.registerPath = options.registerPath;
- this.loginPath = options.loginPath;
- this.callbackPath = options.callbackPath;
- // validation
- if (!this.callbackPath) {
- throw new Error('Callback path is missing!');
- }
+ function WebAuthn(options = {}) {
+ this.registerOptionsChallengePath = options.registerOptionsChallengePath || "/q/webauthn/register-options-challenge";
+ this.loginOptionsChallengePath = options.loginOptionsChallengePath || "/q/webauthn/login-options-challenge";
+ this.registerPath = options.registerPath || "/q/webauthn/register";
+ this.loginPath = options.loginPath || "/q/webauthn/login";
+ this.csrf = options.csrf;
}
WebAuthn.constructor = WebAuthn;
- WebAuthn.prototype.registerOnly = function (user) {
+ WebAuthn.prototype.fetchWithCsrf = function (path, options) {
const self = this;
- if (!self.registerPath) {
- return Promise.reject('Register path missing form the initial configuration!');
+ if(self.csrf) {
+ if(!options.headers) {
+ options.headers = {};
+ }
+ options.headers[self.csrf.header] = self.csrf.value;
+ }
+ return fetch(path, options);
+ }
+
+ WebAuthn.prototype.registerClientSteps = function (user) {
+ const self = this;
+ if (!self.registerOptionsChallengePath) {
+ return Promise.reject('Register challenge path missing form the initial configuration!');
}
- return fetch(self.registerPath, {
+ return self.fetchWithCsrf(self.registerOptionsChallengePath, {
method: 'POST',
headers: {
'Accept': 'application/json',
@@ -152,9 +161,15 @@
WebAuthn.prototype.register = function (user) {
const self = this;
- return self.registerOnly(user)
+ if (!self.registerPath) {
+ throw new Error('Register path is missing!');
+ }
+ if (!user || !user.name) {
+ return Promise.reject('User name (user.name) required');
+ }
+ return self.registerClientSteps(user)
.then(body => {
- return fetch(self.callbackPath, {
+ return self.fetchWithCsrf(self.registerPath + "?" + new URLSearchParams({username: user.name}).toString(), {
method: 'POST',
headers: {
'Accept': 'application/json',
@@ -173,9 +188,12 @@
WebAuthn.prototype.login = function (user) {
const self = this;
- return self.loginOnly(user)
+ if (!self.loginPath) {
+ throw new Error('Login path is missing!');
+ }
+ return self.loginClientSteps(user)
.then(body => {
- return fetch(self.callbackPath, {
+ return self.fetchWithCsrf(self.loginPath, {
method: 'POST',
headers: {
'Accept': 'application/json',
@@ -192,18 +210,18 @@
});
};
- WebAuthn.prototype.loginOnly = function (user) {
+ WebAuthn.prototype.loginClientSteps = function (user) {
const self = this;
- if (!self.loginPath) {
- return Promise.reject('Login path missing from the initial configuration!');
+ if (!self.loginOptionsChallengePath) {
+ return Promise.reject('Login challenge path missing from the initial configuration!');
}
- return fetch(self.loginPath, {
+ return self.fetchWithCsrf(self.loginOptionsChallengePath, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
- body: JSON.stringify(user)
+ body: JSON.stringify(user || {})
})
.then(res => {
if (res.status === 200) {
diff --git a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/LoginResource.java b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/LoginResource.java
index e14bbfbc04f73..a34e51eb8e9b0 100644
--- a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/LoginResource.java
+++ b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/LoginResource.java
@@ -9,12 +9,12 @@
import org.jboss.resteasy.reactive.RestForm;
-import io.quarkus.hibernate.reactive.panache.common.runtime.ReactiveTransactional;
+import io.quarkus.hibernate.reactive.panache.common.WithTransaction;
+import io.quarkus.security.webauthn.WebAuthnCredentialRecord;
import io.quarkus.security.webauthn.WebAuthnLoginResponse;
import io.quarkus.security.webauthn.WebAuthnRegisterResponse;
import io.quarkus.security.webauthn.WebAuthnSecurity;
import io.smallrye.mutiny.Uni;
-import io.vertx.ext.auth.webauthn.Authenticator;
import io.vertx.ext.web.RoutingContext;
@Path("")
@@ -25,7 +25,7 @@ public class LoginResource {
@Path("/login")
@POST
- @ReactiveTransactional
+ @WithTransaction
public Uni login(@RestForm String userName,
@BeanParam WebAuthnLoginResponse webAuthnResponse,
RoutingContext ctx) {
@@ -42,7 +42,7 @@ public Uni login(@RestForm String userName,
// Invalid user
return Uni.createFrom().item(Response.status(Status.BAD_REQUEST).build());
}
- Uni authenticator = this.webAuthnSecurity.login(webAuthnResponse, ctx);
+ Uni authenticator = this.webAuthnSecurity.login(webAuthnResponse, ctx);
return authenticator
// bump the auth counter
@@ -54,6 +54,7 @@ public Uni login(@RestForm String userName,
})
// handle login failure
.onFailure().recoverWithItem(x -> {
+ x.printStackTrace();
// make a proper error response
return Response.status(Status.BAD_REQUEST).build();
});
@@ -63,7 +64,7 @@ public Uni login(@RestForm String userName,
@Path("/register")
@POST
- @ReactiveTransactional
+ @WithTransaction
public Uni register(@RestForm String userName,
@BeanParam WebAuthnRegisterResponse webAuthnResponse,
RoutingContext ctx) {
@@ -80,7 +81,7 @@ public Uni register(@RestForm String userName,
// Duplicate user
return Uni.createFrom().item(Response.status(Status.BAD_REQUEST).build());
}
- Uni authenticator = this.webAuthnSecurity.register(webAuthnResponse, ctx);
+ Uni authenticator = this.webAuthnSecurity.register(userName, webAuthnResponse, ctx);
return authenticator
// store the user
@@ -100,6 +101,7 @@ public Uni register(@RestForm String userName,
// handle login failure
.onFailure().recoverWithItem(x -> {
// make a proper error response
+ x.printStackTrace();
return Response.status(Status.BAD_REQUEST).build();
});
diff --git a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/MyWebAuthnSetup.java b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/MyWebAuthnSetup.java
index 26a5891c715d5..15423104b0f17 100644
--- a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/MyWebAuthnSetup.java
+++ b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/MyWebAuthnSetup.java
@@ -1,6 +1,5 @@
package io.quarkus.it.security.webauthn;
-import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
@@ -8,85 +7,55 @@
import jakarta.enterprise.context.ApplicationScoped;
-import io.quarkus.hibernate.reactive.panache.common.runtime.ReactiveTransactional;
+import io.quarkus.hibernate.reactive.panache.common.WithTransaction;
+import io.quarkus.security.webauthn.WebAuthnCredentialRecord;
import io.quarkus.security.webauthn.WebAuthnUserProvider;
import io.smallrye.mutiny.Uni;
-import io.vertx.ext.auth.webauthn.AttestationCertificates;
-import io.vertx.ext.auth.webauthn.Authenticator;
@ApplicationScoped
public class MyWebAuthnSetup implements WebAuthnUserProvider {
- @ReactiveTransactional
+ @WithTransaction
@Override
- public Uni> findWebAuthnCredentialsByUserName(String userName) {
+ public Uni> findByUserName(String userName) {
return WebAuthnCredential.findByUserName(userName)
- .flatMap(MyWebAuthnSetup::toAuthenticators);
+ .map(list -> list.stream().map(WebAuthnCredential::toWebAuthnCredentialRecord).toList());
}
- @ReactiveTransactional
+ @WithTransaction
@Override
- public Uni> findWebAuthnCredentialsByCredID(String credID) {
- return WebAuthnCredential.findByCredID(credID)
- .flatMap(MyWebAuthnSetup::toAuthenticators);
+ public Uni findByCredentialId(String credentialId) {
+ return WebAuthnCredential.findByCredentialId(credentialId)
+ .onItem().ifNull().failWith(() -> new RuntimeException("No such credentials"))
+ .map(WebAuthnCredential::toWebAuthnCredentialRecord);
}
- @ReactiveTransactional
+ @WithTransaction
@Override
- public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) {
- // leave the scooby user to the manual endpoint, because if we do it here it will be
- // created/updated twice
- if (authenticator.getUserName().equals("scooby"))
- return Uni.createFrom().nullItem();
- return User.findByUserName(authenticator.getUserName())
- .flatMap(user -> {
- // new user
- if (user == null) {
- User newUser = new User();
- newUser.userName = authenticator.getUserName();
- WebAuthnCredential credential = new WebAuthnCredential(authenticator, newUser);
- return credential.persist()
- .flatMap(c -> newUser.persist())
- .onItem().ignore().andContinueWithNull();
- } else {
- // existing user
- user.webAuthnCredential.counter = authenticator.getCounter();
- return Uni.createFrom().nullItem();
- }
- });
- }
-
- private static Uni> toAuthenticators(List dbs) {
- // can't call combine/uni on empty list
- if (dbs.isEmpty())
- return Uni.createFrom().item(Collections.emptyList());
- List> ret = new ArrayList<>(dbs.size());
- for (WebAuthnCredential db : dbs) {
- ret.add(toAuthenticator(db));
+ public Uni store(WebAuthnCredentialRecord credentialRecord) {
+ // this user is handled in the LoginResource endpoint manually
+ if (credentialRecord.getUserName().equals("scooby")) {
+ return Uni.createFrom().voidItem();
}
- return Uni.combine().all().unis(ret).combinedWith(f -> (List) f);
+ User newUser = new User();
+ newUser.userName = credentialRecord.getUserName();
+ WebAuthnCredential credential = new WebAuthnCredential(credentialRecord, newUser);
+ return credential.persist()
+ .flatMap(c -> newUser.persist())
+ .onItem().ignore().andContinueWithNull();
}
- private static Uni toAuthenticator(WebAuthnCredential credential) {
- return credential.fetch(credential.x5c)
- .map(x5c -> {
- Authenticator ret = new Authenticator();
- ret.setAaguid(credential.aaguid);
- AttestationCertificates attestationCertificates = new AttestationCertificates();
- attestationCertificates.setAlg(credential.alg);
- List x5cs = new ArrayList<>(x5c.size());
- for (WebAuthnCertificate webAuthnCertificate : x5c) {
- x5cs.add(webAuthnCertificate.x5c);
+ @WithTransaction
+ @Override
+ public Uni update(String credentialId, long counter) {
+ return WebAuthnCredential.findByCredentialId(credentialId)
+ .invoke(credential -> {
+ // this user is handled in the LoginResource endpoint manually
+ if (!credential.user.userName.equals("scooby")) {
+ credential.counter = counter;
}
- ret.setAttestationCertificates(attestationCertificates);
- ret.setCounter(credential.counter);
- ret.setCredID(credential.credID);
- ret.setFmt(credential.fmt);
- ret.setPublicKey(credential.publicKey);
- ret.setType(credential.type);
- ret.setUserName(credential.userName);
- return ret;
- });
+ })
+ .onItem().ignore().andContinueWithNull();
}
@Override
diff --git a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/WebAuthnCredential.java b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/WebAuthnCredential.java
index b6d6fd0f9396b..ec0e526b9d9f0 100644
--- a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/WebAuthnCredential.java
+++ b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/WebAuthnCredential.java
@@ -1,76 +1,39 @@
package io.quarkus.it.security.webauthn;
-import java.util.ArrayList;
import java.util.List;
+import java.util.UUID;
import jakarta.persistence.Entity;
-import jakarta.persistence.OneToMany;
+import jakarta.persistence.Id;
import jakarta.persistence.OneToOne;
-import jakarta.persistence.Table;
-import jakarta.persistence.UniqueConstraint;
-import io.quarkus.hibernate.reactive.panache.PanacheEntity;
+import io.quarkus.hibernate.reactive.panache.PanacheEntityBase;
+import io.quarkus.security.webauthn.WebAuthnCredentialRecord;
+import io.quarkus.security.webauthn.WebAuthnCredentialRecord.RequiredPersistedData;
import io.smallrye.mutiny.Uni;
-import io.vertx.ext.auth.webauthn.Authenticator;
-import io.vertx.ext.auth.webauthn.PublicKeyCredential;
-@Table(uniqueConstraints = @UniqueConstraint(columnNames = { "userName", "credID" }))
@Entity
-public class WebAuthnCredential extends PanacheEntity {
-
- /**
- * The username linked to this authenticator
- */
- public String userName;
-
- /**
- * The type of key (must be "public-key")
- */
- public String type = "public-key";
+public class WebAuthnCredential extends PanacheEntityBase {
/**
* The non user identifiable id for the authenticator
*/
+ @Id
public String credID;
/**
* The public key associated with this authenticator
*/
- public String publicKey;
+ public byte[] publicKey;
+
+ public long publicKeyAlgorithm;
/**
* The signature counter of the authenticator to prevent replay attacks
*/
public long counter;
- public String aaguid;
-
- /**
- * The Authenticator attestation certificates object, a JSON like:
- *
- *
- * {@code
- * {
- * "alg": "string",
- * "x5c": [
- * "base64"
- * ]
- * }
- * }
- *
- */
- /**
- * The algorithm used for the public credential
- */
- public PublicKeyCredential alg;
-
- /**
- * The list of X509 certificates encoded as base64url.
- */
- @OneToMany(mappedBy = "webAuthnCredential")
- public List x5c = new ArrayList<>();
-
- public String fmt;
+ public UUID aaguid;
// owning side
@OneToOne
@@ -79,35 +42,29 @@ public class WebAuthnCredential extends PanacheEntity {
public WebAuthnCredential() {
}
- public WebAuthnCredential(Authenticator authenticator, User user) {
- aaguid = authenticator.getAaguid();
- if (authenticator.getAttestationCertificates() != null)
- alg = authenticator.getAttestationCertificates().getAlg();
- counter = authenticator.getCounter();
- credID = authenticator.getCredID();
- fmt = authenticator.getFmt();
- publicKey = authenticator.getPublicKey();
- type = authenticator.getType();
- userName = authenticator.getUserName();
- if (authenticator.getAttestationCertificates() != null
- && authenticator.getAttestationCertificates().getX5c() != null) {
- for (String x5c : authenticator.getAttestationCertificates().getX5c()) {
- WebAuthnCertificate cert = new WebAuthnCertificate();
- cert.x5c = x5c;
- cert.webAuthnCredential = this;
- this.x5c.add(cert);
- }
- }
+ public WebAuthnCredential(WebAuthnCredentialRecord credentialRecord, User user) {
+ RequiredPersistedData requiredPersistedData = credentialRecord.getRequiredPersistedData();
+ aaguid = requiredPersistedData.aaguid();
+ counter = requiredPersistedData.counter();
+ credID = requiredPersistedData.credentialId();
+ publicKey = requiredPersistedData.publicKey();
+ publicKeyAlgorithm = requiredPersistedData.publicKeyAlgorithm();
this.user = user;
user.webAuthnCredential = this;
}
+ public WebAuthnCredentialRecord toWebAuthnCredentialRecord() {
+ return WebAuthnCredentialRecord
+ .fromRequiredPersistedData(
+ new RequiredPersistedData(user.userName, credID, aaguid, publicKey, publicKeyAlgorithm, counter));
+ }
+
public static Uni> findByUserName(String userName) {
- return list("userName", userName);
+ return list("user.userName", userName);
}
- public static Uni> findByCredID(String credID) {
- return list("credID", credID);
+ public static Uni findByCredentialId(String credID) {
+ return findById(credID);
}
public Uni fetch(T association) {
diff --git a/integration-tests/security-webauthn/src/main/resources/application.properties b/integration-tests/security-webauthn/src/main/resources/application.properties
index 06af79d80af21..8c351a0f75197 100644
--- a/integration-tests/security-webauthn/src/main/resources/application.properties
+++ b/integration-tests/security-webauthn/src/main/resources/application.properties
@@ -6,3 +6,5 @@
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.webauthn.login-page=/
+quarkus.webauthn.enable-login-endpoint=true
+quarkus.webauthn.enable-registration-endpoint=true
diff --git a/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java b/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java
index 44171258e4675..cefcdd3f22202 100644
--- a/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java
+++ b/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java
@@ -2,11 +2,13 @@
import static io.restassured.RestAssured.given;
+import java.net.URL;
import java.util.function.Consumer;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
+import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper;
import io.quarkus.test.security.webauthn.WebAuthnHardware;
@@ -28,6 +30,9 @@ enum Endpoint {
MANUAL;
}
+ @TestHTTPResource
+ URL url;
+
@Test
public void testWebAuthnUser() {
testWebAuthn("FroMage", User.USER, Endpoint.DEFAULT);
@@ -41,15 +46,15 @@ public void testWebAuthnAdmin() {
private void testWebAuthn(String userName, User user, Endpoint endpoint) {
Filter cookieFilter = new RenardeCookieFilter();
- WebAuthnHardware token = new WebAuthnHardware();
+ WebAuthnHardware token = new WebAuthnHardware(url);
verifyLoggedOut(cookieFilter);
// two-step registration
- String challenge = WebAuthnEndpointHelper.invokeRegistration(userName, cookieFilter);
+ String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge(userName, cookieFilter);
JsonObject registrationJson = token.makeRegistrationJson(challenge);
if (endpoint == Endpoint.DEFAULT)
- WebAuthnEndpointHelper.invokeCallback(registrationJson, cookieFilter);
+ WebAuthnEndpointHelper.invokeRegistration(userName, registrationJson, cookieFilter);
else {
invokeCustomEndpoint("/register", cookieFilter, request -> {
WebAuthnEndpointHelper.addWebAuthnRegistrationFormParameters(request, registrationJson);
@@ -66,10 +71,10 @@ private void testWebAuthn(String userName, User user, Endpoint endpoint) {
verifyLoggedOut(cookieFilter);
// two-step login
- challenge = WebAuthnEndpointHelper.invokeLogin(userName, cookieFilter);
+ challenge = WebAuthnEndpointHelper.obtainLoginChallenge(userName, cookieFilter);
JsonObject loginJson = token.makeLoginJson(challenge);
if (endpoint == Endpoint.DEFAULT)
- WebAuthnEndpointHelper.invokeCallback(loginJson, cookieFilter);
+ WebAuthnEndpointHelper.invokeLogin(loginJson, cookieFilter);
else {
invokeCustomEndpoint("/login", cookieFilter, request -> {
WebAuthnEndpointHelper.addWebAuthnLoginFormParameters(request, loginJson);
@@ -96,10 +101,9 @@ private void invokeCustomEndpoint(String uri, Filter cookieFilter, Consumer> findWebAuthnCredentialsByCredID(String credId) {
+ public Uni findByCredentialId(String credId) {
assertVirtualThread();
- return super.findWebAuthnCredentialsByCredID(credId);
+ return super.findByCredentialId(credId);
}
@Override
- public Uni> findWebAuthnCredentialsByUserName(String userId) {
+ public Uni> findByUserName(String userId) {
assertVirtualThread();
- return super.findWebAuthnCredentialsByUserName(userId);
+ return super.findByUserName(userId);
}
@Override
- public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) {
+ public Uni store(WebAuthnCredentialRecord credentialRecord) {
assertVirtualThread();
- return super.updateOrStoreWebAuthnCredentials(authenticator);
+ return super.store(credentialRecord);
+ }
+
+ @Override
+ public Uni update(String credentialId, long counter) {
+ assertVirtualThread();
+ return super.update(credentialId, counter);
}
private void assertVirtualThread() {
diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/resources/application.properties b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/resources/application.properties
index e69de29bb2d1d..6ef5d8c9ccb2e 100644
--- a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/resources/application.properties
+++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/resources/application.properties
@@ -0,0 +1,2 @@
+quarkus.webauthn.enable-login-endpoint=true
+quarkus.webauthn.enable-registration-endpoint=true
diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java
index 6ff6f8303ec7c..711f4e5140444 100644
--- a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java
+++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java
@@ -2,9 +2,12 @@
import static io.quarkus.virtual.security.webauthn.RunOnVirtualThreadTest.checkLoggedIn;
+import java.net.URL;
+
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
+import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.test.junit.QuarkusIntegrationTest;
import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper;
import io.quarkus.test.security.webauthn.WebAuthnHardware;
@@ -15,6 +18,9 @@
@QuarkusIntegrationTest
class RunOnVirtualThreadIT {
+ @TestHTTPResource
+ URL url;
+
@Test
public void test() {
@@ -30,12 +36,12 @@ public void test() {
.get("/cheese").then().statusCode(302);
CookieFilter cookieFilter = new CookieFilter();
- WebAuthnHardware hardwareKey = new WebAuthnHardware();
- String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter);
+ WebAuthnHardware hardwareKey = new WebAuthnHardware(url);
+ String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter);
JsonObject registration = hardwareKey.makeRegistrationJson(challenge);
// now finalise
- WebAuthnEndpointHelper.invokeCallback(registration, cookieFilter);
+ WebAuthnEndpointHelper.invokeRegistration("stef", registration, cookieFilter);
// make sure our login cookie works
checkLoggedIn(cookieFilter);
@@ -43,11 +49,11 @@ public void test() {
// reset cookies for the login phase
cookieFilter = new CookieFilter();
// now try to log in
- challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter);
+ challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", cookieFilter);
JsonObject login = hardwareKey.makeLoginJson(challenge);
// now finalise
- WebAuthnEndpointHelper.invokeCallback(login, cookieFilter);
+ WebAuthnEndpointHelper.invokeLogin(login, cookieFilter);
// make sure our login cookie still works
checkLoggedIn(cookieFilter);
diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java
index 4d73fc4210d59..a8f7b84031a7d 100644
--- a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java
+++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java
@@ -1,7 +1,6 @@
package io.quarkus.virtual.security.webauthn;
-import static org.hamcrest.Matchers.is;
-
+import java.net.URL;
import java.util.List;
import jakarta.inject.Inject;
@@ -10,7 +9,9 @@
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import io.quarkus.security.webauthn.WebAuthnCredentialRecord;
import io.quarkus.security.webauthn.WebAuthnUserProvider;
+import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit5.virtual.ShouldNotPin;
import io.quarkus.test.junit5.virtual.VirtualThreadUnit;
@@ -19,7 +20,6 @@
import io.restassured.RestAssured;
import io.restassured.filter.cookie.CookieFilter;
import io.vertx.core.json.JsonObject;
-import io.vertx.ext.auth.webauthn.Authenticator;
@QuarkusTest
@VirtualThreadUnit
@@ -29,6 +29,9 @@ class RunOnVirtualThreadTest {
@Inject
WebAuthnUserProvider userProvider;
+ @TestHTTPResource
+ URL url;
+
@Test
public void test() throws Exception {
@@ -43,17 +46,17 @@ public void test() throws Exception {
.given().redirects().follow(false)
.get("/cheese").then().statusCode(302);
- Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely().isEmpty());
+ Assertions.assertTrue(userProvider.findByUserName("stef").await().indefinitely().isEmpty());
CookieFilter cookieFilter = new CookieFilter();
- WebAuthnHardware hardwareKey = new WebAuthnHardware();
- String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter);
+ WebAuthnHardware hardwareKey = new WebAuthnHardware(url);
+ String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter);
JsonObject registration = hardwareKey.makeRegistrationJson(challenge);
// now finalise
- WebAuthnEndpointHelper.invokeCallback(registration, cookieFilter);
+ WebAuthnEndpointHelper.invokeRegistration("stef", registration, cookieFilter);
// make sure we stored the user
- List users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely();
+ List users = userProvider.findByUserName("stef").await().indefinitely();
Assertions.assertEquals(1, users.size());
Assertions.assertTrue(users.get(0).getUserName().equals("stef"));
Assertions.assertEquals(1, users.get(0).getCounter());
@@ -64,14 +67,14 @@ public void test() throws Exception {
// reset cookies for the login phase
cookieFilter = new CookieFilter();
// now try to log in
- challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter);
+ challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", cookieFilter);
JsonObject login = hardwareKey.makeLoginJson(challenge);
// now finalise
- WebAuthnEndpointHelper.invokeCallback(login, cookieFilter);
+ WebAuthnEndpointHelper.invokeLogin(login, cookieFilter);
// make sure we bumped the user
- users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely();
+ users = userProvider.findByUserName("stef").await().indefinitely();
Assertions.assertEquals(1, users.size());
Assertions.assertTrue(users.get(0).getUserName().equals("stef"));
Assertions.assertEquals(2, users.get(0).getCounter());
diff --git a/test-framework/security-webauthn/pom.xml b/test-framework/security-webauthn/pom.xml
index 38923a799df21..3699d95ab3cf9 100644
--- a/test-framework/security-webauthn/pom.xml
+++ b/test-framework/security-webauthn/pom.xml
@@ -1,7 +1,7 @@
-
+
io.quarkus
quarkus-test-framework
diff --git a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.java b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.java
index f99e0b00a4057..b33d01d876ec9 100644
--- a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.java
+++ b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.java
@@ -14,7 +14,7 @@
import io.vertx.core.json.JsonObject;
public class WebAuthnEndpointHelper {
- public static String invokeRegistration(String userName, Filter cookieFilter) {
+ public static String obtainRegistrationChallenge(String userName, Filter cookieFilter) {
JsonObject registerJson = new JsonObject()
.put("name", userName);
ExtractableResponse response = RestAssured
@@ -22,11 +22,11 @@ public static String invokeRegistration(String userName, Filter cookieFilter) {
.contentType(ContentType.JSON)
.filter(cookieFilter)
.log().ifValidationFails()
- .post("/q/webauthn/register")
- .then().statusCode(200)
+ .post("/q/webauthn/register-options-challenge")
+ .then()
.log().ifValidationFails()
+ .statusCode(200)
.cookie(getChallengeCookie(), Matchers.notNullValue())
- .cookie(getChallengeUsernameCookie(), Matchers.notNullValue())
.extract();
// assert stuff
JsonObject responseJson = new JsonObject(response.asString());
@@ -35,21 +35,36 @@ public static String invokeRegistration(String userName, Filter cookieFilter) {
return challenge;
}
- public static void invokeCallback(JsonObject registration, Filter cookieFilter) {
+ public static void invokeLogin(JsonObject login, Filter cookieFilter) {
+ RestAssured
+ .given().body(login.encode())
+ .filter(cookieFilter)
+ .contentType(ContentType.JSON)
+ .log().ifValidationFails()
+ .post("/q/webauthn/login")
+ .then()
+ .log().ifValidationFails()
+ .statusCode(204)
+ .cookie(getChallengeCookie(), Matchers.is(""))
+ .cookie(getMainCookie(), Matchers.notNullValue());
+ }
+
+ public static void invokeRegistration(String username, JsonObject registration, Filter cookieFilter) {
RestAssured
.given().body(registration.encode())
.filter(cookieFilter)
.contentType(ContentType.JSON)
.log().ifValidationFails()
- .post("/q/webauthn/callback")
- .then().statusCode(204)
+ .queryParam("username", username)
+ .post("/q/webauthn/register")
+ .then()
.log().ifValidationFails()
+ .statusCode(204)
.cookie(getChallengeCookie(), Matchers.is(""))
- .cookie(getChallengeUsernameCookie(), Matchers.is(""))
.cookie(getMainCookie(), Matchers.notNullValue());
}
- public static String invokeLogin(String userName, Filter cookieFilter) {
+ public static String obtainLoginChallenge(String userName, Filter cookieFilter) {
JsonObject loginJson = new JsonObject()
.put("name", userName);
ExtractableResponse response = RestAssured
@@ -57,11 +72,11 @@ public static String invokeLogin(String userName, Filter cookieFilter) {
.contentType(ContentType.JSON)
.filter(cookieFilter)
.log().ifValidationFails()
- .post("/q/webauthn/login")
- .then().statusCode(200)
+ .post("/q/webauthn/login-options-challenge")
+ .then()
.log().ifValidationFails()
+ .statusCode(200)
.cookie(getChallengeCookie(), Matchers.notNullValue())
- .cookie(getChallengeUsernameCookie(), Matchers.notNullValue())
.extract();
// assert stuff
JsonObject responseJson = new JsonObject(response.asString());
@@ -113,10 +128,4 @@ public static String getChallengeCookie() {
return config.getOptionalValue("quarkus.webauthn.challenge-cookie-name", String.class)
.orElse("_quarkus_webauthn_challenge");
}
-
- public static String getChallengeUsernameCookie() {
- Config config = ConfigProvider.getConfig();
- return config.getOptionalValue("quarkus.webauthn.challenge-username-cookie-name", String.class)
- .orElse("_quarkus_webauthn_username");
- }
}
diff --git a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHardware.java b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHardware.java
index 3dabdb619f5f9..c8f7197d9eb3a 100644
--- a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHardware.java
+++ b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHardware.java
@@ -2,6 +2,7 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
@@ -19,18 +20,17 @@
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.dataformat.cbor.CBORFactory;
+import com.webauthn4j.data.attestation.authenticator.AuthenticatorData;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.impl.Codec;
-import io.vertx.ext.auth.webauthn.impl.AuthData;
/**
* Provides an emulation of a WebAuthn hardware token, suitable for generating registration
* and login JSON objects that you can send to the Quarkus WebAuthn Security extension.
*
- * The public/private key and id/credID are randomly generated and different for every instance,
- * and the origin is always for http://localhost
+ * The public/private key and id/credID are randomly generated and different for every instance.
*/
public class WebAuthnHardware {
@@ -38,8 +38,9 @@ public class WebAuthnHardware {
private String id;
private byte[] credID;
private int counter = 1;
+ private URL origin;
- public WebAuthnHardware() {
+ public WebAuthnHardware(URL origin) {
KeyPairGenerator generator;
try {
generator = KeyPairGenerator.getInstance("EC");
@@ -53,6 +54,7 @@ public WebAuthnHardware() {
credID = new byte[32];
random.nextBytes(credID);
id = Base64.getUrlEncoder().withoutPadding().encodeToString(credID);
+ this.origin = origin;
}
/**
@@ -65,11 +67,11 @@ public JsonObject makeRegistrationJson(String challenge) {
JsonObject clientData = new JsonObject()
.put("type", "webauthn.create")
.put("challenge", challenge)
- .put("origin", "http://localhost")
+ .put("origin", origin.toString())
.put("crossOrigin", false);
String clientDataEncoded = Base64.getUrlEncoder().encodeToString(clientData.encode().getBytes(StandardCharsets.UTF_8));
- byte[] authBytes = makeAuthBytes();
+ byte[] authBytes = makeAuthBytes(true);
/*
* {"fmt": "none", "attStmt": {}, "authData": h'DATAAAAA'}
*/
@@ -108,12 +110,12 @@ public JsonObject makeLoginJson(String challenge) {
JsonObject clientData = new JsonObject()
.put("type", "webauthn.get")
.put("challenge", challenge)
- .put("origin", "http://localhost")
+ .put("origin", origin.toString())
.put("crossOrigin", false);
byte[] clientDataBytes = clientData.encode().getBytes(StandardCharsets.UTF_8);
String clientDataEncoded = Base64.getUrlEncoder().encodeToString(clientDataBytes);
- byte[] authBytes = makeAuthBytes();
+ byte[] authBytes = makeAuthBytes(false);
String authenticatorData = Base64.getUrlEncoder().encodeToString(authBytes);
// sign the authbytes + hash(client data json)
@@ -148,7 +150,7 @@ public JsonObject makeLoginJson(String challenge) {
.put("type", "public-key");
}
- private byte[] makeAuthBytes() {
+ private byte[] makeAuthBytes(boolean attest) {
Buffer buffer = Buffer.buffer();
String rpDomain = "localhost";
@@ -161,45 +163,47 @@ private byte[] makeAuthBytes() {
byte[] rpIdHash = md.digest(rpDomain.getBytes(StandardCharsets.UTF_8));
buffer.appendBytes(rpIdHash);
- byte flags = AuthData.ATTESTATION_DATA | AuthData.USER_PRESENT;
+ byte flags = AuthenticatorData.BIT_AT | AuthenticatorData.BIT_UP | AuthenticatorData.BIT_UV;
buffer.appendByte(flags);
long signCounter = counter++;
buffer.appendUnsignedInt(signCounter);
- // Attested Data is present
- String aaguidString = "00000000-0000-0000-0000-000000000000";
- String aaguidStringShort = aaguidString.replace("-", "");
- byte[] aaguid = Codec.base16Decode(aaguidStringShort);
- buffer.appendBytes(aaguid);
-
- buffer.appendUnsignedShort(credID.length);
- buffer.appendBytes(credID);
-
- ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic();
- Encoder urlEncoder = Base64.getUrlEncoder();
- String x = urlEncoder.encodeToString(publicKey.getW().getAffineX().toByteArray());
- String y = urlEncoder.encodeToString(publicKey.getW().getAffineY().toByteArray());
-
- CBORFactory cborFactory = new CBORFactory();
- ByteArrayOutputStream byteWriter = new ByteArrayOutputStream();
- try {
- JsonGenerator generator = cborFactory.createGenerator(byteWriter);
- generator.writeStartObject();
- // see CWK and https://tools.ietf.org/html/rfc8152#section-7.1
- generator.writeNumberField("1", 2); // kty: "EC"
- generator.writeNumberField("3", -7); // alg: "ES256"
- generator.writeNumberField("-1", 1); // crv: "P-256"
- // https://tools.ietf.org/html/rfc8152#section-13.1.1
- generator.writeStringField("-2", x); // x, base64url
- generator.writeStringField("-3", y); // y, base64url
- generator.writeEndObject();
- generator.close();
- } catch (IOException t) {
- throw new RuntimeException(t);
+ if (attest) {
+ // Attested Data is present
+ String aaguidString = "00000000-0000-0000-0000-000000000000";
+ String aaguidStringShort = aaguidString.replace("-", "");
+ byte[] aaguid = Codec.base16Decode(aaguidStringShort);
+ buffer.appendBytes(aaguid);
+
+ buffer.appendUnsignedShort(credID.length);
+ buffer.appendBytes(credID);
+
+ ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic();
+ // NOTE: this used to be Base64 URL, but webauthn4j refuses it and wants Base64. I can't find in the spec where it's specified.
+ Encoder urlEncoder = Base64.getEncoder();
+ String x = urlEncoder.encodeToString(publicKey.getW().getAffineX().toByteArray());
+ String y = urlEncoder.encodeToString(publicKey.getW().getAffineY().toByteArray());
+
+ CBORFactory cborFactory = new CBORFactory();
+ ByteArrayOutputStream byteWriter = new ByteArrayOutputStream();
+ try {
+ JsonGenerator generator = cborFactory.createGenerator(byteWriter);
+ generator.writeStartObject();
+ // see CWK and https://tools.ietf.org/html/rfc8152#section-7.1
+ generator.writeNumberField("1", 2); // kty: "EC"
+ generator.writeNumberField("3", -7); // alg: "ES256"
+ generator.writeNumberField("-1", 1); // crv: "P-256"
+ // https://tools.ietf.org/html/rfc8152#section-13.1.1
+ generator.writeStringField("-2", x); // x, base64url
+ generator.writeStringField("-3", y); // y, base64url
+ generator.writeEndObject();
+ generator.close();
+ } catch (IOException t) {
+ throw new RuntimeException(t);
+ }
+ buffer.appendBytes(byteWriter.toByteArray());
}
- buffer.appendBytes(byteWriter.toByteArray());
-
return buffer.getBytes();
}
diff --git a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHelper.java b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHelper.java
new file mode 100644
index 0000000000000..19011267cb182
--- /dev/null
+++ b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHelper.java
@@ -0,0 +1,265 @@
+package io.quarkus.test.security.webauthn;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.dataformat.cbor.CBORFactory;
+import com.fasterxml.jackson.dataformat.cbor.CBORParser;
+import com.webauthn4j.util.Base64UrlUtil;
+
+import io.quarkus.security.webauthn.WebAuthnLoginResponse;
+import io.quarkus.security.webauthn.WebAuthnRegisterResponse;
+import io.vertx.core.buffer.Buffer;
+import io.vertx.core.json.JsonObject;
+
+public class WebAuthnHelper {
+ public static class PrettyPrinter {
+ private int indent;
+
+ private void indent() {
+ for (int i = 0; i < indent; i++) {
+ System.err.print(" ");
+ }
+ }
+
+ public void handleToken(CBORParser parser, JsonToken t) throws IOException {
+ switch (t) {
+ case END_ARRAY:
+ endArray();
+ break;
+ case END_OBJECT:
+ endObject();
+ break;
+ case FIELD_NAME:
+ fieldName(parser.currentName());
+ break;
+ case NOT_AVAILABLE:
+ break;
+ case START_ARRAY:
+ startArray();
+ break;
+ case START_OBJECT:
+ startObject();
+ break;
+ case VALUE_EMBEDDED_OBJECT:
+ Object embeddedObject = parser.getEmbeddedObject();
+ if (parser.currentName().equals("authData")) {
+ dumpAuthData((byte[]) embeddedObject);
+ } else {
+ System.err.println(embeddedObject);
+ }
+ break;
+ case VALUE_FALSE:
+ falseConstant();
+ break;
+ case VALUE_NULL:
+ nullConstant();
+ break;
+ case VALUE_NUMBER_FLOAT:
+ floatValue(parser.getFloatValue());
+ break;
+ case VALUE_NUMBER_INT:
+ intValue(parser.getNumberValue());
+ break;
+ case VALUE_STRING:
+ stringValue(parser.getValueAsString());
+ break;
+ case VALUE_TRUE:
+ trueConstant();
+ break;
+ default:
+ break;
+
+ }
+
+ }
+
+ private void floatValue(float floatValue) {
+ System.err.println(floatValue);
+ }
+
+ private void intValue(Number numberValue) {
+ System.err.println(numberValue);
+ }
+
+ private void stringValue(String value) {
+ System.err.println("\"" + value + "\"");
+ }
+
+ private void nullConstant() {
+ System.err.println("null");
+ }
+
+ private void trueConstant() {
+ System.err.println("true");
+ }
+
+ private void falseConstant() {
+ System.err.println("false");
+ }
+
+ private void startObject() {
+ indent();
+ System.err.println("{");
+ indent++;
+ }
+
+ private void startArray() {
+ indent();
+ System.err.println("[");
+ indent++;
+ }
+
+ public void fieldName(String name) {
+ indent();
+ System.err.print("\"");
+ System.err.print(name);
+ System.err.print("\": ");
+ }
+
+ public void endObject() {
+ indent();
+ System.err.println("}");
+ indent--;
+ }
+
+ public void endArray() {
+ indent();
+ System.err.println("]");
+ indent--;
+ }
+
+ private void dumpAuthData(byte[] embeddedObject) throws IOException {
+ Buffer buf = Buffer.buffer(embeddedObject);
+ startObject();
+ int current = 0;
+ byte[] rpIdHash = buf.getBytes(0, 32);
+ current += 32;
+ fieldName("rpIdHash");
+ stringValue(""); // TODO
+ byte flags = buf.getByte(current);
+ current += 1;
+ fieldName("flags");
+ intValue(flags); // TODO in binary
+ long counter = buf.getUnsignedInt(current);
+ current += 4;
+ fieldName("counter");
+ intValue(counter);
+ if (embeddedObject.length > current) {
+ fieldName("attestedCredentialData");
+ startObject();
+ byte[] aaguid = buf.getBytes(current, current + 16);
+ current += 16;
+ fieldName("aaguid");
+ stringValue(Base64UrlUtil.encodeToString(aaguid));
+
+ int credentialIdLength = buf.getUnsignedShort(current);
+ current += 2;
+ fieldName("credentialIdLength");
+ intValue(credentialIdLength);
+
+ byte[] credentialId = buf.getBytes(current, current + credentialIdLength);
+ current += credentialIdLength;
+ fieldName("credentialId");
+ stringValue(Base64UrlUtil.encodeToString(credentialId));
+
+ fieldName("credentialPublicKey");
+ current += readCBOR(embeddedObject, current);
+
+ endObject();
+ }
+ // TODO: there's more
+ endObject();
+ }
+
+ private int readCBOR(byte[] bytes, int offset) throws IOException {
+ CBORFactory factory = CBORFactory.builder().build();
+ long lastReadByte = offset;
+ try (CBORParser parser = factory.createParser(bytes, offset, bytes.length - offset)) {
+ JsonToken t;
+ while ((t = parser.nextToken()) != null) {
+ // System.err.println("Token: "+t);
+ handleToken(parser, t);
+ }
+ lastReadByte = parser.currentLocation().getByteOffset();
+ }
+ return (int) (lastReadByte - offset);
+ }
+ }
+
+ public static void dumpWebAuthnRequest(JsonObject json) throws IOException {
+ System.err.println(json.encodePrettily());
+ JsonObject response = json.getJsonObject("response");
+ if (response != null) {
+ String attestationObject = response.getString("attestationObject");
+ if (attestationObject != null) {
+ System.err.println("Attestation object:");
+ dumpAttestationObject(attestationObject);
+ }
+ String authenticatorData = response.getString("authenticatorData");
+ if (authenticatorData != null) {
+ System.err.println("Authenticator Data:");
+ dumpAuthenticatorData(authenticatorData);
+ }
+ String clientDataJSON = response.getString("clientDataJSON");
+ if (clientDataJSON != null) {
+ System.err.println("Client Data JSON:");
+ String encoded = new String(Base64UrlUtil.decode(clientDataJSON), StandardCharsets.UTF_8);
+ System.err.println(new JsonObject(encoded).encodePrettily());
+ }
+ }
+ }
+
+ private static void dumpAttestationObject(String attestationObject) throws IOException {
+ CBORFactory factory = CBORFactory.builder().build();
+ PrettyPrinter printer = new PrettyPrinter();
+ try (CBORParser parser = factory.createParser(Base64UrlUtil.decode(attestationObject))) {
+ JsonToken t;
+ while ((t = parser.nextToken()) != null) {
+ // System.err.println("Token: "+t);
+ printer.handleToken(parser, t);
+ }
+ }
+ }
+
+ private static void dumpAuthenticatorData(String authenticatorData) throws IOException {
+ PrettyPrinter printer = new PrettyPrinter();
+ byte[] bytes = Base64UrlUtil.decode(authenticatorData);
+ printer.dumpAuthData(bytes);
+ }
+
+ public static void dumpWebAuthnRequest(WebAuthnRegisterResponse response) {
+ try {
+ dumpWebAuthnRequest(response.toJsonObject());
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ public static void dumpWebAuthnRequest(WebAuthnLoginResponse response) {
+ try {
+ dumpWebAuthnRequest(response.toJsonObject());
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ public static void main(String[] args) {
+ // WebAuthnRegisterResponse response = new WebAuthnRegisterResponse();
+ // response.webAuthnId = "N3P8WalYEtlUPMcD8q7C8hfY9tZ-DZBl7oPZNGMBxjk";
+ // response.webAuthnRawId = "N3P8WalYEtlUPMcD8q7C8hfY9tZ-DZBl7oPZNGMBxjk";
+ // response.webAuthnResponseAttestationObject = "v2NmbXRkbm9uZWdhdHRTdG10v_9oYXV0aERhdGFYxUmWDeWIDoxodDQXD2R2YFuP5K65ooYyx5lc87qDHZdjQQAAAAEAAAAAAAAAAAAAAAAAAAAAACA3c_xZqVgS2VQ8xwPyrsLyF9j21n4NkGXug9k0YwHGOb9hMQJhMyZiLTEBYi0yeCxGR0hxMHlCTWJ5X1RuOGpmWlU4XzZSTDlFNFg4ZnhJSkVOY05NN3UtSEFRPWItM3gsQUtNVEtFRG5DSzhnVVNxamRtdU45bnVzbTRRRXJNY0pBNjV6OWhJOW5TWlP__w==";
+ // response.webAuthnResponseClientDataJSON = "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibk9uVHRac1diSE5rRWNhOEZYY29NVUdIanJOY1c4S1BybWg0REFPQXFxaUVvRDNYdHhVT09TcXFiVXFndHlEbkEzU1VCM25YS21PRUp2WGNFZTBfVnciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0IiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ==";
+ // response.webAuthnType = "public-key";
+ // dumpWebAuthnRequest(response);
+ WebAuthnLoginResponse response = new WebAuthnLoginResponse();
+ response.webAuthnId = "cmokxFnWpNiqBDgI8qL41usvkUCeZC_J8EVS_jD0Brw";
+ response.webAuthnRawId = "cmokxFnWpNiqBDgI8qL41usvkUCeZC_J8EVS_jD0Brw";
+ response.webAuthnResponseAuthenticatorData = "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAAAgAAAAAAAAAAAAAAAAAAAAAAIHJqJMRZ1qTYqgQ4CPKi-NbrL5FAnmQvyfBFUv4w9Aa8v2ExAmEzJmItMQFiLTJ4LFhCbHRrY25LZ2xjTU94bmZYSnAydE1xc2RESFBhNVB5YnIvaFJUY2tSU3c9Yi0zeCxjZWQvdHRvZGdaQjhmUGdHZ0NIM3lIUUU5NjUzVk5GdTNET2JqNFNtZ2dRPf8=";
+ response.webAuthnResponseSignature = "MEUCIQDlT0NRyeElINrF59m54fsAhjVh09ykApfKzUsFw1qCVQIgHHkrPCQedlVo_fWcb7p8ch7tAT8mt3h3-GihBUP8s4o=";
+ response.webAuthnResponseClientDataJSON = "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiQTdYTDFvOTlINEdRZXBESDI5MnAzSUdwU0NNRHU3cUVlRVEwY3dWMU5BYm82ZzFjbC1yc2pGRHZuaVdwbE5hdk1rX1Z4YkJVcG8wdE00c2V2bXpaQUEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwODEvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ==";
+ response.webAuthnType = "public-key";
+ dumpWebAuthnRequest(response);
+ }
+}
diff --git a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnTestUserProvider.java b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnTestUserProvider.java
index b18ad798661b8..e6a5ca5509e71 100644
--- a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnTestUserProvider.java
+++ b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnTestUserProvider.java
@@ -1,29 +1,29 @@
package io.quarkus.test.security.webauthn;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
+import com.webauthn4j.util.Base64UrlUtil;
+
+import io.quarkus.security.webauthn.WebAuthnCredentialRecord;
import io.quarkus.security.webauthn.WebAuthnUserProvider;
import io.smallrye.mutiny.Uni;
-import io.vertx.ext.auth.webauthn.Authenticator;
/**
- * UserProvider suitable for tests, which stores and updates credentials in a list,
+ * UserProvider suitable for tests, which fetches and updates credentials from a list,
* so you can use it in your tests.
- *
- * @see WebAuthnStoringTestUserProvider
- * @see WebAuthnManualTestUserProvider
*/
public class WebAuthnTestUserProvider implements WebAuthnUserProvider {
- private List auths = new ArrayList<>();
+ private List auths = new ArrayList<>();
@Override
- public Uni> findWebAuthnCredentialsByUserName(String userId) {
- List ret = new ArrayList<>();
- for (Authenticator authenticator : auths) {
+ public Uni> findByUserName(String userId) {
+ List ret = new ArrayList<>();
+ for (WebAuthnCredentialRecord authenticator : auths) {
if (authenticator.getUserName().equals(userId)) {
ret.add(authenticator);
}
@@ -32,38 +32,26 @@ public Uni> findWebAuthnCredentialsByUserName(String userId)
}
@Override
- public Uni> findWebAuthnCredentialsByCredID(String credId) {
- List ret = new ArrayList<>();
- for (Authenticator authenticator : auths) {
- if (authenticator.getCredID().equals(credId)) {
- ret.add(authenticator);
+ public Uni findByCredentialId(String credId) {
+ byte[] bytes = Base64UrlUtil.decode(credId);
+ for (WebAuthnCredentialRecord authenticator : auths) {
+ if (Arrays.equals(authenticator.getAttestedCredentialData().getCredentialId(), bytes)) {
+ return Uni.createFrom().item(authenticator);
}
}
- return Uni.createFrom().item(ret);
+ return Uni.createFrom().failure(new RuntimeException("Credentials not found for credential ID " + credId));
}
@Override
- public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) {
- Authenticator existing = find(authenticator.getUserName(), authenticator.getCredID());
- if (existing != null) {
- // update
- existing.setCounter(authenticator.getCounter());
- } else {
- // add
- store(authenticator);
- }
- return Uni.createFrom().nullItem();
+ public Uni update(String credentialId, long counter) {
+ reallyUpdate(credentialId, counter);
+ return Uni.createFrom().voidItem();
}
- private Authenticator find(String userName, String credID) {
- for (Authenticator auth : auths) {
- if (userName.equals(auth.getUserName())
- && credID.equals(auth.getCredID())) {
- // update
- return auth;
- }
- }
- return null;
+ @Override
+ public Uni store(WebAuthnCredentialRecord credentialRecord) {
+ reallyStore(credentialRecord);
+ return Uni.createFrom().voidItem();
}
@Override
@@ -71,24 +59,23 @@ public Set getRoles(String userId) {
return Collections.singleton("admin");
}
- /**
- * Stores a new credential
- *
- * @param authenticator the new credential to store
- */
- public void store(Authenticator authenticator) {
- auths.add(authenticator);
+ // For tests
+
+ public void clear() {
+ auths.clear();
+ }
+
+ public void reallyUpdate(String credentialId, long counter) {
+ byte[] bytes = Base64UrlUtil.decode(credentialId);
+ for (WebAuthnCredentialRecord authenticator : auths) {
+ if (Arrays.equals(authenticator.getAttestedCredentialData().getCredentialId(), bytes)) {
+ authenticator.setCounter(counter);
+ break;
+ }
+ }
}
- /**
- * Updates an existing credential
- *
- * @param userName the user name
- * @param credID the credential ID
- * @param counter the new counter value
- */
- public void update(String userName, String credID, long counter) {
- Authenticator authenticator = find(userName, credID);
- authenticator.setCounter(counter);
+ public void reallyStore(WebAuthnCredentialRecord credentialRecord) {
+ auths.add(credentialRecord);
}
}
\ No newline at end of file