diff --git a/authentik/flows/tests/test_inspector.py b/authentik/flows/tests/test_inspector.py index e1c21d0f3aa6..f867ead6eb7a 100644 --- a/authentik/flows/tests/test_inspector.py +++ b/authentik/flows/tests/test_inspector.py @@ -56,6 +56,7 @@ def test(self): "layout": "stacked", }, "flow_designation": "authentication", + "passkey_challenge": None, "password_fields": False, "primary_action": "Log in", "sources": [], diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index 0583da5732bd..1fb1df4878ee 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -152,11 +152,17 @@ def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Dev return device -def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device: +def validate_challenge_webauthn( + data: dict, + stage_view: StageView, + user: User, + stage: AuthenticatorValidateStage | None = None, +) -> Device: """Validate WebAuthn Challenge""" request = stage_view.request challenge = stage_view.executor.plan.context.get(PLAN_CONTEXT_WEBAUTHN_CHALLENGE) - stage: AuthenticatorValidateStage = stage_view.executor.current_stage + stage = stage or stage_view.executor.current_stage + if "MinuteMaid" in request.META.get("HTTP_USER_AGENT", ""): # Workaround for Android sign-in, when signing into Google Workspace on android while # adding the account to the system (not in Chrome), for some reason `type` is not set diff --git a/authentik/stages/identification/api.py b/authentik/stages/identification/api.py index da0bb2afa0d2..6c4e07b436a1 100644 --- a/authentik/stages/identification/api.py +++ b/authentik/stages/identification/api.py @@ -37,6 +37,7 @@ class Meta: "show_source_labels", "pretend_user_exists", "enable_remember_me", + "webauthn_stage", ] @@ -49,6 +50,7 @@ class IdentificationStageViewSet(UsedByMixin, ModelViewSet): "name", "password_stage", "captcha_stage", + "webauthn_stage", "case_insensitive_matching", "show_matched_user", "enrollment_flow", diff --git a/authentik/stages/identification/migrations/0017_identificationstage_webauthn_stage.py b/authentik/stages/identification/migrations/0017_identificationstage_webauthn_stage.py new file mode 100644 index 000000000000..a42c014262f9 --- /dev/null +++ b/authentik/stages/identification/migrations/0017_identificationstage_webauthn_stage.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.8 on 2025-11-26 16:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "authentik_stages_authenticator_validate", + "0014_alter_authenticatorvalidatestage_device_classes", + ), + ("authentik_stages_identification", "0016_identificationstage_enable_remember_me"), + ] + + operations = [ + migrations.AddField( + model_name="identificationstage", + name="webauthn_stage", + field=models.ForeignKey( + default=None, + help_text="When set, and conditional WebAuthn is available, allow the user to use their passkey as a first factor.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_stages_authenticator_validate.authenticatorvalidatestage", + ), + ), + ] diff --git a/authentik/stages/identification/models.py b/authentik/stages/identification/models.py index 0495f0956daf..ff0126bebc50 100644 --- a/authentik/stages/identification/models.py +++ b/authentik/stages/identification/models.py @@ -8,6 +8,7 @@ from authentik.core.models import Source from authentik.flows.models import Flow, Stage +from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage from authentik.stages.captcha.models import CaptchaStage from authentik.stages.password.models import PasswordStage @@ -57,6 +58,19 @@ class IdentificationStage(Stage): ), ) + webauthn_stage = models.ForeignKey( + AuthenticatorValidateStage, + null=True, + default=None, + on_delete=models.SET_NULL, + help_text=_( + ( + "When set, and conditional WebAuthn is available, allow the user to use their " + "passkey as a first factor." + ), + ), + ) + case_insensitive_matching = models.BooleanField( default=True, help_text=_("When enabled, user fields are matched regardless of their casing."), diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index 136f0be3e4db..29172529dd51 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -8,15 +8,17 @@ from django.core.exceptions import PermissionDenied from django.db.models import Q from django.http import HttpResponse +from django.utils.timezone import now from django.utils.translation import gettext as _ from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema_field from rest_framework.fields import BooleanField, CharField, ChoiceField, DictField, ListField from rest_framework.serializers import ValidationError from sentry_sdk import start_span -from authentik.core.api.utils import PassiveSerializer +from authentik.core.api.utils import JSONDictField, PassiveSerializer from authentik.core.models import Application, Source, User from authentik.endpoints.models import Device +from authentik.events.middleware import audit_ignore from authentik.events.utils import sanitize_item from authentik.flows.challenge import ( Challenge, @@ -32,9 +34,14 @@ from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView from authentik.flows.views.executor import SESSION_KEY_GET from authentik.lib.avatars import DEFAULT_AVATAR -from authentik.lib.utils.reflection import all_subclasses +from authentik.lib.utils.reflection import all_subclasses, class_to_path from authentik.lib.utils.urls import reverse_with_qs from authentik.root.middleware import ClientIPMiddleware +from authentik.stages.authenticator_validate.challenge import ( + get_webauthn_challenge_without_user, + validate_challenge_webauthn, +) +from authentik.stages.authenticator_webauthn.models import WebAuthnDevice from authentik.stages.captcha.stage import ( PLAN_CONTEXT_CAPTCHA_PRIVATE_KEY, CaptchaChallenge, @@ -42,7 +49,11 @@ ) from authentik.stages.identification.models import IdentificationStage from authentik.stages.identification.signals import identification_failed -from authentik.stages.password.stage import authenticate +from authentik.stages.password.stage import ( + PLAN_CONTEXT_METHOD, + PLAN_CONTEXT_METHOD_ARGS, + authenticate, +) class LoginChallengeMixin: @@ -97,25 +108,53 @@ class IdentificationChallenge(Challenge): show_source_labels = BooleanField() enable_remember_me = BooleanField(required=False, default=True) + passkey_challenge = JSONDictField(required=False, allow_null=True) + component = CharField(default="ak-stage-identification") class IdentificationChallengeResponse(ChallengeResponse): """Identification challenge""" - uid_field = CharField() + uid_field = CharField(required=False, allow_blank=True, allow_null=True) password = CharField(required=False, allow_blank=True, allow_null=True) captcha_token = CharField(required=False, allow_blank=True, allow_null=True) + passkey = JSONDictField(required=False, allow_null=True) component = CharField(default="ak-stage-identification") pre_user: User | None = None + passkey_device: WebAuthnDevice | None = None + + def _validate_passkey_response(self, passkey: dict) -> WebAuthnDevice: + """Validate passkey/WebAuthn response for passwordless authentication""" + # Get the webauthn_stage from the current IdentificationStage + current_stage: IdentificationStage = IdentificationStage.objects.get( + pk=self.stage.executor.current_stage.pk + ) + return validate_challenge_webauthn( + passkey, self.stage, self.stage.get_pending_user(), current_stage.webauthn_stage + ) def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: - """Validate that user exists, and optionally their password and captcha token""" - uid_field = attrs["uid_field"] + """Validate that user exists, and optionally their password, captcha token, or passkey""" current_stage: IdentificationStage = self.stage.executor.current_stage client_ip = ClientIPMiddleware.get_client_ip(self.stage.request) + # Check if this is a passkey authentication + passkey = attrs.get("passkey") + if passkey: + device = self._validate_passkey_response(passkey) + self.passkey_device = device + self.pre_user = device.user + # Set backend so password stage policy knows user is already authenticated + self.pre_user.backend = class_to_path(IdentificationStageView) + return attrs + + # Standard username/password flow + uid_field = attrs.get("uid_field") + if not uid_field: + raise ValidationError(_("No identification data provided.")) + pre_user = self.stage.get_user(uid_field) if not pre_user: with start_span( @@ -231,6 +270,19 @@ def get_primary_action(self) -> str: return _("Log in") return _("Continue") + def get_passkey_challenge(self) -> dict | None: + """Generate a WebAuthn challenge for passkey/conditional UI authentication""" + # Refresh from DB to get the latest configuration + current_stage: IdentificationStage = IdentificationStage.objects.get( + pk=self.executor.current_stage.pk + ) + if not current_stage.webauthn_stage: + self.logger.debug("No webauthn_stage configured") + return None + challenge = get_webauthn_challenge_without_user(self, current_stage.webauthn_stage) + self.logger.debug("Generated passkey challenge", challenge=challenge) + return challenge + def get_challenge(self) -> Challenge: current_stage: IdentificationStage = self.executor.current_stage challenge = IdentificationChallenge( @@ -255,6 +307,7 @@ def get_challenge(self) -> Challenge: "show_source_labels": current_stage.show_source_labels, "flow_designation": self.executor.flow.designation, "enable_remember_me": current_stage.enable_remember_me, + "passkey_challenge": self.get_passkey_challenge(), } ) # If the user has been redirected to us whilst trying to access an @@ -307,6 +360,24 @@ def get_challenge(self) -> Challenge: def challenge_valid(self, response: IdentificationChallengeResponse) -> HttpResponse: self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = response.pre_user current_stage: IdentificationStage = self.executor.current_stage + + # Handle passkey authentication + if response.passkey_device: + self.logger.debug("Passkey authentication successful", user=response.pre_user) + self.executor.plan.context[PLAN_CONTEXT_METHOD] = "auth_webauthn_pwl" + self.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {}) + self.executor.plan.context[PLAN_CONTEXT_METHOD_ARGS].update( + { + "device": response.passkey_device, + "device_type": response.passkey_device.device_type, + } + ) + # Update device last_used + with audit_ignore(): + response.passkey_device.last_used = now() + response.passkey_device.save() + return self.executor.stage_ok() + if not current_stage.show_matched_user: self.executor.plan.context[PLAN_CONTEXT_PENDING_USER_IDENTIFIER] = ( response.validated_data.get("uid_field") diff --git a/authentik/stages/identification/tests.py b/authentik/stages/identification/tests.py index 7445c23af8d2..ecbffafef2fd 100644 --- a/authentik/stages/identification/tests.py +++ b/authentik/stages/identification/tests.py @@ -9,6 +9,8 @@ from authentik.flows.tests import FlowTestCase from authentik.lib.generators import generate_id from authentik.sources.oauth.models import OAuthSource +from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses +from authentik.stages.authenticator_webauthn.models import WebAuthnDevice from authentik.stages.captcha.models import CaptchaStage from authentik.stages.captcha.tests import RECAPTCHA_PRIVATE_KEY, RECAPTCHA_PUBLIC_KEY from authentik.stages.identification.api import IdentificationStageSerializer @@ -17,6 +19,116 @@ from authentik.stages.password.models import PasswordStage +class TestIdentificationStagePasskey(FlowTestCase): + """Passkey authentication tests""" + + def setUp(self): + super().setUp() + self.user = create_test_admin_user() + self.flow = create_test_flow(FlowDesignation.AUTHENTICATION) + self.webauthn_stage = AuthenticatorValidateStage.objects.create( + name="webauthn-validate", + device_classes=[DeviceClasses.WEBAUTHN], + ) + self.stage = IdentificationStage.objects.create( + name="identification", + user_fields=[UserFields.E_MAIL], + webauthn_stage=self.webauthn_stage, + ) + FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0) + self.device = WebAuthnDevice.objects.create( + user=self.user, + name="Test Passkey", + credential_id="test-credential-id", + public_key="test-public-key", + sign_count=0, + rp_id="testserver", + ) + + def test_passkey_auth_success(self): + """Test passkey sets device, user, backend and updates last_used""" + from unittest.mock import patch + + # Get challenge to initialize session + url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + self.client.get(url) + + with patch( + "authentik.stages.identification.stage.validate_challenge_webauthn", + return_value=self.device, + ): + response = self.client.post( + url, {"passkey": {"id": "test"}}, content_type="application/json" + ) + + self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) + # Verify device last_used was updated + self.device.refresh_from_db() + self.assertIsNotNone(self.device.last_used) + + def test_passkey_challenge_disabled(self): + """Test that passkey challenge is not included when webauthn_stage is not set""" + self.stage.webauthn_stage = None + self.stage.save() + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIsNone(data.get("passkey_challenge")) + + def test_passkey_challenge_enabled(self): + """Test that passkey challenge is included when webauthn_stage is set""" + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIsNotNone(data.get("passkey_challenge")) + passkey_challenge = data["passkey_challenge"] + self.assertIn("challenge", passkey_challenge) + self.assertIn("rpId", passkey_challenge) + self.assertEqual(passkey_challenge["allowCredentials"], []) + + def test_passkey_challenge_generation(self): + """Test passkey challenge is generated correctly""" + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIsNotNone(data.get("passkey_challenge")) + + def test_passkey_no_uid_field_required(self): + """Test that uid_field is not required when passkey is provided""" + # Get the challenge first to set up the session + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + self.assertEqual(response.status_code, 200) + + # Submit without uid_field but with passkey (invalid passkey will fail validation) + form_data = { + "passkey": { + "id": "invalid", + "rawId": "invalid", + "type": "public-key", + "response": { + "clientDataJSON": "invalid", + "authenticatorData": "invalid", + "signature": "invalid", + }, + } + } + url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + response = self.client.post(url, form_data, content_type="application/json") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("response_errors", data) + errors = data.get("response_errors", {}) + self.assertNotIn("uid_field", errors) + + class TestIdentificationStage(FlowTestCase): """Identification tests""" diff --git a/blueprints/default/flow-default-authentication-flow.yaml b/blueprints/default/flow-default-authentication-flow.yaml index 18137e3b987f..b495a8d06978 100644 --- a/blueprints/default/flow-default-authentication-flow.yaml +++ b/blueprints/default/flow-default-authentication-flow.yaml @@ -60,6 +60,7 @@ entries: order: 30 stage: !KeyOf default-authentication-mfa-validation target: !KeyOf flow + id: default-authentication-flow-authenticator-validation-binding model: authentik_flows.flowstagebinding - identifiers: order: 100 @@ -78,6 +79,18 @@ entries: # If the user does not have a backend attached to it, they haven't # been authenticated yet and we need the password stage return not hasattr(flow_plan.context.get("pending_user"), "backend") +- model: authentik_policies_expression.expressionpolicy + id: default-authentication-flow-authenticator-validate-optional + identifiers: + name: default-authentication-flow-authenticator-validate-stage + attrs: + expression: | + flow_plan = request.context.get("flow_plan") + if not flow_plan: + return True + # if the authentication method is webauthn (passwordless), then we skip the authenticator + # validation stage by returning false (true will execute the stage) + return not (flow_plan.context.get("auth_method") == "auth_webauthn_pwl") - model: authentik_policies.policybinding identifiers: order: 10 @@ -85,3 +98,10 @@ entries: policy: !KeyOf default-authentication-flow-password-optional attrs: failure_result: true +- model: authentik_policies.policybinding + identifiers: + order: 10 + target: !KeyOf default-authentication-flow-authenticator-validation-binding + policy: !KeyOf default-authentication-flow-authenticator-validate-optional + attrs: + failure_result: true diff --git a/blueprints/schema.json b/blueprints/schema.json index 611e89de6393..ec586ec8ae7b 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -15744,6 +15744,11 @@ "type": "boolean", "title": "Enable remember me", "description": "Show the user the 'Remember me on this device' toggle, allowing repeat users to skip straight to entering their password." + }, + "webauthn_stage": { + "type": "integer", + "title": "Webauthn stage", + "description": "When set, and conditional WebAuthn is available, allow the user to use their passkey as a first factor." } }, "required": [] diff --git a/internal/outpost/flow/solvers.go b/internal/outpost/flow/solvers.go index 6def360c5a3f..745df12aa496 100644 --- a/internal/outpost/flow/solvers.go +++ b/internal/outpost/flow/solvers.go @@ -8,7 +8,8 @@ import ( ) func (fe *FlowExecutor) solveChallenge_Identification(challenge *api.ChallengeTypes, req api.ApiFlowsExecutorSolveRequest) (api.FlowChallengeResponseRequest, error) { - r := api.NewIdentificationChallengeResponseRequest(fe.getAnswer(StageIdentification)) + r := api.NewIdentificationChallengeResponseRequest() + r.SetUidField(fe.getAnswer(StageIdentification)) r.SetPassword(fe.getAnswer(StagePassword)) return api.IdentificationChallengeResponseRequestAsFlowChallengeResponseRequest(r), nil } diff --git a/schema.yml b/schema.yml index f1bb2011fbed..177dd9ba5a92 100644 --- a/schema.yml +++ b/schema.yml @@ -29200,6 +29200,11 @@ paths: name: show_source_labels schema: type: boolean + - in: query + name: webauthn_stage + schema: + type: string + format: uuid tags: - stages security: @@ -39307,6 +39312,10 @@ components: enable_remember_me: type: boolean default: true + passkey_challenge: + type: object + additionalProperties: {} + nullable: true required: - flow_designation - password_fields @@ -39323,15 +39332,17 @@ components: default: ak-stage-identification uid_field: type: string - minLength: 1 + nullable: true password: type: string nullable: true captcha_token: type: string nullable: true - required: - - uid_field + passkey: + type: object + additionalProperties: {} + nullable: true IdentificationStage: type: object description: IdentificationStage Serializer @@ -39423,6 +39434,12 @@ components: type: boolean description: Show the user the 'Remember me on this device' toggle, allowing repeat users to skip straight to entering their password. + webauthn_stage: + type: string + format: uuid + nullable: true + description: When set, and conditional WebAuthn is available, allow the + user to use their passkey as a first factor. required: - component - meta_model_name @@ -39501,6 +39518,12 @@ components: type: boolean description: Show the user the 'Remember me on this device' toggle, allowing repeat users to skip straight to entering their password. + webauthn_stage: + type: string + format: uuid + nullable: true + description: When set, and conditional WebAuthn is available, allow the + user to use their passkey as a first factor. required: - name IframeLogoutChallenge: @@ -46822,6 +46845,12 @@ components: type: boolean description: Show the user the 'Remember me on this device' toggle, allowing repeat users to skip straight to entering their password. + webauthn_stage: + type: string + format: uuid + nullable: true + description: When set, and conditional WebAuthn is available, allow the + user to use their passkey as a first factor. PatchedInitialPermissionsRequest: type: object description: InitialPermissions serializer diff --git a/tests/e2e/test_flows_authenticators_webauthn.py b/tests/e2e/test_flows_authenticators_webauthn.py index 12d3fe7b39e7..be3789cb89d9 100644 --- a/tests/e2e/test_flows_authenticators_webauthn.py +++ b/tests/e2e/test_flows_authenticators_webauthn.py @@ -1,5 +1,7 @@ """test flow with WebAuthn Stage""" +from time import sleep + from selenium.webdriver.common.virtual_authenticator import ( Protocol, Transport, @@ -7,10 +9,12 @@ ) from authentik.blueprints.tests import apply_blueprint +from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage from authentik.stages.authenticator_webauthn.models import ( AuthenticatorWebAuthnStage, WebAuthnDevice, ) +from authentik.stages.identification.models import IdentificationStage from tests.e2e.test_flows_login_sfe import login_sfe from tests.e2e.utils import SeleniumTestCase, retry @@ -95,3 +99,40 @@ def test_webauthn_authenticate_sfe(self): login_sfe(self.driver, self.user) 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_passkey_login(self): + """Test passkey login at identification stage""" + self.register() + + # Configure identification stage to allow passkey login + webauthn_validate_stage = AuthenticatorValidateStage.objects.get( + name="default-authentication-mfa-validation" + ) + ident_stage = IdentificationStage.objects.get(name="default-authentication-identification") + ident_stage.webauthn_stage = webauthn_validate_stage + ident_stage.save() + + self.driver.delete_all_cookies() + + # Navigate to login page + self.driver.get(self.url("authentik_core:if-flow", flow_slug="default-authentication-flow")) + + # Wait for identification stage to load (ensures passkey challenge is triggered) + flow_executor = self.get_shadow_root("ak-flow-executor") + self.get_shadow_root("ak-stage-identification", flow_executor) + + # The virtual authenticator should automatically respond to the conditional WebAuthn request + # triggered by the identification stage when passkey_challenge is present. + # We need to wait for the passkey autofill to trigger and complete. + sleep(2) + + # If passkey auth succeeded, we should skip password and MFA stages + # and go directly to the library + self.wait_for_url(self.if_user_url("/library")) + self.assert_user(self.user) diff --git a/web/src/admin/stages/identification/IdentificationStageForm.ts b/web/src/admin/stages/identification/IdentificationStageForm.ts index 6d3ef8eecbcd..ba7ef1509368 100644 --- a/web/src/admin/stages/identification/IdentificationStageForm.ts +++ b/web/src/admin/stages/identification/IdentificationStageForm.ts @@ -18,6 +18,7 @@ import { IdentificationStage, Stage, StagesApi, + StagesAuthenticatorValidateListRequest, StagesCaptchaListRequest, StagesPasswordListRequest, UserFieldsEnum, @@ -192,6 +193,42 @@ export class IdentificationStageForm extends BaseStageForm > + +
+ + => { + const args: StagesAuthenticatorValidateListRequest = { + ordering: "name", + }; + if (query !== undefined) { + args.search = query; + } + const stages = await new StagesApi( + DEFAULT_CONFIG, + ).stagesAuthenticatorValidateList(args); + return stages.results; + }} + .groupBy=${(items: Stage[]) => + groupBy(items, (stage) => stage.verboseNamePlural)} + .renderElement=${(stage: Stage): string => stage.name} + .value=${(stage: Stage | undefined): string | undefined => stage?.pk} + .selected=${(stage: Stage): boolean => + stage.pk === this.instance?.webauthnStage} + blankable + > + +

