From 44544dc3bc5f786ba8f4445ebb5fa8d731d60907 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 20 Jul 2026 17:52:56 -0500 Subject: [PATCH] docs(android): record the M9 Part 2 native-SSO app plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the "M9 plan — native SSO login" block to android/PLAN.md: the two-part (backend-first) split, the frozen Part-1 bridge contract the app codes against (/auth/providers, /auth/mobile/sso/start Custom-Tab redirect, the code/error callback deep link, /auth/mobile/sso/exchange), and the six Part-2 app work items (PKCE+state, SsoAuthManager, SsoApi+DTOs, the callback intent-filter, the login-screen provider list, and the JVM tests). Co-Authored-By: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr --- android/PLAN.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/android/PLAN.md b/android/PLAN.md index 7c16117..bc5463e 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -316,6 +316,76 @@ pinned. No `website`/`link`/`servuo-plugins` code change is expected in Part 2. Ships as `RunicGateway/Android-app#15`; bumps `versionCode`/`versionName` for a post-v1 release (§10). Like M1–M4 it records itself in the §9 build-progress block on landing. +### M9 plan — native SSO login (in progress) + +M9 spans `website/` + `android-app/` + `docs/`, so — like M7 — it ships in **two parts**, backend +first (the app is a pure consumer of the bridge contract; §4.2, §9 item 10). + +**Part 1 — `website/` backend + `docs/` — ✅ LANDED** (the Mobile SSO Authorization Bridge: +`mobile_auth_sessions`/`mobile_auth_codes` tables, `GET /auth/mobile/sso/start`, the `mode:'mobile'` +branch in the reused SSO callback + TOTP completion, `POST /auth/mobile/sso/exchange`, the exact-match +`MOBILE_AUTH_REDIRECT_URIS` allowlist, and bridge-table cleanup). Canonical ref: +`../website/BACKEND_DESIGN.md` → "Mobile SSO Authorization Bridge". + +**Part 2 — the Android app (this milestone).** The native in-app "Sign in with Google / Discord" +client. **Additive** — a new auth slice (`core/auth/sso` + a `SsoApi`/`SsoAuthManager` + a login-screen +provider list) that feeds the *existing* M3 session machinery; it adds **no** new token-storage or +refresh code, and touches no other screen. **No backend work** — every endpoint it calls is merged. + +The Part-1 contract the app codes against (verified against the merged `website` source): +- `GET /auth/providers` → `[{ id, name, icon, loginUrl, priority }]` (public discovery, no secrets). + `icon` ∈ `google|discord|oidc|oauth2`. Render the provider buttons from this — don't hardcode. +- `GET /auth/mobile/sso/start?provider&code_challenge&state&redirect_uri` — **opened in a Custom Tab** + (not an XHR): it 302s through the IdP and finally deep-links back to `redirect_uri`. `redirect_uri` + must be an **exact** allowlist entry — the app always sends the one fixed callback + `runicgateway://auth/callback`. +- The callback deep link carries **either** `?code=&state=` (success) **or** + `?error=&state=` (`invalid_provider`/`provider_unavailable`/`server_error`, or an + IdP/link refusal) — **never a token**. +- `POST /auth/mobile/sso/exchange` `{ code, code_verifier }` → the **same** `{ accessToken, + refreshToken, expiresIn, user }` pair as `/auth/mobile/login`; `401` on an unknown/expired/used code + or a PKCE-verifier mismatch. + +Work items: + +1. **PKCE + state (Layer B, app↔website).** A pure-JVM `Pkce` helper (unit-testable, no Android + framework types): `code_verifier` = 32 random bytes base64url (RFC 7636 S256), `code_challenge` = + base64url(SHA-256(verifier)), plus a random `state`. `java.util.Base64` URL encoder without padding + + `MessageDigest` — matches the backend's `crypto.createHash('sha256')…base64url` exactly. +2. **`SsoAuthManager` (Singleton) — the flow orchestrator.** Holds the **pending** `{state, verifier}` + in memory (lost on process death → the exchange fails closed and the user retries; acceptable and + safe, documented). `buildStartUrl(provider)` mints PKCE+state, stashes pending, and builds the + absolute `/start` URL off `BaseUrlHolder` for the Custom Tab. `isCallback(uri)` matches our scheme; + `complete(uri)` verifies `state` (CSRF), maps an `error`, exchanges the `code` with the stashed + `verifier`, and on success drives `SessionManager.onSignedIn` — the *same* entry the password login + uses, so push registration (`PushManager` observes the session) and the menu react identically. It + exposes an `outcome: StateFlow` (Idle/Success/Failed(reason)) the login screen consumes, robust to a + ViewModel/activity recreation while the Custom Tab is foreground. +3. **`SsoApi` + DTOs.** `GET api/v1/auth/providers` → `List`; `POST + api/v1/auth/mobile/sso/exchange` tagged `Http.NO_SESSION_HEADER` (no bearer; a credential-style + `401` must not be read as an expired session or trip the refresh `Authenticator`) → the reused + `MobileTokenResponse`. Lenient Json (additive fields safe, §8). +4. **Deep link.** Register the `runicgateway://auth/callback` intent-filter on `MainActivity` + (`VIEW` + `DEFAULT` + `BROWSABLE`, `scheme/host/path` from one shared constant) and set + `launchMode="singleTop"` so the returning Custom Tab reuses the running task; `onCreate`/`onNewIntent` + route a matching `ACTION_VIEW` intent to `SsoAuthManager.complete` on `lifecycleScope`. Custom scheme + only for now — App Links deferred (`APP_LINKS.md`). +5. **Login screen.** Replace the single "SSO on the website" hand-off with a native provider list from + `GET /auth/providers`: one button per provider (Google/Discord/OIDC glyph from `icon`), each opening + its `/start` URL in a Custom Tab via the existing `WebHandoff`. The `LoginViewModel` collects + `SsoAuthManager.outcome` → a success pops back like a password sign-in; a failure surfaces a friendly + inline error (reusing the existing `LoginError` channel + a new SSO string). Falls back to the + website login hand-off when discovery returns no providers or the base URL is unset. +6. **Tests (JVM, `testDebugUnitTest`).** `Pkce` (verifier charset/length, challenge = base64url-SHA-256 + of a known vector, no padding), start-URL building (encoded params, fixed `redirect_uri`), and + `SsoAuthManager.complete` over a fake `SsoApi` + `SessionManager`: success signs in; a mismatched or + missing `state` fails without exchanging; an `error=` callback maps to the right reason; a `401` + exchange maps to expired-code; a missing pending (process death) fails closed. + +Ships as a `RunicGateway/Android-app` PR; bumps `versionCode`/`versionName` for a post-v1 release +(§10) and records itself in the §9 build-progress block on landing. No `website`/`link`/`servuo-plugins` +code change is expected in Part 2. + **Prerequisite progress (§8):** all v1 prerequisites are **done** (2026-07-19) — ✅ password reset (item 2; website#75 + docs#8), ✅ role-agnostic `/auth/me/*` self surface (item 1; website#76 + docs#10), ✅ version/health surfacing (item 4) and ✅ branding for mobile (item 6). **Push notifications (item 3) -- 2.49.1