mirror of
https://github.com/goauthentik/authentik
synced 2026-04-25 17:15:26 +02:00
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:
committed by
GitHub
parent
196bce348f
commit
15b93a5e9d
@@ -56,6 +56,7 @@ class TestFlowInspector(APITestCase):
|
||||
"layout": "stacked",
|
||||
},
|
||||
"flow_designation": "authentication",
|
||||
"passkey_challenge": None,
|
||||
"password_fields": False,
|
||||
"primary_action": "Log in",
|
||||
"sources": [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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."),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
35
schema.yml
35
schema.yml
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? ""}
|
||||
|
||||
Reference in New Issue
Block a user