Summary
@spfn/auth has passkeys as a sign-in credential (#76) and device-bound sessions, but no second factor: an account is protected by whichever single credential the attacker obtains. A password phished from a person lets the attacker register a new device and hold the account. What is missing is an optional second step the owner can enrol, that the package then requires at the moments a stolen first credential would be used — a new-device registration and a sensitive action — while a person who never enrols keeps full access.
Outcome
- Enrolment, optional.
POST /_auth/mfa/totp/enroll (secret + otpauth URI), /_auth/mfa/totp/confirm (first code), /_auth/mfa/disable (needs recent authentication). A passkey already enrolled through /_auth/passkeys/* can be marked as second factor without a TOTP. GET /_auth/mfa/status → { enrolled, methods, recoveryCodesRemaining }.
- Recovery codes. Ten single-use codes generated at enrolment, shown once, stored hashed,
POST /_auth/mfa/recovery/regenerate (needs recent authentication). A recovery code consumed at step-up counts down and the app can warn at 2 remaining.
- Step-up at new-device registration. When an enrolled account registers a key on a new device by password or OAuth, the registration answers
202 { mfaRequired: true, challengeId } and the key is not active until POST /_auth/mfa/verify { challengeId, code | recoveryCode | passkey assertion } succeeds. Device-code approval from an already-trusted device is itself the second factor and does not step up. Passkey login does not step up.
- Step-up for sensitive actions.
requireRecentAuth({ maxAgeMillis }) middleware becomes requireStepUp({ maxAgeMillis }): for an enrolled account, "recent" means a second-factor verification within the window, not only a fresh login. Used by disable, recovery regenerate, password change, revokeAllKeys.
- A recommendation hook, not a mandate.
authLoginEvent and the new-device event carry mfaEnrolled: boolean so an app can prompt at first login and at each new device. The package never blocks an un-enrolled account.
Narrow review dispatch contract
- Required behavior: the five items above; an un-enrolled account behaves exactly as today across every route.
- Touched surfaces: key registration paths, a new
mfa route group, requireRecentAuth, schema (TOTP secret encrypted at rest with the existing auth encryption key, recovery code hashes, challenge rows with TTL), README.
- Supported inputs: TOTP with ±1 step drift, rate-limited to the existing login limiter; recovery codes; passkey assertion reusing the existing verify path.
- Trust boundary: challenge ids are unguessable and single-use; a pending challenge never yields a usable key; secrets and codes never appear in logs or in
status.
- Explicit exclusions: SMS/email OTP, admin-forced MFA policy, WebAuthn attestation checks, UI components.
- Stop condition: tests for enrol/confirm/disable, step-up on password and OAuth new-device paths, no step-up on device-code and passkey paths, drift and rate limit, recovery code consumption, un-enrolled account regression suite unchanged.
Summary
@spfn/authhas passkeys as a sign-in credential (#76) and device-bound sessions, but no second factor: an account is protected by whichever single credential the attacker obtains. A password phished from a person lets the attacker register a new device and hold the account. What is missing is an optional second step the owner can enrol, that the package then requires at the moments a stolen first credential would be used — a new-device registration and a sensitive action — while a person who never enrols keeps full access.Outcome
POST /_auth/mfa/totp/enroll(secret + otpauth URI),/_auth/mfa/totp/confirm(first code),/_auth/mfa/disable(needs recent authentication). A passkey already enrolled through/_auth/passkeys/*can be marked as second factor without a TOTP.GET /_auth/mfa/status→{ enrolled, methods, recoveryCodesRemaining }.POST /_auth/mfa/recovery/regenerate(needs recent authentication). A recovery code consumed at step-up counts down and the app can warn at 2 remaining.202 { mfaRequired: true, challengeId }and the key is not active untilPOST /_auth/mfa/verify { challengeId, code | recoveryCode | passkey assertion }succeeds. Device-code approval from an already-trusted device is itself the second factor and does not step up. Passkey login does not step up.requireRecentAuth({ maxAgeMillis })middleware becomesrequireStepUp({ maxAgeMillis }): for an enrolled account, "recent" means a second-factor verification within the window, not only a fresh login. Used by disable, recovery regenerate, password change,revokeAllKeys.authLoginEventand the new-device event carrymfaEnrolled: booleanso an app can prompt at first login and at each new device. The package never blocks an un-enrolled account.Narrow review dispatch contract
mfaroute group,requireRecentAuth, schema (TOTP secret encrypted at rest with the existing auth encryption key, recovery code hashes, challenge rows with TTL), README.status.