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

@@ -24,14 +24,32 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.RunicGateway"> android:theme="@style/Theme.RunicGateway">
<!-- singleTop so the SSO Custom Tab returning via the deep link reuses the
running task (onNewIntent) instead of stacking a second activity. -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.RunicGateway"> android:theme="@style/Theme.RunicGateway">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Native SSO callback (M9, PLAN.md §4.2). The bridge deep-links the
one-time authorization code back to this fixed, app-owned custom
scheme; it must match SsoAuthManager.REDIRECT_URI and the backend's
MOBILE_AUTH_REDIRECT_URIS allowlist exactly. Custom scheme only for
now — HTTPS App Links are deferred (docs/android/APP_LINKS.md). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="runicgateway"
android:host="auth"
android:path="/callback" />
</intent-filter>
</activity> </activity>
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11). <!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).

View File

@@ -5,11 +5,13 @@ package com.runicgateway.app
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.lifecycleScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -18,6 +20,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.runicgateway.app.core.auth.sso.SsoAuthManager
import com.runicgateway.app.core.push.PushNotifier import com.runicgateway.app.core.push.PushNotifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -30,6 +33,8 @@ import com.runicgateway.app.ui.connect.ConnectScreen
import com.runicgateway.app.ui.theme.RunicGatewayTheme import com.runicgateway.app.ui.theme.RunicGatewayTheme
import com.runicgateway.app.ui.theme.parseBrandColor import com.runicgateway.app.ui.theme.parseBrandColor
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
/** /**
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run * Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
@@ -40,6 +45,13 @@ import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
// Native SSO bridge — handles the runicgateway://auth/callback deep link (M9,
// §4.2). Field-injected because the callback can arrive independent of any
// ViewModel; a successful exchange flips the SessionManager the whole app
// observes, and the login screen consumes SsoAuthManager.outcome.
@Inject
lateinit var ssoAuthManager: SsoAuthManager
// The stream a tapped push notification wants to open (§11, M7 Part 2 item 7). // The stream a tapped push notification wants to open (§11, M7 Part 2 item 7).
// Set from the launching intent and from onNewIntent (the activity is singleTop), // Set from the launching intent and from onNewIntent (the activity is singleTop),
// consumed once by RunicApp which navigates to the stream's screen. // consumed once by RunicApp which navigates to the stream's screen.
@@ -48,6 +60,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM) pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
handleSsoCallback(intent)
// Dark-only app (M5): force light system-bar icons over the transparent bars so // Dark-only app (M5): force light system-bar icons over the transparent bars so
// they stay legible on the deep blue-black surfaces regardless of system theme. // they stay legible on the deep blue-black surfaces regardless of system theme.
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT) val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
@@ -82,10 +95,30 @@ class MainActivity : ComponentActivity() {
} }
} }
/** A notification tapped while the activity is already running (singleTop). */ /**
* A notification tap or an SSO callback arriving while the activity is already
* running (singleTop) — the common case, since the Custom Tab overlays the live
* app during sign-in.
*/
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let { pendingStream = it } intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let { pendingStream = it }
handleSsoCallback(intent)
}
/**
* Route a `runicgateway://auth/callback` VIEW intent into the SSO bridge (M9,
* §4.2). Runs on the activity's lifecycle scope; the exchange result surfaces
* on `SsoAuthManager.outcome` (success signs the session in; failure is shown
* on the login screen). Non-callback intents are ignored.
*/
private fun handleSsoCallback(intent: Intent?) {
val data: Uri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data ?: return
if (!ssoAuthManager.matchesCallback(data.scheme, data.host, data.path)) return
val state = data.getQueryParameter("state")
val code = data.getQueryParameter("code")
val error = data.getQueryParameter("error")
lifecycleScope.launch { ssoAuthManager.complete(state = state, code = code, error = error) }
} }
} }

View File

