Skip to content

auth: optional second factor (TOTP or passkey) with recovery codes and step-up on new-device registration #95

Description

@tonite31

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

  1. 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 }.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions