Repository: privacyidea/privacyidea

Issue:
Support for one user signing in to the webui with one of multiple Fido-tokens
Using U2F or WebAuthn to sign in to the webui seems to only work for a single token. If the user has multiple U2F tokens, or WebAuthn tokens, respectively, only whichever token happens to be the first one of its type in the multi-challenge will work. Need to investigate further, marking this as possible bug.


Discussion:
It looks like as if the *challenge* of the challenge response only contains **one** challenge data, that only works for one of the multiple tokens. 
This is true for the webui but might also be true for all /validate/check requests.

I think I remeber that originally a user could also only have one working u2f token.
Actually it looks like it is the other way around:
We create a new challenge for every token but only check/sign the first challenge:
https://github.com/privacyidea/privacyidea/blob/07d29ef96f7416acbfc4b469fca7ce0edb3a18c6/privacyidea/static/components/login/factories/webauthn.js#L82


Resolving patch:
diff --git a/privacyidea/lib/tokens/u2ftoken.py b/privacyidea/lib/tokens/u2ftoken.py
--- a/privacyidea/lib/tokens/u2ftoken.py
+++ b/privacyidea/lib/tokens/u2ftoken.py
@@ -30,6 +30,7 @@
 from privacyidea.api.lib.utils import getParam, attestation_certificate_allowed
 from privacyidea.lib.config import get_from_config
 from privacyidea.lib.tokenclass import TokenClass
+from privacyidea.lib.token import get_tokens
 from privacyidea.lib.log import log_with
 import logging
 from privacyidea.models import Challenge
@@ -43,6 +44,7 @@
 from privacyidea.lib.error import ValidateError, PolicyError, ParameterError
 from privacyidea.lib.policy import SCOPE, GROUP, ACTION, get_action_values_from_options
 from privacyidea.lib.policy import Match
+from privacyidea.lib.challenge import get_challenges
 from privacyidea.lib.utils import is_true, hexlify_and_unicode, to_unicode
 import binascii
 import json
@@ -437,11 +439,26 @@ def create_challenge(self, transactionid=None, options=None):
         lookup_for = tokentype.capitalize() + 'ChallengeValidityTime'
         validity = int(get_from_config(lookup_for, validity))
 
-        challenge = geturandom(32)
+        # if a transaction id is given, check if there are other u2f token and
+        # reuse the challenge
+        challenge = None
+        if transactionid:
+            for c in get_challenges(transaction_id=transactionid):
+                if get_tokens(serial=c.serial, tokentype=self.get_class_type(),
+                              count=True):
+                    challenge = c.challenge
+                    break
+
+        if not challenge:
+            nonce = geturandom(32)
+            challenge = hexlify_and_unicode(nonce)
+        else:
+            nonce = binascii.unhexlify(challenge)
+
         # Create the challenge in the database
         db_challenge = Challenge(self.token.serial,
                                  transaction_id=transactionid,
-                                 challenge=hexlify_and_unicode(challenge),
+                                 challenge=challenge,
                                  data=None,
                                  session=options.get("session"),
                                  validitytime=validity)
@@ -450,7 +467,7 @@ def create_challenge(self, transactionid=None, options=None):
         key_handle_hex = sec_object.getKey()
         key_handle_bin = binascii.unhexlify(key_handle_hex)
         key_handle_url = url_encode(key_handle_bin)