@@ -0,0 +1,50 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
/**
* PKCE + CSRF-state primitives for the Mobile SSO Authorization Bridge — "Layer B"
* of the two PKCE layers (app ↔ website; PLAN.md §4.2, BACKEND_DESIGN "Two PKCE
* layers"). The app proves at `/exchange` that it holds the verifier for the
* challenge it registered at `/start`, so an intercepted callback code is useless
* to anyone but this app.
*
* Pure JVM (no Android framework types) so it unit-tests on the plain test runner.
* The encoding mirrors the backend exactly (RFC 7636 S256): the challenge is
* `base64url(SHA-256(verifier))` with no padding, matching Node's
* `crypto.createHash('sha256').update(verifier).digest('base64url')`.
*/
object Pkce {
private val random = SecureRandom()
// RFC 4648 §5 URL-safe base64 without padding — the base64url the backend uses.
private val encoder = Base64.getUrlEncoder().withoutPadding()
/**
* A fresh high-entropy `code_verifier`: 32 random bytes → 43 base64url chars,
* comfortably inside RFC 7636's 43128 range and identical in form to the
* verifier the website generates for its own IdP layer.
*/
fun newVerifier(): String = randomToken()
/** A fresh opaque CSRF `state` (same entropy/shape as a verifier). */
fun newState(): String = randomToken()
/** `code_challenge` for [verifier] using the S256 method. */
fun challengeOf(verifier: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))
return encoder.encodeToString(digest)
}
private fun randomToken(): String {
val bytes = ByteArray(32)
random.nextBytes(bytes)
return encoder.encodeToString(bytes)
}
}

View File

