feat(auth): M9 Part 2 — native in-app SSO via the mobile bridge #16

Merged
whitlocktech merged 1 commits from feat/m9-native-sso into main 2026-07-20 23:14:47 +00:00
Member

What & why

The app side of M9 native SSO login (docs/android/PLAN.md §4.2, §9 item 10). Native "Sign in with Google / Discord" without shipping any OAuth secret in the app — the website stays the identity authority. A new, additive auth slice that feeds the existing M3 session machinery; no other screen's data flow changes, and no backend work (every endpoint is already merged in Part 1).

How it works

  1. The login screen renders a button per provider from GET /auth/providers (discovery, never secrets).
  2. Tapping one mints PKCE (Layer B) + a CSRF state, stashes them, and opens GET /auth/mobile/sso/start?provider&code_challenge&state&redirect_uri in a Custom Tab.
  3. The website bounces through the IdP (+ TOTP if enabled) and deep-links back to the fixed runicgateway://auth/callback with ?code&state (success) or ?error&state (failure) — never a token.
  4. MainActivity (singleTop) parses the callback and hands the raw params to SsoAuthManager, which verifies state, POST /auth/mobile/sso/exchanges the one-time code with the stashed verifier, and drives the same SessionManager.onSignedIn the password login uses — so push registration and the menu react identically.

