6 Commits

Author SHA1 Message Date
f6deeb9624 ci(sonarqube): add non-blocking SonarQube analysis on push to main
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m48s
Mirrors the website repo's setup: a source-based scan of app/src/main
(Kotlin) that reports to the self-hosted SonarQube server after merge,
never gating PRs. Project key runic-gateway-android-app.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:13:39 -05:00
0ea6495d9e Merge pull request 'feat(sso): App Links autoVerify callback + paired-host trust check' (#17) from feat/app-links into main
All checks were successful
Release APK / release (push) Successful in 9m14s
Reviewed-on: #17
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 00:20:49 +00:00
3050443aac Merge branch 'main' into feat/app-links
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m39s
2026-07-20 23:43:59 +00:00
987ddb54f8 feat(sso): App Links autoVerify callback + paired-host trust check
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m53s
Add the app side of Android App Links (M9 follow-up, docs/android/APP_LINKS.md),
layered on the M9 Part 2 native SSO callback:

- Build-time `appLinkHost` Gradle property -> BuildConfig.APP_LINK_HOST +
  manifestPlaceholders["appLinkHost"]. autoVerify needs a literal host, so the
  generic multi-tenant build leaves it empty (placeholder falls back to the
  reserved runic-gateway.invalid sentinel, making the filter inert); a
  white-label build bakes one host with -PappLinkHost=play.myshard.com.
- Manifest: an autoVerify https `/mobile/callback` intent-filter beside the
  unchanged custom-scheme one (the permanent fallback).
- SsoAuthManager: request the https App Link redirect_uri iff the baked host
  matches the paired shard host; matchesAppLinkCallback() enforces a paired-host
  trust check (host must equal the currently-paired base URL host) as
  defense-in-depth. Both matchers feed the same complete()/exchange path.
- MainActivity routes custom-scheme and App Link callbacks identically.

+5 JVM tests (SsoAuthManagerTest -> 14). Built green (JDK 21,
-Pksp.incremental=false); white-label host substitution verified in the merged
manifest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 18:37:32 -05:00
ab68fab382 Merge pull request 'feat(auth): M9 Part 2 — native in-app SSO via the mobile bridge' (#16) from feat/m9-native-sso into main
Reviewed-on: #16
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 23:14:45 +00:00
7665975d59 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
2026-07-20 17:52:36 -05:00
16 changed files with 928 additions and 7 deletions

View File

@@ -0,0 +1,54 @@
# Run SonarQube static analysis against the code that just landed on `main` and
# report the results to the self-hosted SonarQube server for review. This is
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
# not on pull_request, so it never gates a PR. It complements pr-checks.yml
# (which gates PRs) and release.yml (which ships the APK) — this one only feeds
# the dashboard.
#
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
# My Account → Security in SonarQube for the
# runic-gateway-android-app project (or a global one).
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
# http://192.168.0.56:9000
# (kept as a variable, not committed, so the internal address stays out of git.)
#
# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be
# able to reach SONAR_HOST_URL on your network. Nothing here waits on the
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
# dashboard when you want to.
#
# Scope: this analyses the Kotlin source directly (the Sonar scanner reads
# sonar-project.properties). It does NOT run a Gradle build, so no Android SDK /
# JDK install is needed — the Kotlin analyzer is source-based. See the "Optional
# enrichment" note in sonar-project.properties for wiring in Android Lint /
# coverage reports later.
name: SonarQube
on:
push:
branches: [main]
# Allow re-running the analysis on demand from the Actions tab.
workflow_dispatch: {}
concurrency:
group: sonarqube-${{ github.ref }}
cancel-in-progress: true
jobs:
analysis:
runs-on: ubuntu-latest
steps:
- name: Check out (full history for accurate new-code + blame)
uses: actions/checkout@v4
with:
# SonarQube uses git history to attribute issues to authors and to
# compute "new code". A shallow clone degrades both.
fetch-depth: 0
- name: Run SonarQube scan
uses: sonarsource/sonarqube-scan-action@v4
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}

View File

@@ -48,6 +48,19 @@ android {
versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0" versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Android App Links host (docs/android/APP_LINKS.md). autoVerify needs a
// *literal* host at build time, so a single multi-tenant APK cannot verify
// open-ended shard domains: App Links are a build-time opt-in. Left empty for
// the generic build (custom scheme only); a white-label/first-party build
// bakes one host with `-PappLinkHost=play.myshard.com`.
// • BuildConfig.APP_LINK_HOST — SsoAuthManager reads it to pick the redirect.
// • manifestPlaceholder appLinkHost — substituted into the intent-filter host;
// empty falls back to the reserved `.invalid` sentinel so the autoVerify
// filter is inert (matches no real link, never verifies).
val appLinkHost = (project.findProperty("appLinkHost") as String?)?.trim().orEmpty()
buildConfigField("String", "APP_LINK_HOST", "\"$appLinkHost\"")
manifestPlaceholders["appLinkHost"] = appLinkHost.ifBlank { "runic-gateway.invalid" }
} }
signingConfigs { signingConfigs {

View File

@@ -24,14 +24,48 @@
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. This is the permanent
fallback on every build (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>
<!-- App Links hardening (docs/android/APP_LINKS.md): a verified https
callback that only the domain's real owner can claim. autoVerify
needs a literal host, so ${appLinkHost} is baked at build time
(build.gradle.kts). The generic build leaves it as the reserved
runic-gateway.invalid sentinel — the filter then matches no real
link and never verifies. A white-label build sets -PappLinkHost. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="${appLinkHost}"
android:path="/mobile/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,34 @@ 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 an SSO callback VIEW intent into the bridge (M9, §4.2): either the
* custom-scheme `runicgateway://auth/callback` (always) or the verified https
* App Link `https://<paired-host>/mobile/callback` (opt-in hardening —
* docs/android/APP_LINKS.md). Both feed the *same* exchange; the result surfaces
* on `SsoAuthManager.outcome` (success signs the session in; failure shows 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
val isCallback = ssoAuthManager.matchesCallback(data.scheme, data.host, data.path) ||
ssoAuthManager.matchesAppLinkCallback(data.scheme, data.host, data.path)
if (!isCallback) 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,238 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
import com.runicgateway.app.BuildConfig
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)
/**
* The host this build baked an App Link intent-filter for (`BuildConfig.APP_LINK_HOST`,
* empty on the generic multi-tenant build — see docs/android/APP_LINKS.md).
* `internal var` only so unit tests can exercise the App Link path without a build
* flavor; production never reassigns it.
*/
internal var appLinkHost: String = BuildConfig.APP_LINK_HOST
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", redirectUriFor(base.host))
.build()
.toString()
}
/**
* The `redirect_uri` to request for a shard on [pairedHost]: the verified https
* App Link callback **iff** this build baked an App Link host that matches the
* paired host (a white-label/first-party build for exactly this shard — which is
* also responsible for enabling `mobile_app_links_enabled` server-side); otherwise
* the fixed custom-scheme callback, which every build/shard always supports.
*/
private fun redirectUriFor(pairedHost: String): String =
if (appLinkHost.isNotBlank() && appLinkHost.equals(pairedHost, ignoreCase = true)) {
"https://$pairedHost$APP_LINK_CALLBACK_PATH"
} else {
REDIRECT_URI
}
/** True if a deep link's scheme/host/path are our fixed custom-scheme SSO callback. */
fun matchesCallback(scheme: String?, host: String?, path: String?): Boolean =
scheme == CALLBACK_SCHEME && host == CALLBACK_HOST && path == CALLBACK_PATH
/**
* True if a deep link is a verified https App Link callback for the shard we are
* **currently paired to**. The `host == pairedHost` check is defense-in-depth:
* `autoVerify` already means only a real, opted-in shard domain can route here,
* but the app still refuses an https callback whose host isn't the paired shard.
* Returns false before a shard is configured (no paired host to trust).
*/
fun matchesAppLinkCallback(scheme: String?, host: String?, path: String?): Boolean {
val pairedHost = baseUrlHolder.current?.host ?: return false
return scheme == "https" && path == APP_LINK_CALLBACK_PATH &&
host != null && host.equals(pairedHost, ignoreCase = true)
}
/**
* 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"
/**
* Path of the verified https App Link callback (`https://<shard-host>/mobile/callback`).
* Must match the app's `autoVerify` intent-filter in `AndroidManifest.xml` and the
* backend's self-origin allowlist entry (docs/android/APP_LINKS.md §3.2/§4.2).
*/
const val APP_LINK_CALLBACK_PATH = "/mobile/callback"
}
}

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,223 @@
/*
* 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"))
}
// ── App Links (docs/android/APP_LINKS.md) ────────────────────────────────
@Test fun `matchesAppLinkCallback accepts only https, the app-link path, and the paired host`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
// Paired to shard.example.com (managerWith default base).
assertTrue(mgr.matchesAppLinkCallback("https", "shard.example.com", "/mobile/callback"))
// Host-trust: a foreign host is refused even over https + right path.
assertTrue(!mgr.matchesAppLinkCallback("https", "evil.example.com", "/mobile/callback"))
// Wrong scheme / wrong path.
assertTrue(!mgr.matchesAppLinkCallback("http", "shard.example.com", "/mobile/callback"))
assertTrue(!mgr.matchesAppLinkCallback("https", "shard.example.com", "/callback"))
// Host match is case-insensitive.
assertTrue(mgr.matchesAppLinkCallback("https", "SHARD.EXAMPLE.COM", "/mobile/callback"))
}
@Test fun `matchesAppLinkCallback is false before a shard is paired`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()), base = null)
assertTrue(!mgr.matchesAppLinkCallback("https", "shard.example.com", "/mobile/callback"))
}
@Test fun `buildStartUrl requests the custom scheme when no app-link host is baked`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
// Generic build: appLinkHost defaults to BuildConfig.APP_LINK_HOST ("" in tests).
val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
assertEquals(SsoAuthManager.REDIRECT_URI, redirect)
}
@Test fun `buildStartUrl requests the https app-link callback when the baked host matches the paired host`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
mgr.appLinkHost = "shard.example.com" // white-label build baked this shard's host
val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
assertEquals("https://shard.example.com/mobile/callback", redirect)
}
@Test fun `buildStartUrl falls back to the custom scheme when the baked host does not match the paired shard`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
mgr.appLinkHost = "other-shard.example.com" // built for a different shard than the paired one
val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
assertEquals(SsoAuthManager.REDIRECT_URI, redirect)
}
}

32
sonar-project.properties Normal file
View File

@@ -0,0 +1,32 @@
# SonarQube analysis config for the Android-app repo.
# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main.
# The project key must match the one created in SonarQube (dashboard URL
# ?id=runic-gateway-android-app).
sonar.projectKey=runic-gateway-android-app
sonar.projectName=runic gateway android app
# Analysed application code. The single :app module's Kotlin sources.
# SonarQube's Kotlin analyzer works on source directly, so no compiled classes
# or Gradle build are required for the scan.
sonar.sources=app/src/main
# Local unit tests (app/src/test). Instrumented tests (app/src/androidTest) can
# be added here once that source set exists.
sonar.tests=app/src/test
# Never analyse build output, Gradle internals, or generated code.
sonar.exclusions=**/build/**,**/.gradle/**,**/generated/**
sonar.sourceEncoding=UTF-8
# ── Optional enrichment (enable once the reports are produced in CI) ──
# For richer Kotlin/Android results, run the reporters in sonarqube.yml and point
# SonarQube at their output:
# • Android Lint: ./gradlew lintDebug → app/build/reports/lint-results-debug.xml
# sonar.androidLint.reportPaths=app/build/reports/lint-results-debug.xml
# • JaCoCo coverage (needs a coverage-enabled test run):
# sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/.../*.xml
# The alternative to the CLI scanner used here is the SonarQube Gradle plugin
# (org.sonarqube), which auto-discovers these reports; the CLI + properties file
# is used instead to keep this repo's setup identical to website/ and link/.