@@ -0,0 +1,195 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.IOException
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import javax.inject.Singleton
/**
* Orchestrates the native "Sign in with Google/Discord" flow — the app half of the
* Mobile SSO Authorization Bridge (PLAN.md §4.2, BACKEND_DESIGN "Mobile SSO
* Authorization Bridge"). It never adds a parallel auth path: a successful exchange
* drives the *same* [SessionManager.onSignedIn] the password login uses, so the
* menu, push registration, and re-validation all react identically.
*
* The flow:
* 1. [buildStartUrl] mints PKCE (Layer B) + a CSRF `state`, stashes them, and
* returns the `/auth/mobile/sso/start` URL the caller opens in a Custom Tab.
* 2. The website bounces through the IdP and deep-links back to
* [REDIRECT_URI] with `?code&state` (success) or `?error&state` (failure).
* 3. [complete] verifies `state`, exchanges the `code` with the stashed verifier,
* and signs the user in — publishing the result on [outcome]. `MainActivity`
* parses the callback `Uri` (the Android edge) and hands the raw params here,
* so this class stays free of framework types and unit-tests on the JVM.
*
* The pending `{state, verifier}` lives only in memory: if the process is killed
* while the Custom Tab is foreground it is lost and the exchange **fails closed**
* (the user simply retries) — never a security downgrade.
*
* Threading: [buildStartUrl] runs on the UI thread; [complete] runs on the
* activity's coroutine scope after a deep link. The pending holder is an
* [AtomicReference] and [outcome] a [StateFlow], so a ViewModel/activity recreation
* while the Custom Tab is open cannot drop a result.
*/
@Singleton
class SsoAuthManager @Inject constructor(
private val ssoApi: SsoApi,
private val sessionManager: SessionManager,
private val baseUrlHolder: BaseUrlHolder,
) {
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
enum class Failure {
/** The user cancelled or the IdP/website refused (e.g. no linked account). */
DENIED,
/** The callback `state` didn't match — CSRF guard, or the pending flow was lost. */
STATE_MISMATCH,
/** The one-time code was unknown / expired / already used, or PKCE failed. */
EXPIRED_CODE,
/** Offline / DNS / TLS / timeout during the exchange. */
NETWORK,
/** Any other server failure, or a missing base URL / malformed callback. */
SERVER,
}
/** The observable result of the most recent flow; the login screen consumes it. */
sealed interface Outcome {
data object Idle : Outcome
data object Success : Outcome
data class Failed(val reason: Failure) : Outcome
}
private data class Pending(val state: String, val verifier: String)
private val pending = AtomicReference<Pending?>(null)
private val _outcome = MutableStateFlow<Outcome>(Outcome.Idle)
val outcome: StateFlow<Outcome> = _outcome.asStateFlow()
/** Ack a delivered [outcome] so it isn't re-handled after a recomposition. */
fun consumeOutcome() {
_outcome.value = Outcome.Idle
}
/**
* Build the `/auth/mobile/sso/start` URL for [providerId] and stash the pending
* PKCE verifier + CSRF state. Returns null when no shard site is configured yet
* (the caller then keeps the website hand-off fallback). Also resets [outcome]
* to [Outcome.Idle] so a stale prior result can't fire against the new attempt.
*/
fun buildStartUrl(providerId: String): String? {
val base = baseUrlHolder.current ?: return null
val verifier = Pkce.newVerifier()
val challenge = Pkce.challengeOf(verifier)
val state = Pkce.newState()
pending.set(Pending(state = state, verifier = verifier))
_outcome.value = Outcome.Idle
return base.newBuilder()
.addPathSegments("api/v1/auth/mobile/sso/start")
.addQueryParameter("provider", providerId)
.addQueryParameter("code_challenge", challenge)
.addQueryParameter("state", state)
.addQueryParameter("redirect_uri", REDIRECT_URI)
.build()
.toString()
}
/** True if a deep link's scheme/host/path are our fixed SSO callback. */
fun matchesCallback(scheme: String?, host: String?, path: String?): Boolean =
scheme == CALLBACK_SCHEME && host == CALLBACK_HOST && path == CALLBACK_PATH
/**
* Handle the parsed callback params from a returned [REDIRECT_URI] deep link:
* verify `state`, map an `error`, else exchange the `code` and sign in.
* Publishes the result on [outcome]. Idempotent-safe: the pending is cleared on
* entry, so a duplicate delivery of the same callback finds no pending and fails
* as [Failure.STATE_MISMATCH] rather than double-exchanging (the backend also
* single-uses the code).
*/
suspend fun complete(state: String?, code: String?, error: String?) {
val stashed = pending.getAndSet(null)
// CSRF: the callback must echo the exact state we generated at /start.
if (stashed == null || state.isNullOrEmpty() || state != stashed.state) {
_outcome.value = Outcome.Failed(Failure.STATE_MISMATCH)
return
}
// A website/IdP-side failure comes back as ?error=… (never with a code).
if (!error.isNullOrEmpty()) {
_outcome.value = Outcome.Failed(mapError(error))
return
}
if (code.isNullOrBlank()) {
_outcome.value = Outcome.Failed(Failure.SERVER)
return
}
val response = try {
ssoApi.exchange(MobileSsoExchangeRequest(code = code, codeVerifier = stashed.verifier))
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
_outcome.value = Outcome.Failed(Failure.NETWORK)
return
} catch (_: Exception) {
_outcome.value = Outcome.Failed(Failure.SERVER)
return
}
if (response.isSuccessful) {
val body = response.body()
if (body == null) {
_outcome.value = Outcome.Failed(Failure.SERVER)
return
}
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
_outcome.value = Outcome.Success
return
}
_outcome.value = Outcome.Failed(if (response.code() == 401) Failure.EXPIRED_CODE else Failure.SERVER)
}
// The bridge's start + callback error codes → user-facing failure reasons.
// Start (mobileSso.controller): invalid_provider | provider_unavailable | server_error.
// Callback (sso.controller): not_linked | disabled | session_expired | error,
// plus a forwarded IdP access_denied.
private fun mapError(error: String): Failure = when (error) {
// Link-only policy refused, or the account is inactive, or the user declined.
"not_linked", "disabled", "access_denied" -> Failure.DENIED
// The bridge session aged out mid-flow — start over.
"session_expired" -> Failure.EXPIRED_CODE
// invalid_provider / provider_unavailable / server_error / error / anything else.
else -> Failure.SERVER
}
companion object {
const val CALLBACK_SCHEME = "runicgateway"
const val CALLBACK_HOST = "auth"
const val CALLBACK_PATH = "/callback"
/**
* The one fixed, application-owned callback the bridge redirects to. Must
* match the `MOBILE_AUTH_REDIRECT_URIS` allowlist entry on the backend and
* the intent-filter in `AndroidManifest.xml` exactly (PLAN.md §4.2).
*/
const val REDIRECT_URI = "$CALLBACK_SCHEME://$CALLBACK_HOST$CALLBACK_PATH"
}
}

View File

