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"
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 {

View File

@@ -24,14 +24,48 @@
android:supportsRtl="true"
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
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.RunicGateway">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</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>
<!-- 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.graphics.Color
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.lifecycleScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -18,6 +20,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.runicgateway.app.core.auth.sso.SsoAuthManager
import com.runicgateway.app.core.push.PushNotifier
import androidx.hilt.navigation.compose.hiltViewModel
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.parseBrandColor
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
@@ -40,6 +45,13 @@ import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
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).
// Set from the launching intent and from onNewIntent (the activity is singleTop),
// consumed once by RunicApp which navigates to the stream's screen.
@@ -48,6 +60,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
handleSsoCallback(intent)
// 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.
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) {
super.onNewIntent(intent)
setIntent(intent)
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.push.PushManager
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.MobileLogoutRequest
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 kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json
@@ -26,11 +28,25 @@ import javax.inject.Singleton
@Singleton
class AuthRepository @Inject constructor(
private val authApi: AuthApi,
private val ssoApi: SsoApi,
private val sessionManager: SessionManager,
private val pushManager: PushManager,
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). */
sealed interface 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.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.SsoApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -96,6 +97,11 @@ object NetworkModule {
@Singleton
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. */
@Provides
@Singleton

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -56,6 +57,13 @@ fun LoginScreen(
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(
modifier = modifier
.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 ──
viewModel.registerUrl?.let { url ->
TextButton(
@@ -167,11 +198,6 @@ fun LoginScreen(
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.SERVER -> R.string.login_error_server
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.viewModelScope
import com.runicgateway.app.core.auth.sso.SsoAuthManager
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.LoginResult
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -25,11 +27,12 @@ import javax.inject.Inject
@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val ssoAuthManager: SsoAuthManager,
private val websiteUrls: WebsiteUrls,
) : ViewModel() {
/** 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(
val username: String = "",
@@ -40,11 +43,40 @@ class LoginViewModel @Inject constructor(
val submitting: Boolean = false,
val error: LoginError? = null,
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())
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 onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
fun onCodeChange(value: String) =
@@ -52,8 +84,33 @@ class LoginViewModel @Inject constructor(
val registerUrl: String? get() = websiteUrls.register()
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()
/**
* 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() {
val s = _state.value
if (s.submitting) return

View File

@@ -61,11 +61,14 @@
<string name="login_register">Create an account</string>
<string name="login_forgot">Forgot your password?</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_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_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_sso">Couldn\'t complete that sign-in. Please try again.</string>
<!-- ── Auth: account (§5, §6.3) ────────────────────────────────────── -->
<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/.