docs(android): record the M9 Part 2 native-SSO app plan #24
@@ -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
|
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.
|
(§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=<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
|
**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),
|
(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)
|
✅ version/health surfacing (item 4) and ✅ branding for mobile (item 6). **Push notifications (item 3)
|
||||||
|
|||||||
Reference in New Issue
Block a user