From b90378e60d9e14a1601af90a07b7dcc36e4567ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 18:39:40 +0000 Subject: [PATCH] feat: automatic portal login after connecting Opening the portal (automatically after connect and via the button) now fetches a fresh one-time login link from POST /api/v1/client/portal-link right before opening it. On a non-2xx status, network error, timeout over 5 s, missing url or a url whose scheme/host differs from the configured portal URL, the plain portal URL is opened as before. The link carries a single-use ticket: it is never cached, stored or logged. The once-per-session auto-open gate moved into the ViewModel. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../gatecontrol/android/ui/vpn/VpnScreen.kt | 15 +- .../android/ui/vpn/VpnViewModel.kt | 69 +++++++- .../android/ui/vpn/VpnViewModelTest.kt | 154 +++++++++++++++++ .../gatecontrol/android/network/ApiClient.kt | 7 + .../gatecontrol/android/network/ApiModels.kt | 13 ++ .../gatecontrol/android/network/PortalLink.kt | 62 +++++++ .../android/network/PortalLinkTest.kt | 158 ++++++++++++++++++ 7 files changed, 464 insertions(+), 14 deletions(-) create mode 100644 core/network/src/main/java/com/gatecontrol/android/network/PortalLink.kt create mode 100644 core/network/src/test/java/com/gatecontrol/android/network/PortalLinkTest.kt diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt index 9cd9cfd..d04e31d 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt @@ -157,18 +157,11 @@ fun VpnScreen( } } - // Auto-open portal once per tunnel session (connectedSince-keyed, not a plain boolean, - // so re-foregrounding the app on the same session does not re-fire the browser). - // ponytail: open-once-per-tunnel-session via connectedSince identity; a new user-initiated - // connect mints a new connectedSince and re-opens, a blip/re-foreground on the same session - // does not. autoOpen is keyed so a delayed permissions fetch that flips it true re-evaluates. - var lastOpenedSince by rememberSaveable { mutableStateOf(0L) } + // Auto-open portal once per tunnel session; the ViewModel keys it on + // connectedSince. autoOpen/portalUrl are keys so a delayed permissions + // fetch that enables it re-evaluates for the current session. LaunchedEffect(tunnelState, portalUrl, autoOpen) { - val st = tunnelState - if (st is TunnelState.Connected && autoOpen && !portalUrl.isNullOrBlank() && st.connectedSince != lastOpenedSince) { - lastOpenedSince = st.connectedSince - viewModel.openPortal(context) - } + viewModel.autoOpenPortalIfNeeded(context, tunnelState) } // Tick every second to update connection duration diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt index 75da888..8877715 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt @@ -12,6 +12,7 @@ import com.gatecontrol.android.network.MachineBindingMonitor import com.gatecontrol.android.network.PermissionFlags import com.gatecontrol.android.network.TrafficStats import com.gatecontrol.android.network.VpnService +import com.gatecontrol.android.network.getPortalLink import com.gatecontrol.android.common.ClientPolicy import com.gatecontrol.android.service.ClientPolicyManager import com.gatecontrol.android.service.TunnelConnector @@ -19,6 +20,9 @@ import com.gatecontrol.android.tunnel.TunnelManager import com.gatecontrol.android.tunnel.TunnelState import com.gatecontrol.android.tunnel.TunnelStats import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -27,6 +31,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject @@ -227,11 +232,69 @@ class VpnViewModel @Inject constructor( } } + /** In-flight portal-link fetch; a second tap while it runs is ignored. */ + private var portalJob: Job? = null + + /** connectedSince of the tunnel session the portal was auto-opened for (0 = none). */ + private var autoOpenedSince = 0L + + /** Opens the portal on a tap. See [openPortalWithLogin]. */ fun openPortal(context: android.content.Context) { - val url = portalUrl.value ?: return - if (!url.startsWith("https://")) return + openPortalWithLogin(context) + } + + /** + * Auto-open once per tunnel session (keyed on connectedSince, not a plain + * boolean): re-foregrounding or recomposing on the same session does not + * re-open the browser, a new connect mints a new connectedSince and does. + * Does nothing until the server enabled auto-open and sent a portal URL, + * so a delayed permissions fetch can still trigger it for this session. + */ + fun autoOpenPortalIfNeeded(context: android.content.Context, state: TunnelState) { + if (state !is TunnelState.Connected) return + if (!autoOpenPortal.value || portalUrl.value.isNullOrBlank()) return + if (state.connectedSince == autoOpenedSince) return + autoOpenedSince = state.connectedSince + openPortalWithLogin(context) + } + + /** + * Fetches a fresh one-time login link right before opening (never cached: + * the ticket is single-use and short-lived) and falls back to the plain + * portal URL when the server cannot provide one. The fetch runs in + * [viewModelScope] so the tap stays responsive; the browser is started on + * the main thread. The link is never logged. + */ + private fun openPortalWithLogin(context: android.content.Context) { + val fallback = portalUrl.value ?: return + if (!fallback.startsWith("https://")) return + if (portalJob?.isActive == true) return + val appContext = context.applicationContext ?: context + portalJob = viewModelScope.launch { + val link = fetchPortalLink(fallback) + withContext(Dispatchers.Main) { + portalOpener(appContext, link ?: fallback) + } + } + } + + private suspend fun fetchPortalLink(portalUrl: String): String? { + val serverUrl = setupRepository.getServerUrl() + if (serverUrl.isEmpty()) return null + return try { + apiClientProvider.getClient(serverUrl).getPortalLink(portalUrl) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.d("Portal link unavailable (${e.javaClass.simpleName})") + null + } + } + + /** Starts the browser for [url]; replaceable in tests. */ + internal var portalOpener: (android.content.Context, String) -> Unit = { ctx, url -> runCatching { - context.startActivity( + ctx.startActivity( android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) ) diff --git a/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt b/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt index 9a2e15b..0753779 100644 --- a/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt @@ -269,4 +269,158 @@ class VpnViewModelTest { testDispatcher.scheduler.advanceUntilIdle() coVerify(exactly = 0) { tunnelManager.disconnect() } } + + // --- Portal auto-login --- + + private val portal = "https://portal.example.com" + private val opened = mutableListOf() + private val context = mockk(relaxed = true) + + private fun enablePortal(autoOpen: Boolean = true) { + coEvery { apiClient.getPermissions() } returns PermissionsResponse( + ok = true, + permissions = PermissionFlags(services = false, traffic = false, dns = false, rdp = false), + scopes = emptyList(), + portalUrl = portal, + autoOpenPortal = autoOpen, + ) + viewModel.loadPermissions() + testDispatcher.scheduler.advanceUntilIdle() + viewModel.portalOpener = { _, url -> opened += url } + } + + private fun portalLink(url: String?) = retrofit2.Response.success( + com.gatecontrol.android.network.PortalLinkResponse(ok = true, url = url, expiresIn = 60), + ) + + @Test + fun `openPortal opens the one-time login link`() = runTest { + enablePortal() + coEvery { apiClient.requestPortalLink() } returns portalLink("$portal/auto?t=ticket1") + + viewModel.openPortal(context) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf("$portal/auto?t=ticket1"), opened) + } + + @Test + fun `openPortal fetches a fresh link on every tap`() = runTest { + enablePortal() + coEvery { apiClient.requestPortalLink() } returnsMany listOf( + portalLink("$portal/auto?t=a"), + portalLink("$portal/auto?t=b"), + ) + + viewModel.openPortal(context) + testDispatcher.scheduler.advanceUntilIdle() + viewModel.openPortal(context) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf("$portal/auto?t=a", "$portal/auto?t=b"), opened) + coVerify(exactly = 2) { apiClient.requestPortalLink() } + } + + @Test + fun `openPortal falls back to the portal URL on 404`() = runTest { + enablePortal() + coEvery { apiClient.requestPortalLink() } returns retrofit2.Response.error( + 404, "{}".toResponseBody(null), + ) + + viewModel.openPortal(context) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf(portal), opened) + } + + @Test + fun `openPortal falls back to the portal URL after the 5 s timeout`() = runTest { + enablePortal() + coEvery { apiClient.requestPortalLink() } coAnswers { + kotlinx.coroutines.delay(30_000) + portalLink("$portal/auto?t=late") + } + + viewModel.openPortal(context) + testDispatcher.scheduler.advanceTimeBy(4_900) + testDispatcher.scheduler.runCurrent() + assertTrue(opened.isEmpty()) + testDispatcher.scheduler.advanceTimeBy(200) + testDispatcher.scheduler.runCurrent() + + assertEquals(listOf(portal), opened) + } + + @Test + fun `openPortal falls back to the portal URL on host mismatch`() = runTest { + enablePortal() + coEvery { apiClient.requestPortalLink() } returns portalLink("https://evil.example.com/auto?t=x") + + viewModel.openPortal(context) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf(portal), opened) + } + + @Test + fun `openPortal falls back to the portal URL on network error`() = runTest { + enablePortal() + coEvery { apiClient.requestPortalLink() } throws java.io.IOException("offline") + + viewModel.openPortal(context) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf(portal), opened) + } + + @Test + fun `openPortal ignores a second tap while the link is loading`() = runTest { + enablePortal() + coEvery { apiClient.requestPortalLink() } coAnswers { + kotlinx.coroutines.delay(1_000) + portalLink("$portal/auto?t=x") + } + + viewModel.openPortal(context) + viewModel.openPortal(context) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, opened.size) + coVerify(exactly = 1) { apiClient.requestPortalLink() } + } + + @Test + fun `auto-open calls the endpoint once per tunnel session`() = runTest { + enablePortal() + coEvery { apiClient.requestPortalLink() } returns portalLink("$portal/auto?t=x") + val session = TunnelState.Connected(connectedSince = 1_000L) + + viewModel.autoOpenPortalIfNeeded(context, session) + testDispatcher.scheduler.advanceUntilIdle() + // Recomposition / re-foreground on the same session + viewModel.autoOpenPortalIfNeeded(context, session) + viewModel.autoOpenPortalIfNeeded(context, TunnelState.Connected(connectedSince = 1_000L)) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { apiClient.requestPortalLink() } + assertEquals(1, opened.size) + + // A new connect is a new session + viewModel.autoOpenPortalIfNeeded(context, TunnelState.Connected(connectedSince = 2_000L)) + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 2) { apiClient.requestPortalLink() } + } + + @Test + fun `auto-open does nothing when disabled or not connected`() = runTest { + enablePortal(autoOpen = false) + + viewModel.autoOpenPortalIfNeeded(context, TunnelState.Connected(connectedSince = 1_000L)) + viewModel.autoOpenPortalIfNeeded(context, TunnelState.Disconnected) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 0) { apiClient.requestPortalLink() } + assertTrue(opened.isEmpty()) + } } diff --git a/core/network/src/main/java/com/gatecontrol/android/network/ApiClient.kt b/core/network/src/main/java/com/gatecontrol/android/network/ApiClient.kt index 3bcb4fd..c728f7d 100644 --- a/core/network/src/main/java/com/gatecontrol/android/network/ApiClient.kt +++ b/core/network/src/main/java/com/gatecontrol/android/network/ApiClient.kt @@ -55,6 +55,13 @@ interface ApiClient { @Body body: RequestBody, ): SupportBundleUploadResponse + /** + * Mints a one-time automatic login link for the portal. Use + * [getPortalLink], which validates the answer and never throws. + */ + @POST("api/v1/client/portal-link") + suspend fun requestPortalLink(): Response + @GET("api/v1/client/peer-info") suspend fun getPeerInfo(@Query("peerId") peerId: Int): PeerInfoResponse diff --git a/core/network/src/main/java/com/gatecontrol/android/network/ApiModels.kt b/core/network/src/main/java/com/gatecontrol/android/network/ApiModels.kt index 1184146..d91b4a2 100644 --- a/core/network/src/main/java/com/gatecontrol/android/network/ApiModels.kt +++ b/core/network/src/main/java/com/gatecontrol/android/network/ApiModels.kt @@ -406,3 +406,16 @@ data class PiholeBlockingRequest( val enabled: Boolean, val timer: Int? = null ) + +/** + * One-time portal login link (`POST /client/portal-link`). [url] carries a + * single-use ticket: never log, store or report it. + */ +data class PortalLinkResponse( + val ok: Boolean = false, + @SerializedName("url") val url: String? = null, + @SerializedName("expiresIn") val expiresIn: Int? = null, +) { + // The ticket must not end up in a log line through an accidental toString(). + override fun toString(): String = "PortalLinkResponse(ok=$ok, url=${if (url == null) "null" else "[REDACTED]"}, expiresIn=$expiresIn)" +} diff --git a/core/network/src/main/java/com/gatecontrol/android/network/PortalLink.kt b/core/network/src/main/java/com/gatecontrol/android/network/PortalLink.kt new file mode 100644 index 0000000..6571011 --- /dev/null +++ b/core/network/src/main/java/com/gatecontrol/android/network/PortalLink.kt @@ -0,0 +1,62 @@ +package com.gatecontrol.android.network + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import timber.log.Timber + +/** Upper bound for the portal-link request before falling back to the plain portal URL. */ +const val PORTAL_LINK_TIMEOUT_MS = 5_000L + +/** + * Fetches a fresh one-time automatic login link for the portal + * (`POST /api/v1/client/portal-link`). + * + * Returns the link, or null when the caller has to fall back to the plain + * [portalUrl]: non-2xx status, network error, no answer within [timeoutMs], + * missing `url`, or a `url` whose scheme or host differs from [portalUrl]. + * + * The link carries a secret ticket: it is never logged, stored or cached — + * every call mints a new one. Log lines only name the failure class. + */ +suspend fun ApiClient.getPortalLink( + portalUrl: String, + timeoutMs: Long = PORTAL_LINK_TIMEOUT_MS, +): String? { + val response = try { + withTimeoutOrNull(timeoutMs) { requestPortalLink() } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Class name only: exception messages may echo request/response details. + Timber.d("Portal link unavailable (${e.javaClass.simpleName}) — using portal URL") + return null + } + if (response == null) { + Timber.d("Portal link timed out after $timeoutMs ms — using portal URL") + return null + } + if (!response.isSuccessful) { + Timber.d("Portal link returned HTTP ${response.code()} — using portal URL") + response.errorBody()?.close() + return null + } + val link = response.body()?.url?.takeIf { it.isNotBlank() } + if (link == null) { + Timber.d("Portal link response has no url — using portal URL") + return null + } + if (!isSameOrigin(link, portalUrl)) { + Timber.w("Portal link points to a different scheme/host than the portal URL — ignored") + return null + } + return link +} + +/** True when [link] has the same scheme and host as [portalUrl] (case-insensitive). */ +internal fun isSameOrigin(link: String, portalUrl: String): Boolean { + val a = link.toHttpUrlOrNull() ?: return false + val b = portalUrl.toHttpUrlOrNull() ?: return false + // HttpUrl normalises scheme and host to lower case. + return a.scheme == b.scheme && a.host == b.host +} diff --git a/core/network/src/test/java/com/gatecontrol/android/network/PortalLinkTest.kt b/core/network/src/test/java/com/gatecontrol/android/network/PortalLinkTest.kt new file mode 100644 index 0000000..ad2723b --- /dev/null +++ b/core/network/src/test/java/com/gatecontrol/android/network/PortalLinkTest.kt @@ -0,0 +1,158 @@ +package com.gatecontrol.android.network + +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import timber.log.Timber +import java.util.concurrent.TimeUnit + +// runBlocking, not runTest: the timeout must run on real time, as the +// MockWebServer answers on real time. +class PortalLinkTest { + + private lateinit var server: MockWebServer + private lateinit var apiClient: ApiClient + private lateinit var portalUrl: String + private val logLines = mutableListOf() + + private val captureTree = object : Timber.Tree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + synchronized(logLines) { logLines += message + (t?.toString() ?: "") } + } + } + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + portalUrl = server.url("/").toString().trimEnd('/') + // Same log level as debug builds (headers, never bodies). + val httpLog = HttpLoggingInterceptor { line -> synchronized(logLines) { logLines += line } } + .apply { level = HttpLoggingInterceptor.Level.HEADERS } + apiClient = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().addInterceptor(httpLog).build()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(ApiClient::class.java) + Timber.plant(captureTree) + } + + @AfterEach + fun tearDown() { + Timber.uproot(captureTree) + server.shutdown() + } + + private fun ok(url: String) = MockResponse().setResponseCode(200) + .setBody("""{"ok":true,"url":"$url","expiresIn":60}""") + + @Test + fun `returns the one-time link on success`() = runBlocking { + val link = "$portalUrl/auto?t=secret-ticket-123" + server.enqueue(ok(link)) + + assertEquals(link, apiClient.getPortalLink(portalUrl)) + + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertEquals("/api/v1/client/portal-link", recorded.path) + } + + @Test + fun `falls back on 404`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(404).setBody("""{"ok":false}""")) + assertNull(apiClient.getPortalLink(portalUrl)) + } + + @Test + fun `falls back on 500`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + assertNull(apiClient.getPortalLink(portalUrl)) + } + + @Test + fun `falls back on timeout`() = runBlocking { + server.enqueue(ok("$portalUrl/auto?t=late").setHeadersDelay(3, TimeUnit.SECONDS)) + val started = System.nanoTime() + assertNull(apiClient.getPortalLink(portalUrl, timeoutMs = 300)) + assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) < 2_500) + } + + @Test + fun `default timeout is five seconds`() { + assertEquals(5_000L, PORTAL_LINK_TIMEOUT_MS) + } + + @Test + fun `falls back on network error`() = runBlocking { + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)) + assertNull(apiClient.getPortalLink(portalUrl)) + } + + @Test + fun `falls back when url is missing`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{"ok":true,"expiresIn":60}""")) + assertNull(apiClient.getPortalLink(portalUrl)) + } + + @Test + fun `falls back on malformed body`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(200).setBody("not json")) + assertNull(apiClient.getPortalLink(portalUrl)) + } + + @Test + fun `falls back on host mismatch`() = runBlocking { + server.enqueue(ok("http://evil.example.com:${server.port}/auto?t=abc")) + assertNull(apiClient.getPortalLink(portalUrl)) + } + + @Test + fun `falls back on scheme mismatch`() = runBlocking { + server.enqueue(ok(portalUrl.replaceFirst("http://", "https://") + "/auto?t=abc")) + assertNull(apiClient.getPortalLink(portalUrl)) + } + + @Test + fun `same-origin check compares scheme and host only`() { + assertTrue(isSameOrigin("https://Portal.Example.com/auto?t=x", "https://portal.example.com")) + assertTrue(isSameOrigin("https://portal.example.com:8443/auto?t=x", "https://portal.example.com/")) + assertFalse(isSameOrigin("https://portal.example.com.evil.io/auto?t=x", "https://portal.example.com")) + assertFalse(isSameOrigin("http://portal.example.com/auto?t=x", "https://portal.example.com")) + assertFalse(isSameOrigin("javascript:alert(1)", "https://portal.example.com")) + assertFalse(isSameOrigin("https://portal.example.com/auto", "not a url")) + } + + @Test + fun `the link and its ticket are never logged`() = runBlocking { + val ticket = "tkt-9f8e7d6c5b4a" + server.enqueue(ok("$portalUrl/auto?t=$ticket")) + assertEquals("$portalUrl/auto?t=$ticket", apiClient.getPortalLink(portalUrl)) + server.enqueue(ok("https://evil.example.com/auto?t=$ticket")) + assertNull(apiClient.getPortalLink(portalUrl)) + + val logs = synchronized(logLines) { logLines.joinToString("\n") } + assertTrue(logs.contains("portal-link"), "HTTP log should have been captured") + assertFalse(logs.contains(ticket), "ticket leaked into logs:\n$logs") + assertFalse(logs.contains("auto?t="), "portal link leaked into logs:\n$logs") + } + + @Test + fun `response toString hides the link`() { + val text = PortalLinkResponse(ok = true, url = "https://p.example.com/auto?t=abc", expiresIn = 60).toString() + assertFalse(text.contains("abc")) + } +}