Changes

  • core/auth/sso/Pkce — pure-JVM RFC 7636 S256 verifier/challenge + state, encoded to match the backend's base64url(SHA-256) byte-for-byte.
  • core/auth/sso/SsoAuthManager (Singleton) — the flow orchestrator: buildStartUrl, matchesCallback, and complete(state, code, error); pending {state, verifier} held in memory (fails closed on process death); exposes an outcome: StateFlow the login screen consumes (robust to a VM/activity recreation while the Custom Tab is foreground). No new token-storage or refresh code.
  • data/api/SsoApi + SsoDtoGET /auth/providers and POST /auth/mobile/sso/exchange (tagged NO_SESSION so a credential-style 401 isn't misread as an expired session or trips the refresh Authenticator); AuthRepository.ssoProviders() for discovery.
  • MainActivity + manifest — the runicgateway://auth/callback intent-filter (VIEW+DEFAULT+BROWSABLE) and launchMode="singleTop"; the Uri is parsed at the Android edge and raw params handed to the manager (keeping it framework-free and unit-testable).
  • ui/auth/LoginScreen + LoginViewModel — a native provider button list opening the bridge in a Custom Tab; a success pops back like a password sign-in, a failure surfaces a friendly inline message. Falls back to the website login hand-off when discovery is empty or the base URL is unset.

Contract (Part 1, already merged)

GET /auth/providers[{ id, name, icon, loginUrl, priority }]; GET /auth/mobile/sso/start (Custom-Tab redirect, exact-match redirect_uri allowlist); the ?code&state / ?error&state callback; POST /auth/mobile/sso/exchange { code, code_verifier } → the same { accessToken, refreshToken, expiresIn, user } pair as /auth/mobile/login.

Scope

Custom scheme only for now — HTTPS App Links are deferred (docs/android/APP_LINKS.md). Docs plan block: RunicGateway/docs docs/m9-native-sso-part2.

Testing

14 new JVM unit tests (:app:testDebugUnitTest +14): Pkce RFC-7636 vector + charset/no-padding, start-URL building (encoded params + fixed redirect_uri), and the full complete() flow over a fake SsoApi + real SessionManager — success signs in; a mismatched/missing state and a missing pending (process death) fail without exchanging; an error= callback → declined; a 401 exchange → expired-code; a replay finds no pending. :app:testDebugUnitTest + :app:lintDebug + :app:assembleDebug green (JDK 21, -Pksp.incremental=false). On-device pass against a live IdP is the one open QA item.

AI disclosure

Authored with Claude Code (Claude Opus). Commits carry a Co-Authored-By: Claude trailer per org policy.

🤖 Generated with Claude Code

## What & why The app side of **M9 native SSO login** (docs/android/PLAN.md §4.2, §9 item 10). Native "Sign in with Google / Discord" **without shipping any OAuth secret in the app** — the website stays the identity authority. A new, additive auth slice that feeds the *existing* M3 session machinery; **no other screen's data flow changes, and no backend work** (every endpoint is already merged in Part 1). ## How it works 1. The login screen renders a button per provider from `GET /auth/providers` (discovery, never secrets). 2. Tapping one mints **PKCE (Layer B) + a CSRF `state`**, stashes them, and opens `GET /auth/mobile/sso/start?provider&code_challenge&state&redirect_uri` in a **Custom Tab**. 3. The website bounces through the IdP (+ TOTP if enabled) and deep-links back to the fixed `runicgateway://auth/callback` with `?code&state` (success) or `?error&state` (failure) — **never a token**. 4. `MainActivity` (singleTop) parses the callback and hands the raw params to `SsoAuthManager`, which verifies `state`, `POST /auth/mobile/sso/exchange`s the one-time `code` with the stashed verifier, and drives the **same** `SessionManager.onSignedIn` the password login uses — so push registration and the menu react identically. ## Changes - **`core/auth/sso/Pkce`** — pure-JVM RFC 7636 S256 verifier/challenge + `state`, encoded to match the backend's `base64url(SHA-256)` byte-for-byte. - **`core/auth/sso/SsoAuthManager`** (Singleton) — the flow orchestrator: `buildStartUrl`, `matchesCallback`, and `complete(state, code, error)`; pending `{state, verifier}` held in memory (**fails closed** on process death); exposes an `outcome: StateFlow` the login screen consumes (robust to a VM/activity recreation while the Custom Tab is foreground). No new token-storage or refresh code. - **`data/api/SsoApi` + `SsoDto`** — `GET /auth/providers` and `POST /auth/mobile/sso/exchange` (tagged `NO_SESSION` so a credential-style `401` isn't misread as an expired session or trips the refresh `Authenticator`); `AuthRepository.ssoProviders()` for discovery. - **`MainActivity` + manifest** — the `runicgateway://auth/callback` intent-filter (`VIEW`+`DEFAULT`+`BROWSABLE`) and `launchMode="singleTop"`; the Uri is parsed at the Android edge and raw params handed to the manager (keeping it framework-free and unit-testable). - **`ui/auth/LoginScreen` + `LoginViewModel`** — a native provider button list opening the bridge in a Custom Tab; a success pops back like a password sign-in, a failure surfaces a friendly inline message. Falls back to the website login hand-off when discovery is empty or the base URL is unset. ## Contract (Part 1, already merged) `GET /auth/providers` → `[{ id, name, icon, loginUrl, priority }]`; `GET /auth/mobile/sso/start` (Custom-Tab redirect, exact-match `redirect_uri` allowlist); the `?code&state` / `?error&state` callback; `POST /auth/mobile/sso/exchange { code, code_verifier }` → the same `{ accessToken, refreshToken, expiresIn, user }` pair as `/auth/mobile/login`. ## Scope Custom scheme only for now — HTTPS App Links are deferred (`docs/android/APP_LINKS.md`). Docs plan block: **`RunicGateway/docs`** `docs/m9-native-sso-part2`. ## Testing 14 new JVM unit tests (`:app:testDebugUnitTest` +14): `Pkce` RFC-7636 vector + charset/no-padding, start-URL building (encoded params + fixed `redirect_uri`), and the full `complete()` flow over a fake `SsoApi` + real `SessionManager` — success signs in; a mismatched/missing `state` and a missing pending (process death) fail **without** exchanging; an `error=` callback → declined; a `401` exchange → expired-code; a replay finds no pending. `:app:testDebugUnitTest` + `:app:lintDebug` + `:app:assembleDebug` green (JDK 21, `-Pksp.incremental=false`). **On-device pass against a live IdP is the one open QA item.** ## AI disclosure Authored with **Claude Code** (Claude Opus). Commits carry 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 22:54:05 +00:00
feat(auth): M9 Part 2 — native in-app SSO via the mobile bridge
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m34s
7665975d59
Add the app client for the Mobile SSO Authorization Bridge (PLAN.md §4.2):
native "Sign in with <provider>" without shipping any OAuth secret.

- Pkce: pure-JVM RFC 7636 S256 verifier/challenge + CSRF state, encoded to
  match the backend's base64url(SHA-256) exactly.
- SsoAuthManager (Singleton): mints PKCE+state, builds the /auth/mobile/sso/start
  URL for a Custom Tab, verifies the returned state, exchanges the one-time code
  with the stashed verifier, and drives the existing SessionManager.onSignedIn —
  no new token-storage or refresh code. Pending flow is in-memory (fails closed on
  process death). Exposes an outcome StateFlow the login screen consumes.
- SsoApi + DTOs: GET /auth/providers discovery and POST /auth/mobile/sso/exchange
  (tagged NO_SESSION so a credential 401 isn't read as an expired session).
- MainActivity: runicgateway://auth/callback intent-filter + singleTop; parses the
  callback Uri (the Android edge) and hands raw params to SsoAuthManager.
- LoginScreen/ViewModel: render a button per discovered provider, opening the
  bridge in a Custom Tab; fall back to the website login hand-off when none.

Additive — no other screen's data flow changes; no backend work. Custom scheme
only for now (App Links deferred, APP_LINKS.md).

Tests (JVM, +14): Pkce vector/charset, start-URL building, and the full
complete() flow over a fake SsoApi + real SessionManager (success signs in;
state mismatch / missing pending fail without exchanging; error callback →
declined; 401 → expired-code; replay finds no pending).

Co-Authored-By: Claude <noreply@anthropic.com>
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 22:58:01 +00:00
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-07-20 22:58:07 +00:00
whitlocktech merged commit ab68fab382 into main 2026-07-20 23:14:47 +00:00
whitlocktech deleted branch feat/m9-native-sso 2026-07-20 23:14:48 +00:00
Sign in to join this conversation.
No description provided.