+ ${msg( + "When set, allows users to authenticate using passkeys directly from the browser's autofill dropdown without entering a username first.", + )} +

+
+
+
diff --git a/web/src/common/helpers/webauthn.ts b/web/src/common/helpers/webauthn.ts index d3985440d0d2..52f5af49fe4a 100644 --- a/web/src/common/helpers/webauthn.ts +++ b/web/src/common/helpers/webauthn.ts @@ -26,6 +26,19 @@ export function checkWebAuthnSupport() { throw new Error(msg("WebAuthn not supported by browser.")); } +/** + * Check if the browser supports WebAuthn conditional UI (passkey autofill) + */ +export async function isConditionalMediationAvailable(): Promise { + if ( + typeof window.PublicKeyCredential !== "undefined" && + typeof window.PublicKeyCredential.isConditionalMediationAvailable === "function" + ) { + return await window.PublicKeyCredential.isConditionalMediationAvailable(); + } + return false; +} + /** * Transforms items in the credentialCreateOptions generated on the server * into byte arrays expected by the navigator.credentials.create() call diff --git a/web/src/flow/stages/identification/IdentificationStage.ts b/web/src/flow/stages/identification/IdentificationStage.ts index db4cd9b89612..ce1f25c50880 100644 --- a/web/src/flow/stages/identification/IdentificationStage.ts +++ b/web/src/flow/stages/identification/IdentificationStage.ts @@ -4,6 +4,12 @@ import "#flow/components/ak-flow-card"; import "#flow/components/ak-flow-password-input"; import "#flow/stages/captcha/CaptchaStage"; +import { + isConditionalMediationAvailable, + transformAssertionForServer, + transformCredentialRequestOptions, +} from "#common/helpers/webauthn"; + import { AKFormErrors } from "#components/ak-field-errors"; import { AKLabel } from "#components/ak-label"; @@ -103,6 +109,9 @@ export class IdentificationStage extends BaseStage< this.captchaLoaded = true; }; + // AbortController for conditional WebAuthn request + #passkeyAbortController: AbortController | null = null; + //#endregion //#region Lifecycle @@ -113,9 +122,17 @@ export class IdentificationStage extends BaseStage< if (changedProperties.has("challenge") && this.challenge !== undefined) { this.#autoRedirect(); this.#createHelperForm(); + this.#startConditionalWebAuthn(); } } + disconnectedCallback(): void { + super.disconnectedCallback(); + // Abort any pending conditional WebAuthn request when component is removed + this.#passkeyAbortController?.abort(); + this.#passkeyAbortController = null; + } + //#endregion #autoRedirect(): void { @@ -135,6 +152,70 @@ export class IdentificationStage extends BaseStage< this.host.challenge = source.challenge; } + /** + * Start a conditional WebAuthn request for passkey autofill. + * This allows users to select a passkey from the browser's autofill dropdown. + */ + async #startConditionalWebAuthn(): Promise { + // Check if passkey challenge is provided + // Note: passkeyChallenge is added dynamically and may not be in the generated types yet + const passkeyChallenge = ( + this.challenge as IdentificationChallenge & { + passkeyChallenge?: PublicKeyCredentialRequestOptions; + } + )?.passkeyChallenge; + + if (!passkeyChallenge) { + return; + } + + // Check if browser supports conditional mediation + const isAvailable = await isConditionalMediationAvailable(); + if (!isAvailable) { + console.debug("authentik/identification: Conditional mediation not available"); + return; + } + + // Abort any existing request + this.#passkeyAbortController?.abort(); + this.#passkeyAbortController = new AbortController(); + + try { + const publicKeyOptions = transformCredentialRequestOptions(passkeyChallenge); + + // Start the conditional WebAuthn request + const credential = (await navigator.credentials.get({ + publicKey: publicKeyOptions, + mediation: "conditional", + signal: this.#passkeyAbortController.signal, + })) as PublicKeyCredential | null; + + if (!credential) { + console.debug("authentik/identification: No credential returned"); + return; + } + + // Transform and submit the passkey response + const transformedCredential = transformAssertionForServer(credential); + + await this.host?.submit( + { + passkey: transformedCredential, + }, + { + invisible: true, + }, + ); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + // Request was aborted, this is expected when navigating away + console.debug("authentik/identification: Conditional WebAuthn aborted"); + return; + } + console.warn("authentik/identification: Conditional WebAuthn failed", error); + } + } + //#region Helper Form #createHelperForm(): void { @@ -329,6 +410,15 @@ export class IdentificationStage extends BaseStage< }; const label = OR_LIST_FORMATTERS.format(fields.map((f) => uiFields[f])); + // Check if passkey login is enabled to add webauthn to autocomplete + const passkeyChallenge = ( + this.challenge as IdentificationChallenge & { + passkeyChallenge?: PublicKeyCredentialRequestOptions; + } + )?.passkeyChallenge; + // When passkey is enabled, add "webauthn" to autocomplete to enable passkey autofill + const autocomplete = passkeyChallenge ? "username webauthn" : "username"; + return html`${this.challenge.flowDesignation === FlowDesignationEnum.Recovery ? html`

@@ -346,7 +436,7 @@ export class IdentificationStage extends BaseStage< name="uidField" placeholder=${label} autofocus="" - autocomplete="username" + autocomplete=${autocomplete} spellcheck="false" class="pf-c-form-control" value=${this.#rememberMe?.username ?? ""}