@@ -0,0 +1,40 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse
import com.runicgateway.app.data.api.dto.SsoProviderDto
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
/**
* The native SSO bridge surface (PLAN.md §4.2, M9). Discovery lists the shard's
* enabled providers; exchange trades a callback authorization code (+ its PKCE
* verifier) for the same bearer pair as `/auth/mobile/login`.
*
* The redirect leg (`/auth/mobile/sso/start`) is **not** here — it is opened in a
* Custom Tab as a URL (the browser follows the 302 through the IdP), not called as
* an XHR. See [com.runicgateway.app.core.auth.sso.SsoAuthManager].
*
* Exchange is tagged [com.runicgateway.app.core.net.Http.NO_SESSION_HEADER]: it
* carries no bearer (the user isn't signed in yet) and a `401` (bad/expired code or
* PKCE mismatch) must never be misread as an expired session or trip the refresh
* [com.runicgateway.app.core.net.TokenAuthenticator]. It returns a raw [Response]
* so the caller can distinguish `401` from other failures.
*/
interface SsoApi {
/** Public discovery — the enabled providers to render login buttons for. */
@GET("api/v1/auth/providers")
suspend fun providers(): List<SsoProviderDto>
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
@Headers("X-Runic-No-Session: 1")
@POST("api/v1/auth/mobile/sso/exchange")
suspend fun exchange(@Body body: MobileSsoExchangeRequest): Response<MobileTokenResponse>
}

View File

@@ -0,0 +1,42 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shapes for the Mobile SSO Authorization Bridge (PLAN.md §4.2, M9). The
* success payload of `/auth/mobile/sso/exchange` is the shared [MobileTokenResponse]
* (same pair as `/auth/mobile/login`) — this file only adds the two shapes unique
* to the bridge. Every DTO ignores unknown keys (NetworkModule's lenient Json), so
* additive backend fields stay safe (§8).
*/
/**
* One entry of `GET /auth/providers` — public discovery, never secrets. [icon] is
* the provider kind (`google` | `discord` | `oidc` | `oauth2`); the app renders a
* button per provider from this list rather than hardcoding a set. [loginUrl] is
* the *website* start path (unused by the app, which builds its own
* `/auth/mobile/sso/start` URL); kept so the shape matches the backend exactly.
*/
@Serializable
data class SsoProviderDto(
val id: String,
val name: String,
val icon: String? = null,
val loginUrl: String? = null,
val priority: Int? = null,
)
/**
* `POST /auth/mobile/sso/exchange` body — the one-time authorization code from the
* callback deep link plus the PKCE verifier stashed at `/start` (Layer B). Wire
* name is snake_case to match the backend's `{ code, code_verifier }`.
*/
@Serializable
data class MobileSsoExchangeRequest(
val code: String,
@SerialName("code_verifier") val codeVerifier: String,
)

View File

@@ -6,9 +6,11 @@ package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.push.PushManager import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.data.api.AuthApi import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileLoginRequest import com.runicgateway.app.data.api.dto.MobileLoginRequest
import com.runicgateway.app.data.api.dto.MobileLogoutRequest import com.runicgateway.app.data.api.dto.MobileLogoutRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse import com.runicgateway.app.data.api.dto.MobileTokenResponse
import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.api.dto.TotpRequiredError import com.runicgateway.app.data.api.dto.TotpRequiredError
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -26,11 +28,25 @@ import javax.inject.Singleton
@Singleton @Singleton
class AuthRepository @Inject constructor( class AuthRepository @Inject constructor(
private val authApi: AuthApi, private val authApi: AuthApi,
private val ssoApi: SsoApi,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val pushManager: PushManager, private val pushManager: PushManager,
private val json: Json, private val json: Json,
) { ) {
/**
* The shard's enabled SSO providers for the native login buttons (§4.2). Public
* discovery, never secrets. Returns an empty list on any failure — the login
* screen then keeps the website hand-off fallback rather than showing nothing.
*/
suspend fun ssoProviders(): List<SsoProviderDto> = try {
ssoApi.providers()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
emptyList()
}
/** Outcome of a login attempt (§4.1). */ /** Outcome of a login attempt (§4.1). */
sealed interface LoginResult { sealed interface LoginResult {
data object Success : LoginResult data object Success : LoginResult

View File

@@ -17,6 +17,7 @@ import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.NotificationsApi import com.runicgateway.app.data.api.NotificationsApi
import com.runicgateway.app.data.api.PlayerShardApi import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.SsoApi
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -96,6 +97,11 @@ object NetworkModule {
@Singleton @Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java) fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
/** Native SSO discovery + code exchange (§4.2, M9) — on the main client. */
@Provides
@Singleton
fun provideSsoApi(retrofit: Retrofit): SsoApi = retrofit.create(SsoApi::class.java)
/** Role-agnostic self-service (§6.4) — bearer-authed on the main client. */ /** Role-agnostic self-service (§6.4) — bearer-authed on the main client. */
@Provides @Provides
@Singleton @Singleton

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -56,6 +57,13 @@ fun LoginScreen(
if (state.signedIn) onSignedIn() if (state.signedIn) onSignedIn()
} }
// Open a freshly-minted SSO /start URL in a Custom Tab, exactly once (§4.2).
LaunchedEffect(state.ssoLaunchUrl) {
val url = state.ssoLaunchUrl ?: return@LaunchedEffect
WebHandoff.open(context, url)
viewModel.onSsoLaunchConsumed()
}
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -155,6 +163,29 @@ fun LoginScreen(
} }
} }
// ── Native SSO (§4.2, M9): a button per enabled provider that opens the
// Custom-Tab bridge and returns the user signed in. Falls back to the
// website login hand-off when the shard exposes no providers.
if (state.ssoProviders.isNotEmpty()) {
state.ssoProviders.forEach { provider ->
OutlinedButton(
onClick = { viewModel.onSsoProviderClick(provider) },
enabled = !state.submitting,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
Text(stringResource(R.string.login_sso_provider, provider.name))
}
}
} else {
viewModel.ssoLoginUrl?.let { url ->
TextButton(onClick = { WebHandoff.open(context, url) }) {
Text(stringResource(R.string.login_sso))
}
}
}
// ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ── // ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──
viewModel.registerUrl?.let { url -> viewModel.registerUrl?.let { url ->
TextButton( TextButton(
@@ -167,11 +198,6 @@ fun LoginScreen(
Text(stringResource(R.string.login_forgot)) Text(stringResource(R.string.login_forgot))
} }
} }
viewModel.ssoLoginUrl?.let { url ->
TextButton(onClick = { WebHandoff.open(context, url) }) {
Text(stringResource(R.string.login_sso))
}
}
} }
} }
@@ -181,4 +207,5 @@ private fun loginErrorRes(error: LoginError): Int = when (error) {
LoginError.RATE_LIMITED -> R.string.login_error_rate_limited LoginError.RATE_LIMITED -> R.string.login_error_rate_limited
LoginError.SERVER -> R.string.login_error_server LoginError.SERVER -> R.string.login_error_server
LoginError.NETWORK -> R.string.login_error_network LoginError.NETWORK -> R.string.login_error_network
LoginError.SSO -> R.string.login_error_sso
} }

