stages/identification: Add WebAuthn conditional UI (passkey autofill) support (#18377)

* add passkey_login to identification stage

* handle passkey auth in identification stage

* Add passkey settings in identification stage in the admin UI

* Add UI changes for basic passkey conditional login

* Fix linting

* rework

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update tests

* update admin form

* allow passing stage to validate_challenge_webauthn

* update flows/tests/test_inspector.py

* update for new field

* Fix linting

* update go solvers for identification challenge

* Refactor tests

* Skip mfa validation if user already authenticated via passkey at identification stage

* Add skip_if_passkey_authenticated option to authenticator validate stage and UI

* Add e2e test for passkey login conditional ui

* add policy

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* Remove skip_if_passkey_authenticated

* fix blueprint

* Set backend so password stage policy knows user is already authenticated

* Set backend so password stage policy knows user is already authenticated

* fix linting

* slight tweaks

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* simplify e2e test

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Marcelo Elizeche Landó
2025-12-11 11:49:05 -03:00
committed by GitHub
parent 196bce348f
commit 15b93a5e9d
15 changed files with 484 additions and 13 deletions

View File

@@ -56,6 +56,7 @@ class TestFlowInspector(APITestCase):
"layout": "stacked",
},
"flow_designation": "authentication",
"passkey_challenge": None,
"password_fields": False,
"primary_action": "Log in",
"sources": [],

View File

@@ -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

View File

@@ -37,6 +37,7 @@ class IdentificationStageSerializer(StageSerializer):
"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",

View File

@@ -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",
),
),
]

View File

@@ -8,6 +8,7 @@ from rest_framework.serializers import BaseSerializer
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."),

View File

@@ -8,15 +8,17 @@ from typing import Any
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.planner import (
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.captcha.stage import (
)
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 @@ class IdentificationStageView(ChallengeStageView):
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 @@ class IdentificationStageView(ChallengeStageView):
"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 @@ class IdentificationStageView(ChallengeStageView):
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")

View File

@@ -9,6 +9,8 @@ from authentik.flows.models import FlowDesignation, FlowStageBinding
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 import BACKEND_INBUILT
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"""

View File

@@ -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

View File

@@ -14508,6 +14508,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": []

View File

@@ -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
}

View File

@@ -29238,6 +29238,11 @@ paths:
name: show_source_labels
schema:
type: boolean
- in: query
name: webauthn_stage
schema:
type: string
format: uuid
tags:
- stages
security:
@@ -39278,6 +39283,10 @@ components:
enable_remember_me:
type: boolean
default: true
passkey_challenge:
type: object
additionalProperties: {}
nullable: true
required:
- flow_designation
- password_fields
@@ -39294,15 +39303,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
@@ -39395,6 +39406,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
- flow_set
@@ -39470,6 +39487,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:
@@ -46742,6 +46765,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

View File

@@ -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 selenium.webdriver.common.virtual_authenticator import (
)
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 @@ class TestFlowsAuthenticatorWebAuthn(SeleniumTestCase):
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)

View File

@@ -18,6 +18,7 @@ import {
IdentificationStage,
Stage,
StagesApi,
StagesAuthenticatorValidateListRequest,
StagesCaptchaListRequest,
StagesPasswordListRequest,
UserFieldsEnum,
@@ -192,6 +193,42 @@ export class IdentificationStageForm extends BaseStageForm<IdentificationStage>
></ak-switch-input>
</div>
</ak-form-group>
<ak-form-group label="${msg("Passkey settings")}">
<div class="pf-c-form">
<ak-form-element-horizontal
label=${msg("WebAuthn Authenticator Validation Stage")}
name="webauthnStage"
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Stage[]> => {
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
>
</ak-search-select>
<p class="pf-c-form__helper-text">
${msg(
"When set, allows users to authenticate using passkeys directly from the browser's autofill dropdown without entering a username first.",
)}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group label="${msg("Source settings")}">
<div class="pf-c-form">
<ak-form-element-horizontal label=${msg("Sources")} required name="sources">

View File

@@ -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<boolean> {
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

View File

@@ -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<void> {
// 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`
<p>
@@ -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 ?? ""}