-        challenge_url = url_encode(challenge)
+        challenge_url = url_encode(nonce)
         u2f_sign_request = {"appId": self.get_tokeninfo("appId"),
                             "version": U2F_Version,
                             "challenge": challenge_url,
diff --git a/privacyidea/lib/tokens/webauthn.py b/privacyidea/lib/tokens/webauthn.py
--- a/privacyidea/lib/tokens/webauthn.py
+++ b/privacyidea/lib/tokens/webauthn.py
@@ -1493,7 +1493,7 @@ def verify(self):
             # ceremony was initiated, verify that credential.id identifies one
             # of the public key credentials that were listed in allowCredentials.
             if self.allow_credentials and self.assertion_response.get('id') not in self.allow_credentials:
-                raise AuthenticationRejectedException('Invalid credential.')
+                raise AuthenticationRejectedException('Credential not allowed.')
 
             # Step 2.
             #
@@ -1502,7 +1502,8 @@ def verify(self):
             # identified by credential.id.
             user_handle = self.assertion_response.get('userHandle')
             if user_handle and not user_handle == self.webauthn_user.user_id:
-                raise AuthenticationRejectedException('Invalid credential.')
+                raise AuthenticationRejectedException('Invalid credential: user '
+                                                      'handle does not match.')
 
             # Step 3.
             #
diff --git a/privacyidea/lib/tokens/webauthntoken.py b/privacyidea/lib/tokens/webauthntoken.py
--- a/privacyidea/lib/tokens/webauthntoken.py
+++ b/privacyidea/lib/tokens/webauthntoken.py
@@ -155,7 +155,7 @@
 *timeout*, *attestation*, and *authenticatorSelectionList* are optional. If
 *attestation* is not provided, the client should default to `direct`
 attestation. If *timeout* is not provided, it may be omitted, or a sensible
-default chosen. Any other optional values must be omiffed, if the server has
+default chosen. Any other optional values must be omitted, if the server has
 not sent them. Please note that the nonce will be a binary, encoded using the
 web-safe base64 algorithm specified by WebAuthn, and needs to be decoded and
 passed as Uint8Array.
@@ -924,7 +924,7 @@ def get_init_detail(self, params=None, user=None):
                 raise ValueError("Creating a WebAuthn token requires user to be provided")
 
             # To aid with unit testing a fixed nonce may be passed in.
-            nonce = params['nonce'] if 'nonce' in params else self._get_nonce()
+            nonce = self._get_nonce()
 
             # Create the challenge in the database
             challenge = Challenge(serial=self.token.serial,
@@ -1069,17 +1069,34 @@ def create_challenge(self, transactionid=None, options=None):
 
         message = self._get_message(options)
 
-        # To aid with unit testing a fixed nonce may be passed in.
-        nonce = options['nonce'] if 'nonce' in options else self._get_nonce()
+        # if a transaction id is given, check if there are other webauthn
+        # token and reuse the challenge.
+        # TODO: It might be more sensible to pass around a list of all tokens
+        #  currently doing challenge creation in this request.
+        challenge = None
+        if transactionid:
+            for c in get_challenges(transaction_id=transactionid):
+                # TODO: this throws an exception if the token does not exists
+                #  but just created a challenge with it...
+                if get_tokens(serial=c.serial, tokentype=self.get_class_type(),
+                              count=True):
+                    challenge = c.challenge
+                    break
+
+        if not challenge:
+            nonce = self._get_nonce()
+            challenge = hexlify_and_unicode(nonce)
+        else:
+            nonce = binascii.unhexlify(challenge)
 
         # Create the challenge in the database
-        challenge = Challenge(serial=self.token.serial,
-                              transaction_id=transactionid,
-                              challenge=hexlify_and_unicode(nonce),
-                              data=None,
-                              session=getParam(options, "session", optional),
-                              validitytime=self._get_challenge_validity_time())
-        challenge.save()
+        db_challenge = Challenge(serial=self.token.serial,
+                                 transaction_id=transactionid,
+                                 challenge=challenge,
+                                 data=None,
+                                 session=getParam(options, "session", optional),
+                                 validitytime=self._get_challenge_validity_time())
+        db_challenge.save()
 
         public_key_credential_request_options = WebAuthnAssertionOptions(
             challenge=webauthn_b64_encode(nonce),
@@ -1101,7 +1118,7 @@ def create_challenge(self, transactionid=None, options=None):
             "img": user.icon_url
         }
 
-        return True, message, challenge.transaction_id, response_details
+        return True, message, db_challenge.transaction_id, response_details
     
     @check_token_locked
     def check_otp(self, otpval, counter=None, window=None, options=None):


Test patch:
diff --git a/tests/test_lib_tokens_u2f.py b/tests/test_lib_tokens_u2f.py
--- a/tests/test_lib_tokens_u2f.py
+++ b/tests/test_lib_tokens_u2f.py
@@ -8,8 +8,11 @@
                                         parse_registration_data, url_decode,
                                         check_response, parse_response_data,
                                         der_encode, der_decode)
-from privacyidea.lib.token import init_token
+from privacyidea.lib.token import init_token, remove_token, check_user_pass
+from privacyidea.lib.user import User
+from privacyidea.lib.policy import set_policy, SCOPE, ACTION, delete_policy
 from privacyidea.lib.config import set_privacyidea_config
+from privacyidea.lib.challenge import get_challenges
 from privacyidea.lib.utils import hexlify_and_unicode, to_bytes
 from privacyidea.lib.error import TokenAdminError
 from privacyidea.lib import _
@@ -283,3 +286,118 @@ def test_05_der_signature(self):
         with self.assertRaisesRegexp(Exception,
                                      "The signature needs to be 64 bytes."):
             der_decode(binascii.unhexlify(long_sig_hex))
+
+
+class MultipleU2FTokenTestCase(MyTestCase):
+    app_id = 'https://localhost:5000'
+    reg_data1 = ("BQSUSmNE4buL5xIqMlaRyJSMHhSvr37LpTT2e-zxyVoY21dzl1gZwcTws9I8r"
+                 "CCKwHV-j9dt3NsWIPypid8PsKWsQMU-IQlPNvVsMXxXyhMLjBoUvdiDZShlo5"
+                 "w-P6_i_bT2mDF9O07q2lMW-AXGyIKKSrlsh-1oJBklRUYQ0sbfyEgwggK8MII"
+                 "BpKADAgECAgQDrfASMA0GCSqGSIb3DQEBCwUAMC4xLDAqBgNVBAMTI1l1Ymlj"
+                 "byBVMkYgUm9vdCBDQSBTZXJpYWwgNDU3MjAwNjMxMCAXDTE0MDgwMTAwMDAwM"
+                 "FoYDzIwNTAwOTA0MDAwMDAwWjBtMQswCQYDVQQGEwJTRTESMBAGA1UECgwJWX"
+                 "ViaWNvIEFCMSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMSY"
+                 "wJAYDVQQDDB1ZdWJpY28gVTJGIEVFIFNlcmlhbCA2MTczMDgzNDBZMBMGByqG"
+                 "SM49AgEGCCqGSM49AwEHA0IABBmeh5wWLbfcOe5KQqBGFqWzCf7KCS92vglI-"
+                 "W1ulcrkzGXNVKBZz73HybMbKx1sGER5wsBh9BiqlUtZaiwc-hejbDBqMCIGCS"
+                 "sGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS43MBMGCysGAQQBguUcAgE"
+                 "BBAQDAgQwMCEGCysGAQQBguUcAQEEBBIEEPormdyeOUJXj5JKMNI8QRgwDAYD"
+                 "VR0TAQH_BAIwADANBgkqhkiG9w0BAQsFAAOCAQEAKOuzZ_7R2PDiievKn_bYB"
+                 "1fGDprlfLFyjJscOMq7vYTZI32oMawhlJ8PLfwMMWv9sXWzbmOiK7tYDq3KUo"
+                 "DQeYQOWh4lcmJaO_uHYDPb-yKpack4uJzhcTWUAKElLZcCqRKT1UUZ6WDdIs6"
+                 "KJ-sF6355t1DAAv7ZAWtxHsmtdFAb2RTLvo7ZVxKBt09E6wd85h7LBquFqXJV"
+                 "Jn7o45gr9D8Msho4LSNeueTObbKYxAVCUEAjKyth4QzXDGIVvAO36UBxtw4S0"
+                 "cR_lmVaLvmdTOVafxtLH_kU7hNtnmEgRxSIZGmIgEQxFmU4ibhkhtnJyf-8k4"
+                 "VFNWmzRXRLjKC0NzBEAiBcVPalM6D9t0JFcDFEJW4OiMr945SDuiJFaxlwi9M"
+                 "SawIgdpy0pATSpLXaiFfUqxoFXaMfo-oSqssdZdcWIlWGCpI")
+    client_data1 = ('eyJjaGFsbGVuZ2UiOiJJeF9HbWo1SkVFYktDR3k0QV9BVVZFYWRGTWNJam'
+                    'hBOC1meTJha1ZFaUFRIiwib3JpZ2luIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6'
+                    'NTAwMCIsInR5cCI6Im5hdmlnYXRvci5pZC5maW5pc2hFbnJvbGxtZW50In0')
+
+    reg_data2 = ('BQREKCB4dlbzcqkU7T1talLdlX8pV-aJa0ijSpM2JIXg490mvhXVRquJuSkhr'
+                 'iz0YFrBsB0FjtbTejlsHeKO5de2QKfcG86b738Bq_RntwwH9Nf1VrsFHrdHnh'
+                 '57-9-z7dTGKVD57W_LQch2QtjgZcPslUq19kbz-PXs-WJxgRNhMMUwggK8MII'
+                 'BpKADAgECAgQDrfASMA0GCSqGSIb3DQEBCwUAMC4xLDAqBgNVBAMTI1l1Ymlj'
+                 'byBVMkYgUm9vdCBDQSBTZXJpYWwgNDU3MjAwNjMxMCAXDTE0MDgwMTAwMDAwM'
+                 'FoYDzIwNTAwOTA0MDAwMDAwWjBtMQswCQYDVQQGEwJTRTESMBAGA1UECgwJWX'
+                 'ViaWNvIEFCMSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMSY'
+                 'wJAYDVQQDDB1ZdWJpY28gVTJGIEVFIFNlcmlhbCA2MTczMDgzNDBZMBMGByqG'
+                 'SM49AgEGCCqGSM49AwEHA0IABBmeh5wWLbfcOe5KQqBGFqWzCf7KCS92vglI-'
+                 'W1ulcrkzGXNVKBZz73HybMbKx1sGER5wsBh9BiqlUtZaiwc-hejbDBqMCIGCS'
+                 'sGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS43MBMGCysGAQQBguUcAgE'
+                 'BBAQDAgQwMCEGCysGAQQBguUcAQEEBBIEEPormdyeOUJXj5JKMNI8QRgwDAYD'
+                 'VR0TAQH_BAIwADANBgkqhkiG9w0BAQsFAAOCAQEAKOuzZ_7R2PDiievKn_bYB'
+                 '1fGDprlfLFyjJscOMq7vYTZI32oMawhlJ8PLfwMMWv9sXWzbmOiK7tYDq3KUo'
+                 'DQeYQOWh4lcmJaO_uHYDPb-yKpack4uJzhcTWUAKElLZcCqRKT1UUZ6WDdIs6'
+                 'KJ-sF6355t1DAAv7ZAWtxHsmtdFAb2RTLvo7ZVxKBt09E6wd85h7LBquFqXJV'
+                 'Jn7o45gr9D8Msho4LSNeueTObbKYxAVCUEAjKyth4QzXDGIVvAO36UBxtw4S0'
+                 'cR_lmVaLvmdTOVafxtLH_kU7hNtnmEgRxSIZGmIgEQxFmU4ibhkhtnJyf-8k4'
+                 'VFNWmzRXRLjKC0NzBFAiEAgx6TM_lyZE6SQx05Wlot-hMK-Cp2p8itseOPNp_'
+                 'xtu4CIDpSYJYOb10vM32rjrBNDd-AoKJWbIQRtAwjVEZjXwz6')
+
+    client_data2 = ('eyJjaGFsbGVuZ2UiOiJUdU84MDVPTEQzdXpUNmNnalZ6blRJSjRrZXg4N'
+                    'WlremlUTG1aNC1jQnBvIiwib3JpZ2luIjoiaHR0cHM6Ly9sb2NhbGhvc3'
+                    'Q6NTAwMCIsInR5cCI6Im5hdmlnYXRvci5pZC5maW5pc2hFbnJvbGxtZW5'
+                    '0In0')
+
+    def setUp(self):
+        self.setUp_user_realms()
+        self.user = User(login='cornelius', resolver=self.resolvername1,
+                         realm=self.realm1)
+        set_privacyidea_config("u2f.appId", self.app_id)
+        # init step 1
+        self.token1 = init_token({'type': 'u2f'})
+        self.serial1 = self.token1.token.serial
+        # finish init step 1
+        res = self.token1.get_init_detail()
+        # init step 2
+        self.token1 = init_token({"type": "u2f",
+                                  "serial": self.serial1,
+                                  "regdata": self.reg_data1,
+                                  "clientdata": self.client_data1},
+                                 user=self.user)
+        # Token 2
+        # init step 1
+        self.token2 = init_token({'type': 'u2f'})
+        self.serial2 = self.token2.token.serial
+        # finish init step 1
+        res = self.token2.get_init_detail()
+        # init step 2
+        self.token2 = init_token({"type": "u2f",
+                                  "serial": self.serial2,
+                                  "regdata": self.reg_data2,
+                                  "clientdata": self.client_data2},
+                                 user=self.user)
+
+    def tearDown(self):
+        remove_token(serial=self.serial1)
+        remove_token(serial=self.serial2)
+
+    # TODO: also test challenge-response with different tokens (u2f + totp)
+    def test_01_multiple_token(self):
+        set_policy("otppin", scope=SCOPE.AUTH, action="{0!s}=none".format(ACTION.OTPPIN))
+        res, reply = check_user_pass(self.user, '')
+        self.assertFalse(res)
+        self.assertIn('transaction_id', reply, reply)
+        tid = reply['transaction_id']
+        self.assertIn('multi_challenge', reply, reply)
+        self.assertEqual(len(reply['multi_challenge']), 2, reply['multi_challenge'])
+        self.assertIn('messages', reply, reply)
+        self.assertEqual(len(reply['messages']), 2, reply['messages'])
+        # check that the serials of the challenges are different
+        chal1 = reply['multi_challenge'][0]
+        chal2 = reply['multi_challenge'][1]
+        self.assertNotEqual(chal1['serial'], chal2['serial'],
+                            reply['multi_challenge'])
+        self.assertEqual(chal1['transaction_id'], chal2['transaction_id'],
+                         reply['multi_challenge'])
+        # Now make sure that the requests contain the same challenge
+        self.assertEqual(chal1['attributes']['u2fSignRequest']['challenge'],
+                         chal2['attributes']['u2fSignRequest']['challenge'],
+                         reply['multi_challenge'])
+        # check that we have two challenges in the db with the same challenge
+        chals = get_challenges(transaction_id=tid)
+        self.assertEqual(len(chals), 2, chals)
+        self.assertEqual(chals[0].challenge, chals[1].challenge, chals)
+
+        delete_policy('otppin')
diff --git a/tests/test_lib_tokens_webauthn.py b/tests/test_lib_tokens_webauthn.py
--- a/tests/test_lib_tokens_webauthn.py
+++ b/tests/test_lib_tokens_webauthn.py
@@ -61,7 +61,7 @@
 import struct
 import unittest
 from copy import copy
-
+from mock import patch
 from privacyidea.lib.user import User
 
 from privacyidea.lib.config import set_privacyidea_config
@@ -76,8 +76,9 @@
 from privacyidea.lib.tokens.webauthntoken import (WebAuthnTokenClass, WEBAUTHNACTION, WEBAUTHNCONFIG,
                                                   DEFAULT_AUTHENTICATOR_ATTESTATION_FORM,
                                                   DEFAULT_USER_VERIFICATION_REQUIREMENT, WEBAUTHNINFO)
-from privacyidea.lib.token import init_token
-from privacyidea.lib.policy import set_policy, SCOPE
+from privacyidea.lib.token import init_token, check_user_pass, remove_token
+from privacyidea.lib.policy import set_policy, SCOPE, ACTION, delete_policy
+from privacyidea.lib.challenge import get_challenges
 
 TRUST_ANCHOR_DIR = "{}/testdata/trusted_attestation_roots".format(os.path.abspath(os.path.dirname(__file__)))
 REGISTRATION_RESPONSE_TMPL = {
@@ -244,8 +245,9 @@ def test_02_token_init(self):
         self.assertEqual(RP_NAME, self.token.get_tokeninfo(WEBAUTHNINFO.RELYING_PARTY_NAME))
 
     def test_03_token_update(self):
-        self.init_params['nonce'] = webauthn_b64_decode(REGISTRATION_CHALLENGE)
-        self.token.get_init_detail(self.init_params, self.user)
+        with patch('privacyidea.lib.tokens.webauthntoken.WebAuthnTokenClass._get_nonce') as mock_nonce:
+            mock_nonce.return_value = webauthn_b64_decode(REGISTRATION_CHALLENGE)
+            self.token.get_init_detail(self.init_params, self.user)
         self.token.update({
             'type': 'webauthn',
             'serial': self.token.token.serial,
@@ -311,10 +313,11 @@ def test_06_missing_origin(self):
         self.assertTrue(sign_count == -1)
 
     def test_07_none_attestation(self):
-        self.init_params['nonce'] = webauthn_b64_decode(NONE_ATTESTATION_REGISTRATION_CHALLENGE)
-        self.init_params[WEBAUTHNACTION.AUTHENTICATOR_ATTESTATION_FORM] = 'none'
-        self.user = User(login=NONE_ATTESTATION_USER_NAME)
-        self.token.get_init_detail(self.init_params, self.user)
+        with patch('privacyidea.lib.tokens.webauthntoken.WebAuthnTokenClass._get_nonce') as mock_nonce:
+            mock_nonce.return_value = webauthn_b64_decode(NONE_ATTESTATION_REGISTRATION_CHALLENGE)
+            self.init_params[WEBAUTHNACTION.AUTHENTICATOR_ATTESTATION_FORM] = 'none'
+            self.user = User(login=NONE_ATTESTATION_USER_NAME)
+            self.token.get_init_detail(self.init_params, self.user)
         self.token.update({
             'type': 'webauthn',
             'serial': self.token.token.serial,
@@ -470,3 +473,173 @@ def test_06_duplicate_authentication_fail_assertion(self):
 
     def test_07_webauthn_b64_decode(self):
         self.assertEqual(webauthn_b64_decode(URL_DECODE_TEST_STRING), URL_DECODE_EXPECTED_RESULT)
+
+
+class MultipleWebAuthnTokenTestCase(MyTestCase):
+    rp_name = 'pitest'
+    rp_id = 'pitest.local'
+    app_id = 'https://pitest.local:5000'
+    client_data1 = ('eyJjaGFsbGVuZ2UiOiJlTENMaUpsaEpPdHZhdURuR05ERHdRWUZieFZBbE'
+                    'JOTGZjcXQ3NEZ6cGVZ\r\nIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhh'
+                    'c2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2lu\r\nIjoiaHR0cHM6Ly'
+                    '9waXRlc3QubG9jYWw6NTAwMCIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ')
+    reg_data1 = ('o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEgwRgIhAOV477bXb_0txmSFn'
+                 'n-Sgnhlrdl_edxK\r\naDVKZZ-jy89HAiEAivdhbia91Y8UsFprkvuymfRJr2'
+                 'FhKk3btE1T2uG_PXhjeDVjgVkCwDCCArww\r\nggGkoAMCAQICBAOt8BIwDQY'
+                 'JKoZIhvcNAQELBQAwLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290\r\nIENB'
+                 'IFNlcmlhbCA0NTcyMDA2MzEwIBcNMTQwODAxMDAwMDAwWhgPMjA1MDA5MDQwM'
+                 'DAwMDBaMG0x\r\nCzAJBgNVBAYTAlNFMRIwEAYDVQQKDAlZdWJpY28gQUIxIj'
+                 'AgBgNVBAsMGUF1dGhlbnRpY2F0b3Ig\r\nQXR0ZXN0YXRpb24xJjAkBgNVBAM'
+                 'MHVl1YmljbyBVMkYgRUUgU2VyaWFsIDYxNzMwODM0MFkwEwYH\r\nKoZIzj0C'
+                 'AQYIKoZIzj0DAQcDQgAEGZ6HnBYtt9w57kpCoEYWpbMJ_soJL3a-CUj5bW6Vy'
+                 'uTMZc1U\r\noFnPvcfJsxsrHWwYRHnCwGH0GKqVS1lqLBz6F6NsMGowIgYJKw'
+                 'YBBAGCxAoCBBUxLjMuNi4xLjQu\r\nMS40MTQ4Mi4xLjcwEwYLKwYBBAGC5Rw'
+                 'CAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQ-iuZ3J45\r\nQlePkkow0jxB'
+                 'GDAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQAo67Nn_tHY8OKJ6'
+                 '8qf\r\n9tgHV8YOmuV8sXKMmxw4yru9hNkjfagxrCGUnw8t_Awxa_2xdbNuY6'
+                 'Iru1gOrcpSgNB5hA5aHiVy\r\nYlo7-4dgM9v7IqlpyTi4nOFxNZQAoSUtlwK'
+                 'pEpPVRRnpYN0izoon6wXrfnm3UMAC_tkBa3Eeya10\r\nUBvZFMu-jtlXEoG3'
+                 'T0TrB3zmHssGq4WpclUmfujjmCv0PwyyGjgtI1655M5tspjEBUJQQCMrK2Hh'
+                 '\r\nDNcMYhW8A7fpQHG3DhLRxH-WZVou-Z1M5Vp_G0sf-RTuE22eYSBHFIhkaYi'
+                 'ARDEWZTiJuGSG2cnJ\r\n_7yThUU1abNFdEuMoLQ3aGF1dGhEYXRhWMQWJeYw'
+                 'kTqKuQrIIynVbIb5ZwDrxRtUsbT69Ja-Yeiy\r\n9kEAAAAAAAAAAAAAAAAAA'
+                 'AAAAAAAAABA8Fiap8SFbm99-iwuVdn2BbJ-gWJkWlQhasMDTOeLRoGX\r\nzp'
+                 'c8k-cldR2neh4enFZe_eMftsJMhxdxl4Py_4idEaUBAgMmIAEhWCDXbuIEoFe'
+                 'XpAnfvegi4KkY\r\nNc0FjraVVgZrTtBaVnoZLyJYIDTYuM6O11SMTZuSWWBO'
+                 'FBW3B3UrxBj-HjPola1okria')
+    serial1 = 'WAN0000704B'
+    nonce1 = 'eLCLiJlhJOtvauDnGNDDwQYFbxVAlBNLfcqt74FzpeY'
+
+    client_data2 = ('eyJjaGFsbGVuZ2UiOiJGbnlMMEZ6VlZFQ3U5d01IajJQTVhzRVBYa0xwQy'
+                    '0tNkFRY0duNHdZX3hn\r\nIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhh'
+                    'c2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2lu\r\nIjoiaHR0cHM6Ly'
+                    '9waXRlc3QubG9jYWw6NTAwMCIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ')
+    reg_data2 = ('o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEcwRQIhANTcUgL-wcH6b61n6'
+                 '_eolC37Dw9G7_h6\r\nznFfsG37lObtAiBSEx5hny-NPyycq7vfkHV3Jzj3f_'
+                 'Vn5bh6haYizInC8mN4NWOBWQJTMIICTzCC\r\nATegAwIBAgIEEjbRfzANBgk'
+                 'qhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3Qg\r\nQ0Eg'
+                 'U2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwM'
+                 'DAwMFowMTEv\r\nMC0GA1UEAwwmWXViaWNvIFUyRiBFRSBTZXJpYWwgMjM5Mj'
+                 'U3MzQxMDMyNDEwODcwWTATBgcqhkjO\r\nPQIBBggqhkjOPQMBBwNCAATTZak'
+                 'eXpng1bQ5wNmvu4f0BY5H3RKxRO2xTSsz-NNcFRPkDXnw-Zmr\r\n4jZxlZOB'
+                 'ydwrB4WLgqxjR2IEzPc01q4hozswOTAiBgkrBgEEAYLECgIEFTEuMy42LjEuN'
+                 'C4xLjQx\r\nNDgyLjEuNTATBgsrBgEEAYLlHAIBAQQEAwIFIDANBgkqhkiG9w'
+                 '0BAQsFAAOCAQEAIhubs7JyJPE-\r\nvqMi8DUer0ZJZqNvcmmFfI4j-eUFtVJ'
+                 '13U5BIj5_JhEJFGnPkp-lJj5sx3aBskhtqvQfsc-r6FUI\r\n8T9nUPbIGyne'
+                 'YBtecgi7-mR25WSpHX1kq1JK0E67Ws4hixUm8XH4fN71I5joQyxQub8VeBl6t'
+                 'uu-\r\nMqvRdpM4OJwkuMl6zuPxvGFkdsr0LxNn3yko0CZVxjudPNCrabaZb-'
+                 'VzeIuZUvgCq0-UEVWxCdwe\r\nIOxtJUIXWFfuq-GbR4pfJheGDTGdPkWmD8Q'
+                 'GmDVpBWHczmQmiHUG10WXn4Bn2zFIgAtoMFje34jx\r\n1fXrvNjWMqRlN9jo'
+                 'oxvQY4Rrf2hhdXRoRGF0YVjEFiXmMJE6irkKyCMp1WyG-WcA68UbVLG0-vSW'
+                 '\r\nvmHosvZBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQNEGCFfy7qPnR-evAu5q4'
+                 '1SvPKP0w5KmRqQLbjEX\r\nA0jyIzTiwYnY39CYWaMmpPmWyOsFNyKgkpjFI4'
+                 '8qsworRHWlAQIDJiABIVggz5UnPRI1wM8RErq3\r\nzvQeEMdPkH0t5OLrWb-'
+                 '3j7pFZ3ciWCBOKhWKM_s8Ayf65080LBPp3nfarOadhoZbbSqUN2LjFg')
+    serial2 = 'WAN0001831A'
+    nonce2 = 'FnyL0FzVVECu9wMHj2PMXsEPXkLpC--6AQcGn4wY_xg'
+
+    init_params = {
+            WEBAUTHNACTION.RELYING_PARTY_ID: rp_id,
+            WEBAUTHNACTION.RELYING_PARTY_NAME: rp_name,
+            WEBAUTHNACTION.TIMEOUT: TIMEOUT,
+            WEBAUTHNACTION.AUTHENTICATOR_ATTESTATION_FORM: DEFAULT_AUTHENTICATOR_ATTESTATION_FORM,
+            WEBAUTHNACTION.USER_VERIFICATION_REQUIREMENT: DEFAULT_USER_VERIFICATION_REQUIREMENT,
+            WEBAUTHNACTION.PUBLIC_KEY_CREDENTIAL_ALGORITHM_PREFERENCE: PUBLIC_KEY_CREDENTIAL_ALGORITHM_PREFERENCE
+        }
+    auth_options = {
+        WEBAUTHNACTION.ALLOWED_TRANSPORTS: ALLOWED_TRANSPORTS,
+        WEBAUTHNACTION.USER_VERIFICATION_REQUIREMENT: DEFAULT_USER_VERIFICATION_REQUIREMENT,
+        WEBAUTHNACTION.TIMEOUT: TIMEOUT}
+
+    def setUp(self):
+        self.setUp_user_realms()
+        set_policy(name="WebAuthn", scope=SCOPE.ENROLL,
+                   action='{0!s}={1!s},{2!s}={3!s}'.format(WEBAUTHNACTION.RELYING_PARTY_NAME,
+                                                           self.rp_name,
+                                                           WEBAUTHNACTION.RELYING_PARTY_ID,
+                                                           self.rp_id))
+        set_privacyidea_config(WEBAUTHNCONFIG.APP_ID, self.app_id)
+        self.user = User(login='hans', realm=self.realm1,
+                         resolver=self.resolvername1)
+        # TODO: extract token enrollment into a local function
+        # init token step 1
+        self.token1 = init_token({'type': 'webauthn',
+                                  'serial': self.serial1},
+                                 user=self.user)
+        # TODO: use mocking to set nonce
+        with patch('privacyidea.lib.tokens.webauthntoken.WebAuthnTokenClass._get_nonce') as mock_nonce:
+            mock_nonce.return_value = webauthn_b64_decode(self.nonce1)
+            res = self.token1.get_init_detail(self.init_params, self.user)
+        self.assertEqual(self.serial1, res['serial'], res)
+        self.assertEqual(self.nonce1, res['webAuthnRegisterRequest']['nonce'], res)
+
+        # init token step 2
+        self.token1.update({
+            'type': 'webauthn',
+            'serial': self.serial1,
+            'regdata': self.reg_data1,
+            'clientdata': self.client_data1,
+            WEBAUTHNACTION.RELYING_PARTY_ID: self.rp_id,
+            WEBAUTHNACTION.AUTHENTICATOR_ATTESTATION_LEVEL: ATTESTATION_LEVEL.NONE,
+            'HTTP_ORIGIN': self.app_id
+        })
+        res = self.token1.get_init_detail()
+        self.assertEqual('Yubico U2F EE Serial 61730834',
+                         res['webAuthnRegisterResponse']['subject'], res)
+        # enroll the second webauthn token
+        # init token step 1
+        self.token2 = init_token({'type': 'webauthn',
+                                  'serial': self.serial2},
+                                 user=self.user)
+        with patch('privacyidea.lib.tokens.webauthntoken.WebAuthnTokenClass._get_nonce') as mock_nonce:
+            mock_nonce.return_value = webauthn_b64_decode(self.nonce2)
+            res = self.token2.get_init_detail(self.init_params, self.user)
+        self.assertEqual(self.serial2, res['serial'], res)
+        self.assertEqual(self.nonce2, res['webAuthnRegisterRequest']['nonce'], res)
+
+        # init token step 2
+        self.token2.update({
+            'type': 'webauthn',
+            'serial': self.serial2,
+            'regdata': self.reg_data2,
+            'clientdata': self.client_data2,
+            WEBAUTHNACTION.RELYING_PARTY_ID: self.rp_id,
+            WEBAUTHNACTION.AUTHENTICATOR_ATTESTATION_LEVEL: ATTESTATION_LEVEL.NONE,
+            'HTTP_ORIGIN': self.app_id
+        })
+        res = self.token2.get_init_detail()
+        self.assertEqual('Yubico U2F EE Serial 23925734103241087',
+                         res['webAuthnRegisterResponse']['subject'], res)
+
+    def tearDown(self):
+        remove_token(serial=self.serial1)
+        remove_token(serial=self.serial2)
+
+    # TODO: also test challenge-response with different tokens (webauthn + totp)
+    def test_01_mulitple_webauthntoken_auth(self):
+        set_policy("otppin", scope=SCOPE.AUTH, action="{0!s}=none".format(ACTION.OTPPIN))
+        res, reply = check_user_pass(self.user, '', options=self.auth_options)
+        self.assertFalse(res)
+        self.assertIn('transaction_id', reply, reply)
+        tid = reply['transaction_id']
+        self.assertIn('multi_challenge', reply, reply)
+        self.assertEqual(len(reply['multi_challenge']), 2, reply['multi_challenge'])
+        self.assertIn('messages', reply, reply)
+        self.assertEqual(len(reply['messages']), 2, reply['messages'])
+        # check that the serials of the challenges are different
+        chal1 = reply['multi_challenge'][0]
+        chal2 = reply['multi_challenge'][1]
+        self.assertNotEqual(chal1['serial'], chal2['serial'],
+                            reply['multi_challenge'])
+        self.assertEqual(chal1['transaction_id'], chal2['transaction_id'],
+                         reply['multi_challenge'])
+        # Now make sure that the requests contain the same challenge
+        self.assertEqual(chal1['attributes']['webAuthnSignRequest']['challenge'],
+                         chal2['attributes']['webAuthnSignRequest']['challenge'],
+                         reply['multi_challenge'])
+        # check that we have two challenges in the db with the same challenge
+        chals = get_challenges(transaction_id=tid)
+        self.assertEqual(len(chals), 2, chals)
+        self.assertEqual(chals[0].challenge, chals[1].challenge, chals)
+
+        delete_policy('otppin')
diff --git a/tests/testdata/passwords b/tests/testdata/passwords
--- a/tests/testdata/passwords
+++ b/tests/testdata/passwords
@@ -14,3 +14,4 @@ usernotoken::1114:1114:::
 multichal::1115:1115:::
 nönäscii:IXsH4ttQlw2xA:1116:1116:Nön,field2,field3,field4,field5::
 user@test::1117:1117:::
+hans::1118:1118:::