feat(m3): native auth — login+TOTP, token storage, refresh, access-level menu #8

Merged
whitlocktech merged 1 commits from feat/m3-auth into main 2026-07-20 00:19:51 +00:00
Member

M3 — Auth (functional pass)

Implements M3 of docs/android/PLAN.md §4: the native auth surface. Functional Kotlin pass only — placeholder Compose UI; the design pass is M5. No backend/API change — the app is a pure consumer of the existing mobile bearer + /auth/me surface, so no Swagger regeneration.

What's in it

  • Native username/password (+ single-request TOTP) login over POST /auth/mobile/login (§4.1). A 401 { totpRequired } reveals the code field (parsed from the error body) and a wrong code re-lands as a code error; 429 surfaces a friendly backoff message.
  • Token storage in EncryptedSharedPreferences — a TokenStore (interface + Tink/AES-256-GCM impl) behind SessionManager, the single source of truth for the in-memory bearer + the observable Session. Base URL stays in plain DataStore (§4.3).
  • AuthInterceptor attaches the bearer; TokenAuthenticator does a one-shot, mutex-serialized refresh on a bearer 401 and replays the request. Refresh runs on its own bare client (no interceptor/authenticator) so it can never recurse; rotated single-use tokens are stored atomically; a dead refresh (401) signs out while a transient network error keeps the session.
  • Logout (POST /auth/mobile/logout, this session or all devices) tears down locally even if the call fails.
  • GET /auth/me role re-validation on every resume (LifecycleResumeEffect); a surviving 401 signs out, so a server-side demotion drops menu access promptly (role stays advisory — the backend is authority).
  • Declarative access-level menu — one list filtered by visibleEntries (public / signed-in / player), a Sign in / Sign out toggle, and a My Account screen (identity + role + sign-out / sign-out-everywhere).
  • Custom-Tab hand-offs (androidx.browser) to the website's own pages for register (/account/register), forgot-password (/account/forgot), and SSO (/account/login) — no native screens (§4.2).
  • The Settings → Server switch now also clears the stored session (§3).

DTOs + the Retrofit interfaces are hand-authored + spec-aligned (as recorded for M1).

Decision: biometric app-lock deferred to M6

§4.3/§9 flag an optional biometric app-lock. Deferred to M6 (release hardening): tokens are already encrypted at rest, so it is opt-in UX, not a v1 requirement. Recorded in docs/android/PLAN.md (docs PR: docs/android-m3-landed).

Tests (18, all green)

Auth-DTO decode (incl. totpRequired vs a plain credential 401), the SessionManager lifecycle over a fake store, and the access-level menu filter + role mapping.

Verification

./gradlew lint test assembleDebug passes locally.

Local-build note: on a JDK 21 host, build with -Pksp.incremental=false (KSP incremental storage crash). CI runs JDK 17 and is unaffected.


AI-assisted: authored with Claude Code (Opus). AI-authored commit carries a Co-Authored-By: Claude trailer per org policy.

🤖 Generated with Claude Code

