diff --git a/examples/pom.xml b/examples/pom.xml
index f51747121d1..37e78770f9b 100644
--- a/examples/pom.xml
+++ b/examples/pom.xml
@@ -206,6 +206,8 @@
security-tomcat-user-identitystoresecurity-custom-identitystoresecurity-openid
+ security-passkey-2fa
+ security-passkey-2fa-programmatic
diff --git a/examples/security-passkey-2fa-programmatic/pom.xml b/examples/security-passkey-2fa-programmatic/pom.xml
new file mode 100644
index 00000000000..9cc6eb49007
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/pom.xml
@@ -0,0 +1,117 @@
+
+
+
+ 4.0.0
+ org.superbiz
+ security-passkey-2fa-programmatic
+ 11.0.0-SNAPSHOT
+ war
+
+ TomEE :: Examples :: Jakarta Security Passkey 2FA (programmatic SecurityContext.authenticate)
+
+
+ UTF-8
+ 11.0.0-SNAPSHOT
+
+ 0.30.3.RELEASE
+
+
+
+
+
+ org.apache.tomee
+ jakartaee-api
+ 11.0.0-M1
+ provided
+
+
+
+ com.webauthn4j
+ webauthn4j-core
+ ${version.webauthn4j}
+
+
+
+ junit
+ junit
+ 4.13.2
+ test
+
+
+
+ com.webauthn4j
+ webauthn4j-test
+ ${version.webauthn4j}
+ test
+
+
+
+ org.apache.tomee.bom
+ tomee-microprofile
+ ${version.tomee}
+ test
+
+
+
+
+ passkey
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.14.1
+
+ 17
+ 17
+ 17
+
+
+
+
+
+ org.apache.tomee.maven
+ tomee-maven-plugin
+ ${version.tomee}
+
+ microprofile
+ passkey-programmatic
+
+
+
+
+
+
+
+ localhost
+ file://${basedir}/target/repo/
+
+
+ localhost
+ file://${basedir}/target/snapshot-repo/
+
+
+
diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/CredentialStore.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/CredentialStore.java
new file mode 100644
index 00000000000..94647deacaf
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/CredentialStore.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.superbiz.passkey;
+
+import com.webauthn4j.credential.CredentialRecord;
+import jakarta.enterprise.context.ApplicationScoped;
+
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+@ApplicationScoped
+public class CredentialStore {
+
+ public static final class Entry {
+ private final String username;
+ private final byte[] credentialId;
+ private CredentialRecord record;
+
+ private Entry(final String username, final byte[] credentialId, final CredentialRecord record) {
+ this.username = username;
+ this.credentialId = credentialId;
+ this.record = record;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public byte[] getCredentialId() {
+ return credentialId;
+ }
+
+ public CredentialRecord getRecord() {
+ return record;
+ }
+ }
+
+ private final Map byCredentialId = new ConcurrentHashMap<>();
+
+ public void save(final String username, final byte[] credentialId, final CredentialRecord record) {
+ byCredentialId.put(key(credentialId), new Entry(username, credentialId, record));
+ }
+
+ public Optional find(final byte[] credentialId) {
+ return Optional.ofNullable(byCredentialId.get(key(credentialId)));
+ }
+
+ public List credentialIds(final String username) {
+ final List ids = new ArrayList<>();
+ for (final Entry entry : byCredentialId.values()) {
+ if (entry.username.equals(username)) {
+ ids.add(entry.credentialId);
+ }
+ }
+ return ids;
+ }
+
+ public boolean hasCredentials(final String username) {
+ return !credentialIds(username).isEmpty();
+ }
+
+ public void updateRecord(final byte[] credentialId, final CredentialRecord updated) {
+ final Entry entry = byCredentialId.get(key(credentialId));
+ if (entry != null) {
+ entry.record = updated;
+ }
+ }
+
+ private static String key(final byte[] credentialId) {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(credentialId);
+ }
+}
diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/Http.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/Http.java
new file mode 100644
index 00000000000..af96f41c32b
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/Http.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.superbiz.passkey;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+public class Http {
+
+ private Http() {
+ }
+
+ public static String body(final HttpServletRequest request) throws IOException {
+ return new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ }
+
+ public static void json(final HttpServletResponse response, final String json) throws IOException {
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.setContentType("application/json;charset=UTF-8");
+ response.getWriter().write(json);
+ }
+
+ public static void error(final HttpServletResponse response, final int status, final String message) throws IOException {
+ response.setStatus(status);
+ response.setContentType("application/json;charset=UTF-8");
+ response.getWriter().write("{\"error\":\"" + message.replace("\"", "'") + "\"}");
+ }
+}
diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyAuthenticationMechanism.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyAuthenticationMechanism.java
new file mode 100644
index 00000000000..4c5187bb6c3
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyAuthenticationMechanism.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.superbiz.passkey;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.security.enterprise.AuthenticationException;
+import jakarta.security.enterprise.AuthenticationStatus;
+import jakarta.security.enterprise.authentication.mechanism.http.AutoApplySession;
+import jakarta.security.enterprise.authentication.mechanism.http.HttpAuthenticationMechanism;
+import jakarta.security.enterprise.authentication.mechanism.http.HttpMessageContext;
+import jakarta.security.enterprise.credential.Credential;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+
+@ApplicationScoped
+@AutoApplySession
+public class PasskeyAuthenticationMechanism implements HttpAuthenticationMechanism {
+
+ @Override
+ public AuthenticationStatus validateRequest(final HttpServletRequest request,
+ final HttpServletResponse response,
+ final HttpMessageContext httpMessageContext)
+ throws AuthenticationException {
+
+ final Credential credential = httpMessageContext.getAuthParameters().getCredential();
+
+ if (credential instanceof PasskeyCredential passkey) {
+ return httpMessageContext.notifyContainerAboutLogin(passkey.getCallerName(),
+ passkey.getGroups());
+ }
+
+ return httpMessageContext.doNothing();
+ }
+}
diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyCredential.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyCredential.java
new file mode 100644
index 00000000000..360d29feff9
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyCredential.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.superbiz.passkey;
+
+import jakarta.security.enterprise.credential.Credential;
+
+import java.util.Set;
+
+public class PasskeyCredential implements Credential {
+
+ private final String callerName;
+ private final Set groups;
+
+ public PasskeyCredential(final String callerName, final Set groups) {
+ this.callerName = callerName;
+ this.groups = groups;
+ }
+
+ public String getCallerName() {
+ return callerName;
+ }
+
+ public Set getGroups() {
+ return groups;
+ }
+}
diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyLoginServlet.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyLoginServlet.java
new file mode 100644
index 00000000000..df3ae4f8f8b
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyLoginServlet.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.superbiz.passkey;
+
+import jakarta.inject.Inject;
+import jakarta.security.enterprise.AuthenticationStatus;
+import jakarta.security.enterprise.SecurityContext;
+import jakarta.security.enterprise.authentication.mechanism.http.AuthenticationParameters;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.annotation.WebServlet;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+
+import java.io.IOException;
+import java.util.Set;
+
+@WebServlet("/api/login/*")
+public class PasskeyLoginServlet extends HttpServlet {
+
+ static final String FIRST_FACTOR_USER = "passkey.firstFactor.user";
+
+ @Inject
+ private UserRepository users;
+
+ @Inject
+ private WebAuthnService webAuthn;
+
+ @Inject
+ private SecurityContext securityContext;
+
+ @Override
+ protected void doGet(final HttpServletRequest request, final HttpServletResponse response)
+ throws ServletException, IOException {
+
+ if ("/assertion-options".equals(request.getPathInfo())) {
+ final String username = firstFactorUser(request);
+ if (username == null) {
+ Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Complete the password step first");
+ return;
+ }
+ Http.json(response, webAuthn.assertionOptions(request, username));
+ return;
+ }
+
+ Http.error(response, HttpServletResponse.SC_NOT_FOUND, "Unknown endpoint");
+ }
+
+ @Override
+ protected void doPost(final HttpServletRequest request, final HttpServletResponse response)
+ throws ServletException, IOException {
+
+ final String path = request.getPathInfo();
+ if ("/password".equals(path)) {
+ handlePassword(request, response);
+ } else if ("/assertion".equals(path)) {
+ handleAssertion(request, response);
+ } else {
+ Http.error(response, HttpServletResponse.SC_NOT_FOUND, "Unknown endpoint");
+ }
+ }
+
+ private void handlePassword(final HttpServletRequest request, final HttpServletResponse response)
+ throws IOException {
+
+ final var body = WebAuthnService.parse(Http.body(request));
+ final String username = body.getString("username", null);
+ final String password = body.getString("password", null);
+
+ if (username == null || password == null || !users.validatePassword(username, password)) {
+ Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Bad username or password");
+ return;
+ }
+
+ request.getSession(true).setAttribute(FIRST_FACTOR_USER, username);
+ Http.json(response, "{\"firstFactor\":true}");
+ }
+
+ private void handleAssertion(final HttpServletRequest request, final HttpServletResponse response)
+ throws IOException {
+
+ final String firstFactorUser = firstFactorUser(request);
+ if (firstFactorUser == null) {
+ Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Complete the password step first");
+ return;
+ }
+
+ final String assertedUser;
+ try {
+ assertedUser = webAuthn.finishAssertion(request, Http.body(request));
+ } catch (final RuntimeException e) {
+ Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Passkey verification failed");
+ return;
+ }
+
+ if (!firstFactorUser.equals(assertedUser)) {
+ Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Passkey does not match the user");
+ return;
+ }
+
+ final Set roles = users.roles(assertedUser).orElse(Set.of());
+
+ final AuthenticationStatus status = securityContext.authenticate(
+ request, response,
+ AuthenticationParameters.withParams().credential(new PasskeyCredential(assertedUser, roles)));
+
+ request.getSession().removeAttribute(FIRST_FACTOR_USER);
+
+ if (status == AuthenticationStatus.SUCCESS) {
+ Http.json(response, "{\"authenticated\":true,\"redirect\":\"" + request.getContextPath() + "/app\"}");
+ } else {
+ Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Authentication failed: " + status);
+ }
+ }
+
+ private static String firstFactorUser(final HttpServletRequest request) {
+ final HttpSession session = request.getSession(false);
+ return session == null ? null : (String) session.getAttribute(FIRST_FACTOR_USER);
+ }
+}
diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyRegistrationServlet.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyRegistrationServlet.java
new file mode 100644
index 00000000000..a11751c3783
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyRegistrationServlet.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.superbiz.passkey;
+
+import jakarta.inject.Inject;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.annotation.WebServlet;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+
+import java.io.IOException;
+
+@WebServlet("/api/register/*")
+public class PasskeyRegistrationServlet extends HttpServlet {
+
+ @Inject
+ private WebAuthnService webAuthn;
+
+ @Override
+ protected void doGet(final HttpServletRequest request, final HttpServletResponse response)
+ throws ServletException, IOException {
+
+ if ("/options".equals(request.getPathInfo())) {
+ final String username = currentUser(request);
+ if (username == null) {
+ Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Log in with your password first");
+ return;
+ }
+ Http.json(response, webAuthn.registrationOptions(request, username));
+ return;
+ }
+
+ Http.error(response, HttpServletResponse.SC_NOT_FOUND, "Unknown endpoint");
+ }
+
+ @Override
+ protected void doPost(final HttpServletRequest request, final HttpServletResponse response)
+ throws ServletException, IOException {
+
+ final String username = currentUser(request);
+ if (username == null) {
+ Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Log in with your password first");
+ return;
+ }
+
+ try {
+ webAuthn.finishRegistration(request, username, Http.body(request));
+ } catch (final RuntimeException e) {
+ Http.error(response, HttpServletResponse.SC_BAD_REQUEST, "Registration failed");
+ return;
+ }
+
+ Http.json(response, "{\"registered\":true}");
+ }
+
+ private static String currentUser(final HttpServletRequest request) {
+ if (request.getUserPrincipal() != null) {
+ return request.getUserPrincipal().getName();
+ }
+ final HttpSession session = request.getSession(false);
+ return session == null ? null : (String) session.getAttribute(PasskeyLoginServlet.FIRST_FACTOR_USER);
+ }
+}
diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/ProtectedServlet.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/ProtectedServlet.java
new file mode 100644
index 00000000000..5c8590c28c2
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/ProtectedServlet.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.superbiz.passkey;
+
+import jakarta.annotation.security.DeclareRoles;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.annotation.HttpConstraint;
+import jakarta.servlet.annotation.ServletSecurity;
+import jakarta.servlet.annotation.WebServlet;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.security.Principal;
+
+@WebServlet("/app")
+@DeclareRoles({"user", "admin"})
+@ServletSecurity(@HttpConstraint(rolesAllowed = "user"))
+public class ProtectedServlet extends HttpServlet {
+
+ @Override
+ protected void doGet(final HttpServletRequest request, final HttpServletResponse response)
+ throws ServletException, IOException {
+
+ final Principal principal = request.getUserPrincipal();
+ final String name = principal == null ? null : principal.getName();
+
+ response.setContentType("text/html;charset=UTF-8");
+ response.getWriter().write(
+ ""
+ + "
Protected area
"
+ + "
You reached this page on a follow-up request, so the passkey login "
+ + "was persisted across requests.
"
+ + "
caller: " + name + "
"
+ + "
role \"user\": " + request.isUserInRole("user") + "
"
+ + "
role \"admin\": " + request.isUserInRole("admin") + "
"
+ + "");
+ }
+}
diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/UserRepository.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/UserRepository.java
new file mode 100644
index 00000000000..d9b47949195
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/UserRepository.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.superbiz.passkey;
+
+import jakarta.enterprise.context.ApplicationScoped;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+@ApplicationScoped
+public class UserRepository {
+
+ private static final Map PASSWORDS = Map.of(
+ "jon", "doe",
+ "iron", "man");
+
+ private static final Map> ROLES = Map.of(
+ "jon", Set.of("user"),
+ "iron", Set.of("user", "admin"));
+
+ public boolean validatePassword(final String username, final String password) {
+ final String expected = PASSWORDS.get(username);
+ return expected != null && expected.equals(password);
+ }
+
+ public boolean exists(final String username) {
+ return PASSWORDS.containsKey(username);
+ }
+
+ public Optional> roles(final String username) {
+ return Optional.ofNullable(ROLES.get(username));
+ }
+}
diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/WebAuthnService.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/WebAuthnService.java
new file mode 100644
index 00000000000..a1fb676ccb5
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/WebAuthnService.java
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.superbiz.passkey;
+
+import com.webauthn4j.WebAuthnManager;
+import com.webauthn4j.credential.CredentialRecord;
+import com.webauthn4j.credential.CredentialRecordImpl;
+import com.webauthn4j.data.AuthenticationData;
+import com.webauthn4j.data.AuthenticationParameters;
+import com.webauthn4j.data.RegistrationData;
+import com.webauthn4j.data.RegistrationParameters;
+import com.webauthn4j.data.client.Origin;
+import com.webauthn4j.data.client.challenge.Challenge;
+import com.webauthn4j.data.client.challenge.DefaultChallenge;
+import com.webauthn4j.server.ServerProperty;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.json.Json;
+import jakarta.json.JsonObject;
+import jakarta.servlet.http.HttpServletRequest;
+
+import java.util.Base64;
+import java.util.List;
+
+@ApplicationScoped
+public class WebAuthnService {
+
+ private static final String RP_NAME = "TomEE Passkey Demo";
+ private static final long TIMEOUT_MS = 60_000L;
+
+ static final String REG_CHALLENGE = "passkey.registration.challenge";
+ static final String AUTH_CHALLENGE = "passkey.assertion.challenge";
+
+ private final WebAuthnManager webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager();
+
+ @Inject
+ private CredentialStore credentialStore;
+
+ public String registrationOptions(final HttpServletRequest request, final String username) {
+ final Challenge challenge = new DefaultChallenge();
+ request.getSession().setAttribute(REG_CHALLENGE, encode(challenge.getValue()));
+
+ final var pubKeyCredParams = Json.createArrayBuilder()
+ .add(Json.createObjectBuilder().add("type", "public-key").add("alg", -7)) // ES256
+ .add(Json.createObjectBuilder().add("type", "public-key").add("alg", -257)); // RS256
+
+ final var excludeCredentials = Json.createArrayBuilder();
+ for (final byte[] id : credentialStore.credentialIds(username)) {
+ excludeCredentials.add(Json.createObjectBuilder()
+ .add("type", "public-key")
+ .add("id", encode(id)));
+ }
+
+ return Json.createObjectBuilder()
+ .add("challenge", encode(challenge.getValue()))
+ .add("rp", Json.createObjectBuilder().add("id", rpId(request)).add("name", RP_NAME))
+ .add("user", Json.createObjectBuilder()
+ .add("id", encode(username.getBytes()))
+ .add("name", username)
+ .add("displayName", username))
+ .add("pubKeyCredParams", pubKeyCredParams)
+ .add("timeout", TIMEOUT_MS)
+ .add("attestation", "none")
+ .add("authenticatorSelection", Json.createObjectBuilder()
+ .add("residentKey", "preferred")
+ .add("userVerification", "preferred"))
+ .add("excludeCredentials", excludeCredentials)
+ .build()
+ .toString();
+ }
+
+ public void finishRegistration(final HttpServletRequest request,
+ final String username,
+ final String responseJson) {
+
+ final ServerProperty serverProperty = serverProperty(request, REG_CHALLENGE);
+
+ final RegistrationData registrationData = webAuthnManager.parseRegistrationResponseJSON(responseJson);
+
+ // pubKeyCredParams = null (accept what we offered), UV not required, UP required
+ final RegistrationParameters parameters =
+ new RegistrationParameters(serverProperty, null, false, true);
+
+ webAuthnManager.verify(registrationData, parameters);
+
+ final CredentialRecord record = new CredentialRecordImpl(
+ registrationData.getAttestationObject(),
+ registrationData.getCollectedClientData(),
+ registrationData.getClientExtensions(),
+ registrationData.getTransports());
+
+ final byte[] credentialId = registrationData.getAttestationObject()
+ .getAuthenticatorData()
+ .getAttestedCredentialData()
+ .getCredentialId();
+
+ credentialStore.save(username, credentialId, record);
+ request.getSession().removeAttribute(REG_CHALLENGE);
+ }
+
+ public String assertionOptions(final HttpServletRequest request, final String username) {
+ final Challenge challenge = new DefaultChallenge();
+ request.getSession().setAttribute(AUTH_CHALLENGE, encode(challenge.getValue()));
+
+ final var allowCredentials = Json.createArrayBuilder();
+ for (final byte[] id : credentialStore.credentialIds(username)) {
+ allowCredentials.add(Json.createObjectBuilder()
+ .add("type", "public-key")
+ .add("id", encode(id)));
+ }
+
+ return Json.createObjectBuilder()
+ .add("challenge", encode(challenge.getValue()))
+ .add("rpId", rpId(request))
+ .add("timeout", TIMEOUT_MS)
+ .add("userVerification", "preferred")
+ .add("allowCredentials", allowCredentials)
+ .build()
+ .toString();
+ }
+
+ public String finishAssertion(final HttpServletRequest request, final String responseJson) {
+ final ServerProperty serverProperty = serverProperty(request, AUTH_CHALLENGE);
+
+ final AuthenticationData authenticationData = webAuthnManager.parseAuthenticationResponseJSON(responseJson);
+
+ final CredentialStore.Entry entry = credentialStore.find(authenticationData.getCredentialId())
+ .orElseThrow(() -> new IllegalStateException("Unknown credential"));
+
+ final List allowCredentials = null;
+ final AuthenticationParameters parameters =
+ new AuthenticationParameters(serverProperty, entry.getRecord(), allowCredentials, false, true);
+
+ webAuthnManager.verify(authenticationData, parameters);
+
+ entry.getRecord().setCounter(authenticationData.getAuthenticatorData().getSignCount());
+ credentialStore.updateRecord(authenticationData.getCredentialId(), entry.getRecord());
+
+ request.getSession().removeAttribute(AUTH_CHALLENGE);
+ return entry.getUsername();
+ }
+
+ private ServerProperty serverProperty(final HttpServletRequest request, final String challengeAttr) {
+ final String stored = (String) request.getSession().getAttribute(challengeAttr);
+ if (stored == null) {
+ throw new IllegalStateException("No challenge in session - call the options endpoint first");
+ }
+ final Challenge challenge = new DefaultChallenge(decode(stored));
+ return new ServerProperty(origin(request), rpId(request), challenge, null);
+ }
+
+ private static String rpId(final HttpServletRequest request) {
+ return request.getServerName();
+ }
+
+ private static Origin origin(final HttpServletRequest request) {
+ final String scheme = request.getScheme();
+ final int port = request.getServerPort();
+ final boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
+ final String authority = defaultPort
+ ? request.getServerName()
+ : request.getServerName() + ":" + port;
+ return new Origin(scheme + "://" + authority);
+ }
+
+ private static String encode(final byte[] bytes) {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ private static byte[] decode(final String base64Url) {
+ return Base64.getUrlDecoder().decode(base64Url);
+ }
+
+ static JsonObject parse(final String json) {
+ return Json.createReader(new java.io.StringReader(json)).readObject();
+ }
+}
diff --git a/examples/security-passkey-2fa-programmatic/src/main/webapp/WEB-INF/beans.xml b/examples/security-passkey-2fa-programmatic/src/main/webapp/WEB-INF/beans.xml
new file mode 100644
index 00000000000..2d6f5444a4d
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/src/main/webapp/WEB-INF/beans.xml
@@ -0,0 +1,23 @@
+
+
+
+
diff --git a/examples/security-passkey-2fa-programmatic/src/main/webapp/index.html b/examples/security-passkey-2fa-programmatic/src/main/webapp/index.html
new file mode 100644
index 00000000000..44e02051997
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/src/main/webapp/index.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+ TomEE Passkey 2FA demo
+
+
+
+
TomEE - Passkey as a second factor
+
This demo authenticates in two steps: a password (first factor) followed by a
+ passkey / WebAuthn assertion (second factor).
+
+
Register a passkey - log in with your password, then enrol an authenticator.
+
Log in - password, then passkey, then reach the protected page.
+
Protected page - only reachable once both factors have passed.
+
+
Demo users: jon / doe and iron / man.
+
Passkeys require a secure context: use http://localhost or HTTPS.
+
+
diff --git a/examples/security-passkey-2fa-programmatic/src/main/webapp/js/webauthn.js b/examples/security-passkey-2fa-programmatic/src/main/webapp/js/webauthn.js
new file mode 100644
index 00000000000..336c2923af3
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/src/main/webapp/js/webauthn.js
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.
+ */
+
+// --- base64url <-> ArrayBuffer helpers -------------------------------------
+
+function b64urlToBuf(value) {
+ const padded = value.replace(/-/g, '+').replace(/_/g, '/');
+ const binary = atob(padded);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+}
+
+function bufToB64url(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ for (let i = 0; i < bytes.length; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+function log(message) {
+ const el = document.getElementById('log');
+ if (el) {
+ el.textContent += message + '\n';
+ }
+}
+
+// --- first factor ----------------------------------------------------------
+
+async function passwordStep(username, password) {
+ const res = await fetch('api/login/password', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({username, password})
+ });
+ if (!res.ok) {
+ throw new Error('Password step failed (' + res.status + ')');
+ }
+ log('Password accepted for ' + username);
+}
+
+// --- registration (enrol a passkey) ----------------------------------------
+
+async function registerPasskey() {
+ const optionsRes = await fetch('api/register/options');
+ if (!optionsRes.ok) {
+ throw new Error('Could not get registration options (' + optionsRes.status + ')');
+ }
+ const options = await optionsRes.json();
+
+ // decode the server-provided base64url fields into ArrayBuffers
+ options.challenge = b64urlToBuf(options.challenge);
+ options.user.id = b64urlToBuf(options.user.id);
+ (options.excludeCredentials || []).forEach(c => c.id = b64urlToBuf(c.id));
+
+ const credential = await navigator.credentials.create({publicKey: options});
+
+ const payload = {
+ id: credential.id,
+ rawId: bufToB64url(credential.rawId),
+ type: credential.type,
+ clientExtensionResults: credential.getClientExtensionResults(),
+ response: {
+ clientDataJSON: bufToB64url(credential.response.clientDataJSON),
+ attestationObject: bufToB64url(credential.response.attestationObject),
+ transports: credential.response.getTransports ? credential.response.getTransports() : []
+ }
+ };
+
+ const res = await fetch('api/register', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(payload)
+ });
+ if (!res.ok) {
+ throw new Error('Registration failed (' + res.status + ')');
+ }
+ log('Passkey registered.');
+}
+
+// --- login (assert with a passkey - the 2nd factor) ------------------------
+
+async function loginWithPasskey() {
+ const optionsRes = await fetch('api/login/assertion-options');
+ if (!optionsRes.ok) {
+ throw new Error('Could not get assertion options (' + optionsRes.status + ')');
+ }
+ const options = await optionsRes.json();
+
+ options.challenge = b64urlToBuf(options.challenge);
+ (options.allowCredentials || []).forEach(c => c.id = b64urlToBuf(c.id));
+
+ const assertion = await navigator.credentials.get({publicKey: options});
+
+ const payload = {
+ id: assertion.id,
+ rawId: bufToB64url(assertion.rawId),
+ type: assertion.type,
+ clientExtensionResults: assertion.getClientExtensionResults(),
+ response: {
+ clientDataJSON: bufToB64url(assertion.response.clientDataJSON),
+ authenticatorData: bufToB64url(assertion.response.authenticatorData),
+ signature: bufToB64url(assertion.response.signature),
+ userHandle: assertion.response.userHandle ? bufToB64url(assertion.response.userHandle) : null
+ }
+ };
+
+ const res = await fetch('api/login/assertion', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(payload)
+ });
+ if (!res.ok) {
+ throw new Error('Passkey login failed (' + res.status + ')');
+ }
+ const result = await res.json();
+ log('Authenticated. Following redirect to the protected page...');
+ // Deliberately a fresh navigation: proves the login survived to a new request.
+ window.location = result.redirect;
+}
diff --git a/examples/security-passkey-2fa-programmatic/src/main/webapp/login.html b/examples/security-passkey-2fa-programmatic/src/main/webapp/login.html
new file mode 100644
index 00000000000..1d0c6048a3d
--- /dev/null
+++ b/examples/security-passkey-2fa-programmatic/src/main/webapp/login.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+ Log in
+
+
+
+
Log in
+
Password first, then your passkey. On success you are redirected to the
+ protected page - a brand new request that only works if the login was
+ persisted to the session.
"
+ + "");
+ }
+}
diff --git a/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/UserRepository.java b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/UserRepository.java
new file mode 100644
index 00000000000..d9b47949195
--- /dev/null
+++ b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/UserRepository.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.superbiz.passkey;
+
+import jakarta.enterprise.context.ApplicationScoped;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+@ApplicationScoped
+public class UserRepository {
+
+ private static final Map PASSWORDS = Map.of(
+ "jon", "doe",
+ "iron", "man");
+
+ private static final Map> ROLES = Map.of(
+ "jon", Set.of("user"),
+ "iron", Set.of("user", "admin"));
+
+ public boolean validatePassword(final String username, final String password) {
+ final String expected = PASSWORDS.get(username);
+ return expected != null && expected.equals(password);
+ }
+
+ public boolean exists(final String username) {
+ return PASSWORDS.containsKey(username);
+ }
+
+ public Optional> roles(final String username) {
+ return Optional.ofNullable(ROLES.get(username));
+ }
+}
diff --git a/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/WebAuthnService.java b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/WebAuthnService.java
new file mode 100644
index 00000000000..a1fb676ccb5
--- /dev/null
+++ b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/WebAuthnService.java
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.superbiz.passkey;
+
+import com.webauthn4j.WebAuthnManager;
+import com.webauthn4j.credential.CredentialRecord;
+import com.webauthn4j.credential.CredentialRecordImpl;
+import com.webauthn4j.data.AuthenticationData;
+import com.webauthn4j.data.AuthenticationParameters;
+import com.webauthn4j.data.RegistrationData;
+import com.webauthn4j.data.RegistrationParameters;
+import com.webauthn4j.data.client.Origin;
+import com.webauthn4j.data.client.challenge.Challenge;
+import com.webauthn4j.data.client.challenge.DefaultChallenge;
+import com.webauthn4j.server.ServerProperty;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.json.Json;
+import jakarta.json.JsonObject;
+import jakarta.servlet.http.HttpServletRequest;
+
+import java.util.Base64;
+import java.util.List;
+
+@ApplicationScoped
+public class WebAuthnService {
+
+ private static final String RP_NAME = "TomEE Passkey Demo";
+ private static final long TIMEOUT_MS = 60_000L;
+
+ static final String REG_CHALLENGE = "passkey.registration.challenge";
+ static final String AUTH_CHALLENGE = "passkey.assertion.challenge";
+
+ private final WebAuthnManager webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager();
+
+ @Inject
+ private CredentialStore credentialStore;
+
+ public String registrationOptions(final HttpServletRequest request, final String username) {
+ final Challenge challenge = new DefaultChallenge();
+ request.getSession().setAttribute(REG_CHALLENGE, encode(challenge.getValue()));
+
+ final var pubKeyCredParams = Json.createArrayBuilder()
+ .add(Json.createObjectBuilder().add("type", "public-key").add("alg", -7)) // ES256
+ .add(Json.createObjectBuilder().add("type", "public-key").add("alg", -257)); // RS256
+
+ final var excludeCredentials = Json.createArrayBuilder();
+ for (final byte[] id : credentialStore.credentialIds(username)) {
+ excludeCredentials.add(Json.createObjectBuilder()
+ .add("type", "public-key")
+ .add("id", encode(id)));
+ }
+
+ return Json.createObjectBuilder()
+ .add("challenge", encode(challenge.getValue()))
+ .add("rp", Json.createObjectBuilder().add("id", rpId(request)).add("name", RP_NAME))
+ .add("user", Json.createObjectBuilder()
+ .add("id", encode(username.getBytes()))
+ .add("name", username)
+ .add("displayName", username))
+ .add("pubKeyCredParams", pubKeyCredParams)
+ .add("timeout", TIMEOUT_MS)
+ .add("attestation", "none")
+ .add("authenticatorSelection", Json.createObjectBuilder()
+ .add("residentKey", "preferred")
+ .add("userVerification", "preferred"))
+ .add("excludeCredentials", excludeCredentials)
+ .build()
+ .toString();
+ }
+
+ public void finishRegistration(final HttpServletRequest request,
+ final String username,
+ final String responseJson) {
+
+ final ServerProperty serverProperty = serverProperty(request, REG_CHALLENGE);
+
+ final RegistrationData registrationData = webAuthnManager.parseRegistrationResponseJSON(responseJson);
+
+ // pubKeyCredParams = null (accept what we offered), UV not required, UP required
+ final RegistrationParameters parameters =
+ new RegistrationParameters(serverProperty, null, false, true);
+
+ webAuthnManager.verify(registrationData, parameters);
+
+ final CredentialRecord record = new CredentialRecordImpl(
+ registrationData.getAttestationObject(),
+ registrationData.getCollectedClientData(),
+ registrationData.getClientExtensions(),
+ registrationData.getTransports());
+
+ final byte[] credentialId = registrationData.getAttestationObject()
+ .getAuthenticatorData()
+ .getAttestedCredentialData()
+ .getCredentialId();
+
+ credentialStore.save(username, credentialId, record);
+ request.getSession().removeAttribute(REG_CHALLENGE);
+ }
+
+ public String assertionOptions(final HttpServletRequest request, final String username) {
+ final Challenge challenge = new DefaultChallenge();
+ request.getSession().setAttribute(AUTH_CHALLENGE, encode(challenge.getValue()));
+
+ final var allowCredentials = Json.createArrayBuilder();
+ for (final byte[] id : credentialStore.credentialIds(username)) {
+ allowCredentials.add(Json.createObjectBuilder()
+ .add("type", "public-key")
+ .add("id", encode(id)));
+ }
+
+ return Json.createObjectBuilder()
+ .add("challenge", encode(challenge.getValue()))
+ .add("rpId", rpId(request))
+ .add("timeout", TIMEOUT_MS)
+ .add("userVerification", "preferred")
+ .add("allowCredentials", allowCredentials)
+ .build()
+ .toString();
+ }
+
+ public String finishAssertion(final HttpServletRequest request, final String responseJson) {
+ final ServerProperty serverProperty = serverProperty(request, AUTH_CHALLENGE);
+
+ final AuthenticationData authenticationData = webAuthnManager.parseAuthenticationResponseJSON(responseJson);
+
+ final CredentialStore.Entry entry = credentialStore.find(authenticationData.getCredentialId())
+ .orElseThrow(() -> new IllegalStateException("Unknown credential"));
+
+ final List allowCredentials = null;
+ final AuthenticationParameters parameters =
+ new AuthenticationParameters(serverProperty, entry.getRecord(), allowCredentials, false, true);
+
+ webAuthnManager.verify(authenticationData, parameters);
+
+ entry.getRecord().setCounter(authenticationData.getAuthenticatorData().getSignCount());
+ credentialStore.updateRecord(authenticationData.getCredentialId(), entry.getRecord());
+
+ request.getSession().removeAttribute(AUTH_CHALLENGE);
+ return entry.getUsername();
+ }
+
+ private ServerProperty serverProperty(final HttpServletRequest request, final String challengeAttr) {
+ final String stored = (String) request.getSession().getAttribute(challengeAttr);
+ if (stored == null) {
+ throw new IllegalStateException("No challenge in session - call the options endpoint first");
+ }
+ final Challenge challenge = new DefaultChallenge(decode(stored));
+ return new ServerProperty(origin(request), rpId(request), challenge, null);
+ }
+
+ private static String rpId(final HttpServletRequest request) {
+ return request.getServerName();
+ }
+
+ private static Origin origin(final HttpServletRequest request) {
+ final String scheme = request.getScheme();
+ final int port = request.getServerPort();
+ final boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
+ final String authority = defaultPort
+ ? request.getServerName()
+ : request.getServerName() + ":" + port;
+ return new Origin(scheme + "://" + authority);
+ }
+
+ private static String encode(final byte[] bytes) {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ private static byte[] decode(final String base64Url) {
+ return Base64.getUrlDecoder().decode(base64Url);
+ }
+
+ static JsonObject parse(final String json) {
+ return Json.createReader(new java.io.StringReader(json)).readObject();
+ }
+}
diff --git a/examples/security-passkey-2fa/src/main/webapp/WEB-INF/beans.xml b/examples/security-passkey-2fa/src/main/webapp/WEB-INF/beans.xml
new file mode 100644
index 00000000000..2d6f5444a4d
--- /dev/null
+++ b/examples/security-passkey-2fa/src/main/webapp/WEB-INF/beans.xml
@@ -0,0 +1,23 @@
+
+
+
+
diff --git a/examples/security-passkey-2fa/src/main/webapp/index.html b/examples/security-passkey-2fa/src/main/webapp/index.html
new file mode 100644
index 00000000000..44e02051997
--- /dev/null
+++ b/examples/security-passkey-2fa/src/main/webapp/index.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+ TomEE Passkey 2FA demo
+
+
+
+
TomEE - Passkey as a second factor
+
This demo authenticates in two steps: a password (first factor) followed by a
+ passkey / WebAuthn assertion (second factor).
+
+
Register a passkey - log in with your password, then enrol an authenticator.
+
Log in - password, then passkey, then reach the protected page.
+
Protected page - only reachable once both factors have passed.
+
+
Demo users: jon / doe and iron / man.
+
Passkeys require a secure context: use http://localhost or HTTPS.
+
+
diff --git a/examples/security-passkey-2fa/src/main/webapp/js/webauthn.js b/examples/security-passkey-2fa/src/main/webapp/js/webauthn.js
new file mode 100644
index 00000000000..336c2923af3
--- /dev/null
+++ b/examples/security-passkey-2fa/src/main/webapp/js/webauthn.js
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.
+ */
+
+// --- base64url <-> ArrayBuffer helpers -------------------------------------
+
+function b64urlToBuf(value) {
+ const padded = value.replace(/-/g, '+').replace(/_/g, '/');
+ const binary = atob(padded);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+}
+
+function bufToB64url(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ for (let i = 0; i < bytes.length; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+function log(message) {
+ const el = document.getElementById('log');
+ if (el) {
+ el.textContent += message + '\n';
+ }
+}
+
+// --- first factor ----------------------------------------------------------
+
+async function passwordStep(username, password) {
+ const res = await fetch('api/login/password', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({username, password})
+ });
+ if (!res.ok) {
+ throw new Error('Password step failed (' + res.status + ')');
+ }
+ log('Password accepted for ' + username);
+}
+
+// --- registration (enrol a passkey) ----------------------------------------
+
+async function registerPasskey() {
+ const optionsRes = await fetch('api/register/options');
+ if (!optionsRes.ok) {
+ throw new Error('Could not get registration options (' + optionsRes.status + ')');
+ }
+ const options = await optionsRes.json();
+
+ // decode the server-provided base64url fields into ArrayBuffers
+ options.challenge = b64urlToBuf(options.challenge);
+ options.user.id = b64urlToBuf(options.user.id);
+ (options.excludeCredentials || []).forEach(c => c.id = b64urlToBuf(c.id));
+
+ const credential = await navigator.credentials.create({publicKey: options});
+
+ const payload = {
+ id: credential.id,
+ rawId: bufToB64url(credential.rawId),
+ type: credential.type,
+ clientExtensionResults: credential.getClientExtensionResults(),
+ response: {
+ clientDataJSON: bufToB64url(credential.response.clientDataJSON),
+ attestationObject: bufToB64url(credential.response.attestationObject),
+ transports: credential.response.getTransports ? credential.response.getTransports() : []
+ }
+ };
+
+ const res = await fetch('api/register', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(payload)
+ });
+ if (!res.ok) {
+ throw new Error('Registration failed (' + res.status + ')');
+ }
+ log('Passkey registered.');
+}
+
+// --- login (assert with a passkey - the 2nd factor) ------------------------
+
+async function loginWithPasskey() {
+ const optionsRes = await fetch('api/login/assertion-options');
+ if (!optionsRes.ok) {
+ throw new Error('Could not get assertion options (' + optionsRes.status + ')');
+ }
+ const options = await optionsRes.json();
+
+ options.challenge = b64urlToBuf(options.challenge);
+ (options.allowCredentials || []).forEach(c => c.id = b64urlToBuf(c.id));
+
+ const assertion = await navigator.credentials.get({publicKey: options});
+
+ const payload = {
+ id: assertion.id,
+ rawId: bufToB64url(assertion.rawId),
+ type: assertion.type,
+ clientExtensionResults: assertion.getClientExtensionResults(),
+ response: {
+ clientDataJSON: bufToB64url(assertion.response.clientDataJSON),
+ authenticatorData: bufToB64url(assertion.response.authenticatorData),
+ signature: bufToB64url(assertion.response.signature),
+ userHandle: assertion.response.userHandle ? bufToB64url(assertion.response.userHandle) : null
+ }
+ };
+
+ const res = await fetch('api/login/assertion', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(payload)
+ });
+ if (!res.ok) {
+ throw new Error('Passkey login failed (' + res.status + ')');
+ }
+ const result = await res.json();
+ log('Authenticated. Following redirect to the protected page...');
+ // Deliberately a fresh navigation: proves the login survived to a new request.
+ window.location = result.redirect;
+}
diff --git a/examples/security-passkey-2fa/src/main/webapp/login.html b/examples/security-passkey-2fa/src/main/webapp/login.html
new file mode 100644
index 00000000000..1d0c6048a3d
--- /dev/null
+++ b/examples/security-passkey-2fa/src/main/webapp/login.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+ Log in
+
+
+
+
Log in
+
Password first, then your passkey. On success you are redirected to the
+ protected page - a brand new request that only works if the login was
+ persisted to the session.