Compare commits
4 Commits
ab68fab382
...
ci/sonarqu
| Author | SHA1 | Date | |
|---|---|---|---|
| f6deeb9624 | |||
| 0ea6495d9e | |||
| 3050443aac | |||
| 987ddb54f8 |
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 {
|
||||||
|
|||||||
@@ -39,8 +39,8 @@
|
|||||||
<!-- Native SSO callback (M9, PLAN.md §4.2). The bridge deep-links the
|
<!-- Native SSO callback (M9, PLAN.md §4.2). The bridge deep-links the
|
||||||
one-time authorization code back to this fixed, app-owned custom
|
one-time authorization code back to this fixed, app-owned custom
|
||||||
scheme; it must match SsoAuthManager.REDIRECT_URI and the backend's
|
scheme; it must match SsoAuthManager.REDIRECT_URI and the backend's
|
||||||
MOBILE_AUTH_REDIRECT_URIS allowlist exactly. Custom scheme only for
|
MOBILE_AUTH_REDIRECT_URIS allowlist exactly. This is the permanent
|
||||||
now — HTTPS App Links are deferred (docs/android/APP_LINKS.md). -->
|
fallback on every build (docs/android/APP_LINKS.md). -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
@@ -50,6 +50,22 @@
|
|||||||
android:host="auth"
|
android:host="auth"
|
||||||
android:path="/callback" />
|
android:path="/callback" />
|
||||||
</intent-filter>
|
</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).
|
||||||
|
|||||||
@@ -108,14 +108,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route a `runicgateway://auth/callback` VIEW intent into the SSO bridge (M9,
|
* Route an SSO callback VIEW intent into the bridge (M9, §4.2): either the
|
||||||
* §4.2). Runs on the activity's lifecycle scope; the exchange result surfaces
|
* custom-scheme `runicgateway://auth/callback` (always) or the verified https
|
||||||
* on `SsoAuthManager.outcome` (success signs the session in; failure is shown
|
* App Link `https://<paired-host>/mobile/callback` (opt-in hardening —
|
||||||
* on the login screen). Non-callback intents are ignored.
|
* 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?) {
|
private fun handleSsoCallback(intent: Intent?) {
|
||||||
val data: Uri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data ?: return
|
val data: Uri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data ?: return
|
||||||
if (!ssoAuthManager.matchesCallback(data.scheme, data.host, data.path)) 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 state = data.getQueryParameter("state")
|
||||||
val code = data.getQueryParameter("code")
|
val code = data.getQueryParameter("code")
|
||||||
val error = data.getQueryParameter("error")
|
val error = data.getQueryParameter("error")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.core.auth.sso
|
package com.runicgateway.app.core.auth.sso
|
||||||
|
|
||||||
|
import com.runicgateway.app.BuildConfig
|
||||||
import com.runicgateway.app.core.auth.SessionManager
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
import com.runicgateway.app.data.api.SsoApi
|
||||||
@@ -78,6 +79,14 @@ class SsoAuthManager @Inject constructor(
|
|||||||
|
|
||||||
private val pending = AtomicReference<Pending?>(null)
|
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)
|
private val _outcome = MutableStateFlow<Outcome>(Outcome.Idle)
|
||||||
val outcome: StateFlow<Outcome> = _outcome.asStateFlow()
|
val outcome: StateFlow<Outcome> = _outcome.asStateFlow()
|
||||||
|
|
||||||
@@ -104,15 +113,42 @@ class SsoAuthManager @Inject constructor(
|
|||||||
.addQueryParameter("provider", providerId)
|
.addQueryParameter("provider", providerId)
|
||||||
.addQueryParameter("code_challenge", challenge)
|
.addQueryParameter("code_challenge", challenge)
|
||||||
.addQueryParameter("state", state)
|
.addQueryParameter("state", state)
|
||||||
.addQueryParameter("redirect_uri", REDIRECT_URI)
|
.addQueryParameter("redirect_uri", redirectUriFor(base.host))
|
||||||
.build()
|
.build()
|
||||||
.toString()
|
.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if a deep link's scheme/host/path are our fixed SSO callback. */
|
/**
|
||||||
|
* 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 =
|
fun matchesCallback(scheme: String?, host: String?, path: String?): Boolean =
|
||||||
scheme == CALLBACK_SCHEME && host == CALLBACK_HOST && path == CALLBACK_PATH
|
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:
|
* Handle the parsed callback params from a returned [REDIRECT_URI] deep link:
|
||||||
* verify `state`, map an `error`, else exchange the `code` and sign in.
|
* verify `state`, map an `error`, else exchange the `code` and sign in.
|
||||||
@@ -191,5 +227,12 @@ class SsoAuthManager @Inject constructor(
|
|||||||
* the intent-filter in `AndroidManifest.xml` exactly (PLAN.md §4.2).
|
* the intent-filter in `AndroidManifest.xml` exactly (PLAN.md §4.2).
|
||||||
*/
|
*/
|
||||||
const val REDIRECT_URI = "$CALLBACK_SCHEME://$CALLBACK_HOST$CALLBACK_PATH"
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,4 +179,45 @@ class SsoAuthManagerTest {
|
|||||||
assertTrue(!mgr.matchesCallback("runicgateway", "auth", "/other"))
|
assertTrue(!mgr.matchesCallback("runicgateway", "auth", "/other"))
|
||||||
assertTrue(!mgr.matchesCallback("runicgateway", "evil", "/callback"))
|
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