## M3 — Auth (functional pass) Implements **M3** of `docs/android/PLAN.md` §4: the native auth surface. Functional Kotlin pass only — placeholder Compose UI; the design pass is M5. **No backend/API change** — the app is a pure consumer of the existing mobile bearer + `/auth/me` surface, so no Swagger regeneration. ### What's in it - **Native username/password (+ single-request TOTP) login** over `POST /auth/mobile/login` (§4.1). A `401 { totpRequired }` reveals the code field (parsed from the error body) and a wrong code re-lands as a code error; `429` surfaces a friendly backoff message. - **Token storage in EncryptedSharedPreferences** — a `TokenStore` (interface + Tink/AES-256-GCM impl) behind `SessionManager`, the single source of truth for the in-memory bearer + the observable `Session`. Base URL stays in plain DataStore (§4.3). - **`AuthInterceptor`** attaches the bearer; **`TokenAuthenticator`** does a **one-shot, mutex-serialized refresh** on a bearer `401` and replays the request. Refresh runs on its own **bare** client (no interceptor/authenticator) so it can never recurse; rotated single-use tokens are stored atomically; a dead refresh (`401`) signs out while a transient network error keeps the session. - **Logout** (`POST /auth/mobile/logout`, this session or all devices) tears down locally even if the call fails. - **`GET /auth/me` role re-validation on every resume** (`LifecycleResumeEffect`); a surviving `401` signs out, so a server-side demotion drops menu access promptly (role stays advisory — the backend is authority). - **Declarative access-level menu** — one list filtered by `visibleEntries` (public / signed-in / player), a Sign in / Sign out toggle, and a **My Account** screen (identity + role + sign-out / sign-out-everywhere). - **Custom-Tab hand-offs** (androidx.browser) to the website's own pages for register (`/account/register`), forgot-password (`/account/forgot`), and SSO (`/account/login`) — no native screens (§4.2). - The Settings → **Server switch now also clears the stored session** (§3). DTOs + the Retrofit interfaces are hand-authored + spec-aligned (as recorded for M1). ### Decision: biometric app-lock deferred to M6 §4.3/§9 flag an *optional* biometric app-lock. Deferred to M6 (release hardening): tokens are already encrypted at rest, so it is opt-in UX, not a v1 requirement. Recorded in `docs/android/PLAN.md` (docs PR: `docs/android-m3-landed`). ### Tests (18, all green) Auth-DTO decode (incl. `totpRequired` vs a plain credential `401`), the `SessionManager` lifecycle over a fake store, and the access-level menu filter + role mapping. ### Verification `./gradlew lint test assembleDebug` passes locally. > Local-build note: on a JDK 21 host, build with `-Pksp.incremental=false` (KSP incremental storage crash). CI runs JDK 17 and is unaffected. --- **AI-assisted:** authored with **Claude Code** (Opus). AI-authored commit carries a `Co-Authored-By: Claude` trailer per org policy. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-07-20 00:09:41 +00:00
feat(m3): native auth — login+TOTP, token storage, refresh, access-level menu
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m41s
1c56eda64b
Implements M3 (docs/android/PLAN.md §4): the functional Kotlin auth pass.

- Native username/password (+ single-request TOTP) login over the existing
  POST /auth/mobile/login; a 401 { totpRequired } reveals the code field, 429
  surfaces a backoff message (§4.1).
- Token pair in EncryptedSharedPreferences (TokenStore behind SessionManager,
  the single source of truth for the in-memory bearer + observable Session);
  base URL stays in plain DataStore (§4.3).
- OkHttp AuthInterceptor (bearer) + TokenAuthenticator: one-shot, mutex-
  serialized refresh-on-401 that replays the request, on its own bare client so
  it can never recurse; single-use rotation; dead refresh signs out, transient
  network keeps the session.
- Logout (POST /auth/mobile/logout, this session or all devices) tears down
  locally even on failure.
- GET /auth/me re-validates the role on every resume; a surviving 401 signs out
  (role stays advisory — backend is authority).
- Declarative access-level menu (visibleEntries: public/signed-in/player) with a
  Sign in / Sign out toggle + a My Account screen.
- Custom-Tab hand-offs (androidx.browser) to the website for register / forgot-
  password / SSO — no native screens (§4.2).
- Settings → Server switch now also clears the stored session (§3).

Biometric app-lock is deferred to M6 (tokens already encrypted at rest; it is
opt-in UX, not a v1 requirement — decided at M3).

JVM unit tests (18): auth-DTO decode (incl. totpRequired vs a plain credential
401), the SessionManager lifecycle over a fake store, and the menu access filter
+ role mapping. No backend/API change — a pure consumer of the existing mobile
bearer + /auth/me surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
whitlocktech approved these changes 2026-07-20 00:17:42 +00:00
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-07-20 00:17:51 +00:00
whitlocktech merged commit ca704caaaf into main 2026-07-20 00:19:51 +00:00
whitlocktech deleted branch feat/m3-auth 2026-07-20 00:19:51 +00:00
Sign in to join this conversation.
No description provided.