Odoo18-Base/addons/auth_passkey/tests/test_passkey_demo.py

439 lines
26 KiB
Python
Raw Permalink Normal View History

2025-01-06 10:57:38 +07:00
import json
from contextlib import contextmanager
from lxml import etree
from unittest.mock import patch
from odoo.http import request
from odoo.tools import SQL, mute_logger
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
class PasskeyTest(HttpCaseWithUserDemo):
@classmethod
def setUpClass(self):
super().setUpClass()
self.admin_user = self.env.ref('base.user_admin')
# Hard-coded webauthn keys, challenges and responses, used in the below unit tests.
self.passkeys = {
'test-yubikey': {
'user': self.admin_user,
'credential_identifier': 'L2p6jvcWuCMTRmkZHKqqvQbz0Dhk3JbJOx1F8ci99nSNjlfx3Z7nkigMdUACLggB',
'public_key': 'pQECAyYgASFYIC9qeo73FrgjE0ZpGRwxLIG50L4kNlhj2DIyqSc_YiRSIlgg2q6bL2-IoJ6j_GkVTdfPKyx8RF5e8wzX9-Zk37AykM8=',
'host': 'http://localhost:8069',
'registration': {
'challenge': 'Uoa6M5jEP7I3ToyK9QA0vf8IcsezfeJk0rgs1pLUWrMgF9vd0-7Dv5iV3xW7r70-YqkweRXhACmDPmhHKtAIeQ',
'response': {
"id": "L2p6jvcWuCMTRmkZHKqqvQbz0Dhk3JbJOx1F8ci99nSNjlfx3Z7nkigMdUACLggB",
"rawId": "L2p6jvcWuCMTRmkZHKqqvQbz0Dhk3JbJOx1F8ci99nSNjlfx3Z7nkigMdUACLggB",
"response": {
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjCSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2PFAAAAAgAAAAAAAAAAAAAAAAAAAAAAMC9qeo73FrgjE0ZpGRyqqr0G89A4ZNyWyTsdRfHIvfZ0jY5X8d2e55IoDHVAAi4IAaUBAgMmIAEhWCAvanqO9xa4IxNGaRkcMSyBudC-JDZYY9gyMqknP2IkUiJYINqumy9viKCeo_xpFU3XzyssfEReXvMM1_fmZN-wMpDPoWtjcmVkUHJvdGVjdAI",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiVW9hNk01akVQN0kzVG95SzlRQTB2ZjhJY3NlemZlSmswcmdzMXBMVVdyTWdGOXZkMC03RHY1aVYzeFc3cjcwLVlxa3dlUlhoQUNtRFBtaEhLdEFJZVEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwNjkiLCJjcm9zc09yaWdpbiI6ZmFsc2V9",
"transports": [
"nfc",
"usb",
],
"publicKeyAlgorithm": -7,
"publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEL2p6jvcWuCMTRmkZHDEsgbnQviQ2WGPYMjKpJz9iJFLarpsvb4ignqP8aRVN188rLHxEXl7zDNf35mTfsDKQzw",
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2PFAAAAAgAAAAAAAAAAAAAAAAAAAAAAMC9qeo73FrgjE0ZpGRyqqr0G89A4ZNyWyTsdRfHIvfZ0jY5X8d2e55IoDHVAAi4IAaUBAgMmIAEhWCAvanqO9xa4IxNGaRkcMSyBudC-JDZYY9gyMqknP2IkUiJYINqumy9viKCeo_xpFU3XzyssfEReXvMM1_fmZN-wMpDPoWtjcmVkUHJvdGVjdAI"
},
"type": "public-key",
"clientExtensionResults": {},
"authenticatorAttachment": "cross-platform"
}
},
'auth': {
'challenge': 'DKrw5IeFiL0w_y5oB0RATPrqG1eFOC2P7yEiXCstBRuZYbSBBkAWfhAIInkMqWjYIN8vbKn7J3PMX_ThznHpqg',
'response': {
"id": "L2p6jvcWuCMTRmkZHKqqvQbz0Dhk3JbJOx1F8ci99nSNjlfx3Z7nkigMdUACLggB",
"rawId": "L2p6jvcWuCMTRmkZHKqqvQbz0Dhk3JbJOx1F8ci99nSNjlfx3Z7nkigMdUACLggB",
"response": {
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAw",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiREtydzVJZUZpTDB3X3k1b0IwUkFUUHJxRzFlRk9DMlA3eUVpWENzdEJSdVpZYlNCQmtBV2ZoQUlJbmtNcVdqWUlOOHZiS243SjNQTVhfVGh6bkhwcWciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwNjkiLCJjcm9zc09yaWdpbiI6ZmFsc2V9",
"signature": "MEUCIQD5iaPp48QMS3amx4PS89kv_EBAo3bBkaWnLzWlSgFSXgIgLWKEv9xR_ZwVXZbw2zx459RKbrQuAcd-UqD4gJw1lWY",
"userHandle": "Mg"
},
"type": "public-key",
"clientExtensionResults": {},
"authenticatorAttachment": "cross-platform",
},
},
},
'test-yubikey-nano': {
'user': self.admin_user,
'credential_identifier': 'wtw0u7D8rp7nq7WBWFCt_FRhEHpU6EHvEgTn3BBid5N-UE5a9XCzS8NaVuh7ydFz',
'public_key': 'pQECAyYgASFYIMLcNLuw_K6e56u1gVioLcAJF8v8eUw7kfqTOqDdl7nFIlggFSs_nZWewd_JqzeWzXmJ6Wmn_nKuo82rCdoOZ-oewOU=',
'host': 'http://localhost:8069',
'auth': {
'challenge': 'oj09zruUyqUMIFO0ol5UltUd955Qqw9iche5w_g9k6jByR69ioWtnC-RWLRie_8sqHO_T2bICJplaQNPRxfpeA',
'response': {
"id": "wtw0u7D8rp7nq7WBWFCt_FRhEHpU6EHvEgTn3BBid5N-UE5a9XCzS8NaVuh7ydFz",
"rawId": "wtw0u7D8rp7nq7WBWFCt_FRhEHpU6EHvEgTn3BBid5N-UE5a9XCzS8NaVuh7ydFz",
"response": {
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAABg",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoib2owOXpydVV5cVVNSUZPMG9sNVVsdFVkOTU1UXF3OWljaGU1d19nOWs2akJ5UjY5aW9XdG5DLVJXTFJpZV84c3FIT19UMmJJQ0pwbGFRTlBSeGZwZUEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwNjkiLCJjcm9zc09yaWdpbiI6ZmFsc2V9",
"signature": "MEUCIDj-tI1yRGqnqd6uZeuInPaGY0yNYwC-5W4d024zwUs0AiEApJAst0t7G40ZRp1_TIKbftD-p9BkmafTPZBBe4Ps0P0",
"userHandle": "Mg"
},
"type": "public-key",
"clientExtensionResults": {},
"authenticatorAttachment": "cross-platform",
}
},
},
'test-keepassxc': {
'user': self.user_demo,
'credential_identifier': 'y6aJVJsvvSSkbwTeGZ1FbQP_jCDho7EBPwZq-3lAjQ0',
'public_key': 'pQECAyYgASFYICjw-NoCHMkYYbRo8Q4SgJ4tZc8BSEmuEI0XmA6hUqR_IlggjtuBgyhwnr7PqABF2o8vCniMVa7_mTG6_l9Pc4eI4mo=',
'host': 'https://localhost:8888',
'supports_sign_count': False, # keepassxc doesn't support sign_count
'auth': {
'challenge': 'LNpV0dPIMtmpSwGenIH_h1VycQuAgFgQRJ9TPKBoNayScNAErS-rsnaU19n7_AaXzeiYRg3nGI3yuH0ai6UPXA',
'response': {
"id": "y6aJVJsvvSSkbwTeGZ1FbQP_jCDho7EBPwZq-3lAjQ0",
"rawId": "y6aJVJsvvSSkbwTeGZ1FbQP_jCDho7EBPwZq-3lAjQ0",
"response": {
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA",
"clientDataJSON": "eyJjaGFsbGVuZ2UiOiJMTnBWMGRQSU10bXBTd0dlbklIX2gxVnljUXVBZ0ZnUVJKOVRQS0JvTmF5U2NOQUVyUy1yc25hVTE5bjdfQWFYemVpWVJnM25HSTN5dUgwYWk2VVBYQSIsImNyb3NzT3JpZ2luIjpmYWxzZSwib3JpZ2luIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6ODg4OCIsInR5cGUiOiJ3ZWJhdXRobi5nZXQifQ",
"signature": "MEYCIQCqkh2NBQQao5uDTaBKyNhiEpnk4jgbH-PjdLAul9-d0gIhAMObtNTbaEMUILdNgCT01BKNN4NHRzkzsGaDN2Ozu0WX",
"userHandle": "Ng"
},
"type": "public-key",
"clientExtensionResults": {},
"authenticatorAttachment": "platform",
}
},
},
'test-user-verification': {
'user': self.user_demo,
'credential_identifier': '723TCjL_RdQHFk3Ysp-HUymcWoazFi3ZdfZ1bIn6MYC5bAXvI6B-j8G-UA1taMO0',
'public_key': 'pQECAyYgASFYIO9t0woy_0XUBxZN2LKpzFmzmauPpdgt7B1EnoVXHL56IlggUJWIu-UCOAFOCAMUXDXb36pJ49aWNI9Z7njiLQt7amw=',
'host': 'http://localhost:8069',
'auth': {
'challenge': 'MTIzNDU',
'response': {
'id': '723TCjL_RdQHFk3Ysp-HUymcWoazFi3ZdfZ1bIn6MYC5bAXvI6B-j8G-UA1taMO0',
'rawId': '723TCjL_RdQHFk3Ysp-HUymcWoazFi3ZdfZ1bIn6MYC5bAXvI6B-j8G-UA1taMO0',
'response': {
'authenticatorData': 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAADg',
'clientDataJSON': 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiTVRJek5EVSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA2OSIsImNyb3NzT3JpZ2luIjpmYWxzZX0',
'signature': 'MEQCIFYAdM82D9otAfX2s6WY4CyH8i733Km-3TZSYcfwDmbqAiB6OXGuoaMgX13v6LWCIdkCRY9ZTYhNzhXFTs1Wp7-zkQ',
'userHandle': 'Mg'
},
'type': 'public-key',
'clientExtensionResults': {},
'authenticatorAttachment': 'cross-platform'
}
},
},
}
for key, values in self.passkeys.items():
self.cr.execute(SQL(
"""
INSERT INTO auth_passkey_key (name, credential_identifier, public_key, create_uid, write_date, create_date)
VALUES (%s, %s, %s, %s, NOW() AT TIME ZONE 'UTC', NOW() AT TIME ZONE 'UTC')
RETURNING id
""", key, values['credential_identifier'], values['public_key'], values['user'].id,
))
passkey_id = self.cr.fetchone()
values['passkey'] = self.env['auth.passkey.key'].browse(passkey_id)
def rpc(self, model, method, *args, **kwargs):
return self.url_open('/web/dataset/call_kw', headers={"Content-Type": "application/json"}, data=json.dumps({
"params": {
'model': model,
'method': method,
'args': args,
'kwargs': kwargs,
},
})).json()
@contextmanager
def patch_start_auth(self, challenge):
"""Force the webauthn challenge for unit tests testing the authentication"""
origin_start_auth = self.env.registry['auth.passkey.key']._start_auth
def _start_auth(self):
res = origin_start_auth(self)
res['challenge'] = request.session['webauthn_challenge'] = challenge
return res
with patch.object(self.env.registry['auth.passkey.key'], '_start_auth', _start_auth):
yield
@contextmanager
def patch_start_registration(self, challenge):
"""Force the webauthn challenge for unit tests testing the registration"""
origin_start_registration = self.env.registry['auth.passkey.key']._start_registration
def _start_registration(self):
res = origin_start_registration(self)
res['challenge'] = request.session['webauthn_challenge'] = challenge
return res
with patch.object(self.env.registry['auth.passkey.key'], '_start_registration', _start_registration):
yield
def test_registration(self):
passkey = self.passkeys['test-yubikey']
registration = passkey['registration']
webauthn_challenge, webauthn_response = registration['challenge'], registration['response']
self.env['ir.config_parameter'].sudo().set_param('web.base.url', passkey['host'])
with self.patch_start_registration(webauthn_challenge):
# Remove existing user passkeys so the check identity ask for a password authentication by default.
# To mimic the behavior when a user has no passkeys set yet.
self.admin_user.auth_passkey_key_ids.unlink()
# Authenticate the user, as the goal here is to create a new passkey for an already authenticated user.
self.authenticate(self.admin_user.login, self.admin_user.login)
# Click the "Add Passkey" button.
wizard_id = self.rpc('res.users', 'action_create_passkey', self.admin_user.id)['result']['res_id']
# Adding a passkey triggers an identity check. Confirm using the password and run the check.
self.rpc('res.users.identitycheck', 'write', wizard_id, {'password': self.admin_user.login})
action = self.rpc('res.users.identitycheck', 'run_check', wizard_id)['result']
# Create the passkey creation wizard and set a name for the key
wizard_id = self.rpc(action['res_model'], 'create', {'name': 'test-yubikey'})['result']
# Make the key with the webauthn response
response = self.rpc(action['res_model'], 'make_key', wizard_id, webauthn_response)
# Assert the passkey registration is successful
self.assertTrue(response.get('result'))
self.assertFalse(response.get('error'))
self.assertEqual(len(self.admin_user.auth_passkey_key_ids), 1)
self.assertEqual(self.admin_user.auth_passkey_key_ids.name, 'test-yubikey')
self.assertEqual(self.admin_user.auth_passkey_key_ids.sign_count, 0)
def test_authentication(self):
for key in ['test-yubikey', 'test-yubikey-nano', 'test-keepassxc']:
passkey = self.passkeys[key]
auth = passkey['auth']
webauthn_challenge, webauthn_response = auth['challenge'], auth['response']
self.env['ir.config_parameter'].sudo().set_param('web.base.url', passkey['host'])
sign_count = passkey['passkey'].sign_count
with self.patch_start_auth(webauthn_challenge):
# Mimic a user login process
# 1. Open the /web/login page, and get the csrf token which is use to protect the POST request
csrf_token = etree.fromstring(
self.url_open('/web/login').content
).xpath('//input[@name="csrf_token"]')[0].get('value')
# 2. Call the below route to start the webauthn authentication process
# which is done in the web interface when clicking on "Login with a passkey"
# It sets the webauthn challenge in the session
self.url_open('/auth/passkey/start-auth', '{}', headers={"Content-Type": "application/json"})
# 3. POST the login using the webauthn response
response = self.url_open('/web/login', data={
'type': 'webauthn',
'webauthn_response': json.dumps(webauthn_response),
'csrf_token': csrf_token,
'password': '', # Currently mandatory because of `if request.params['password'] != 'admin':`
})
# Assert the login is successful
self.assertEqual(response.status_code, 200)
self.assertTrue(response.headers.get('Set-Cookie'))
if passkey.get('supports_sign_count', True):
# If the passkey supports sign counts, the sign count increases
self.assertGreater(passkey['passkey'].sign_count, sign_count)
else:
# Otherwise it doesn't
self.assertEqual(passkey['passkey'].sign_count, sign_count)
# Replay attacks raises an error
csrf_token = etree.fromstring(
self.url_open('/web/login').content
).xpath('//input[@name="csrf_token"]')[0].get('value')
response = self.url_open('/web/login', data={
'type': 'webauthn',
'webauthn_response': json.dumps(webauthn_response),
'csrf_token': csrf_token,
})
self.assertEqual(response.status_code, 200)
error = etree.fromstring(response.content).xpath('//p[@class="alert alert-danger"]')[0].text.strip()
self.assertEqual(error, 'Cannot find a challenge for this session')
def test_check_identity(self):
for key in ['test-yubikey', 'test-yubikey-nano', 'test-keepassxc']:
passkey = self.passkeys[key]
user, auth = passkey['user'], passkey['auth']
webauthn_challenge, webauthn_response = auth['challenge'], auth['response']
self.env['ir.config_parameter'].sudo().set_param('web.base.url', passkey['host'])
sign_count = passkey['passkey'].sign_count
with self.patch_start_auth(webauthn_challenge):
# Authenticate the user, as the goal here is to assert the `check_identity`
self.authenticate(user.login, user.login)
# Call a method which triggers an identity check
wizard_id = self.rpc('res.users', 'preference_change_password', user.id)['result']['res_id']
# Mimic what the Javascript code is doing when clicking on the button "Use a passkey"
# 1. Call the below route to start the webauthn authentication process
# It sets the webauthn challenge in the session
self.url_open('/auth/passkey/start-auth', '{}', headers={"Content-Type": "application/json"})
# 2. Set the webauthn response in the password field
self.rpc('res.users.identitycheck', 'write', wizard_id, {'password': json.dumps(webauthn_response)})
# 3. Call the check method, which if successful returns the action to run following the identity check
response = self.rpc('res.users.identitycheck', 'run_check', wizard_id)
# Assert the identity check is successful
self.assertTrue(response.get('result'))
self.assertFalse(response.get('error'))
if passkey.get('supports_sign_count', True):
self.assertGreater(passkey['passkey'].sign_count, sign_count)
sign_count = passkey['passkey'].sign_count
else:
self.assertEqual(passkey['passkey'].sign_count, sign_count)
# 4. Attempt a replay attack, without reseting the challenge
self.rpc('res.users.identitycheck', 'write', wizard_id, {'password': json.dumps(webauthn_response)})
with mute_logger('odoo.http'):
response = self.rpc('res.users.identitycheck', 'run_check', wizard_id)
# Assert the authentication failed
self.assertFalse(response.get('result'))
self.assertTrue(response.get('error'))
self.assertEqual(response['error']['data']['name'], 'odoo.exceptions.UserError')
self.assertEqual(
response['error']['data']['message'],
'Incorrect Passkey. Please provide a valid passkey or use a different authentication method.'
)
# The authentication fail, hence the sign count doesn't increase
self.assertEqual(passkey['passkey'].sign_count, sign_count)
# 5. Do a second authentication with the same challenge and same response
# Reset the challenge, which is forced to the same challenge with a mock patch above
self.url_open('/auth/passkey/start-auth', '{}', headers={"Content-Type": "application/json"})
# Write the same webauthn response
self.rpc('res.users.identitycheck', 'write', wizard_id, {'password': json.dumps(webauthn_response)})
with mute_logger('odoo.http'):
response = self.rpc('res.users.identitycheck', 'run_check', wizard_id)
if passkey.get('supports_sign_count', True):
# If the passkey supports sign_count, a replay attack with the same challenge must fail
self.assertFalse(response.get('result'))
self.assertTrue(response.get('error'))
else:
# If the passkey doesn't support sign_count, such as keepassxc, then it should success
self.assertTrue(response.get('result'))
self.assertFalse(response.get('error'))
self.assertEqual(passkey['passkey'].sign_count, sign_count)
# 6. Do a third authentication, with another challenge but the same reponse
# This block is outside the block `with self.patch_start_auth(webauthn_challenge):`
# hence it will generate a random challenge.
self.url_open('/auth/passkey/start-auth', '{}', headers={"Content-Type": "application/json"})
self.rpc('res.users.identitycheck', 'write', wizard_id, {'password': json.dumps(webauthn_response)})
with mute_logger('odoo.http'):
response = self.rpc('res.users.identitycheck', 'run_check', wizard_id)
self.assertFalse(response.get('result'))
self.assertTrue(response.get('error'))
self.assertEqual(passkey['passkey'].sign_count, sign_count)
def test_check_user_verification(self):
"""Asserts authenticating without user verification (not entering the PIN code of the passkey) is prevented.
In addition to ask the browser to require the user verification
during the preparation of the webauthn authentication options,
the fact the user verification actually happened must be verified, server-side.
In the webauthn protocol, the fact the user verification happened
is stored by the browser in the `authenticatorData`,
in the 33rd byte "flags", in the 2nd bit "User Verified (UV)".
https://www.w3.org/TR/webauthn-1/#sec-authenticator-data
In the webauthn response provided in the setup class above, the `authenticatorData` provided
does not have the user verification flag.
This response should therefore not be allowed for authentication,
as we want to require the user to enter his PIN code (User Verification, UV)
in addition to touching the key (User Presence, UP) to act as a 2 factor authentication:
Something you have + Something you know.
Then, we replay the same authentication, with the same challenge,
but this time with an authenticator data with the user verified,
and an updated signature with this new authenticator data,
and then the authentication can be allowed.
"""
passkey = self.passkeys['test-user-verification']
webauthn_challenge, webauthn_response = passkey['auth']['challenge'], passkey['auth']['response']
self.env['ir.config_parameter'].sudo().set_param('web.base.url', passkey['host'])
with self.patch_start_auth(webauthn_challenge):
csrf_token = etree.fromstring(
self.url_open('/web/login').content
).xpath('//input[@name="csrf_token"]')[0].get('value')
self.url_open('/auth/passkey/start-auth', '{}', headers={"Content-Type": "application/json"})
response = self.url_open('/web/login', data={
'type': 'webauthn',
'webauthn_response': json.dumps(webauthn_response),
'csrf_token': csrf_token,
'password': '',
})
# Login unsuccessful, redirected back to /web/login
self.assertTrue(response.url.endswith('/web/login'))
# with the error message
error = etree.fromstring(response.content).xpath('//p[@class="alert alert-danger"]')[0].text.strip()
self.assertEqual(error, 'User verification is required but user was not verified during authentication')
# New authenticator data with the user verification bit turned on (+ counter increased)
# Previous authenticator data without user verified: SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAADg
# New authenticator data with user verified: SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAFQ
# Only the end changes, as:
# - An Authenticator Data is 37 bytes long
# - The user verified (UV) is on the 33rd byte
# - The counter is from the 34th byte to the 37th byte
# https://www.w3.org/TR/webauthn-1/#sec-authenticator-data
# To see the 33rd byte "flags" change:
# ```py
# import base64
# flags = base64.urlsafe_b64decode(authenticator_data + '==')[32:33]
# print(f'{flags[0]:08b}')
# ```
# Without the User Verified authenticator data, the above code prints `00000001`
# With the User Verified authenticator data, the above code prints `00000101`
# bit 0 is the least significant bit:
# - Bit 0: User Present (UP)
# - Bit 2: User Verified (UV)
# The counter is from byte 34 to 37. To get the counter:
# `int.from_bytes(base64.urlsafe_b64decode(authenticator_data + '==')[33:37])`
# In the case of the authenticator data without user verified, the counter is 14
# In the case of the authenticator data with user verified, the counter is 21
# The response with the invalid authenticator data, without the UV flag
# must be played before the response with the valid authenticator data,
# as its counter is lower. Otherwise you would have another error in addition to the missing UV flag:
# `Response sign count of 14 was not greater than current count of 21`
webauthn_response['response']['authenticatorData'] = 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAFQ'
# Signature changes as the authenticator data changed.
webauthn_response['response']['signature'] = 'MEQCIAdcWwNtQVrklYo70p5eHjVdSkA4Pgk6hbCCT6O8-V0BAiBBVKgroyNNOqN5xwO6Rr4yJV61J1TGWoOyUsoUftjypw'
csrf_token = etree.fromstring(
self.url_open('/web/login').content
).xpath('//input[@name="csrf_token"]')[0].get('value')
self.url_open('/auth/passkey/start-auth', '{}', headers={"Content-Type": "application/json"})
response = self.url_open('/web/login', data={
'type': 'webauthn',
'webauthn_response': json.dumps(webauthn_response),
'csrf_token': csrf_token,
'password': '',
})
# Login successful, redirected to /odoo
self.assertTrue(response.url.endswith('/odoo'))