|
|
|
@@ -1326,7 +1326,14 @@ data:
|
|
|
|
|
name: Caddy docs.renner.uno
|
|
|
|
|
state: absent
|
|
|
|
|
|
|
|
|
|
# Passkey-only (WebAuthn) login for selected apps, in two parts:
|
|
|
|
|
# Passkey login for selected apps. Passkey is mandatory for accounts
|
|
|
|
|
# that hold one and unavailable to accounts that do not, so both flows
|
|
|
|
|
# branch on enrollment (policy passkey-user-has-webauthn): enrolled →
|
|
|
|
|
# WebAuthn, not enrolled → password + MFA. Enrollment status is a
|
|
|
|
|
# property of the identified account, so the weaker branch is never
|
|
|
|
|
# something a requester can select.
|
|
|
|
|
#
|
|
|
|
|
# Two parts:
|
|
|
|
|
#
|
|
|
|
|
# 1. A passwordless AUTHENTICATION flow (username → passkey, no
|
|
|
|
|
# password prompt) bound per-provider, so fresh logins to these
|
|
|
|
@@ -1335,7 +1342,13 @@ data:
|
|
|
|
|
# 2. A step-up AUTHORIZATION flow — unlike the authentication flow,
|
|
|
|
|
# this runs on EVERY interactive authorize, so an existing
|
|
|
|
|
# password-only SSO session from another app cannot slip into
|
|
|
|
|
# these apps without presenting a passkey.
|
|
|
|
|
# these apps without presenting a passkey. Two things narrow that:
|
|
|
|
|
# the stage is skipped outright for accounts with no passkey (they
|
|
|
|
|
# have no way to satisfy it), and last_auth_threshold reduces it to
|
|
|
|
|
# "without presenting a passkey in this browser within the window"
|
|
|
|
|
# — one touch then covers every app, including a later
|
|
|
|
|
# password-only session for the same user. The cookie is keyed to
|
|
|
|
|
# that user's device, so it never carries across users.
|
|
|
|
|
passkey-stepup.yaml: |
|
|
|
|
|
version: 1
|
|
|
|
|
metadata:
|
|
|
|
@@ -1356,43 +1369,99 @@ data:
|
|
|
|
|
# flow this replaces.
|
|
|
|
|
authentication: require_authenticated
|
|
|
|
|
|
|
|
|
|
- id: passkey-stepup-webauthn-validation
|
|
|
|
|
# ONE stage object, shared by the step-up authorization flow and
|
|
|
|
|
# the passwordless authentication flow below.
|
|
|
|
|
#
|
|
|
|
|
# `last_auth_threshold` is enforced with a single browser cookie
|
|
|
|
|
# (`authentik_mfa`) whose JWT is both signed with and carries the
|
|
|
|
|
# stage's PK, so a validation stage only ever honours a cookie it
|
|
|
|
|
# wrote itself. Two separate stage objects therefore each demand
|
|
|
|
|
# their own touch, and — sharing the one cookie name — overwrite
|
|
|
|
|
# each other, so neither window survives. Sharing this object is
|
|
|
|
|
# what lets the login touch satisfy the per-app step-up.
|
|
|
|
|
#
|
|
|
|
|
# Declared here, ahead of every !KeyOf reference to it: the
|
|
|
|
|
# blueprint importer resolves entries top-to-bottom.
|
|
|
|
|
- id: passkey-webauthn-validation
|
|
|
|
|
model: authentik_stages_authenticator_validate.authenticatorvalidatestage
|
|
|
|
|
identifiers:
|
|
|
|
|
name: passkey-stepup-webauthn-validation
|
|
|
|
|
name: passkey-webauthn-validation
|
|
|
|
|
state: present
|
|
|
|
|
attrs:
|
|
|
|
|
device_classes:
|
|
|
|
|
- webauthn
|
|
|
|
|
# `configure`, not `deny`: g.renner has Home Assistant access
|
|
|
|
|
# but no enrolled WebAuthn device (live check 2026-07-07), so
|
|
|
|
|
# a hard deny would lock her out with no self-service path.
|
|
|
|
|
# `configure` injects the default WebAuthn enrollment stage
|
|
|
|
|
# inline — first login without a passkey becomes "enroll one
|
|
|
|
|
# now, then validate it". Still passkey-only: there is no way
|
|
|
|
|
# through this stage without a WebAuthn credential.
|
|
|
|
|
not_configured_action: configure
|
|
|
|
|
configuration_stages:
|
|
|
|
|
- !Find [authentik_stages_authenticator_webauthn.authenticatorwebauthnstage, [name, default-authenticator-webauthn-setup]]
|
|
|
|
|
# Skip re-validation if a passkey was already presented within
|
|
|
|
|
# the window. Non-zero for two reasons: (1) no double-touch
|
|
|
|
|
# when the login itself used a passkey seconds earlier;
|
|
|
|
|
# (2) NetBird's dashboard silently re-authorizes via
|
|
|
|
|
# /silent-auth (prompt=none) on token expiry — a flow that
|
|
|
|
|
# always demands interaction would fail those and force a
|
|
|
|
|
# visible re-login every access-token lifetime.
|
|
|
|
|
# `deny`, not `configure`: the authentication flow reaches this
|
|
|
|
|
# stage with the user merely *identified* (a typed username)
|
|
|
|
|
# and no password stage in front of it, so inline enrollment
|
|
|
|
|
# would let anyone who knows a passkey-less username enroll
|
|
|
|
|
# their own passkey and take over that account. The policy
|
|
|
|
|
# binding means only accounts that already hold a passkey reach
|
|
|
|
|
# this stage at all, so `deny` is defence in depth rather than
|
|
|
|
|
# a path anyone walks — accounts without one branch to password
|
|
|
|
|
# + MFA instead.
|
|
|
|
|
not_configured_action: deny
|
|
|
|
|
# Window within which an already-presented passkey satisfies a
|
|
|
|
|
# later run of this stage. Non-zero for two reasons: (1) the
|
|
|
|
|
# per-app step-up must not demand a second touch seconds after
|
|
|
|
|
# the login touch; (2) NetBird's dashboard silently
|
|
|
|
|
# re-authorizes via /silent-auth (prompt=none) on token expiry —
|
|
|
|
|
# a stage that always demands interaction fails those and
|
|
|
|
|
# forces a visible re-login every access-token lifetime.
|
|
|
|
|
#
|
|
|
|
|
# The cookie is browser-scoped, so inside the window a re-login
|
|
|
|
|
# in that same browser is satisfied by the cookie alone and the
|
|
|
|
|
# username is all that gets typed. Accepted for a single touch
|
|
|
|
|
# per app.
|
|
|
|
|
last_auth_threshold: hours=8
|
|
|
|
|
webauthn_user_verification: preferred
|
|
|
|
|
|
|
|
|
|
- model: authentik_flows.flowstagebinding
|
|
|
|
|
# Gate on "this account has a passkey", so enrollment status picks
|
|
|
|
|
# the branch. Enrolled accounts must use WebAuthn — at login and at
|
|
|
|
|
# every authorize — while accounts with no passkey fall back to
|
|
|
|
|
# password + MFA. An attacker cannot pick the weaker branch: the
|
|
|
|
|
# user is already identified when this runs, so the branch follows
|
|
|
|
|
# the victim's enrollment, not the requester's choice.
|
|
|
|
|
#
|
|
|
|
|
# request.user is the flow's pending_user, not the (anonymous)
|
|
|
|
|
# requester: ReevaluateMarker builds the engine with
|
|
|
|
|
# plan.context[PLAN_CONTEXT_PENDING_USER] (flows/markers.py). That
|
|
|
|
|
# requires the bindings below to keep evaluate_on_plan false and
|
|
|
|
|
# re_evaluate_policies true — on-plan evaluation happens before the
|
|
|
|
|
# identification stage has resolved a user.
|
|
|
|
|
- id: policy-has-passkey
|
|
|
|
|
model: authentik_policies_expression.expressionpolicy
|
|
|
|
|
identifiers:
|
|
|
|
|
name: passkey-user-has-webauthn
|
|
|
|
|
state: present
|
|
|
|
|
attrs:
|
|
|
|
|
expression: |
|
|
|
|
|
return ak_user_has_authenticator(request.user, "webauthn")
|
|
|
|
|
|
|
|
|
|
- id: binding-stepup-webauthn
|
|
|
|
|
model: authentik_flows.flowstagebinding
|
|
|
|
|
identifiers:
|
|
|
|
|
target: !KeyOf passkey-stepup-flow
|
|
|
|
|
stage: !KeyOf passkey-stepup-webauthn-validation
|
|
|
|
|
stage: !KeyOf passkey-webauthn-validation
|
|
|
|
|
order: 10
|
|
|
|
|
state: present
|
|
|
|
|
attrs:
|
|
|
|
|
evaluate_on_plan: false
|
|
|
|
|
re_evaluate_policies: true
|
|
|
|
|
|
|
|
|
|
- model: authentik_policies.policybinding
|
|
|
|
|
identifiers:
|
|
|
|
|
policy: !KeyOf policy-has-passkey
|
|
|
|
|
target: !KeyOf binding-stepup-webauthn
|
|
|
|
|
order: 0
|
|
|
|
|
state: present
|
|
|
|
|
attrs:
|
|
|
|
|
enabled: true
|
|
|
|
|
negate: false
|
|
|
|
|
failure_result: false
|
|
|
|
|
|
|
|
|
|
# Passwordless authentication flow: identification (username or
|
|
|
|
|
# email, NO password stage) → WebAuthn validation → login.
|
|
|
|
|
# email, NO password stage) → WebAuthn validation → login, with a
|
|
|
|
|
# password + MFA branch for accounts that have no passkey.
|
|
|
|
|
- id: passkey-passwordless-flow
|
|
|
|
|
model: authentik_flows.flow
|
|
|
|
|
identifiers:
|
|
|
|
@@ -1403,37 +1472,6 @@ data:
|
|
|
|
|
title: "Sign in with your passkey"
|
|
|
|
|
designation: authentication
|
|
|
|
|
|
|
|
|
|
# SEPARATE stage from the step-up one, and deliberately `deny`:
|
|
|
|
|
# at this point in an authentication flow the user has only been
|
|
|
|
|
# *identified* (typed a username), not authenticated — `configure`
|
|
|
|
|
# here would let anyone who knows a passkey-less username enroll
|
|
|
|
|
# their own passkey and take over that account. Inline enrollment
|
|
|
|
|
# for passkey-less users happens exclusively in the authorization
|
|
|
|
|
# step-up stage above, where require_authenticated guarantees a
|
|
|
|
|
# fully authenticated session. A passkey-less user therefore
|
|
|
|
|
# bootstraps via password login on auth.renner.uno, then gets the
|
|
|
|
|
# enrollment prompt on first app access.
|
|
|
|
|
#
|
|
|
|
|
# Declared BEFORE the identification stage because the latter
|
|
|
|
|
# references it via !KeyOf (webauthn_stage), and the blueprint
|
|
|
|
|
# importer resolves entries top-to-bottom.
|
|
|
|
|
- id: passkey-passwordless-webauthn-validation
|
|
|
|
|
model: authentik_stages_authenticator_validate.authenticatorvalidatestage
|
|
|
|
|
identifiers:
|
|
|
|
|
name: passkey-passwordless-webauthn-validation
|
|
|
|
|
state: present
|
|
|
|
|
attrs:
|
|
|
|
|
device_classes:
|
|
|
|
|
- webauthn
|
|
|
|
|
not_configured_action: deny
|
|
|
|
|
# Small non-zero window so a passkey presented via the
|
|
|
|
|
# identification stage's autofill (webauthn_stage below) is
|
|
|
|
|
# not challenged a second time by this stage at order 20 in
|
|
|
|
|
# the same flow run. The skip is session-scoped, so a fresh
|
|
|
|
|
# (attacker) session never inherits it.
|
|
|
|
|
last_auth_threshold: minutes=5
|
|
|
|
|
webauthn_user_verification: preferred
|
|
|
|
|
|
|
|
|
|
# webauthn_stage enables passkey autofill (conditional WebAuthn,
|
|
|
|
|
# authentik >= 2025.12): the browser offers enrolled passkeys in
|
|
|
|
|
# the username field's autofill — one tap, no username typed.
|
|
|
|
@@ -1449,7 +1487,7 @@ data:
|
|
|
|
|
user_fields:
|
|
|
|
|
- username
|
|
|
|
|
- email
|
|
|
|
|
webauthn_stage: !KeyOf passkey-passwordless-webauthn-validation
|
|
|
|
|
webauthn_stage: !KeyOf passkey-webauthn-validation
|
|
|
|
|
|
|
|
|
|
- model: authentik_flows.flowstagebinding
|
|
|
|
|
identifiers:
|
|
|
|
@@ -1458,12 +1496,87 @@ data:
|
|
|
|
|
order: 10
|
|
|
|
|
state: present
|
|
|
|
|
|
|
|
|
|
- model: authentik_flows.flowstagebinding
|
|
|
|
|
# Order 20 and 21/22 are the two mutually exclusive branches.
|
|
|
|
|
- id: binding-passwordless-webauthn
|
|
|
|
|
model: authentik_flows.flowstagebinding
|
|
|
|
|
identifiers:
|
|
|
|
|
target: !KeyOf passkey-passwordless-flow
|
|
|
|
|
stage: !KeyOf passkey-passwordless-webauthn-validation
|
|
|
|
|
stage: !KeyOf passkey-webauthn-validation
|
|
|
|
|
order: 20
|
|
|
|
|
state: present
|
|
|
|
|
attrs:
|
|
|
|
|
evaluate_on_plan: false
|
|
|
|
|
re_evaluate_policies: true
|
|
|
|
|
|
|
|
|
|
- model: authentik_policies.policybinding
|
|
|
|
|
identifiers:
|
|
|
|
|
policy: !KeyOf policy-has-passkey
|
|
|
|
|
target: !KeyOf binding-passwordless-webauthn
|
|
|
|
|
order: 0
|
|
|
|
|
state: present
|
|
|
|
|
attrs:
|
|
|
|
|
enabled: true
|
|
|
|
|
negate: false
|
|
|
|
|
failure_result: false
|
|
|
|
|
|
|
|
|
|
# The negated branch. failure_result: true is the safety net — the
|
|
|
|
|
# UserLoginStage defaults a missing authentication backend to
|
|
|
|
|
# BACKEND_INBUILT instead of refusing (user_login/stage.py), so a
|
|
|
|
|
# flow that drops BOTH branches would log the user in on a typed
|
|
|
|
|
# username alone. A PolicyException is resolved to failure_result
|
|
|
|
|
# before negate is applied (policies/process.py), so a broken
|
|
|
|
|
# expression pulls the password stage IN and degrades everyone to
|
|
|
|
|
# password + MFA rather than to no factor at all.
|
|
|
|
|
- id: binding-passwordless-password
|
|
|
|
|
model: authentik_flows.flowstagebinding
|
|
|
|
|
identifiers:
|
|
|
|
|
target: !KeyOf passkey-passwordless-flow
|
|
|
|
|
stage: !Find [authentik_stages_password.passwordstage, [name, default-authentication-password]]
|
|
|
|
|
order: 21
|
|
|
|
|
state: present
|
|
|
|
|
attrs:
|
|
|
|
|
evaluate_on_plan: false
|
|
|
|
|
re_evaluate_policies: true
|
|
|
|
|
|
|
|
|
|
- model: authentik_policies.policybinding
|
|
|
|
|
identifiers:
|
|
|
|
|
policy: !KeyOf policy-has-passkey
|
|
|
|
|
target: !KeyOf binding-passwordless-password
|
|
|
|
|
order: 0
|
|
|
|
|
state: present
|
|
|
|
|
attrs:
|
|
|
|
|
enabled: true
|
|
|
|
|
negate: true
|
|
|
|
|
failure_result: true
|
|
|
|
|
|
|
|
|
|
# Same MFA validation the default authentication flow runs at its
|
|
|
|
|
# order 30, so the fallback is not a weaker way into these apps
|
|
|
|
|
# than auth.renner.uno's own login already is. It carries
|
|
|
|
|
# not_configured_action: configure, which is safe here and nowhere
|
|
|
|
|
# else in this flow: the password stage above has established a
|
|
|
|
|
# first factor by the time it runs, so it doubles as the enrollment
|
|
|
|
|
# path for accounts holding no authenticator at all.
|
|
|
|
|
- id: binding-passwordless-mfa
|
|
|
|
|
model: authentik_flows.flowstagebinding
|
|
|
|
|
identifiers:
|
|
|
|
|
target: !KeyOf passkey-passwordless-flow
|
|
|
|
|
stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, default-authentication-mfa-validation]]
|
|
|
|
|
order: 22
|
|
|
|
|
state: present
|
|
|
|
|
attrs:
|
|
|
|
|
evaluate_on_plan: false
|
|
|
|
|
re_evaluate_policies: true
|
|
|
|
|
|
|
|
|
|
- model: authentik_policies.policybinding
|
|
|
|
|
identifiers:
|
|
|
|
|
policy: !KeyOf policy-has-passkey
|
|
|
|
|
target: !KeyOf binding-passwordless-mfa
|
|
|
|
|
order: 0
|
|
|
|
|
state: present
|
|
|
|
|
attrs:
|
|
|
|
|
enabled: true
|
|
|
|
|
negate: true
|
|
|
|
|
failure_result: true
|
|
|
|
|
|
|
|
|
|
- model: authentik_flows.flowstagebinding
|
|
|
|
|
identifiers:
|
|
|
|
@@ -1577,3 +1690,19 @@ data:
|
|
|
|
|
redirect_uris:
|
|
|
|
|
- url: https://dash.renner.uno/login/generic_oauth
|
|
|
|
|
matching_mode: strict
|
|
|
|
|
|
|
|
|
|
# Superseded by the single passkey-webauthn-validation stage above.
|
|
|
|
|
# Their flowstagebindings are FK-CASCADEd away with them; the
|
|
|
|
|
# identification stage's webauthn_stage FK is SET_NULL and is
|
|
|
|
|
# re-pointed above, so no dangling reference survives. Declared
|
|
|
|
|
# last: the importer applies entries top-to-bottom inside one
|
|
|
|
|
# transaction, so the replacements exist before these go.
|
|
|
|
|
- model: authentik_stages_authenticator_validate.authenticatorvalidatestage
|
|
|
|
|
identifiers:
|
|
|
|
|
name: passkey-stepup-webauthn-validation
|
|
|
|
|
state: absent
|
|
|
|
|
|
|
|
|
|
- model: authentik_stages_authenticator_validate.authenticatorvalidatestage
|
|
|
|
|
identifiers:
|
|
|
|
|
name: passkey-passwordless-webauthn-validation
|
|
|
|
|
state: absent
|
|
|
|
|