[java-idp-plugin-webauthn] branch main updated: JWEBAUTHN-37 - Lookup user credentials from user handle expects user handle

Phil Smart philip.smart at jisc.ac.uk
Tue Jan 28 16:01:06 UTC 2025


This is an automated email from the git hooks/post-receive script.

philsmart pushed a commit to branch main
in repository java-idp-plugin-webauthn.

View the commit online:
http://git.shibboleth.net/view/?p=java-idp-plugin-webauthn.git;a=commit;h=c84fb90d970a099fca26377e21e746757736311d

The following commit(s) were added to refs/heads/main by this push:
     new c84fb90  JWEBAUTHN-37 - Lookup user credentials from user handle expects user handle
c84fb90 is described below

commit c84fb90d970a099fca26377e21e746757736311d
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Tue Jan 28 16:01:00 2025 +0000

    JWEBAUTHN-37 - Lookup user credentials from user handle expects user
    handle
    
     - Separated the authn flow after the assertion has been received from
    the view into usernameless, passwordless, and 2FA.
     - this means the LookupRegisteredCredentialsFromUserHandle action is
    only run in a usernameless context. But it opens the possibility of
    adding more post assertion flow actions specific to the mode in the
    future.
    
    https://shibboleth.atlassian.net/browse/JWEBAUTHN-37
---
 .../logic/IsPasswordlessAuthenticationMode.java    | 62 ++++++++++++++++++++++
 .../logic/IsSecondFactorAuthenticationMode.java    | 62 ++++++++++++++++++++++
 .../logic/IsUsernamelessAuthenticationMode.java    | 62 ++++++++++++++++++++++
 .../authn/webauthn/impl/CheckCredentialPolicy.java | 12 +++--
 .../webauthn/impl/LookupRegisteredCredentials.java |  3 +-
 .../idp/flows/authn/WebAuthn/webauthn-beans.xml    |  9 ++++
 .../idp/flows/authn/WebAuthn/webauthn-flow.xml     | 55 ++++++++++++++++---
 ...IdPStorageServiceCredentialRespositoryTest.java | 16 ++++++
 8 files changed, 269 insertions(+), 12 deletions(-)

diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/logic/IsPasswordlessAuthenticationMode.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/logic/IsPasswordlessAuthenticationMode.java
new file mode 100644
index 0000000..ef8885b
--- /dev/null
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/logic/IsPasswordlessAuthenticationMode.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    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 net.shibboleth.idp.plugin.authn.webauthn.context.logic;
+
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.ThreadSafe;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/** 
+ * A predicate that lookups into the context to find if the authentication flow is passwordless. 
+ * 
+ * @since 1.1.0
+ */
+ at ThreadSafe
+public class IsPasswordlessAuthenticationMode implements Predicate<ProfileRequestContext> {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(IsPasswordlessAuthenticationMode.class);
+
+    @Override
+    public boolean test(@Nullable final ProfileRequestContext input) {
+        if (input == null) {
+            log.trace("Profile context was null, can not determine if operating in passwordless mode");
+            return false;
+        }
+        final AuthenticationContext authnContext = input.getSubcontext(AuthenticationContext.class);
+        if (authnContext == null) {
+            log.trace("Authentication context was null, can not determine if operating in passwordless mode");
+            return false;
+        }
+        final WebAuthnAuthenticationContext webauthnContext = 
+                authnContext.getSubcontext(WebAuthnAuthenticationContext.class);
+        if (webauthnContext == null) {
+            log.trace("WebAuthn authentication context was null, can not determine if operating in passwordless mode");
+            return false;
+        }
+        final boolean isPasswordless = webauthnContext.isPasswordless();
+        log.trace("Currently operating in passwordless mode '{}'", isPasswordless);
+        return isPasswordless;
+    }
+}
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/logic/IsSecondFactorAuthenticationMode.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/logic/IsSecondFactorAuthenticationMode.java
new file mode 100644
index 0000000..ae875f1
--- /dev/null
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/logic/IsSecondFactorAuthenticationMode.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    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 net.shibboleth.idp.plugin.authn.webauthn.context.logic;
+
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.ThreadSafe;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/** 
+ * A predicate that lookups into the context to find if the authentication flow is acting as a second factor. 
+ * 
+ * @since 1.1.0
+ */
+ at ThreadSafe
+public class IsSecondFactorAuthenticationMode implements Predicate<ProfileRequestContext> {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(IsSecondFactorAuthenticationMode.class);
+
+    @Override
+    public boolean test(@Nullable final ProfileRequestContext input) {
+        if (input == null) {
+            log.trace("Profile context was null, can not determine if operating as a second factor");
+            return false;
+        }
+        final AuthenticationContext authnContext = input.getSubcontext(AuthenticationContext.class);
+        if (authnContext == null) {
+            log.trace("Authentication context was null, can not determine if operating as a second factor");
+            return false;
+        }
+        final WebAuthnAuthenticationContext webauthnContext = 
+                authnContext.getSubcontext(WebAuthnAuthenticationContext.class);
+        if (webauthnContext == null) {
+            log.trace("WebAuthn authentication context was null, can not determine if operating as a second factor");
+            return false;
+        }
+        final boolean isSecondFactor = webauthnContext.isSecondFactor();
+        log.trace("Currently operating in 2FA mode '{}'", isSecondFactor);
+        return isSecondFactor;
+    }
+}
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/logic/IsUsernamelessAuthenticationMode.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/logic/IsUsernamelessAuthenticationMode.java
new file mode 100644
index 0000000..862213c
--- /dev/null
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/context/logic/IsUsernamelessAuthenticationMode.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    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 net.shibboleth.idp.plugin.authn.webauthn.context.logic;
+
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.ThreadSafe;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/** 
+ * A predicate that lookups into the context to find if the authentication flow is usernameless.
+ * 
+ * @since 1.1.0
+ */
+ at ThreadSafe
+public class IsUsernamelessAuthenticationMode implements Predicate<ProfileRequestContext> {
+    
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(IsUsernamelessAuthenticationMode.class);
+
+    @Override
+    public boolean test(@Nullable final ProfileRequestContext input) {
+        if (input == null) {
+            log.trace("Profile context was null, can not determine if operating in usernameless mode");
+            return false;
+        }
+        final AuthenticationContext authnContext = input.getSubcontext(AuthenticationContext.class);
+        if (authnContext == null) {
+            log.trace("Authentication context was null, can not determine if operating in usernameless mode");
+            return false;
+        }
+        final WebAuthnAuthenticationContext webauthnContext = 
+                authnContext.getSubcontext(WebAuthnAuthenticationContext.class);
+        if (webauthnContext == null) {
+            log.trace("WebAuthn authentication context was null, can not determine if operating in usernameless mode");
+            return false;
+        }
+        final boolean isUsernameless = webauthnContext.isUsernameless();
+        log.trace("Currently operating in usernameless mode '{}'", isUsernameless);
+        return isUsernameless;
+    }
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicy.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicy.java
index 1d5bcd8..c8a3f86 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicy.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicy.java
@@ -51,9 +51,10 @@ import net.shibboleth.shared.primitive.LoggerFactory;
 
 /**
  * A policy engine action that checks with the configured policy if the credential, used to sign the assertion, can 
- * be used to authenticate. If #rejectIfNoCredentialsFound is true, and the credential used to sign the assertion
- * can not be found from the credential repository, a {@link WebAuthnAuthenticationEventIds#CREDENTIAL_POLICY_REJECTION}
- * will be returned, else the policy is ignored. 
+ * be used to authenticate. If it is rejected by the policy, a 
+ * {@link WebAuthnAuthenticationEventIds#CREDENTIAL_POLICY_REJECTION} is returned. If #rejectIfNoCredentialsFound is 
+ * true, and the credential used to sign the assertion can not be found from the credential repository, a 
+ * {@link WebAuthnAuthenticationEventIds#CREDENTIAL_POLICY_REJECTION} will be returned, else the policy is ignored. 
  * 
  * @event {EventIds#INVALID_PROFILE_CTX}\
  * @event {WebAuthnAuthenticationEventIds#CREDENTIAL_POLICY_REJECTION}
@@ -216,7 +217,7 @@ public class CheckCredentialPolicy extends AbstractWebAuthnAction<WebAuthnAuthen
         }).toList();
 
         if (credentials.isEmpty()) {
-            log.trace("{} UserHandle '{}' has no registered credential, policy can not be applied",getLogPrefix(), 
+            log.trace("{} UserHandle '{}' does not have a registered credential, policy can not be applied",getLogPrefix(), 
                     userHandle.getBase64());
             if (!rejectIfNoCredentialFound.test(profileRequestContext)) {                
                 return;
@@ -229,7 +230,8 @@ public class CheckCredentialPolicy extends AbstractWebAuthnAction<WebAuthnAuthen
             }             
         }
         
-        // Reject if more than one credential that matches the credentialID and userHandle in the assertion. 
+        // Reject if more than one credential that matches the credentialID and userHandle in the assertion. Should
+        // never happen.
         if (credentials.size() != 1) {
             log.debug("{} Credential ID '{}' for userHandle '{}' has more than one registered credential, "
                     + "policy can not be applied, rejecting",getLogPrefix(), assertion.getId(),
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentials.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentials.java
index 2a14de7..ffeceb5 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentials.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentials.java
@@ -43,7 +43,8 @@ import net.shibboleth.shared.primitive.LoggerFactory;
 
 /**
  * An action that lookups existing registered credentials based on the username contained in the WebAuthn context 
- * and sets them back onto the context. Also sets the user.id from the userHandle associated with the username.
+ * and sets them back onto the context. Also sets the user.id from the userHandle associated with the username back
+ * onto the WebAuthn context.
  * 
  * <p>
  * If the username is not required and is not found in the context (e.g. failed c14n) the set of existing credentials
diff --git a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-beans.xml b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-beans.xml
index 1aee6c5..7b7f33f 100644
--- a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-beans.xml
+++ b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-beans.xml
@@ -132,6 +132,15 @@
 
     <bean id="CreatePublicKeyCredentialRequestOptions" scope="prototype" parent="AbstractWebAuthnAuthenticationAction"
         class="net.shibboleth.idp.plugin.authn.webauthn.impl.CreatePublicKeyCredentialRequestOptions"/>
+        
+    <bean id="IsUsernamelessAuthenticationMode" scope="prototype"
+        class="net.shibboleth.idp.plugin.authn.webauthn.context.logic.IsUsernamelessAuthenticationMode"/>
+        
+    <bean id="IsPasswordlessAuthenticationMode" scope="prototype"
+        class="net.shibboleth.idp.plugin.authn.webauthn.context.logic.IsPasswordlessAuthenticationMode"/>
+    
+    <bean id="IsSecondFactorAuthenticationMode" scope="prototype"
+        class="net.shibboleth.idp.plugin.authn.webauthn.context.logic.IsSecondFactorAuthenticationMode"/>
 
     <bean id="ExtractPublicKeyCredentialAssertionFromFormRequest" scope="prototype" parent="AbstractWebAuthnAuthenticationAction"
         class="net.shibboleth.idp.plugin.authn.webauthn.impl.ExtractPublicKeyCredentialAssertionFromFormRequest"
diff --git a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-flow.xml b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-flow.xml
index d5b6d37..6468cfb 100644
--- a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-flow.xml
+++ b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-flow.xml
@@ -133,15 +133,58 @@
             <evaluate expression="flowRequestContext.getExternalContext().getNativeRequest()" result="viewScope.request" />
             <evaluate expression="flowRequestContext.getExternalContext().getNativeResponse()" result="viewScope.response" />
         </on-render>
-       <transition on="proceed" to="ValidatePublicKeyCredential" />
-       
+       <transition on="proceed" to="ExtractPublicKeyCredentialAssertion" />       
     </view-state>
-     
-    <action-state id="ValidatePublicKeyCredential">
+    
+    <action-state id="ExtractPublicKeyCredentialAssertion">
         <evaluate expression="ExtractPublicKeyCredentialAssertionFromFormRequest"/>
-        <!-- lookup credentials here so we can exit the process before validation if no registered credentials exist and
-        the authentication plugin has been configured to trigger a custom event. Useful for the usernameless flow. -->
+        <evaluate expression="'proceed'" />        
+        <transition on="proceed" to="BranchOnAuthenticationMode" />
+    </action-state>
+    
+    <!-- Determine which authentication mode we are using so different actions can apply -->
+    <decision-state id="BranchOnAuthenticationMode">
+        <if test="IsSecondFactorAuthenticationMode.test(opensamlProfileRequestContext)"
+            then="SecondFactorAssertion" 
+            else="BranchOnPasswordlessOrUsernamelessMode" />        
+    </decision-state>
+    
+    <decision-state id="BranchOnPasswordlessOrUsernamelessMode">
+        <if test="IsUsernamelessAuthenticationMode.test(opensamlProfileRequestContext)"
+            then="UsernamelessAssertion" 
+            else="BranchOnPasswordlessMode" />        
+    </decision-state>
+    
+    <!-- By this point, if we are not operating in any mode, this represents a fundamental problem with the flow -->
+    <decision-state id="BranchOnPasswordlessMode">
+        <if test="IsPasswordlessAuthenticationMode.test(opensamlProfileRequestContext)"
+            then="PasswordlessAssertion" 
+            else="RuntimeException" />        
+    </decision-state>
+
+    <action-state id="UsernamelessAssertion">
+        <!-- 
+        lookup credentials here so we can exit the process before validation if no registered credentials exist and
+        the plugin has been configured to trigger a custom event. Also set the known credentials onto the context
+        for the credential policies to run.
+        -->
         <evaluate expression="LookupRegisteredCredentialsFromUserHandle"/>
+        <evaluate expression="'proceed'" />
+        
+        <transition on="proceed" to="ValidatePublicKeyCredential" />    
+    </action-state>
+    
+    <action-state id="SecondFactorAssertion">    
+        <evaluate expression="'proceed'" />        
+        <transition on="proceed" to="ValidatePublicKeyCredential" />    
+    </action-state>
+    
+    <action-state id="PasswordlessAssertion">
+        <evaluate expression="'proceed'" />        
+        <transition on="proceed" to="ValidatePublicKeyCredential" />    
+    </action-state>
+     
+    <action-state id="ValidatePublicKeyCredential">      
         <evaluate expression="CheckCredentialPolicy"/>
         <evaluate expression="ValidateWebAuthnAssertion"/>
         <evaluate expression="'proceed'" />
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespositoryTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespositoryTest.java
index fe30485..c11ebf0 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespositoryTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespositoryTest.java
@@ -124,6 +124,22 @@ public class IdPStorageServiceCredentialRespositoryTest extends AbstractWebAuthn
                 registration.getCredential().getCredentialId());
     }
     
+    @Test
+    public void testAddTwoRegistrationByUsernameWithSameHandleAndCredentialID() throws Exception {     
+        
+        final CredentialRecord registration = createRegistration("jdoe", "John Doe", "user-handle".getBytes());
+        repo.addRegistrationByUsername("jdoe", registration);
+        // add the same credential a second time. There should only be 1 registered.
+        repo.addRegistrationByUsername("jdoe", registration);
+        
+        final var registrations = repo.getRegistrationsByUsername("jdoe");
+        assertNotNull(registrations);
+        assertEquals(registrations.size(), 1);
+        assertEquals(registrations.iterator().next().getUsername(),"jdoe");
+        assertEquals(registrations.iterator().next().getCredential().getCredentialId(),
+                registration.getCredential().getCredentialId());
+    }
+    
     @Test
     public void testAddTwoRegistrationsByUsername() throws Exception {
         

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list