feat(auth): M9 Part 2 — native in-app SSO via the mobile bridge #16
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/m9-native-sso"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
GET /auth/providers(discovery, never secrets).state, stashes them, and opensGET /auth/mobile/sso/start?provider&code_challenge&state&redirect_uriin a Custom Tab.runicgateway://auth/callbackwith?code&state(success) or?error&state(failure) — never a token.MainActivity(singleTop) parses the callback and hands the raw params toSsoAuthManager, which verifiesstate,POST /auth/mobile/sso/exchanges the one-timecodewith the stashed verifier, and drives the sameSessionManager.onSignedInthe 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'sbase64url(SHA-256)byte-for-byte.core/auth/sso/SsoAuthManager(Singleton) — the flow orchestrator:buildStartUrl,matchesCallback, andcomplete(state, code, error); pending{state, verifier}held in memory (fails closed on process death); exposes anoutcome: StateFlowthe 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/providersandPOST /auth/mobile/sso/exchange(taggedNO_SESSIONso a credential-style401isn't misread as an expired session or trips the refreshAuthenticator);AuthRepository.ssoProviders()for discovery.MainActivity+ manifest — therunicgateway://auth/callbackintent-filter (VIEW+DEFAULT+BROWSABLE) andlaunchMode="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-matchredirect_uriallowlist); the?code&state/?error&statecallback;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/docsdocs/m9-native-sso-part2.Testing
14 new JVM unit tests (
:app:testDebugUnitTest+14):PkceRFC-7636 vector + charset/no-padding, start-URL building (encoded params + fixedredirect_uri), and the fullcomplete()flow over a fakeSsoApi+ realSessionManager— success signs in; a mismatched/missingstateand a missing pending (process death) fail without exchanging; anerror=callback → declined; a401exchange → expired-code; a replay finds no pending.:app:testDebugUnitTest+:app:lintDebug+:app:assembleDebuggreen (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: Claudetrailer per org policy.🤖 Generated with Claude Code