Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
94cbed2
add passkey_login to identification stage
melizeche Nov 21, 2025
9812894
handle passkey auth in identification stage
melizeche Nov 24, 2025
e9a191c
Add passkey settings in identification stage in the admin UI
melizeche Nov 25, 2025
aa7c5ad
Add UI changes for basic passkey conditional login
melizeche Nov 25, 2025
5a64945
Fix linting
melizeche Nov 25, 2025
40b9d04
rework
BeryJu Nov 26, 2025
fcdcfb4
fix merge conflicts
melizeche Nov 26, 2025
5654529
update tests
melizeche Nov 26, 2025
eab77ab
update admin form
melizeche Nov 26, 2025
13213b3
allow passing stage to validate_challenge_webauthn
melizeche Nov 26, 2025
ba433c2
update flows/tests/test_inspector.py
melizeche Nov 26, 2025
85ce3d0
update for new field
melizeche Nov 26, 2025
2a999c5
Fix linting
melizeche Nov 26, 2025
c4c4117
update go solvers for identification challenge
melizeche Nov 26, 2025
625e4b5
Refactor tests
melizeche Nov 26, 2025
5224651
Skip mfa validation if user already authenticated via passkey at iden…
melizeche Nov 26, 2025
96a2962
Add skip_if_passkey_authenticated option to authenticator validate st…
melizeche Nov 26, 2025
6a6371c
Add e2e test for passkey login conditional ui
melizeche Nov 26, 2025
3e5a78a
Merge branch 'main' into passkey_conditional
melizeche Nov 27, 2025
619d7e0
add policy
BeryJu Dec 3, 2025
fc5a04f
Remove skip_if_passkey_authenticated
melizeche Dec 5, 2025
2c065cc
fix blueprint
melizeche Dec 5, 2025
a80d97b
Set backend so password stage policy knows user is already authenticated
melizeche Dec 5, 2025
bb5958d
Set backend so password stage policy knows user is already authenticated
melizeche Dec 5, 2025
9a7f2fb
fix linting
melizeche Dec 5, 2025
01878bf
Merge branch 'main' into passkey_conditional
melizeche Dec 5, 2025
6b44e3e
Merge branch 'main' into passkey_conditional
melizeche Dec 9, 2025
5bc944f
slight tweaks
BeryJu Dec 10, 2025
056830b
simplify e2e test
melizeche Dec 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions authentik/flows/tests/test_inspector.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def test(self):
"layout": "stacked",
},
"flow_designation": "authentication",
"passkey_challenge": None,
"password_fields": False,
"primary_action": "Log in",
"sources": [],
Expand Down
10 changes: 8 additions & 2 deletions authentik/stages/authenticator_validate/challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
if stage is None:
stage = 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
Expand Down
2 changes: 2 additions & 0 deletions authentik/stages/identification/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class Meta:
"show_source_labels",
"pretend_user_exists",
"enable_remember_me",
"webauthn_stage",
]


Expand All @@ -49,6 +50,7 @@ class IdentificationStageViewSet(UsedByMixin, ModelViewSet):
"name",
"password_stage",
"captcha_stage",
"webauthn_stage",
"case_insensitive_matching",
"show_matched_user",
"enrollment_flow",
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
14 changes: 14 additions & 0 deletions authentik/stages/identification/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."),
Expand Down
83 changes: 77 additions & 6 deletions authentik/stages/identification/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,17 +34,26 @@
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,
verify_captcha_token,
)
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:
Expand Down Expand Up @@ -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(IdentificationChallengeResponse)
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(
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down
112 changes: 112 additions & 0 deletions authentik/stages/identification/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"""

Expand Down
Loading
Loading