feat(auth): trusted devices & recovery codes on the mobile client #23

Merged
whitlocktech merged 1 commits from feature/trusted-devices-mfa into main 2026-07-22 06:26:56 +00:00
Member

What & why

Consumes the merged backend trusted-device + MFA feature (RunicGateway/website#93, docs#32) on the Android client, per docs/android/PLAN.md §4.1.1. The backend is fully additive; this PR just uses the new endpoints. No existing behavior changes for accounts that don't opt in.

Login (POST /auth/mobile/login)

  • On the existing 401 { totpRequired } step: a "Trust this device" checkbox and a "use a recovery code instead" toggle (sends recoveryCode instead of code).
  • Sends trustDevice / device_name and replays a stored X-Trust-Token; a valid token skips the TOTP prompt entirely (straight 200).
  • A returned trustToken is stored in a dedicated, username-scoped EncryptedSharedPreferences file (runic_trust, AES-256-GCM) — separate from the session store.

Account → Security

  • Trusted Devices screen: list (GET /auth/me/trusted-devices), revoke one (DELETE …/:id), untrust all (DELETE …/trusted-devices), and trust the current device (POST …/trusted-devices, persists the returned token).
  • Recovery Codes screen: remaining count (GET …/recovery-codes/status) + password-stepped regenerate (POST …/recovery-codes/generate) with a show-once copy/share display. The one-time batch returned by enabling 2FA is also surfaced on the account screen.

Two deliberate design decisions (documented in docs PR RunicGateway/docs#…)

  1. The trust token deliberately survives logout. It is the native analogue of the web rg_trust cookie, which per the canonical design survives logout so the next login skips 2FA. On mobile the token is only ever consulted at a fresh login — i.e. exactly after a logout / dead-refresh sign-out — so clearing it there would make the whole feature a no-op. It's therefore kept in its own store, untouched by SessionManager.onSignedOut, and cleared only on a Settings → Server switch, an untrust-all, or server-side revocation (password change/reset, TOTP disable, which make any surviving token inert). This supersedes the earlier handoff note that said clear-on-logout.
  2. The login-time trust cap (trustLimitReached) is surfaced + resolved on the Trusted Devices screen rather than a blocking login-time modal, because the native login has already issued the session by that point (unlike the web cookie step).

How it was tested

  • :app:testDebugUnitTest154 pass / 0 fail (15 new: DTO decode for every new wire shape incl. the login trust/cap fields and one-time recovery codes, plus AccountRepository logic — the 409 cap-body parse into a typed outcome, revoke, and recovery status/generate).
  • :app:assembleDebug clean (JDK 21, -Pksp.incremental=false).
  • Wire field names cross-checked against the merged backend (mobile.controller.js, account.controller.js, session.service/token for the X-Trust-Token header).

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and tests pass locally.
  • Added/updated tests and docs.
  • Commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • AI tools were used. Tool(s): Claude Code (Opus 4.8). I have reviewed and understand every change, and take responsibility for it. AI-authored commits carry a Co-Authored-By trailer.

License

  • I agree my contribution is licensed under this project's license (GNU GPL v3.0 or later).

🤖 Generated with Claude Code

https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr

## What & why Consumes the merged backend trusted-device + MFA feature (RunicGateway/website#93, docs#32) on the Android client, per `docs/android/PLAN.md §4.1.1`. The backend is fully additive; this PR just *uses* the new endpoints. No existing behavior changes for accounts that don't opt in. ### Login (`POST /auth/mobile/login`) - On the existing `401 { totpRequired }` step: a **"Trust this device"** checkbox and a **"use a recovery code instead"** toggle (sends `recoveryCode` instead of `code`). - Sends `trustDevice` / `device_name` and replays a stored `X-Trust-Token`; a valid token skips the TOTP prompt entirely (straight `200`). - A returned `trustToken` is stored in a **dedicated, username-scoped EncryptedSharedPreferences file** (`runic_trust`, AES-256-GCM) — separate from the session store. ### Account → Security - **Trusted Devices** screen: list (`GET /auth/me/trusted-devices`), revoke one (`DELETE …/:id`), untrust all (`DELETE …/trusted-devices`), and trust the current device (`POST …/trusted-devices`, persists the returned token). - **Recovery Codes** screen: remaining count (`GET …/recovery-codes/status`) + password-stepped regenerate (`POST …/recovery-codes/generate`) with a **show-once** copy/share display. The one-time batch returned by enabling 2FA is also surfaced on the account screen. ## Two deliberate design decisions (documented in docs PR RunicGateway/docs#…) 1. **The trust token deliberately survives logout.** It is the native analogue of the web `rg_trust` cookie, which per the canonical design survives logout so the next login skips 2FA. On mobile the token is only ever consulted at a *fresh* login — i.e. exactly after a logout / dead-refresh sign-out — so clearing it there would make the whole feature a no-op. It's therefore kept in its own store, untouched by `SessionManager.onSignedOut`, and cleared only on a **Settings → Server switch**, an **untrust-all**, or server-side revocation (password change/reset, TOTP disable, which make any surviving token inert). *This supersedes the earlier handoff note that said clear-on-logout.* 2. **The login-time trust cap** (`trustLimitReached`) is surfaced + resolved on the Trusted Devices screen rather than a blocking login-time modal, because the native login has already issued the session by that point (unlike the web cookie step). ## How it was tested - `:app:testDebugUnitTest` → **154 pass / 0 fail** (15 new: DTO decode for every new wire shape incl. the login trust/cap fields and one-time recovery codes, plus `AccountRepository` logic — the `409` cap-body parse into a typed outcome, revoke, and recovery status/generate). - `:app:assembleDebug` clean (JDK 21, `-Pksp.incremental=false`). - Wire field names cross-checked against the merged backend (`mobile.controller.js`, `account.controller.js`, `session.service`/`token` for the `X-Trust-Token` header). ## Checklist - [x] I have read CONTRIBUTING.md. - [x] The change builds and tests pass locally. - [x] Added/updated tests and docs. - [x] Commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [x] AI tools were used. Tool(s): `Claude Code (Opus 4.8)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits carry a `Co-Authored-By` trailer. ## License - [x] I agree my contribution is licensed under this project's license (**GNU GPL v3.0 or later**). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
wtclaude added 1 commit 2026-07-22 05:43:16 +00:00
@
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m22s
a1fa4901ef
feat(auth): trusted devices & recovery codes on the mobile client

Consumes the merged backend trusted-device + MFA feature
(RunicGateway/website#93, docs#32) per docs/android/PLAN.md §4.1.1.

Login (POST /auth/mobile/login):
- "Trust this device" checkbox and a "use a recovery code instead"
  toggle on the 401 { totpRequired } step; sends trustDevice /
  recoveryCode / device_name and replays a stored X-Trust-Token.
- A returned trustToken is stored in a dedicated, username-scoped
  EncryptedSharedPreferences file (runic_trust, AES-256-GCM), separate
  from the session store so it deliberately SURVIVES logout — the token
  is only consulted at a fresh login, so clearing it there would make
  the feature a no-op. Cleared only on a Settings→Server switch,
  untrust-all, or server-side revocation. (Supersedes the handoff note
  that said clear-on-logout; matches the canonical rg_trust design.)

Account → Security:
- Trusted Devices screen: list / revoke one / untrust all / trust this
  device (persists the returned token).
- Recovery Codes screen: remaining count + password-stepped regenerate
  with a show-once copy/share display; the one-time batch from enabling
  2FA is also surfaced on the account screen.

Login-time trust cap (trustLimitReached) is surfaced + resolved on the
Trusted Devices screen rather than a blocking login modal, since the
native login has already issued the session.

Tests: DTO decode for all new wire shapes + AccountRepository logic
(the 409 cap-body parse, revoke, recovery). 154 unit tests pass;
assembleDebug clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
@
whitlocktech approved these changes 2026-07-22 06:07:57 +00:00
wtclaude referenced this issue from a commit 2026-07-22 06:24:03 +00:00
@
whitlocktech merged commit 03d4ef6fad into main 2026-07-22 06:26:56 +00:00
whitlocktech deleted branch feature/trusted-devices-mfa 2026-07-22 06:26:57 +00:00
Sign in to join this conversation.
No description provided.