Merge pull request 'docs(android): record the M9 Part 2 native-SSO app plan' (#24) from docs/m9-native-sso-part2 into main

Reviewed-on: #24
This commit is contained in:
2026-07-20 22:56:27 +00:00

View File

@@ -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 M1M4 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=<one-time>&state=<echoed>` (success) **or**
`?error=<reason>&state=<echoed>` (`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<SsoProviderDto>`; `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)