fix(auth): count a passkey as the second factor on the managed-profile acts

An account whose only strong credential is a primary passkey was refused
auth.stepup.mfa_not_enrolled on every action behind requireFreshMfa, so it
could not create, edit, hand over or delete a managed profile or manage its
guardians, although a passkey sign-in or re-proof already stamps the
session the gate reads.

requireFreshMfa now defaults to the any-possession proof source: a passkey
makes the gate reachable, and a passkey re-proof through the dialog clears
it. The refusal offers passkey in meta.methods, never the password.

hasSecondFactorEnrolled keeps its narrow meaning on purpose. It decides
whether the conditional gates apply at all (account deletion, the data
reset, the password change, the export, offering manage access, the
recent-proof branches and the native Bearer rules), so none of those
change, and password sign-in still challenges only TOTP and security keys.
The MFA-management routes keep the second-factor source. The decision and
its limits are written beside FreshFactorProofSource.

Copy, the OpenAPI description and the sharing and key-rotation docs say
"second factor or passkey" where this gate applies.

Refs #959
This commit is contained in:
Marc-André Bombeck committed 2026-10-17 09:56:30 +02:00
1 parent d9b6061d28
commit 792fc89a93
17 files changed
+381 -63

No files matched your search

+7 -5
View File
@@ -21687,9 +21687,10 @@ paths:
"401":
description: "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the
proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode:
auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a second factor cannot
create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives here too, including
for a Bearer caller, whose transport this gate does not read."
auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account with neither a second factor nor
a passkey cannot create a managed profile until it enrols one. A passkey counts: an account whose only
strong credential is a passkey is offered `passkey` in `meta.methods`. The ordinary unauthenticated 401
arrives here too, including for a Bearer caller, whose transport this gate does not read."
content:
application/json:
schema:
@@ -34618,8 +34619,9 @@ components:
Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an
unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated
(`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent
management relationship, and an account with no second factor enrolled is refused rather than waved through.
Rate-limited to ten an hour per caller."
management relationship, and an account with no second factor enrolled is refused rather than waved through. A
primary passkey counts as that factor here: a passkey sign-in or a passkey re-proof at POST /api/auth/reproof
stamps the session. Rate-limited to ten an hour per caller."
UpdateManagedProfileRequest:
type: object
properties: