Compare commits
6 Commits
d97c06d6e1
...
ci/sonarqu
| Author | SHA1 | Date | |
|---|---|---|---|
| f6deeb9624 | |||
| 0ea6495d9e | |||
| 3050443aac | |||
| 987ddb54f8 | |||
| ab68fab382 | |||
| 7665975d59 |
54
.gitea/workflows/sonarqube.yml
Normal file
54
.gitea/workflows/sonarqube.yml
Normal 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 }}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
app/src/main/java/com/runicgateway/app/core/auth/sso/Pkce.kt
Normal file
50
app/src/main/java/com/runicgateway/app/core/auth/sso/Pkce.kt
Normal 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 43–128 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/src/main/java/com/runicgateway/app/data/api/SsoApi.kt
Normal file
40
app/src/main/java/com/runicgateway/app/data/api/SsoApi.kt
Normal 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>
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.auth.sso
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKCE Layer B primitives (PLAN.md §4.2). The challenge encoding must match the
|
||||||
|
* backend byte-for-byte (`base64url(SHA-256(verifier))`, no padding) or `/exchange`
|
||||||
|
* rejects every code — so it is pinned against the RFC 7636 test vector.
|
||||||
|
*/
|
||||||
|
class PkceTest {
|
||||||
|
|
||||||
|
// RFC 4648 §5 URL-safe base64 alphabet, no padding.
|
||||||
|
private val base64UrlNoPad = Regex("^[A-Za-z0-9_-]+$")
|
||||||
|
|
||||||
|
@Test fun `challenge matches the RFC 7636 vector`() {
|
||||||
|
// RFC 7636 Appendix B.
|
||||||
|
val verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||||
|
assertEquals("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", Pkce.challengeOf(verifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `verifier is url-safe base64 without padding`() {
|
||||||
|
val verifier = Pkce.newVerifier()
|
||||||
|
assertTrue("verifier charset: $verifier", base64UrlNoPad.matches(verifier))
|
||||||
|
// 32 random bytes → 43 base64 chars (no padding), inside RFC 7636's 43–128.
|
||||||
|
assertEquals(43, verifier.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `challenge is url-safe base64 without padding`() {
|
||||||
|
val challenge = Pkce.challengeOf(Pkce.newVerifier())
|
||||||
|
assertTrue("challenge charset: $challenge", base64UrlNoPad.matches(challenge))
|
||||||
|
// SHA-256 (32 bytes) → 43 base64 chars, no '=' padding.
|
||||||
|
assertEquals(43, challenge.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `verifiers and states are unique per call`() {
|
||||||
|
assertNotEquals(Pkce.newVerifier(), Pkce.newVerifier())
|
||||||
|
assertNotEquals(Pkce.newState(), Pkce.newState())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `state is url-safe base64 without padding`() {
|
||||||
|
assertTrue(base64UrlNoPad.matches(Pkce.newState()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
32
sonar-project.properties
Normal 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/.
|
||||||
Reference in New Issue
Block a user