View File

@@ -5,7 +5,9 @@ package com.runicgateway.app.ui.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.auth.sso.SsoAuthManager
import com.runicgateway.app.core.web.WebsiteUrls import com.runicgateway.app.core.web.WebsiteUrls
import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.repository.AuthRepository import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.data.repository.AuthRepository.LoginResult import com.runicgateway.app.data.repository.AuthRepository.LoginResult
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -25,11 +27,12 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LoginViewModel @Inject constructor( class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val ssoAuthManager: SsoAuthManager,
private val websiteUrls: WebsiteUrls, private val websiteUrls: WebsiteUrls,
) : ViewModel() { ) : ViewModel() {
/** The transient error surfaced under the form after a failed attempt. */ /** The transient error surfaced under the form after a failed attempt. */
enum class LoginError { INVALID_CREDENTIALS, BAD_CODE, RATE_LIMITED, SERVER, NETWORK } enum class LoginError { INVALID_CREDENTIALS, BAD_CODE, RATE_LIMITED, SERVER, NETWORK, SSO }
data class UiState( data class UiState(
val username: String = "", val username: String = "",
@@ -40,11 +43,40 @@ class LoginViewModel @Inject constructor(
val submitting: Boolean = false, val submitting: Boolean = false,
val error: LoginError? = null, val error: LoginError? = null,
val signedIn: Boolean = false, val signedIn: Boolean = false,
/** The shard's enabled SSO providers (§4.2); empty → website hand-off fallback. */
val ssoProviders: List<SsoProviderDto> = emptyList(),
/** A `/auth/mobile/sso/start` URL the screen should open in a Custom Tab, once. */
val ssoLaunchUrl: String? = null,
) )
private val _state = MutableStateFlow(UiState()) private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow() val state: StateFlow<UiState> = _state.asStateFlow()
init {
// Discover the native SSO providers to render buttons for (§4.2).
viewModelScope.launch {
val providers = authRepository.ssoProviders()
if (providers.isNotEmpty()) _state.update { it.copy(ssoProviders = providers) }
}
// Consume the SSO bridge outcome: a returned callback completes here even if
// this ViewModel was recreated while the Custom Tab was foreground (§4.2).
viewModelScope.launch {
ssoAuthManager.outcome.collect { outcome ->
when (outcome) {
SsoAuthManager.Outcome.Success -> {
ssoAuthManager.consumeOutcome()
_state.update { it.copy(submitting = false, signedIn = true) }
}
is SsoAuthManager.Outcome.Failed -> {
ssoAuthManager.consumeOutcome()
_state.update { it.copy(submitting = false, error = mapSsoError(outcome.reason)) }
}
SsoAuthManager.Outcome.Idle -> Unit
}
}
}
}
fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) } fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) } fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
fun onCodeChange(value: String) = fun onCodeChange(value: String) =
@@ -52,8 +84,33 @@ class LoginViewModel @Inject constructor(
val registerUrl: String? get() = websiteUrls.register() val registerUrl: String? get() = websiteUrls.register()
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword() val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
/** Website login hand-off — the fallback when native SSO discovery is empty (§4.2). */
val ssoLoginUrl: String? get() = websiteUrls.login() val ssoLoginUrl: String? get() = websiteUrls.login()
/**
* Begin a native SSO flow for [provider]: mint PKCE + state and surface the
* `/start` URL for the screen to open in a Custom Tab. No-op (leaves a SERVER
* error) if the base URL isn't set yet — the website fallback still shows.
*/
fun onSsoProviderClick(provider: SsoProviderDto) {
if (_state.value.submitting) return
val url = ssoAuthManager.buildStartUrl(provider.id)
if (url == null) {
_state.update { it.copy(error = LoginError.SSO) }
return
}
_state.update { it.copy(error = null, ssoLaunchUrl = url) }
}
/** The screen has opened the Custom Tab; clear so it isn't re-launched on recompose. */
fun onSsoLaunchConsumed() = _state.update { it.copy(ssoLaunchUrl = null) }
private fun mapSsoError(reason: SsoAuthManager.Failure): LoginError = when (reason) {
SsoAuthManager.Failure.NETWORK -> LoginError.NETWORK
else -> LoginError.SSO
}
fun submit() { fun submit() {
val s = _state.value val s = _state.value
if (s.submitting) return if (s.submitting) return

View File

@@ -61,11 +61,14 @@
<string name="login_register">Create an account</string> <string name="login_register">Create an account</string>
<string name="login_forgot">Forgot your password?</string> <string name="login_forgot">Forgot your password?</string>
<string name="login_sso">Sign in with Google or Discord (on the website)</string> <string name="login_sso">Sign in with Google or Discord (on the website)</string>
<!-- %1$s is the provider name, e.g. "Google" or "Discord" (native SSO, M9). -->
<string name="login_sso_provider">Sign in with %1$s</string>
<string name="login_error_credentials">Incorrect username or password.</string> <string name="login_error_credentials">Incorrect username or password.</string>
<string name="login_error_code">That code didn\'t match. Try the current code.</string> <string name="login_error_code">That code didn\'t match. Try the current code.</string>
<string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string> <string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string>
<string name="login_error_server">Something went wrong. Please try again.</string> <string name="login_error_server">Something went wrong. Please try again.</string>
<string name="login_error_network">Can\'t reach the site. Check your connection and try again.</string> <string name="login_error_network">Can\'t reach the site. Check your connection and try again.</string>
<string name="login_error_sso">Couldn\'t complete that sign-in. Please try again.</string>
<!-- ── Auth: account (§5, §6.3) ────────────────────────────────────── --> <!-- ── Auth: account (§5, §6.3) ────────────────────────────────────── -->
<string name="account_title">My account</string> <string name="account_title">My account</string>

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"))
}
}