From baa4deda99295ae3088fdc9eea4b32c323f79872 Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Sat, 7 Jun 2025 09:31:16 +0200 Subject: [PATCH] tests/e2e: WebAuthn E2E tests (#14461) * a start of webauthn testing Signed-off-by: Jens Langhammer * separate file, just do it via localhost Signed-off-by: Jens Langhammer * remove unneeded stuff Signed-off-by: Jens Langhammer * add auth and sfe tests Signed-off-by: Jens Langhammer * auto select device challenge if only 1 Signed-off-by: Jens Langhammer * revert a thing Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- .../traefik_single/config-static.yaml | 2 +- .../e2e/test_flows_authenticators_webauthn.py | 97 +++++++++++++++++++ tests/e2e/test_flows_login_sfe.py | 43 ++++---- web/packages/sfe/src/index.ts | 7 +- 4 files changed, 124 insertions(+), 25 deletions(-) create mode 100644 tests/e2e/test_flows_authenticators_webauthn.py diff --git a/tests/e2e/proxy_forward_auth/traefik_single/config-static.yaml b/tests/e2e/proxy_forward_auth/traefik_single/config-static.yaml index 6a9480f652..7006e7e16d 100644 --- a/tests/e2e/proxy_forward_auth/traefik_single/config-static.yaml +++ b/tests/e2e/proxy_forward_auth/traefik_single/config-static.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://json.schemastore.org/traefik-v2.json +# yaml-language-server: $schema=https://json.schemastore.org/traefik-v3.json api: insecure: true debug: true diff --git a/tests/e2e/test_flows_authenticators_webauthn.py b/tests/e2e/test_flows_authenticators_webauthn.py new file mode 100644 index 0000000000..12d3fe7b39 --- /dev/null +++ b/tests/e2e/test_flows_authenticators_webauthn.py @@ -0,0 +1,97 @@ +"""test flow with WebAuthn Stage""" + +from selenium.webdriver.common.virtual_authenticator import ( + Protocol, + Transport, + VirtualAuthenticatorOptions, +) + +from authentik.blueprints.tests import apply_blueprint +from authentik.stages.authenticator_webauthn.models import ( + AuthenticatorWebAuthnStage, + WebAuthnDevice, +) +from tests.e2e.test_flows_login_sfe import login_sfe +from tests.e2e.utils import SeleniumTestCase, retry + + +class TestFlowsAuthenticatorWebAuthn(SeleniumTestCase): + """test flow with WebAuthn Stage""" + + host = "localhost" + + def register(self): + options = VirtualAuthenticatorOptions( + protocol=Protocol.CTAP2, + transport=Transport.INTERNAL, + has_resident_key=True, + has_user_verification=True, + is_user_verified=True, + ) + self.driver.add_virtual_authenticator(options) + + self.driver.get(self.url("authentik_core:if-flow", flow_slug="default-authentication-flow")) + self.login() + + self.wait_for_url(self.if_user_url("/library")) + self.assert_user(self.user) + + self.driver.get( + self.url( + "authentik_flows:configure", + stage_uuid=AuthenticatorWebAuthnStage.objects.first().stage_uuid, + ) + ) + + self.wait_for_url(self.if_user_url("/library")) + self.assertTrue(WebAuthnDevice.objects.filter(user=self.user, confirmed=True).exists()) + + @retry() + @apply_blueprint( + "default/flow-default-authentication-flow.yaml", + "default/flow-default-invalidation-flow.yaml", + ) + @apply_blueprint("default/flow-default-authenticator-webauthn-setup.yaml") + def test_webauthn_setup(self): + """Test WebAuthn setup""" + self.register() + + @retry() + @apply_blueprint( + "default/flow-default-authentication-flow.yaml", + "default/flow-default-invalidation-flow.yaml", + ) + @apply_blueprint("default/flow-default-authenticator-webauthn-setup.yaml") + def test_webauthn_authenticate(self): + """Test WebAuthn authentication""" + self.register() + self.driver.delete_all_cookies() + + self.driver.get(self.url("authentik_core:if-flow", flow_slug="default-authentication-flow")) + self.login() + + self.wait_for_url(self.if_user_url("/library")) + + self.assert_user(self.user) + + @retry() + @apply_blueprint( + "default/flow-default-authentication-flow.yaml", + "default/flow-default-invalidation-flow.yaml", + ) + @apply_blueprint("default/flow-default-authenticator-webauthn-setup.yaml") + def test_webauthn_authenticate_sfe(self): + """Test WebAuthn authentication (SFE)""" + self.register() + self.driver.delete_all_cookies() + + self.driver.get( + self.url( + "authentik_core:if-flow", + flow_slug="default-authentication-flow", + query={"sfe": True}, + ) + ) + login_sfe(self.driver, self.user) + self.wait_for_url(self.if_user_url("/library")) + self.assert_user(self.user) diff --git a/tests/e2e/test_flows_login_sfe.py b/tests/e2e/test_flows_login_sfe.py index 2200a57aa3..601aa113a8 100644 --- a/tests/e2e/test_flows_login_sfe.py +++ b/tests/e2e/test_flows_login_sfe.py @@ -4,34 +4,35 @@ from time import sleep from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys +from selenium.webdriver.remote.webdriver import WebDriver from authentik.blueprints.tests import apply_blueprint +from authentik.core.models import User from tests.e2e.utils import SeleniumTestCase, retry +def login_sfe(driver: WebDriver, user: User): + """Do entire login flow adjusted for SFE""" + flow_executor = driver.find_element(By.ID, "flow-sfe-container") + identification_stage = flow_executor.find_element(By.ID, "ident-form") + + identification_stage.find_element(By.CSS_SELECTOR, "input[name=uid_field]").click() + identification_stage.find_element(By.CSS_SELECTOR, "input[name=uid_field]").send_keys( + user.username + ) + identification_stage.find_element(By.CSS_SELECTOR, "input[name=uid_field]").send_keys( + Keys.ENTER + ) + + password_stage = flow_executor.find_element(By.ID, "password-form") + password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(user.username) + password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(Keys.ENTER) + sleep(1) + + class TestFlowsLoginSFE(SeleniumTestCase): """test default login flow""" - def login(self): - """Do entire login flow adjusted for SFE""" - flow_executor = self.driver.find_element(By.ID, "flow-sfe-container") - identification_stage = flow_executor.find_element(By.ID, "ident-form") - - identification_stage.find_element(By.CSS_SELECTOR, "input[name=uid_field]").click() - identification_stage.find_element(By.CSS_SELECTOR, "input[name=uid_field]").send_keys( - self.user.username - ) - identification_stage.find_element(By.CSS_SELECTOR, "input[name=uid_field]").send_keys( - Keys.ENTER - ) - - password_stage = flow_executor.find_element(By.ID, "password-form") - password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys( - self.user.username - ) - password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(Keys.ENTER) - sleep(1) - @retry() @apply_blueprint( "default/flow-default-authentication-flow.yaml", @@ -46,6 +47,6 @@ class TestFlowsLoginSFE(SeleniumTestCase): query={"sfe": True}, ) ) - self.login() + login_sfe(self.driver, self.user) self.wait_for_url(self.if_user_url("/library")) self.assert_user(self.user) diff --git a/web/packages/sfe/src/index.ts b/web/packages/sfe/src/index.ts index 6d6372fe11..0da39dd481 100644 --- a/web/packages/sfe/src/index.ts +++ b/web/packages/sfe/src/index.ts @@ -403,6 +403,9 @@ class AuthenticatorValidateStage extends Stage } render() { + if (this.challenge.deviceChallenges.length === 1) { + this.deviceChallenge = this.challenge.deviceChallenges[0]; + } if (!this.deviceChallenge) { return this.renderChallengePicker(); } @@ -431,9 +434,7 @@ class AuthenticatorValidateStage extends Stage ${ challenges.length > 0 ? "

Select an authentication method.

" - : ` -

No compatible authentication method available

- ` + : `

No compatible authentication method available

` } ${challenges .map((challenge) => {