Skip to content

feat(account): optional two-factor sign-in and revoked-session handling (#615) - #617

Merged
kipavy merged 1 commit into
devfrom
feat/two-factor-615
Oct 11, 2026
Merged

kipavy merged 1 commit into
devfrom
feat/two-factor-615

Conversation

@kipavy

@kipavy kipavy commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Closes #615. Client half of VoltiusApp/server#104 (refresh token revocation) and VoltiusApp/server#105 (TOTP + recovery codes), both merged on the server.

Two-factor sign-in is opt-in per account, off by default, free on every tier, and identical on a self-hosted server. Local accounts are not concerned.

Screens, captured from the live build: https://claude.ai/artifact/WzX7eMmvtm6tQq82XaKPs5

Two-factor

  • Settings → Account: a two-factor row, only on a server that advertises two_factor in /v1/meta (or when the account already has it on, so it can always be turned off).
    • Turn on: master password → QR code and key → first code → ten recovery codes with copy and save-to-file. The codes view closes only through "I saved them".
    • Manage: new recovery codes, or turn off (master password plus a code or a recovery code).
    • Change master password asks for a code when two-factor is on.
  • Signing in: first launch, lock screen, cloud sign-in and add-account all fall into the same code step when the server answers 428 TWO_FACTOR_REQUIRED. It takes an authenticator code or a recovery code, names a refusal (wrong code, locked for 15 minutes, server cannot check codes, sign-in took too long) and then runs the follow-up of the sign-in it interrupted.
  • Unlocking is no longer a sign-in. login() used to post /auth/login on every password unlock of a cloud vault. When the password opens the vault from this device's cached secrets and the server still honours the session, it now only refreshes. Without that, an enrolled account would be asked for a code at every unlock.
  • login() writes nothing to the keychain until the server accepts the sign-in. It used to store the vault key, the password and a healed mode: "local" first, so a sign-in left at the code step would have read as a local account on the next launch.

Revoked sessions

The server can now end a session (password change, two-factor on or off, log out everywhere), and /v1/auth/refresh answers 401 for it.

  • /v1/auth/refresh had three callers (authTokens, sync, account) and none handled a refusal: the sync loop retried every five seconds indefinitely. They share one refreshAccessToken now.
  • A 401 is remembered, so the loop stops asking, and a toast offers Sign in. That opens the cloud modal reduced to the master password of the current account (new reauth purpose) and goes through login(), so a password changed on another device is handled the way the lock screen already handles it.
  • A 401 for a refresh token replaced while the request was in flight is retried with the new one. The server announces the version bump before answering the request that caused it; found live, where enabling two-factor made this device report itself signed out.

Refactor

signInServerAccount and the re-auth branch of login() each posted the login with their own error mapping. Both go through serverLogin, which hands the session to a continuation, immediately or after the code step.

Checks

  • tsc --noEmit clean. Full suite: 811 files, 6421 tests passed before the last two fixes; the 44 affected files (418 tests) re-run after them.
  • 39 new tests: code step in each sign-in path, refusals, unlock without sign-in, revocation and the in-flight race, the settings flows, the API module.
  • i18n: 43 keys in en, fr, ru, zh, tr, cs; key parity passes.
  • Live, headless desktop against a local server built from server main with TOTP_ENC_KEY: enrol, wrong code, recovery codes, regenerate, lock and unlock (no code asked), log out everywhere from a second session → toast → sign in again → wrong code → recovery code → sync resumes, sign out → first-launch sign-in with a code.

Not done

  • Android was not run. No emulator on the build box (no /dev/kvm). The screens are the shared components; the code field sets inputMode="numeric" and autoComplete="one-time-code". Needs a pass on a phone.
  • No docs page yet.
  • New dependency: qrcode-generator 2.0.4 (MIT, no dependencies) for the QR code.

Do not release a client with this before the server carrying #104/#105 is deployed with TOTP_ENC_KEY set and backed up. Against an older server nothing changes: the row stays hidden and sign-in is as before.

…ng (#615)

Client half of VoltiusApp/server#104 and #105.

Two-factor sign-in
- Settings > Account gets a two-factor row on servers that offer it:
  password, QR code and key, first code, then the recovery codes with
  copy and save-to-file. Managing it offers new recovery codes and
  turning it off. Changing the master password asks for a code.
- Every sign-in path (first launch, lock screen, cloud sign-in, add
  account) falls into a code step when the server answers 428. The
  step takes an authenticator code or a recovery code and then runs the
  follow-up of the sign-in it interrupted.
- Unlocking is no longer a sign-in: when the password opens the vault
  from this device's cached secrets and the server still honours the
  session, login() does not post to /auth/login. Without that a
  two-factor account would be asked for a code on every unlock.
- login() writes nothing to the keychain until the server has accepted
  the sign-in, so one abandoned at the code step does not leave an
  account that reads as local.

Revoked sessions
- /v1/auth/refresh had three callers and none handled a refusal: the
  sync loop retried every five seconds for good. There is now one
  refreshAccessToken. A 401 is remembered, so the loop stops asking,
  and a toast offers to sign in again, which asks for the master
  password of the current account and nothing else.
- A 401 for a refresh token that was replaced while the request was in
  flight is retried with the new one. The server announces a version
  bump before it answers the request that caused it, so enabling
  two-factor made this device report itself signed out.

signInServerAccount and the re-auth branch of login() posted the login
separately with different error mapping; both go through serverLogin.
@kipavy
kipavy merged commit 17b87e7 into dev Oct 11, 2026
5 checks passed
@kipavy
kipavy deleted the feat/two-factor-615 branch October 11, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant