From 7665975d59a8c6dd1e3cea29f53583ba072d5a9c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 20 Jul 2026 17:52:36 -0500 Subject: [PATCH] =?UTF-8?q?feat(auth):=20M9=20Part=202=20=E2=80=94=20nativ?= =?UTF-8?q?e=20in-app=20SSO=20via=20the=20mobile=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the app client for the Mobile SSO Authorization Bridge (PLAN.md §4.2): native "Sign in with " 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 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr --- app/src/main/AndroidManifest.xml | 18 ++ .../java/com/runicgateway/app/MainActivity.kt | 35 +++- .../runicgateway/app/core/auth/sso/Pkce.kt | 50 +++++ .../app/core/auth/sso/SsoAuthManager.kt | 195 ++++++++++++++++++ .../com/runicgateway/app/data/api/SsoApi.kt | 40 ++++ .../runicgateway/app/data/api/dto/SsoDto.kt | 42 ++++ .../app/data/repository/AuthRepository.kt | 16 ++ .../com/runicgateway/app/di/NetworkModule.kt | 6 + .../runicgateway/app/ui/auth/LoginScreen.kt | 37 +++- .../app/ui/auth/LoginViewModel.kt | 59 +++++- app/src/main/res/values/strings.xml | 3 + .../app/core/auth/sso/PkceTest.kt | 49 +++++ .../app/core/auth/sso/SsoAuthManagerTest.kt | 182 ++++++++++++++++ 13 files changed, 725 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/core/auth/sso/Pkce.kt create mode 100644 app/src/main/java/com/runicgateway/app/core/auth/sso/SsoAuthManager.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/SsoApi.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/SsoDto.kt create mode 100644 app/src/test/java/com/runicgateway/app/core/auth/sso/PkceTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/core/auth/sso/SsoAuthManagerTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 48e62da..11822b2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,14 +24,32 @@ android:supportsRtl="true" android:theme="@style/Theme.RunicGateway"> + + + + + + + + + + Sign in with %1$s Incorrect username or password. That code didn\'t match. Try the current code. Too many attempts. Please try again shortly. Something went wrong. Please try again. Can\'t reach the site. Check your connection and try again. + Couldn\'t complete that sign-in. Please try again. My account diff --git a/app/src/test/java/com/runicgateway/app/core/auth/sso/PkceTest.kt b/app/src/test/java/com/runicgateway/app/core/auth/sso/PkceTest.kt new file mode 100644 index 0000000..6295d1a --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/auth/sso/PkceTest.kt @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.auth.sso + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * PKCE Layer B primitives (PLAN.md §4.2). The challenge encoding must match the + * backend byte-for-byte (`base64url(SHA-256(verifier))`, no padding) or `/exchange` + * rejects every code — so it is pinned against the RFC 7636 test vector. + */ +class PkceTest { + + // RFC 4648 §5 URL-safe base64 alphabet, no padding. + private val base64UrlNoPad = Regex("^[A-Za-z0-9_-]+$") + + @Test fun `challenge matches the RFC 7636 vector`() { + // RFC 7636 Appendix B. + val verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + assertEquals("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", Pkce.challengeOf(verifier)) + } + + @Test fun `verifier is url-safe base64 without padding`() { + val verifier = Pkce.newVerifier() + assertTrue("verifier charset: $verifier", base64UrlNoPad.matches(verifier)) + // 32 random bytes → 43 base64 chars (no padding), inside RFC 7636's 43–128. + assertEquals(43, verifier.length) + } + + @Test fun `challenge is url-safe base64 without padding`() { + val challenge = Pkce.challengeOf(Pkce.newVerifier()) + assertTrue("challenge charset: $challenge", base64UrlNoPad.matches(challenge)) + // SHA-256 (32 bytes) → 43 base64 chars, no '=' padding. + assertEquals(43, challenge.length) + } + + @Test fun `verifiers and states are unique per call`() { + assertNotEquals(Pkce.newVerifier(), Pkce.newVerifier()) + assertNotEquals(Pkce.newState(), Pkce.newState()) + } + + @Test fun `state is url-safe base64 without padding`() { + assertTrue(base64UrlNoPad.matches(Pkce.newState())) + } +} diff --git a/app/src/test/java/com/runicgateway/app/core/auth/sso/SsoAuthManagerTest.kt b/app/src/test/java/com/runicgateway/app/core/auth/sso/SsoAuthManagerTest.kt new file mode 100644 index 0000000..aa9a008 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/auth/sso/SsoAuthManagerTest.kt @@ -0,0 +1,182 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.auth.sso + +import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.core.auth.SessionManager +import com.runicgateway.app.core.auth.StoredSession +import com.runicgateway.app.core.auth.TokenStore +import com.runicgateway.app.core.net.BaseUrlHolder +import com.runicgateway.app.data.api.SsoApi +import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest +import com.runicgateway.app.data.api.dto.MobileTokenResponse +import com.runicgateway.app.data.api.dto.SafeUserDto +import com.runicgateway.app.data.api.dto.SsoProviderDto +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Response + +/** + * The native SSO bridge orchestration (PLAN.md §4.2, M9): start-URL building, the + * CSRF/state guard, the code→token exchange, and that a success drives the *same* + * [SessionManager] the password login uses. Runs over a fake [SsoApi] + a real + * [SessionManager] on a fake [TokenStore]; no Android framework types are touched. + */ +class SsoAuthManagerTest { + + private class FakeTokenStore(var stored: StoredSession? = null) : TokenStore { + override fun load(): StoredSession? = stored + override fun save(session: StoredSession) { stored = session } + override fun clear() { stored = null } + } + + /** Records the exchange it was called with and returns a scripted response. */ + private class FakeSsoApi( + private val exchangeResult: () -> Response, + ) : SsoApi { + var exchangeCalls = 0 + var lastRequest: MobileSsoExchangeRequest? = null + + override suspend fun providers(): List = emptyList() + + override suspend fun exchange(body: MobileSsoExchangeRequest): Response { + exchangeCalls++ + lastRequest = body + return exchangeResult() + } + } + + private fun tokenPair() = MobileTokenResponse( + accessToken = "access-A", + refreshToken = "refresh-A", + expiresIn = "15m", + user = SafeUserDto(id = 7, username = "alice", role = "player"), + ) + + private fun error(code: Int): Response = + Response.error(code, "".toResponseBody("application/json".toMediaTypeOrNull())) + + private fun managerWith( + api: SsoApi, + session: SessionManager, + base: String? = "https://shard.example.com/", + ): SsoAuthManager { + val holder = BaseUrlHolder() + if (base != null) holder.set(base.toHttpUrl()) + return SsoAuthManager(api, session, holder) + } + + /** Build a start URL and pull the generated `state` back out of it. */ + private fun startAndState(mgr: SsoAuthManager, provider: String = "google"): String { + val url = mgr.buildStartUrl(SsoProviderDto(id = provider, name = "Google").id)!! + return url.toHttpUrl().queryParameter("state")!! + } + + @Test fun `buildStartUrl carries provider, challenge, state and the fixed redirect`() { + val mgr = managerWith(FakeSsoApi { tokenPair().let { Response.success(it) } }, SessionManager(FakeTokenStore())) + val url = mgr.buildStartUrl("google")!! + val http = url.toHttpUrl() + + assertTrue(url.startsWith("https://shard.example.com/api/v1/auth/mobile/sso/start")) + assertEquals("google", http.queryParameter("provider")) + assertEquals(SsoAuthManager.REDIRECT_URI, http.queryParameter("redirect_uri")) + assertTrue(!http.queryParameter("code_challenge").isNullOrBlank()) + assertTrue(!http.queryParameter("state").isNullOrBlank()) + } + + @Test fun `buildStartUrl returns null when no base url is set`() { + val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()), base = null) + assertNull(mgr.buildStartUrl("google")) + } + + @Test fun `successful callback exchanges and signs in`() = runTest { + val api = FakeSsoApi { Response.success(tokenPair()) } + val session = SessionManager(FakeTokenStore()) + val mgr = managerWith(api, session) + + val state = startAndState(mgr) + mgr.complete(state = state, code = "auth-code-1", error = null) + + assertEquals(1, api.exchangeCalls) + assertEquals("auth-code-1", api.lastRequest?.code) + assertTrue(session.state.value is Session.SignedIn) + assertEquals("access-A", session.currentAccessToken()) + assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value) + } + + @Test fun `state mismatch fails without exchanging`() = runTest { + val api = FakeSsoApi { Response.success(tokenPair()) } + val session = SessionManager(FakeTokenStore()) + val mgr = managerWith(api, session) + + startAndState(mgr) // establishes a pending with a different state + mgr.complete(state = "not-the-state", code = "auth-code-1", error = null) + + assertEquals(0, api.exchangeCalls) + assertTrue(session.state.value is Session.SignedOut) + assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value) + } + + @Test fun `missing pending flow (process death) fails closed`() = runTest { + val api = FakeSsoApi { Response.success(tokenPair()) } + val mgr = managerWith(api, SessionManager(FakeTokenStore())) + + // No buildStartUrl → nothing stashed; a callback can't be trusted. + mgr.complete(state = "anything", code = "auth-code-1", error = null) + + assertEquals(0, api.exchangeCalls) + assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value) + } + + @Test fun `error callback maps to a declined sign-in and does not exchange`() = runTest { + val api = FakeSsoApi { Response.success(tokenPair()) } + val mgr = managerWith(api, SessionManager(FakeTokenStore())) + + val state = startAndState(mgr) + mgr.complete(state = state, code = null, error = "access_denied") + + assertEquals(0, api.exchangeCalls) + assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.DENIED), mgr.outcome.value) + } + + @Test fun `401 exchange maps to expired code`() = runTest { + val api = FakeSsoApi { error(401) } + val session = SessionManager(FakeTokenStore()) + val mgr = managerWith(api, session) + + val state = startAndState(mgr) + mgr.complete(state = state, code = "stale-code", error = null) + + assertEquals(1, api.exchangeCalls) + assertTrue(session.state.value is Session.SignedOut) + assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.EXPIRED_CODE), mgr.outcome.value) + } + + @Test fun `a second delivery of the same callback finds no pending`() = runTest { + val api = FakeSsoApi { Response.success(tokenPair()) } + val mgr = managerWith(api, SessionManager(FakeTokenStore())) + + val state = startAndState(mgr) + mgr.complete(state = state, code = "auth-code-1", error = null) + mgr.complete(state = state, code = "auth-code-1", error = null) // replay + + // Only the first delivery exchanged; the replay fails the state guard. + assertEquals(1, api.exchangeCalls) + assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value) + } + + @Test fun `matchesCallback only accepts the fixed scheme host and path`() { + val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore())) + assertTrue(mgr.matchesCallback("runicgateway", "auth", "/callback")) + assertTrue(!mgr.matchesCallback("https", "auth", "/callback")) + assertTrue(!mgr.matchesCallback("runicgateway", "auth", "/other")) + assertTrue(!mgr.matchesCallback("runicgateway", "evil", "/callback")) + } +} -- 2.49.1