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

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
This commit is contained in:
2026-07-20 17:52:36 -05:00
parent d97c06d6e1
commit 7665975d59
13 changed files with 725 additions and 7 deletions

View File

@@ -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 43128.
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()))
}
}

View File

@@ -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<MobileTokenResponse>,
) : SsoApi {
var exchangeCalls = 0
var lastRequest: MobileSsoExchangeRequest? = null
override suspend fun providers(): List<SsoProviderDto> = emptyList()
override suspend fun exchange(body: MobileSsoExchangeRequest): Response<MobileTokenResponse> {
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<MobileTokenResponse> =
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"))
}
}