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
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:
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user