From 369580ec8c76743a0734614564289012c900ce0a Mon Sep 17 00:00:00 2001 From: Vauth Date: Thu, 29 Oct 2026 16:43:20 +0000 Subject: [PATCH 01/11] Fix HTTP 406 on login: browser-based PKCE sign-in Mozilla's Fastly bot protection blocks direct FxA password login POSTs with HTTP 406 (empty body), which no API-side fix can bypass. This mirrors the official Mozilla VPN client login flow: - New BrowserLoginScreen: in-app WebView opens the Guardian login URL (vpn.mozilla.org/api/v2/vpn/login/android) which redirects to accounts.firefox.com; the real browser engine passes the Fastly challenge automatically. - PKCE (S256) code pair; completion redirect to /oauth/success is intercepted and the code is exchanged via /api/v2/vpn/login/verify for a Guardian JWT session. - FastlyChallengeSolver rewritten: replays the actually-served challenge body, ignores HTTP status, solves pow/pat/clientmetrics. - GuardianClient: same body-aware 406 challenge handling. - LoginScreen: 'Sign in with browser' as the recommended primary button; legacy email/password login kept as a fallback with a friendly error when Fastly blocks it. --- .../foxyvpn/data/FastlyChallengeSolver.kt | 136 ++++++++++---- .../vauth/foxyvpn/data/FxaAuthRepository.kt | 137 +++++++++++++- .../com/vauth/foxyvpn/data/GuardianClient.kt | 22 ++- .../foxyvpn/ui/screens/BrowserLoginScreen.kt | 168 ++++++++++++++++++ .../vauth/foxyvpn/ui/screens/LoginScreen.kt | 87 ++++++++- 5 files changed, 506 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt b/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt index a9bb4ad5..4fe2e120 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt @@ -27,22 +27,44 @@ private const val HTML_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" private val CHALLENGE_PREFIX_REGEX = Regex("/_fs-ch-[A-Za-z0-9]+") -private val INIT_CALL_REGEX = Regex("init\\((\\[[^\\]]*\\]),\\s*\"([^\"]+)\",\\s*\"([^\"]+)\"") +private val INIT_CALL_REGEX = Regex("init\\((\\[[^\\]]*\\]),\\s*\"([^\"]+)\"\\s*,\\s*\"([^\"]+)\"") class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { - private class NoChallengePage : Exception() + /** + * Solve the Fastly bot-protection challenge and install the clearance cookies into + * the shared cookie jar. + * + * Fastly serves the challenge page with HTTP 406 (sometimes 403/200/503), so the + * HTTP status alone is meaningless — only body markers decide whether a response is + * a challenge. When the caller already received a challenged response it can hand + * that body over via [challengedBody] together with the origin that served it + * ([challengedOrigin]); the solver then replays that exact challenge instead of + * probing the hosts again. + */ + suspend fun solveAndInstall( + challengedBody: String? = null, + challengedOrigin: String? = null, + ) = withContext(Dispatchers.IO) { + if (!challengedBody.isNullOrEmpty() && looksLikeChallenge(challengedBody)) { + val origin = challengedOrigin?.takeIf { it.startsWith("https://") } + ?: baseOriginFromPrefix(challengedBody) + ?: throw FastlyChallengeError("challenge page did not contain a usable origin") + solveChallengePage(challengedBody, origin) + return@withContext + } - suspend fun solveAndInstall() = withContext(Dispatchers.IO) { var lastError: Exception? = null for (base in listOf("https://api.accounts.firefox.com", "https://accounts.firefox.com")) { - try { - solveOnHost(base) - return@withContext - } catch (_: NoChallengePage) { - continue - } catch (e: FastlyChallengeError) { - lastError = e + for (userAgent in listOf(MOZILLA_VPN_USER_AGENT, SOLVER_USER_AGENT)) { + try { + val page = fetchPage(newSolverClient(SimpleCookieJar()), base + "/", userAgent) + if (!looksLikeChallenge(page)) continue + solveChallengePage(page, base, userAgent) + return@withContext + } catch (e: FastlyChallengeError) { + lastError = e + } } } throw lastError ?: FastlyChallengeError("host did not serve a Fastly challenge page") @@ -53,6 +75,13 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { return if (idx > 0) prefixUrl.substring(0, idx) else prefixUrl } + private fun baseOriginFromPrefix(html: String): String? { + // Relative asset paths ("../..") are rare; Fastly normally embeds same-origin + // "/_fs-ch-..." references, so without an absolute hint there is nothing to do. + val absolute = Regex("https://[A-Za-z0-9.-]+/_fs-ch-[A-Za-z0-9]+").find(html)?.value + return absolute?.let { baseOrigin(it) } + } + private fun solvePow(base: String, targetHex: String): String? { val target = try { targetHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() @@ -71,13 +100,26 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { return null } + /** GET that never inspects the status code: challenge pages arrive with 406/403/200/503. */ + private fun fetchPage(client: OkHttpClient, url: String, userAgent: String): String { + val request = Request.Builder().url(url) + .header("User-Agent", userAgent) + .header("Accept", HTML_ACCEPT) + .build() + client.newCall(request).execute().use { response -> + return response.body?.string().orEmpty() + } + } + + /** GET for challenge assets that must succeed (script.js, post-back, ...). */ private fun fetchText( client: OkHttpClient, url: String, + userAgent: String, accept: String = HTML_ACCEPT, ): String { val request = Request.Builder().url(url) - .header("User-Agent", SOLVER_USER_AGENT) + .header("User-Agent", userAgent) .header("Accept", accept) .build() client.newCall(request).execute().use { response -> @@ -86,10 +128,12 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { } } - private fun fetchChallengePage(client: OkHttpClient, pageUrl: String): Pair { - val body = fetchText(client, pageUrl) - val isChallenge = body.contains("/_fs-ch-") && body.contains("Client Challenge") - return body to isChallenge + private fun looksLikeChallenge(body: String): Boolean { + if (body.length !in 1..2_000_000) return false + if (!CHALLENGE_PREFIX_REGEX.containsMatchIn(body)) return false + return body.contains("Client Challenge", ignoreCase = true) || + body.contains("script.js") || + body.contains("fst-post-back") } private fun parseChallengeInit(script: String): Pair { @@ -105,13 +149,13 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { return challenges to last.groupValues[2] } - private fun fetchPat(client: OkHttpClient, prefixUrl: String, token: String): String { + private fun fetchPat(client: OkHttpClient, prefixUrl: String, token: String, userAgent: String): String { val patUrl = "$prefixUrl/pat?token=${URLEncoder.encode(token, "UTF-8")}" val request = Request.Builder() .url(patUrl) .header("Accept", "text/plain") .header("Content-Type", "application/json") - .header("User-Agent", SOLVER_USER_AGENT) + .header("User-Agent", userAgent) .header("Origin", baseOrigin(prefixUrl)) .post(ByteArray(0).toRequestBody(null)) .build() @@ -150,7 +194,13 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { put("v", 2) } - private fun answerChallenge(client: OkHttpClient, prefixUrl: String, token: String, challenge: JSONObject): JSONObject { + private fun answerChallenge( + client: OkHttpClient, + prefixUrl: String, + token: String, + challenge: JSONObject, + userAgent: String, + ): JSONObject { val data = challenge.optJSONObject("data") ?: JSONObject() return when (val type = challenge.optString("ty")) { "pow" -> { @@ -167,7 +217,7 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { } "pat" -> JSONObject().apply { put("ty", "pat") - put("auth", fetchPat(client, prefixUrl, token)) + put("auth", fetchPat(client, prefixUrl, token, userAgent)) } "clientmetrics" -> clientMetricsAnswer() else -> throw FastlyChallengeError("unsupported Fastly challenge type '$type' (captcha cannot be solved automatically)") @@ -182,29 +232,30 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { } } - private fun solveOnHost(base: String) { + private fun solveChallengePage( + page: String, + origin: String, + userAgent: String = MOZILLA_VPN_USER_AGENT, + ) { val solverJar = SimpleCookieJar() - val solver = ControlPlaneHttp.client.newBuilder() - .cookieJar(solverJar) - .connectTimeout(SOLVE_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .readTimeout(SOLVE_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .build() - - val pageUrl = "$base/" - val (page, isChallenge) = fetchChallengePage(solver, pageUrl) - if (!isChallenge) throw NoChallengePage() + // Seed the solver jar with the shared control-plane cookies: the challenged + // request already stored any Fastly session cookies there via OkHttp. + for (cookie in cookieJar.snapshotAll()) { + solverJar.set(cookie.name, cookie.value, cookie.domain) + } + val solver = newSolverClient(solverJar) val prefixMatch = CHALLENGE_PREFIX_REGEX.find(page) - ?: throw FastlyChallengeError("challenge asset prefix not found on $base") - val prefixUrl = base + prefixMatch.value + ?: throw FastlyChallengeError("challenge asset prefix not found on $origin") + val prefixUrl = origin + prefixMatch.value - val script = fetchText(solver, "$prefixUrl/script.js?reload=true") + val script = fetchText(solver, "$prefixUrl/script.js?reload=true", userAgent) var (challenges, token) = parseChallengeInit(script) repeat(MAX_POST_BACK_ROUNDS) { val answers = JSONArray() for (i in 0 until challenges.length()) { - answers.put(answerChallenge(solver, prefixUrl, token, challenges.getJSONObject(i))) + answers.put(answerChallenge(solver, prefixUrl, token, challenges.getJSONObject(i), userAgent)) } val postBody = JSONObject().apply { put("token", token) @@ -214,16 +265,20 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { .url("$prefixUrl/fst-post-back") .header("Content-Type", "application/json") .header("Accept", "application/json") - .header("User-Agent", SOLVER_USER_AGENT) - .header("Origin", baseOrigin(prefixUrl)) + .header("User-Agent", userAgent) + .header("Origin", origin) .post(postBody) .build() solver.newCall(request).execute().use { response -> if (!response.isSuccessful) throw FastlyChallengeError("challenge post-back returned HTTP ${response.code}") val body = JSONObject(response.body?.string().orEmpty()) if (body.optString("status") == "success") { - val (_, stillChallenged) = fetchChallengePage(solver, pageUrl) - if (stillChallenged) throw FastlyChallengeError("challenge cookie not accepted on this exit IP") + // Re-check with the solver's own client so the freshly issued + // clearance cookies are actually sent. + val recheck = fetchPage(solver, "$origin/", userAgent) + if (looksLikeChallenge(recheck)) { + throw FastlyChallengeError("challenge cookie not accepted on this exit IP") + } installChallengeCookies(solverJar) return } @@ -238,4 +293,11 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { } throw FastlyChallengeError("Fastly challenge did not complete within $MAX_POST_BACK_ROUNDS rounds") } + + private fun newSolverClient(solverJar: SimpleCookieJar): OkHttpClient = + ControlPlaneHttp.client.newBuilder() + .cookieJar(solverJar) + .connectTimeout(SOLVE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(SOLVE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() } diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt index 60630338..bf4b1ccc 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt @@ -43,11 +43,27 @@ private const val STRETCHED_PW_LEN = 32 private const val HKDF_LEN = 32 private const val VERIFICATION_METHOD_EMAIL_2FA = "email-2fa" private const val FXA_ERRNO_INVALID_PARAMETER = 107 -private const val FXA_MAX_CHALLENGE_ATTEMPTS = 5 +private const val FXA_MAX_CHALLENGE_ATTEMPTS = 3 private const val DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 24 * 60 * 60 +// The Guardian (vpn.mozilla.org) browser-login endpoints, mirroring the official +// Mozilla VPN client: it opens the login URL in a browser and finishes the flow by +// posting the authorization code plus PKCE verifier to /api/v2/vpn/login/verify. +private const val GUARDIAN_LOGIN_BASE = "https://vpn.mozilla.org" +private const val GUARDIAN_LOGIN_PATH = "/api/v2/vpn/login/android" +private const val GUARDIAN_LOGIN_VERIFY_PATH = "/api/v2/vpn/login/verify" + private val jsonMediaType = "application/json".toMediaType() +/** Raised when Mozilla's bot protection (Fastly) rejects the request and the challenge cannot be solved. */ +class FastlyBlockedException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** PKCE pair + start URL for the browser sign-in flow. */ +data class BrowserLoginFlow( + val startUrl: String, + val codeVerifier: String, +) + class FxaAuthRepository( private val tokenStore: TokenStore, private val challengeSolver: FastlyChallengeSolver = FastlyChallengeSolver(ControlPlaneHttp.cookieJar), @@ -61,6 +77,41 @@ class FxaAuthRepository( } companion object { + /** + * Build the browser sign-in flow exactly like the official client does: a PKCE + * pair plus the Guardian login URL that redirects to accounts.firefox.com. The + * WebView/system browser passes (and automatically solves) the Fastly bot + * challenges that block direct API password sign-in with HTTP 406. + */ + fun createBrowserLoginFlow(): BrowserLoginFlow { + val verifier = generatePkceVerifier() + val challenge = pkceS256Challenge(verifier) + val url = "$GUARDIAN_LOGIN_BASE$GUARDIAN_LOGIN_PATH".toHttpUrl().newBuilder() + .addQueryParameter("code_challenge", challenge) + .addQueryParameter("code_challenge_method", "S256") + .addQueryParameter("user_agent", MOZILLA_VPN_USER_AGENT) + .addQueryParameter("utm_medium", "vpn-client") + .addQueryParameter("utm_source", "android-customtab-flow") + .addQueryParameter("iap", "true") + .build() + return BrowserLoginFlow(startUrl = url.toString(), codeVerifier = verifier) + } + + private fun generatePkceVerifier(): String { + // Same alphabet and length as the official client. + val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + val bytes = ByteArray(128).also { SecureRandom().nextBytes(it) } + return buildString(128) { + for (b in bytes) append(alphabet[b.toInt().and(0xFF) % alphabet.length]) + } + } + + private fun pkceS256Challenge(verifier: String): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(verifier.toByteArray(Charsets.US_ASCII)) + return Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + } + fun normalizeEmail(email: String): String = email.replace(Regex("[\\p{Z}\\u200B-\\u200D\\uFEFF\\u00A0\\s]+"), "").trim().lowercase() @@ -92,6 +143,62 @@ class FxaAuthRepository( false } + /** + * Finish the browser sign-in: exchange the OAuth code captured from the redirect + * for a Guardian session token, mirroring the official client's login/verify call. + * The stored session is a Guardian JWT (no FxA refresh token is involved in this + * flow), so it stays valid until the JWT expires and re-login happens via browser. + */ + suspend fun completeBrowserLogin(code: String, codeVerifier: String): Result = runCatching { + val cleanCode = sanitizeCode(code) + if (cleanCode.isBlank()) error("The sign-in flow did not return an authorization code") + val body = JSONObject() + .put("code", cleanCode) + .put("code_verifier", codeVerifier) + val data = guardianDo("POST", GUARDIAN_LOGIN_VERIFY_PATH, body) + val token = data.optString("token", "").takeIf { it.isNotBlank() } + ?: error("The sign-in service did not return a session token") + val expiresAt = jwtExpiryEpochSeconds(token) + ?: (System.currentTimeMillis() / 1000 + DEFAULT_ACCESS_TOKEN_TTL_SECONDS) + tokenStore.saveAuth( + RuntimeAuth( + accessToken = token, + refreshToken = null, + expiresAtEpochSeconds = expiresAt, + ), + ) + clearPendingSession() + } + + private suspend fun guardianDo(method: String, path: String, jsonBody: JSONObject): JSONObject = + withContext(Dispatchers.IO) { + val url = "$GUARDIAN_LOGIN_BASE$path".toHttpUrl() + val bodyBytes = jsonBody.toString().toByteArray(Charsets.UTF_8) + val request = Request.Builder().url(url).applyMozillaVpnHeaders() + .header("Content-Type", "application/json") + .method(method, bodyBytes.toRequestBody(jsonMediaType)) + .build() + ControlPlaneHttp.client.newCall(request).execute().use { resp -> + val text = resp.body?.string().orEmpty() + if (resp.code >= 400) { + val errorBody = try { + JSONObject(text) + } catch (e: Exception) { + JSONObject() + } + val message = when { + errorBody.length() > 0 -> + errorBody.optString("message", errorBody.optString("error", "HTTP ${resp.code}")) + text.isEmpty() -> "HTTP ${resp.code}" + else -> text.take(200) + } + val errno = if (errorBody.has("errno")) errorBody.optInt("errno") else null + throw FxaApiError(message, errno, resp.code) + } + if (text.isEmpty()) JSONObject() else JSONObject(text) + } + } + suspend fun restoreSession(): SessionStatus { val stored = runCatching { tokenStore.loadAuth() }.getOrNull() ?: return SessionStatus.NEEDS_LOGIN @@ -246,6 +353,7 @@ class FxaAuthRepository( jsonBody: JSONObject, ): JSONObject = withContext(Dispatchers.IO) { val url = "$FXA_AUTH_SERVER$path".toHttpUrl() + val origin = "${url.scheme}://${url.host}" val bodyBytes = jsonBody.toString().toByteArray(Charsets.UTF_8) var tokenId: String? = null var hmacKey: ByteArray? = null @@ -268,8 +376,15 @@ class FxaAuthRepository( var response: Response = ControlPlaneHttp.client.newCall(buildRequest()).execute() var attempts = 1 while (response.code == 406 && attempts < FXA_MAX_CHALLENGE_ATTEMPTS) { - response.close() - challengeSolver.solveAndInstall() + val challengedBody = response.use { it.body?.string().orEmpty() } + try { + challengeSolver.solveAndInstall(challengedBody, origin) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (solveFailure: Exception) { + AppLogger.w(TAG, "could not solve the Fastly challenge", solveFailure) + throw FastlyBlockedException(FRIENDLY_406_MESSAGE, solveFailure) + } response = ControlPlaneHttp.client.newCall(buildRequest()).execute() attempts++ } @@ -277,13 +392,22 @@ class FxaAuthRepository( response.use { resp -> val text = resp.body?.string().orEmpty() if (resp.code >= 400) { + if (resp.code == 406) { + // A still-challenged response is never a normal API error; do not + // dump its HTML (or an opaque "HTTP 406") into the UI. + throw FastlyBlockedException(FRIENDLY_406_MESSAGE) + } val errorBody = try { JSONObject(text) } catch (e: Exception) { JSONObject() } val errno = if (errorBody.has("errno")) errorBody.optInt("errno") else null - val message = errorBody.optString("message", text.ifEmpty { "HTTP ${resp.code}" }) + val message = when { + errorBody.length() > 0 -> errorBody.optString("message", "HTTP ${resp.code}") + text.isEmpty() -> "HTTP ${resp.code}" + else -> text.take(200) + } throw FxaApiError(message, errno, resp.code) } if (text.isEmpty()) return@withContext JSONObject() @@ -333,6 +457,11 @@ class FxaAuthRepository( } } +private const val FRIENDLY_406_MESSAGE = + "Mozilla's bot protection rejected this request (HTTP 406) and the automatic " + + "challenge could not be completed on this network. Use “Sign in with browser” " + + "on the login screen, or switch networks (Wi-Fi <-> mobile data) and retry." + private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray { val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(key, "HmacSHA256")) diff --git a/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt b/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt index 81ef2e84..47442d82 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/GuardianClient.kt @@ -2,14 +2,17 @@ package com.vauth.foxyvpn.data import android.util.Base64 import com.vauth.foxyvpn.data.model.Entitlement +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import org.json.JSONObject const val GUARDIAN_ENDPOINT_DEFAULT = "https://vpn.mozilla.org" +private const val TAG = "GuardianClient" private const val GUARDIAN_MAX_CHALLENGE_ATTEMPTS = 5 class GuardianHttpError(message: String, val statusCode: Int? = null) : Exception(message) @@ -95,6 +98,10 @@ class GuardianClient( ) private suspend fun authorizedRequest(method: String, url: String, accessToken: String): Response { + val origin = runCatching { url.toHttpUrl() }.getOrNull() + ?.let { "${it.scheme}://${it.host}" } + ?: url + fun buildRequest(): Request { val builder = Request.Builder().url(url).applyMozillaVpnHeaders() builder.header("Content-Type", "application/json") @@ -105,8 +112,19 @@ class GuardianClient( var response = ControlPlaneHttp.client.newCall(buildRequest()).execute() var attempts = 1 while (response.code == 406 && attempts < GUARDIAN_MAX_CHALLENGE_ATTEMPTS) { - response.close() - challengeSolver.solveAndInstall() + val challengedBody = response.use { it.body?.string().orEmpty() } + try { + challengeSolver.solveAndInstall(challengedBody, origin) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (solveFailure: Exception) { + AppLogger.w(TAG, "could not solve the Fastly challenge", solveFailure) + throw GuardianHttpError( + "Mozilla's bot protection rejected this request (HTTP 406) and the challenge " + + "could not be solved automatically. Try again later or on another network.", + 406, + ) + } response = ControlPlaneHttp.client.newCall(buildRequest()).execute() attempts++ } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt new file mode 100644 index 00000000..c1f54a36 --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt @@ -0,0 +1,168 @@ +package com.vauth.foxyvpn.ui.screens + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.CookieManager +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView + +/** Host + path of the OAuth completion redirect served by Guardian. */ +private const val REDIRECT_HOST = "vpn.mozilla.org" +private const val REDIRECT_PATH_PREFIX = "/oauth/success" +private const val DEEP_LINK_SCHEME = "mozilla-vpn" + +/** + * In-app browser sign-in. Loads the official Guardian login URL, which redirects to + * accounts.firefox.com where the user signs in with a real browser engine (this is + * what passes and automatically solves the Fastly bot challenges that block the + * direct API password login with HTTP 406). + * + * When the OAuth flow completes, the browser is redirected to + * https://vpn.mozilla.org/oauth/success?code=... (or the mozilla-vpn:// deep link); + * this composable intercepts that navigation and reports the authorization code. + */ +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun BrowserLoginScreen( + startUrl: String, + busy: Boolean, + onCodeCaptured: (String) -> Unit, + onCanceled: () -> Unit, +) { + var capturedCode by remember { mutableStateOf(null) } + var pageTitle by remember { mutableStateOf("") } + var pageProgress by remember { mutableIntStateOf(0) } + var pageLoaded by remember { mutableStateOf(false) } + val currentOnCode by rememberUpdatedState(onCodeCaptured) + val currentOnCanceled by rememberUpdatedState(onCanceled) + + BackHandler(enabled = true) { + if (!busy) currentOnCanceled() + } + + fun handleNavigation(uri: Uri): Boolean { + if (capturedCode != null) return true + val code = extractAuthCode(uri) ?: return false + capturedCode = code + currentOnCode(code) + return true + } + + Column(Modifier.fillMaxSize()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(end = 16.dp), + ) { + IconButton(onClick = { if (!busy) currentOnCanceled() }, enabled = !busy) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Cancel sign-in") + } + Column(Modifier.weight(1f)) { + Text( + pageTitle.ifBlank { "Sign in with Firefox" }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + ) + Text( + "You will return to FoxyVPN automatically after signing in", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (busy || !pageLoaded || pageProgress < 100) { + LinearProgressIndicator( + progress = { if (busy) 1f else pageProgress / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + } + Box(Modifier.weight(1f)) { + AndroidView( + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + CookieManager.getInstance().setAcceptCookie(true) + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean = handleNavigation(request.url) + + override fun doUpdateVisitedHistory( + view: WebView, + url: String?, + isReload: Boolean, + ) { + if (url != null) { + if (handleNavigation(Uri.parse(url))) return + } + super.doUpdateVisitedHistory(view, url, isReload) + } + + override fun onPageStarted(view: WebView, url: String?, favicon: android.graphics.Bitmap?) { + super.onPageStarted(view, url, favicon) + pageLoaded = false + } + + override fun onPageFinished(view: WebView, url: String?) { + super.onPageFinished(view, url) + pageLoaded = true + pageTitle = view.title.orEmpty() + } + } + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + pageProgress = newProgress + } + } + loadUrl(startUrl) + } + }, + modifier = Modifier.fillMaxSize(), + onRelease = { it.stopLoading() }, + ) + } + } +} + +/** Returns the OAuth authorization code if the navigation is the flow's completion redirect. */ +private fun extractAuthCode(uri: Uri): String? { + val isGuardianRedirect = uri.scheme == "https" && + uri.host == REDIRECT_HOST && + (uri.path?.startsWith(REDIRECT_PATH_PREFIX) == true) + val isDeepLink = uri.scheme == DEEP_LINK_SCHEME + if (!isGuardianRedirect && !isDeepLink) return null + return uri.getQueryParameter("code")?.takeIf { it.isNotBlank() } +} diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt index 991d2bff..a8b76633 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt @@ -17,9 +17,11 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -50,6 +52,10 @@ fun LoginScreen( var awaitingTwoFactor by rememberSaveable { mutableStateOf(authRepository.pendingSessionToken != null) } var isLoading by remember { mutableStateOf(false) } var errorMessage by rememberSaveable { mutableStateOf(null) } + var showLegacyLogin by rememberSaveable { mutableStateOf(false) } + var browserStartUrl by rememberSaveable { mutableStateOf(null) } + var browserVerifier by rememberSaveable { mutableStateOf(null) } + var completingBrowserLogin by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current @@ -60,6 +66,36 @@ fun LoginScreen( code = "" } + fun startBrowserLogin() { + if (completingBrowserLogin) return + errorMessage = null + val flow = FxaAuthRepository.createBrowserLoginFlow() + browserStartUrl = flow.startUrl + browserVerifier = flow.codeVerifier + } + + fun handleBrowserCode(authCode: String) { + val verifier = browserVerifier ?: return + if (completingBrowserLogin) return + completingBrowserLogin = true + errorMessage = null + scope.launch { + val result = authRepository.completeBrowserLogin(authCode, verifier) + completingBrowserLogin = false + result.onSuccess { + browserStartUrl = null + browserVerifier = null + onSignedIn() + }.onFailure { + // Captured codes are single-use; drop the flow so the next attempt + // starts fresh with a new PKCE pair. + browserStartUrl = null + browserVerifier = null + errorMessage = it.message ?: "Browser sign-in failed" + } + } + } + fun doSubmitLogin() { val cleanEmail = FxaAuthRepository.normalizeEmail(email) if (cleanEmail.isBlank() || password.isBlank() || isLoading) return @@ -92,6 +128,20 @@ fun LoginScreen( } } + if (browserStartUrl != null && browserVerifier != null) { + BrowserLoginScreen( + startUrl = browserStartUrl!!, + busy = completingBrowserLogin, + onCodeCaptured = ::handleBrowserCode, + onCanceled = { + browserStartUrl = null + browserVerifier = null + completingBrowserLogin = false + }, + ) + return + } + Box( modifier = Modifier .fillMaxSize() @@ -116,6 +166,41 @@ fun LoginScreen( Spacer(Modifier.padding(top = 24.dp)) if (!awaitingTwoFactor) { + Button( + onClick = { startBrowserLogin() }, + enabled = !completingBrowserLogin && !isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + if (completingBrowserLogin) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Sign in with browser") + } + } + Text( + "Recommended: opens the official Mozilla sign-in page in an in-app browser, which avoids the HTTP 406 bot-protection error.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp), + ) + + Spacer(Modifier.padding(top = 20.dp)) + HorizontalDivider() + Spacer(Modifier.padding(top = 8.dp)) + TextButton( + onClick = { showLegacyLogin = !showLegacyLogin }, + enabled = !completingBrowserLogin, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (showLegacyLogin) "Hide email & password sign-in" else "Use email & password instead") + } + } + + if (showLegacyLogin && !awaitingTwoFactor) { OutlinedTextField( value = email, onValueChange = { @@ -173,7 +258,7 @@ fun LoginScreen( Text("Continue") } } - } else { + } else if (awaitingTwoFactor) { Text("Enter the confirmation code sent to your email", style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.padding(top = 12.dp)) OutlinedTextField( From 48313b19fbfb03f462d6dfe584c32fa1246e5314 Mon Sep 17 00:00:00 2001 From: Vauth Date: Thu, 29 Oct 2026 17:37:28 +0000 Subject: [PATCH 02/11] Fix "'code' not found or expired": start flow via app HTTP client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guardian's /api/v2/vpn/login/android returns JSON (not a redirect) and sets a flow session cookie. The official client performs this GET with its own HTTP stack and later presents the same session when POSTing to /api/v2/vpn/login/verify. Previously the WebView performed the start request, so the flow session lived in the WebView cookie store while the verify call went out from the app's OkHttp client without it — Guardian could not match the authorization code against the PKCE challenge and returned errno 129 "'code' not found or expired". Now startBrowserLoginFlow() performs the start GET via the shared ControlPlaneHttp client (cookie jar keeps the flow session, Fastly 406 challenge handling included) and builds the accounts.firefox.com authorization URL from the returned fxa_oauth JSON, exactly like the official client. The WebView only handles the accounts.firefox.com sign-in and the completion redirect is still intercepted locally. --- .../vauth/foxyvpn/data/FxaAuthRepository.kt | 69 +++++++++++++++---- .../vauth/foxyvpn/ui/screens/LoginScreen.kt | 18 +++-- 2 files changed, 70 insertions(+), 17 deletions(-) mode change 100644 => 100755 app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt mode change 100644 => 100755 app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt old mode 100644 new mode 100755 index bf4b1ccc..d58087d9 --- a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt @@ -76,17 +76,19 @@ class FxaAuthRepository( tokenStore.pendingSessionToken = null } - companion object { - /** - * Build the browser sign-in flow exactly like the official client does: a PKCE - * pair plus the Guardian login URL that redirects to accounts.firefox.com. The - * WebView/system browser passes (and automatically solves) the Fastly bot - * challenges that block direct API password sign-in with HTTP 406. - */ - fun createBrowserLoginFlow(): BrowserLoginFlow { + /** + * Start the browser sign-in flow exactly like the official client does: the app + * itself GETs the Guardian login URL (so Guardian's flow session cookie lands in + * the shared cookie jar) and builds the accounts.firefox.com authorization URL + * from the returned JSON. The browser only handles the actual sign-in; the final + * /login/verify call must present the same flow session, otherwise Guardian + * rejects the code with "'code' not found or expired". + */ + suspend fun startBrowserLoginFlow(): Result = withContext(Dispatchers.IO) { + runCatching { val verifier = generatePkceVerifier() val challenge = pkceS256Challenge(verifier) - val url = "$GUARDIAN_LOGIN_BASE$GUARDIAN_LOGIN_PATH".toHttpUrl().newBuilder() + val startUrl = "$GUARDIAN_LOGIN_BASE$GUARDIAN_LOGIN_PATH".toHttpUrl().newBuilder() .addQueryParameter("code_challenge", challenge) .addQueryParameter("code_challenge_method", "S256") .addQueryParameter("user_agent", MOZILLA_VPN_USER_AGENT) @@ -94,9 +96,48 @@ class FxaAuthRepository( .addQueryParameter("utm_source", "android-customtab-flow") .addQueryParameter("iap", "true") .build() - return BrowserLoginFlow(startUrl = url.toString(), codeVerifier = verifier) + + fun buildStartRequest(): Request = + Request.Builder().url(startUrl).applyMozillaVpnHeaders().build() + + var response = ControlPlaneHttp.client.newCall(buildStartRequest()).execute() + var attempts = 1 + while (response.code == 406 && attempts < FXA_MAX_CHALLENGE_ATTEMPTS) { + val challengedBody = response.use { it.body?.string().orEmpty() } + try { + challengeSolver.solveAndInstall(challengedBody, GUARDIAN_LOGIN_BASE) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (solveFailure: Exception) { + AppLogger.w(TAG, "could not solve the Fastly challenge", solveFailure) + throw FastlyBlockedException(FRIENDLY_406_MESSAGE, solveFailure) + } + response = ControlPlaneHttp.client.newCall(buildStartRequest()).execute() + attempts++ + } + + response.use { resp -> + val text = resp.body?.string().orEmpty() + if (resp.code >= 400) { + throw FxaApiError( + "could not start the sign-in flow: HTTP ${resp.code}", + null, + resp.code, + ) + } + // Response shape: {"fxa_oauth":{"url":"https://accounts.firefox.com/authorization","params":{...}}} + val fxa = JSONObject(text).optJSONObject("fxa_oauth") + ?: error("The sign-in service returned an unexpected response") + val authorizationUrl = fxa.optString("url", "https://accounts.firefox.com/authorization") + val params = fxa.optJSONObject("params") ?: JSONObject() + val builder = authorizationUrl.toHttpUrl().newBuilder() + for (key in params.keys()) builder.addQueryParameter(key, params.optString(key)) + BrowserLoginFlow(startUrl = builder.build().toString(), codeVerifier = verifier) + } } + } + companion object { private fun generatePkceVerifier(): String { // Same alphabet and length as the official client. val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" @@ -145,9 +186,11 @@ class FxaAuthRepository( /** * Finish the browser sign-in: exchange the OAuth code captured from the redirect - * for a Guardian session token, mirroring the official client's login/verify call. - * The stored session is a Guardian JWT (no FxA refresh token is involved in this - * flow), so it stays valid until the JWT expires and re-login happens via browser. + * for a Guardian session token. The shared cookie jar still holds the flow session + * cookie created by [startBrowserLoginFlow], which Guardian requires to match the + * code against the PKCE challenge. The stored session is a Guardian JWT (no FxA + * refresh token is involved in this flow), so it stays valid until the JWT expires + * and re-login happens via browser. */ suspend fun completeBrowserLogin(code: String, codeVerifier: String): Result = runCatching { val cleanCode = sanitizeCode(code) diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt old mode 100644 new mode 100755 index a8b76633..814f8176 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt @@ -67,11 +67,21 @@ fun LoginScreen( } fun startBrowserLogin() { - if (completingBrowserLogin) return + if (completingBrowserLogin || isLoading) return errorMessage = null - val flow = FxaAuthRepository.createBrowserLoginFlow() - browserStartUrl = flow.startUrl - browserVerifier = flow.codeVerifier + isLoading = true + scope.launch { + // The flow must be started by the app's own HTTP client so Guardian's + // flow session cookie lands in the shared cookie jar used by the final + // /login/verify call (the official client does the same). + authRepository.startBrowserLoginFlow() + .onSuccess { flow -> + browserStartUrl = flow.startUrl + browserVerifier = flow.codeVerifier + } + .onFailure { errorMessage = it.message ?: "Could not start the browser sign-in" } + isLoading = false + } } fun handleBrowserCode(authCode: String) { From 7ded331c69e2e5e3ad7678758574c8764ec372d8 Mon Sep 17 00:00:00 2001 From: AHK2030 Date: Thu, 29 Oct 2026 18:30:19 +0000 Subject: [PATCH 03/11] Fix errno 129 for good: allow the /oauth/success GET and sync the flow cookie Guardian registers the authorization code server-side only when the browser actually GETs https://vpn.mozilla.org/oauth/success?code=... (the FxA completion redirect). The previous build blocked that navigation inside shouldOverrideUrlLoading, so the code was never registered and POST /api/v2/vpn/login/verify always failed with 401 errno 129 "'code' not found or expired" (reproduced against the live Guardian API with a probe request). - The WebView now only blocks the mozilla-vpn:// deep link; every https navigation - including the completion redirect - is allowed through, and the code is reported from doUpdateVisitedHistory, i.e. after Guardian answered the completion GET. - The flow session cookie issued on the /login/android start GET is copied from the shared OkHttp cookie jar into the WebView cookie store before the sign-in page loads, and mirrored back into the jar right before /login/verify (Guardian may rotate the session while serving the success page). - User-Agent aligned with the official Android client (sys:android instead of sys:linux). - versionCode 45 / versionName 1.0.10 --- app/build.gradle.kts | 4 +- .../vauth/foxyvpn/data/ControlPlaneHttp.kt | 2 +- .../foxyvpn/ui/screens/BrowserLoginScreen.kt | 100 +++++++++++++++--- .../vauth/foxyvpn/ui/screens/LoginScreen.kt | 9 ++ 4 files changed, 97 insertions(+), 18 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 79ee22b7..5eaf1f48 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,8 +47,8 @@ android { applicationId = "com.vauth.foxyvpn" minSdk = 26 targetSdk = 35 - versionCode = 44 - versionName = "1.0.7" + versionCode = 45 + versionName = "1.0.10" externalNativeBuild { ndkBuild { diff --git a/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt b/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt index d26bfee5..e054a883 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt @@ -17,7 +17,7 @@ import javax.net.SocketFactory private const val TAG = "ControlPlaneHttp" -const val MOZILLA_VPN_USER_AGENT = "MozillaVPN/2.35.0 (sys:linux; iap:true)" +const val MOZILLA_VPN_USER_AGENT = "MozillaVPN/2.35.0 (sys:android 14; iap:true)" fun Request.Builder.applyMozillaVpnHeaders(): Request.Builder { header("User-Agent", MOZILLA_VPN_USER_AGENT) diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt index c1f54a36..5553d4bc 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt @@ -32,6 +32,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import com.vauth.foxyvpn.data.AppLogger +import com.vauth.foxyvpn.data.ControlPlaneHttp + +private const val TAG = "BrowserLoginScreen" /** Host + path of the OAuth completion redirect served by Guardian. */ private const val REDIRECT_HOST = "vpn.mozilla.org" @@ -45,8 +49,12 @@ private const val DEEP_LINK_SCHEME = "mozilla-vpn" * direct API password login with HTTP 406). * * When the OAuth flow completes, the browser is redirected to - * https://vpn.mozilla.org/oauth/success?code=... (or the mozilla-vpn:// deep link); - * this composable intercepts that navigation and reports the authorization code. + * https://vpn.mozilla.org/oauth/success?code=... (or the mozilla-vpn:// deep link). + * The completion navigation MUST be allowed to reach Guardian: Guardian registers + * the authorization code server-side only when the browser actually GETs that URL + * (carrying the flow session cookie). Blocking it — as earlier builds did — makes + * the final /login/verify call fail with "'code' not found or expired". The code + * is reported from doUpdateVisitedHistory, i.e. after the request was answered. */ @SuppressLint("SetJavaScriptEnabled") @Composable @@ -67,12 +75,30 @@ fun BrowserLoginScreen( if (!busy) currentOnCanceled() } - fun handleNavigation(uri: Uri): Boolean { + // Pre-load interception. Only the mozilla-vpn:// deep link is blocked (it can + // never be loaded by the WebView anyway); every https navigation — including + // the final /oauth/success?code=... completion redirect — must be allowed to + // go through so Guardian can register the code server-side. + fun handleOverride(uri: Uri): Boolean { if (capturedCode != null) return true - val code = extractAuthCode(uri) ?: return false + if (uri.scheme == DEEP_LINK_SCHEME) { + extractAuthCode(uri)?.let { code -> + capturedCode = code + currentOnCode(code) + } + return true + } + return false + } + + // Fired once a navigation is committed (the server has already responded), so + // the completion GET has reached Guardian by this point and the code can be + // exchanged safely. + fun handleHistoryUpdate(uri: Uri) { + if (capturedCode != null) return + val code = extractAuthCode(uri) ?: return capturedCode = code currentOnCode(code) - return true } Column(Modifier.fillMaxSize()) { @@ -118,16 +144,14 @@ fun BrowserLoginScreen( override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest, - ): Boolean = handleNavigation(request.url) + ): Boolean = handleOverride(request.url) override fun doUpdateVisitedHistory( view: WebView, url: String?, isReload: Boolean, ) { - if (url != null) { - if (handleNavigation(Uri.parse(url))) return - } + if (url != null) handleHistoryUpdate(Uri.parse(url)) super.doUpdateVisitedHistory(view, url, isReload) } @@ -157,12 +181,58 @@ fun BrowserLoginScreen( } } -/** Returns the OAuth authorization code if the navigation is the flow's completion redirect. */ +/** + * Returns the OAuth authorization code if the navigation is the flow's completion + * redirect. The official redirect target is https://vpn.mozilla.org/oauth/success + * (query carries both `code` and `state`); the mozilla-vpn:// deep link is matched + * as a fallback because Guardian's success page may bounce through it. + */ private fun extractAuthCode(uri: Uri): String? { - val isGuardianRedirect = uri.scheme == "https" && + val code = uri.getQueryParameter("code")?.takeIf { it.isNotBlank() } ?: return null + if (uri.scheme == DEEP_LINK_SCHEME) return code + val isGuardianSuccess = uri.scheme == "https" && uri.host == REDIRECT_HOST && - (uri.path?.startsWith(REDIRECT_PATH_PREFIX) == true) - val isDeepLink = uri.scheme == DEEP_LINK_SCHEME - if (!isGuardianRedirect && !isDeepLink) return null - return uri.getQueryParameter("code")?.takeIf { it.isNotBlank() } + (uri.path?.startsWith(REDIRECT_PATH_PREFIX) == true || + uri.getQueryParameter("state")?.startsWith("pkce-") == true) + return if (isGuardianSuccess) code else null +} + +/** + * Guardian's /oauth/success completion page must be requested by the WebView while + * carrying the flow session cookie that Guardian issued when the app started the + * login flow; without the session, Guardian cannot match the code to the flow and + * /login/verify later fails with "'code' not found or expired". The WebView keeps + * its own cookie store, so copy the flow cookies over before the sign-in page is + * loaded. + */ +internal fun syncGuardianCookiesToWebView() { + val manager = CookieManager.getInstance() + for (cookie in ControlPlaneHttp.cookieJar.snapshotAll()) { + if (!cookie.domain.endsWith("mozilla.org")) continue + val header = buildString { + append(cookie.name).append('=').append(cookie.value) + append("; Path=").append(cookie.path.ifBlank { "/" }) + if (cookie.secure) append("; Secure") + } + runCatching { manager.setCookie("https://${cookie.domain}/", header) } + .onFailure { AppLogger.w(TAG, "could not sync cookie ${cookie.name} into the WebView", it) } + } + manager.flush() +} + +/** + * Mirror whatever cookies the WebView ended up with for vpn.mozilla.org (Guardian + * may have rotated the flow session while serving /oauth/success) back into the + * shared jar used by the final /login/verify call. + */ +internal fun syncWebViewCookiesToGuardianJar() { + val raw = CookieManager.getInstance().getCookie("https://vpn.mozilla.org/") ?: return + for (pair in raw.split(";")) { + val idx = pair.indexOf('=') + if (idx <= 0) continue + val name = pair.substring(0, idx).trim() + val value = pair.substring(idx + 1).trim() + if (name.isEmpty() || value.isEmpty()) continue + ControlPlaneHttp.cookieJar.set(name, value, "vpn.mozilla.org") + } } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt index 814f8176..00a2abec 100755 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt @@ -76,6 +76,11 @@ fun LoginScreen( // /login/verify call (the official client does the same). authRepository.startBrowserLoginFlow() .onSuccess { flow -> + // Copy Guardian's flow session cookie into the WebView cookie + // store: the /oauth/success completion page must carry it, or + // Guardian cannot register the authorization code and the final + // verify call fails with "'code' not found or expired". + syncGuardianCookiesToWebView() browserStartUrl = flow.startUrl browserVerifier = flow.codeVerifier } @@ -90,6 +95,10 @@ fun LoginScreen( completingBrowserLogin = true errorMessage = null scope.launch { + // Mirror whatever cookies the WebView ended up with for vpn.mozilla.org + // (Guardian may have rotated the flow session while serving the + // completion page) into the shared jar used by the verify call. + syncWebViewCookiesToGuardianJar() val result = authRepository.completeBrowserLogin(authCode, verifier) completingBrowserLogin = false result.onSuccess { From 40c07a9be2b0e4fed8afc3275659eda8e65751b0 Mon Sep 17 00:00:00 2001 From: Z User Date: Thu, 29 Oct 2026 19:27:47 +0000 Subject: [PATCH 04/11] Fix Guardian 401: browser sign-in now yields an FxA OAuth token via WebChannel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/v1/fpn/* proxy endpoints only accept an FxA OAuth access token (the grant the reference client uses), but the previous browser flow stored a Guardian JWT from /api/v2/vpn/login/verify — so Guardian answered 401 on both /fpn/token and /fpn/activate. - browser sign-in now opens the accounts.firefox.com sync flow and captures the account session token from the fxaccounts:login WebChannel message (the WebView answers fxa_status and can_link_account like Firefox does) - the app mints the FxA OAuth token locally with the fxa-credentials grant, storing access + refresh tokens - token refresh moved to the dedicated OAuth token server (oauth.accounts.firefox.com); the auth server answers errno 110 for the refresh grant - mintProxyPass now renews the token BEFORE activating the entitlement and fails fast with 'sign in again' once the session is truly dead - sessions holding the retired Guardian JWT are cleared on restore - CI now builds on every branch push --- .github/workflows/android-ci.yml | 1 + app/proguard-rules.pro | 4 + .../vauth/foxyvpn/data/FxaAuthRepository.kt | 234 +++++++-------- .../foxyvpn/ui/screens/BrowserLoginScreen.kt | 272 ++++++++++-------- .../vauth/foxyvpn/ui/screens/LoginScreen.kt | 41 +-- .../com/vauth/foxyvpn/vpn/FoxyVpnService.kt | 22 +- 6 files changed, 290 insertions(+), 284 deletions(-) diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 303e238c..6596f52c 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -2,6 +2,7 @@ name: Android CI on: push: + branches: ["**"] tags: ["v*.*.*"] pull_request: branches: [main] diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index c2d03afa..a1e51fd3 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -20,3 +20,7 @@ -dontwarn com.google.errorprone.annotations.** -dontwarn reactor.blockhound.** + +# The FxA WebChannel bridge is reached from JS by its registered interface name; +# keep its members so release builds never strip the @JavascriptInterface methods. +-keep class com.vauth.foxyvpn.ui.screens.WebChannelBridge { *; } diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt index d58087d9..d87e8651 100755 --- a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt @@ -35,6 +35,10 @@ class FxaRefreshFailure( ) : Exception(message, cause) private const val FXA_AUTH_SERVER = "https://api.accounts.firefox.com/v1" +// The OAuth token server is a separate deployment from the auth server: the +// refresh_token grant lives here (no HAWK signature needed), while the +// fxa-credentials grant lives on the auth server above. +private const val FXA_OAUTH_SERVER = "https://oauth.accounts.firefox.com/v1" private const val FIREFOX_CLIENT_ID = "5882386c6d801776" private const val OAUTH_SCOPE = "profile https://identity.mozilla.com/apps/vpn" private const val PROTOCOL_VERSION = "identity.mozilla.com/picl/v1/" @@ -46,22 +50,25 @@ private const val FXA_ERRNO_INVALID_PARAMETER = 107 private const val FXA_MAX_CHALLENGE_ATTEMPTS = 3 private const val DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 24 * 60 * 60 -// The Guardian (vpn.mozilla.org) browser-login endpoints, mirroring the official -// Mozilla VPN client: it opens the login URL in a browser and finishes the flow by -// posting the authorization code plus PKCE verifier to /api/v2/vpn/login/verify. -private const val GUARDIAN_LOGIN_BASE = "https://vpn.mozilla.org" -private const val GUARDIAN_LOGIN_PATH = "/api/v2/vpn/login/android" -private const val GUARDIAN_LOGIN_VERIFY_PATH = "/api/v2/vpn/login/verify" +/** + * The sync-style sign-in entry point (the same flow Firefox desktop uses to + * connect an account). The accounts.firefox.com page hands the session token to + * the host browser over the WebChannel bridge (command "fxaccounts:login"); our + * in-app WebView intercepts that message, and the app then mints an FxA OAuth + * token locally with the fxa-credentials grant — the only token type Guardian's + * /api/v1/fpn proxy endpoints accept. + */ +private const val WEBCHANNEL_LOGIN_URL = + "https://accounts.firefox.com/?context=fx_desktop_v3&service=sync&action=email" private val jsonMediaType = "application/json".toMediaType() /** Raised when Mozilla's bot protection (Fastly) rejects the request and the challenge cannot be solved. */ class FastlyBlockedException(message: String, cause: Throwable? = null) : Exception(message, cause) -/** PKCE pair + start URL for the browser sign-in flow. */ +/** Start URL for the in-app browser sign-in flow. */ data class BrowserLoginFlow( val startUrl: String, - val codeVerifier: String, ) class FxaAuthRepository( @@ -77,82 +84,15 @@ class FxaAuthRepository( } /** - * Start the browser sign-in flow exactly like the official client does: the app - * itself GETs the Guardian login URL (so Guardian's flow session cookie lands in - * the shared cookie jar) and builds the accounts.firefox.com authorization URL - * from the returned JSON. The browser only handles the actual sign-in; the final - * /login/verify call must present the same flow session, otherwise Guardian - * rejects the code with "'code' not found or expired". + * Start the browser sign-in flow: a plain accounts.firefox.com sync sign-in + * whose session token the in-app WebView receives over the WebChannel bridge + * (see [BrowserLoginScreen]). No network round trip is needed to start it. */ - suspend fun startBrowserLoginFlow(): Result = withContext(Dispatchers.IO) { - runCatching { - val verifier = generatePkceVerifier() - val challenge = pkceS256Challenge(verifier) - val startUrl = "$GUARDIAN_LOGIN_BASE$GUARDIAN_LOGIN_PATH".toHttpUrl().newBuilder() - .addQueryParameter("code_challenge", challenge) - .addQueryParameter("code_challenge_method", "S256") - .addQueryParameter("user_agent", MOZILLA_VPN_USER_AGENT) - .addQueryParameter("utm_medium", "vpn-client") - .addQueryParameter("utm_source", "android-customtab-flow") - .addQueryParameter("iap", "true") - .build() - - fun buildStartRequest(): Request = - Request.Builder().url(startUrl).applyMozillaVpnHeaders().build() - - var response = ControlPlaneHttp.client.newCall(buildStartRequest()).execute() - var attempts = 1 - while (response.code == 406 && attempts < FXA_MAX_CHALLENGE_ATTEMPTS) { - val challengedBody = response.use { it.body?.string().orEmpty() } - try { - challengeSolver.solveAndInstall(challengedBody, GUARDIAN_LOGIN_BASE) - } catch (cancellation: CancellationException) { - throw cancellation - } catch (solveFailure: Exception) { - AppLogger.w(TAG, "could not solve the Fastly challenge", solveFailure) - throw FastlyBlockedException(FRIENDLY_406_MESSAGE, solveFailure) - } - response = ControlPlaneHttp.client.newCall(buildStartRequest()).execute() - attempts++ - } - - response.use { resp -> - val text = resp.body?.string().orEmpty() - if (resp.code >= 400) { - throw FxaApiError( - "could not start the sign-in flow: HTTP ${resp.code}", - null, - resp.code, - ) - } - // Response shape: {"fxa_oauth":{"url":"https://accounts.firefox.com/authorization","params":{...}}} - val fxa = JSONObject(text).optJSONObject("fxa_oauth") - ?: error("The sign-in service returned an unexpected response") - val authorizationUrl = fxa.optString("url", "https://accounts.firefox.com/authorization") - val params = fxa.optJSONObject("params") ?: JSONObject() - val builder = authorizationUrl.toHttpUrl().newBuilder() - for (key in params.keys()) builder.addQueryParameter(key, params.optString(key)) - BrowserLoginFlow(startUrl = builder.build().toString(), codeVerifier = verifier) - } - } + suspend fun startBrowserLoginFlow(): Result = runCatching { + BrowserLoginFlow(startUrl = WEBCHANNEL_LOGIN_URL) } companion object { - private fun generatePkceVerifier(): String { - // Same alphabet and length as the official client. - val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" - val bytes = ByteArray(128).also { SecureRandom().nextBytes(it) } - return buildString(128) { - for (b in bytes) append(alphabet[b.toInt().and(0xFF) % alphabet.length]) - } - } - - private fun pkceS256Challenge(verifier: String): String { - val digest = MessageDigest.getInstance("SHA-256") - .digest(verifier.toByteArray(Charsets.US_ASCII)) - return Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) - } - fun normalizeEmail(email: String): String = email.replace(Regex("[\\p{Z}\\u200B-\\u200D\\uFEFF\\u00A0\\s]+"), "").trim().lowercase() @@ -185,66 +125,32 @@ class FxaAuthRepository( } /** - * Finish the browser sign-in: exchange the OAuth code captured from the redirect - * for a Guardian session token. The shared cookie jar still holds the flow session - * cookie created by [startBrowserLoginFlow], which Guardian requires to match the - * code against the PKCE challenge. The stored session is a Guardian JWT (no FxA - * refresh token is involved in this flow), so it stays valid until the JWT expires - * and re-login happens via browser. + * Finish the browser sign-in: the in-app WebView captured the account's session + * token from the WebChannel "fxaccounts:login" message. Mint an FxA OAuth token + * with the fxa-credentials grant (the same grant and client the reference + * clients use) so Guardian's FPN proxy endpoints accept it, and store both the + * access and the refresh token. */ - suspend fun completeBrowserLogin(code: String, codeVerifier: String): Result = runCatching { - val cleanCode = sanitizeCode(code) - if (cleanCode.isBlank()) error("The sign-in flow did not return an authorization code") - val body = JSONObject() - .put("code", cleanCode) - .put("code_verifier", codeVerifier) - val data = guardianDo("POST", GUARDIAN_LOGIN_VERIFY_PATH, body) - val token = data.optString("token", "").takeIf { it.isNotBlank() } - ?: error("The sign-in service did not return a session token") - val expiresAt = jwtExpiryEpochSeconds(token) - ?: (System.currentTimeMillis() / 1000 + DEFAULT_ACCESS_TOKEN_TTL_SECONDS) - tokenStore.saveAuth( - RuntimeAuth( - accessToken = token, - refreshToken = null, - expiresAtEpochSeconds = expiresAt, - ), - ) - clearPendingSession() - } - - private suspend fun guardianDo(method: String, path: String, jsonBody: JSONObject): JSONObject = - withContext(Dispatchers.IO) { - val url = "$GUARDIAN_LOGIN_BASE$path".toHttpUrl() - val bodyBytes = jsonBody.toString().toByteArray(Charsets.UTF_8) - val request = Request.Builder().url(url).applyMozillaVpnHeaders() - .header("Content-Type", "application/json") - .method(method, bodyBytes.toRequestBody(jsonMediaType)) - .build() - ControlPlaneHttp.client.newCall(request).execute().use { resp -> - val text = resp.body?.string().orEmpty() - if (resp.code >= 400) { - val errorBody = try { - JSONObject(text) - } catch (e: Exception) { - JSONObject() - } - val message = when { - errorBody.length() > 0 -> - errorBody.optString("message", errorBody.optString("error", "HTTP ${resp.code}")) - text.isEmpty() -> "HTTP ${resp.code}" - else -> text.take(200) - } - val errno = if (errorBody.has("errno")) errorBody.optInt("errno") else null - throw FxaApiError(message, errno, resp.code) - } - if (text.isEmpty()) JSONObject() else JSONObject(text) - } + suspend fun completeBrowserLogin(sessionToken: String): Result = runCatching { + val clean = sessionToken.trim().replace(Regex("[\\s\"]"), "").lowercase() + if (!Regex("^[0-9a-f]{64}$").matches(clean)) { + error("The sign-in page did not hand over a usable session token") } + completeLogin(clean) + } suspend fun restoreSession(): SessionStatus { val stored = runCatching { tokenStore.loadAuth() }.getOrNull() ?: return SessionStatus.NEEDS_LOGIN + if (stored.accessToken.count { it == '.' } == 2) { + // A JWT-shaped access token can only come from the retired Guardian + // browser-login flow (/api/v2/vpn/login/verify). Guardian's FPN proxy + // endpoints reject it with 401, so keep the UI honest and ask for a + // fresh sign-in instead of pretending the session is usable. + AppLogger.w(TAG, "stored session is a retired Guardian login token; signing out") + runCatching { tokenStore.clear() } + return SessionStatus.NEEDS_LOGIN + } if (stored.refreshToken == null && !tokenStore.hasValidAccessToken()) { runCatching { tokenStore.clear() } return SessionStatus.NEEDS_LOGIN @@ -289,8 +195,10 @@ class FxaAuthRepository( put("scope", OAUTH_SCOPE) } + // The refresh grant is served by the dedicated OAuth token server, not by + // the HAWK-authenticated auth server (which answers 401 errno 110 for it). val tokenData = try { - fxaDo("POST", "/oauth/token", jsonBody = body) + oauthServerDo("POST", "/oauth/token", jsonBody = body) } catch (rejected: FxaApiError) { throw FxaRefreshFailure( rejected.message ?: "FxA rejected the refresh token", @@ -389,6 +297,62 @@ class FxaAuthRepository( return fxaDo("POST", "/oauth/token", sessionToken = sessionToken, jsonBody = body) } + /** + * JSON call to the OAuth token server (oauth.accounts.firefox.com). Unlike the + * auth server, no HAWK signature is involved; the Fastly anti-bot challenge + * handling is identical. + */ + private suspend fun oauthServerDo(method: String, path: String, jsonBody: JSONObject): JSONObject = + withContext(Dispatchers.IO) { + val url = "$FXA_OAUTH_SERVER$path".toHttpUrl() + val origin = "${url.scheme}://${url.host}" + val bodyBytes = jsonBody.toString().toByteArray(Charsets.UTF_8) + + fun buildRequest(): Request = + Request.Builder().url(url).applyMozillaVpnHeaders() + .header("Content-Type", "application/json") + .method(method, bodyBytes.toRequestBody(jsonMediaType)) + .build() + + var response: Response = ControlPlaneHttp.client.newCall(buildRequest()).execute() + var attempts = 1 + while (response.code == 406 && attempts < FXA_MAX_CHALLENGE_ATTEMPTS) { + val challengedBody = response.use { it.body?.string().orEmpty() } + try { + challengeSolver.solveAndInstall(challengedBody, origin) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (solveFailure: Exception) { + AppLogger.w(TAG, "could not solve the Fastly challenge", solveFailure) + throw FastlyBlockedException(FRIENDLY_406_MESSAGE, solveFailure) + } + response = ControlPlaneHttp.client.newCall(buildRequest()).execute() + attempts++ + } + + response.use { resp -> + val text = resp.body?.string().orEmpty() + if (resp.code >= 400) { + if (resp.code == 406) { + throw FastlyBlockedException(FRIENDLY_406_MESSAGE) + } + val errorBody = try { + JSONObject(text) + } catch (e: Exception) { + JSONObject() + } + val errno = if (errorBody.has("errno")) errorBody.optInt("errno") else null + val message = when { + errorBody.length() > 0 -> errorBody.optString("message", "HTTP ${resp.code}") + text.isEmpty() -> "HTTP ${resp.code}" + else -> text.take(200) + } + throw FxaApiError(message, errno, resp.code) + } + if (text.isEmpty()) JSONObject() else JSONObject(text) + } + } + private suspend fun fxaDo( method: String, path: String, diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt index 5553d4bc..0bdce1fa 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt @@ -1,10 +1,9 @@ package com.vauth.foxyvpn.ui.screens import android.annotation.SuppressLint -import android.net.Uri import android.webkit.CookieManager +import android.webkit.JavascriptInterface import android.webkit.WebChromeClient -import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.compose.BackHandler @@ -33,72 +32,164 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.vauth.foxyvpn.data.AppLogger -import com.vauth.foxyvpn.data.ControlPlaneHttp +import java.lang.ref.WeakReference +import org.json.JSONObject -private const val TAG = "BrowserLoginScreen" +private const val TAG = "BrowserLogin" -/** Host + path of the OAuth completion redirect served by Guardian. */ -private const val REDIRECT_HOST = "vpn.mozilla.org" -private const val REDIRECT_PATH_PREFIX = "/oauth/success" -private const val DEEP_LINK_SCHEME = "mozilla-vpn" +/** + * The WebChannel id the accounts.firefox.com content server always communicates + * on, and the commands this flow must understand. Message envelopes look like + * `{id: "account_updates", message: {command, data, messageId}}`; page -> app + * traffic is a `WebChannelMessageToChrome` CustomEvent whose detail is a JSON + * string, and app -> page replies go out as `WebChannelMessageToContent`. + */ +private const val FXA_CHANNEL_ID = "account_updates" +private const val CMD_FXA_STATUS = "fxaccounts:fxa_status" +private const val CMD_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account" +private const val CMD_LOGIN = "fxaccounts:login" +private const val CMD_OAUTH_LOGIN = "fxaccounts:oauth_login" + +private val SESSION_TOKEN_HEX = Regex("^[0-9a-f]{64}$") + +/** + * accounts.firefox.com adjusts some of its behaviour based on the browser; a + * plain WebView UA gets "please use Firefox" style treatment and can suppress + * the WebChannel handshake. Identifying as mobile Firefox keeps the flow the + * same one Firefox for Android users see. + */ +private const val FIREFOX_ANDROID_UA = + "Mozilla/5.0 (Android 14; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0" + +private fun jsString(value: String): String = JSONObject.quote(value) + +/** + * Installed on every page load. Mirrors the WebChannel bridge that Firefox + * itself provides: it forwards every WebChannelMessageToChrome event to the + * app (foxyWebChannel.onMessage) and exposes a __foxyReply helper that emits + * correctly formatted WebChannelMessageToContent envelopes for the commands + * that expect an answer. + */ +private val WEBCHANNEL_HOOK_JS = """ +(function() { + if (window.__foxyChannelHooked) return; + window.__foxyChannelHooked = true; + var forward = function (detail) { + try { + foxyWebChannel.onMessage(typeof detail === 'string' ? detail : JSON.stringify(detail)); + } catch (e) {} + }; + window.addEventListener('WebChannelMessageToChrome', function (e) { forward(e.detail); }); + document.addEventListener('WebChannelMessageToChrome', function (e) { forward(e.detail); }); + window.__foxyReply = function (channelId, messageId, command, dataJson) { + var envelope = { + id: channelId, + message: { messageId: messageId, command: command, data: JSON.parse(dataJson) } + }; + window.dispatchEvent(new CustomEvent('WebChannelMessageToContent', { detail: JSON.stringify(envelope) })); + }; +})(); +""".trimIndent() + +/** + * Receives the WebChannel traffic forwarded by the injected hook and answers + * the two commands the sign-in flow stalls without: + * - fxaccounts:fxa_status -> capabilities + no signed-in user + * - fxaccounts:can_link_account -> {ok: true} + * and captures the account's session token from fxaccounts:login, which is + * the payload Firefox itself uses to sign the account in. + */ +private class WebChannelBridge( + webView: WebView, + private val onSessionToken: (String) -> Unit, +) { + private val webViewRef = WeakReference(webView) + + @JavascriptInterface + fun onMessage(payload: String) { + val message = runCatching { + JSONObject(payload).optJSONObject("message") ?: JSONObject() + }.getOrNull() ?: return + val command = message.optString("command") + val data = message.optJSONObject("data") ?: JSONObject() + val messageId = message.optString("messageId") + AppLogger.i(TAG, "webchannel: $command") + + when (command) { + CMD_FXA_STATUS -> reply( + messageId, + CMD_FXA_STATUS, + """{"capabilities":{},"signedInUser":null}""", + ) + CMD_CAN_LINK_ACCOUNT -> reply( + messageId, + CMD_CAN_LINK_ACCOUNT, + """{"ok":true}""", + ) + CMD_LOGIN -> { + val token = data.optString("sessionToken", "").trim() + val email = data.optString("email", "") + if (SESSION_TOKEN_HEX.matches(token)) { + AppLogger.i(TAG, "webchannel: session token received" + if (email.isBlank()) "" else " for $email") + onSessionToken(token) + } else { + AppLogger.w( + TAG, + "webchannel: login event carried no usable session token " + + "(verified=${data.optBoolean("verified", false)})", + ) + } + } + CMD_OAUTH_LOGIN -> AppLogger.w( + TAG, + "webchannel: oauth_login received but OAuth codes cannot be redeemed by this app", + ) + } + } + + private fun reply(messageId: String, command: String, dataJson: String) { + val view = webViewRef.get() ?: return + val script = "window.__foxyReply && window.__foxyReply(" + + "${jsString(FXA_CHANNEL_ID)}, ${jsString(messageId)}, ${jsString(command)}, ${jsString(dataJson)});" + view.post { + runCatching { view.evaluateJavascript(script, null) } + .onFailure { AppLogger.w(TAG, "could not answer webchannel command $command", it) } + } + } +} /** - * In-app browser sign-in. Loads the official Guardian login URL, which redirects to - * accounts.firefox.com where the user signs in with a real browser engine (this is - * what passes and automatically solves the Fastly bot challenges that block the - * direct API password login with HTTP 406). - * - * When the OAuth flow completes, the browser is redirected to - * https://vpn.mozilla.org/oauth/success?code=... (or the mozilla-vpn:// deep link). - * The completion navigation MUST be allowed to reach Guardian: Guardian registers - * the authorization code server-side only when the browser actually GETs that URL - * (carrying the flow session cookie). Blocking it — as earlier builds did — makes - * the final /login/verify call fail with "'code' not found or expired". The code - * is reported from doUpdateVisitedHistory, i.e. after the request was answered. + * In-app browser sign-in. Loads the accounts.firefox.com sync sign-in page; the + * page delivers the account session token to the "browser" (this WebView) over + * the WebChannel bridge once the user has signed in, and the app mints an FxA + * OAuth token from it (fxa-credentials grant) — the only token type Guardian's + * FPN proxy endpoints accept. */ @SuppressLint("SetJavaScriptEnabled") @Composable fun BrowserLoginScreen( startUrl: String, busy: Boolean, - onCodeCaptured: (String) -> Unit, + errorMessage: String? = null, + onSessionCaptured: (String) -> Unit, onCanceled: () -> Unit, ) { - var capturedCode by remember { mutableStateOf(null) } + var capturedToken by remember { mutableStateOf(null) } var pageTitle by remember { mutableStateOf("") } var pageProgress by remember { mutableIntStateOf(0) } var pageLoaded by remember { mutableStateOf(false) } - val currentOnCode by rememberUpdatedState(onCodeCaptured) + val currentOnSession by rememberUpdatedState(onSessionCaptured) val currentOnCanceled by rememberUpdatedState(onCanceled) BackHandler(enabled = true) { if (!busy) currentOnCanceled() } - // Pre-load interception. Only the mozilla-vpn:// deep link is blocked (it can - // never be loaded by the WebView anyway); every https navigation — including - // the final /oauth/success?code=... completion redirect — must be allowed to - // go through so Guardian can register the code server-side. - fun handleOverride(uri: Uri): Boolean { - if (capturedCode != null) return true - if (uri.scheme == DEEP_LINK_SCHEME) { - extractAuthCode(uri)?.let { code -> - capturedCode = code - currentOnCode(code) - } - return true - } - return false - } - - // Fired once a navigation is committed (the server has already responded), so - // the completion GET has reached Guardian by this point and the code can be - // exchanged safely. - fun handleHistoryUpdate(uri: Uri) { - if (capturedCode != null) return - val code = extractAuthCode(uri) ?: return - capturedCode = code - currentOnCode(code) + fun handleSession(token: String) { + if (busy) return + if (capturedToken == token) return + capturedToken = token + currentOnSession(token) } Column(Modifier.fillMaxSize()) { @@ -130,6 +221,16 @@ fun BrowserLoginScreen( modifier = Modifier.fillMaxWidth(), ) } + if (errorMessage != null) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } Box(Modifier.weight(1f)) { AndroidView( factory = { context -> @@ -138,32 +239,29 @@ fun BrowserLoginScreen( settings.domStorageEnabled = true settings.loadWithOverviewMode = true settings.useWideViewPort = true + settings.userAgentString = FIREFOX_ANDROID_UA CookieManager.getInstance().setAcceptCookie(true) CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + addJavascriptInterface( + WebChannelBridge(this, ::handleSession), + "foxyWebChannel", + ) webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading( - view: WebView, - request: WebResourceRequest, - ): Boolean = handleOverride(request.url) - - override fun doUpdateVisitedHistory( + override fun onPageStarted( view: WebView, url: String?, - isReload: Boolean, + favicon: android.graphics.Bitmap?, ) { - if (url != null) handleHistoryUpdate(Uri.parse(url)) - super.doUpdateVisitedHistory(view, url, isReload) - } - - override fun onPageStarted(view: WebView, url: String?, favicon: android.graphics.Bitmap?) { super.onPageStarted(view, url, favicon) pageLoaded = false + runCatching { view.evaluateJavascript(WEBCHANNEL_HOOK_JS, null) } } override fun onPageFinished(view: WebView, url: String?) { super.onPageFinished(view, url) pageLoaded = true pageTitle = view.title.orEmpty() + runCatching { view.evaluateJavascript(WEBCHANNEL_HOOK_JS, null) } } } webChromeClient = object : WebChromeClient() { @@ -180,59 +278,3 @@ fun BrowserLoginScreen( } } } - -/** - * Returns the OAuth authorization code if the navigation is the flow's completion - * redirect. The official redirect target is https://vpn.mozilla.org/oauth/success - * (query carries both `code` and `state`); the mozilla-vpn:// deep link is matched - * as a fallback because Guardian's success page may bounce through it. - */ -private fun extractAuthCode(uri: Uri): String? { - val code = uri.getQueryParameter("code")?.takeIf { it.isNotBlank() } ?: return null - if (uri.scheme == DEEP_LINK_SCHEME) return code - val isGuardianSuccess = uri.scheme == "https" && - uri.host == REDIRECT_HOST && - (uri.path?.startsWith(REDIRECT_PATH_PREFIX) == true || - uri.getQueryParameter("state")?.startsWith("pkce-") == true) - return if (isGuardianSuccess) code else null -} - -/** - * Guardian's /oauth/success completion page must be requested by the WebView while - * carrying the flow session cookie that Guardian issued when the app started the - * login flow; without the session, Guardian cannot match the code to the flow and - * /login/verify later fails with "'code' not found or expired". The WebView keeps - * its own cookie store, so copy the flow cookies over before the sign-in page is - * loaded. - */ -internal fun syncGuardianCookiesToWebView() { - val manager = CookieManager.getInstance() - for (cookie in ControlPlaneHttp.cookieJar.snapshotAll()) { - if (!cookie.domain.endsWith("mozilla.org")) continue - val header = buildString { - append(cookie.name).append('=').append(cookie.value) - append("; Path=").append(cookie.path.ifBlank { "/" }) - if (cookie.secure) append("; Secure") - } - runCatching { manager.setCookie("https://${cookie.domain}/", header) } - .onFailure { AppLogger.w(TAG, "could not sync cookie ${cookie.name} into the WebView", it) } - } - manager.flush() -} - -/** - * Mirror whatever cookies the WebView ended up with for vpn.mozilla.org (Guardian - * may have rotated the flow session while serving /oauth/success) back into the - * shared jar used by the final /login/verify call. - */ -internal fun syncWebViewCookiesToGuardianJar() { - val raw = CookieManager.getInstance().getCookie("https://vpn.mozilla.org/") ?: return - for (pair in raw.split(";")) { - val idx = pair.indexOf('=') - if (idx <= 0) continue - val name = pair.substring(0, idx).trim() - val value = pair.substring(idx + 1).trim() - if (name.isEmpty() || value.isEmpty()) continue - ControlPlaneHttp.cookieJar.set(name, value, "vpn.mozilla.org") - } -} diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt index 00a2abec..801e818d 100755 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt @@ -54,7 +54,6 @@ fun LoginScreen( var errorMessage by rememberSaveable { mutableStateOf(null) } var showLegacyLogin by rememberSaveable { mutableStateOf(false) } var browserStartUrl by rememberSaveable { mutableStateOf(null) } - var browserVerifier by rememberSaveable { mutableStateOf(null) } var completingBrowserLogin by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current @@ -69,47 +68,28 @@ fun LoginScreen( fun startBrowserLogin() { if (completingBrowserLogin || isLoading) return errorMessage = null - isLoading = true scope.launch { - // The flow must be started by the app's own HTTP client so Guardian's - // flow session cookie lands in the shared cookie jar used by the final - // /login/verify call (the official client does the same). authRepository.startBrowserLoginFlow() - .onSuccess { flow -> - // Copy Guardian's flow session cookie into the WebView cookie - // store: the /oauth/success completion page must carry it, or - // Guardian cannot register the authorization code and the final - // verify call fails with "'code' not found or expired". - syncGuardianCookiesToWebView() - browserStartUrl = flow.startUrl - browserVerifier = flow.codeVerifier - } + .onSuccess { flow -> browserStartUrl = flow.startUrl } .onFailure { errorMessage = it.message ?: "Could not start the browser sign-in" } isLoading = false } } - fun handleBrowserCode(authCode: String) { - val verifier = browserVerifier ?: return + fun handleBrowserSession(sessionToken: String) { if (completingBrowserLogin) return completingBrowserLogin = true errorMessage = null scope.launch { - // Mirror whatever cookies the WebView ended up with for vpn.mozilla.org - // (Guardian may have rotated the flow session while serving the - // completion page) into the shared jar used by the verify call. - syncWebViewCookiesToGuardianJar() - val result = authRepository.completeBrowserLogin(authCode, verifier) + val result = authRepository.completeBrowserLogin(sessionToken) completingBrowserLogin = false result.onSuccess { browserStartUrl = null - browserVerifier = null + errorMessage = null onSignedIn() }.onFailure { - // Captured codes are single-use; drop the flow so the next attempt - // starts fresh with a new PKCE pair. - browserStartUrl = null - browserVerifier = null + // Keep the sign-in page open so the error stays visible and the + // user can simply sign in again for a fresh session token. errorMessage = it.message ?: "Browser sign-in failed" } } @@ -147,15 +127,16 @@ fun LoginScreen( } } - if (browserStartUrl != null && browserVerifier != null) { + if (browserStartUrl != null) { BrowserLoginScreen( startUrl = browserStartUrl!!, busy = completingBrowserLogin, - onCodeCaptured = ::handleBrowserCode, + errorMessage = errorMessage, + onSessionCaptured = ::handleBrowserSession, onCanceled = { browserStartUrl = null - browserVerifier = null completingBrowserLogin = false + errorMessage = null }, ) return @@ -201,7 +182,7 @@ fun LoginScreen( } } Text( - "Recommended: opens the official Mozilla sign-in page in an in-app browser, which avoids the HTTP 406 bot-protection error.", + "Recommended: opens the official Mozilla sign-in page in an in-app browser and returns automatically once you are signed in.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp), diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt index e7f54629..6bbe67c5 100644 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt @@ -677,10 +677,24 @@ class FoxyVpnService : VpnService() { val pass = try { guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) - } catch (invalid: TokenInvalidError) { - AppLogger.w(TAG, "proxy pass rejected, activating Guardian entitlement and retrying", invalid) - guardian.activateGuardian(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) - guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) + } catch (rejected: TokenInvalidError) { + // Mirror the reference client's recovery order: renew the OAuth + // token first, then activate the entitlement, and only give up + // after both fail. The final TokenInvalidError is fatal for the + // dial loop, so a dead session surfaces as "sign in again" + // instead of burning through all dial attempts. + AppLogger.w(TAG, "Guardian rejected the OAuth access token; renewing it and retrying", rejected) + renewAccessToken(tokenStore)?.let { renewed -> + auth = renewed + AppLogger.i(TAG, "the account's OAuth token was renewed after the rejection") + } + try { + guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) + } catch (stillRejected: TokenInvalidError) { + AppLogger.w(TAG, "still rejected; activating the Guardian entitlement and retrying", stillRejected) + guardian.activateGuardian(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) + guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) + } } if (pass.quotaRemaining != null) { settingsStore.cachedQuotaRemaining = pass.quotaRemaining From af2a245c97bbaef9369ff8d80d84894ca08066fa Mon Sep 17 00:00:00 2001 From: Vauth <122077080+Vauth@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:47:25 +0330 Subject: [PATCH 05/11] Refactor authentication: purge email/password login and harden browser sign-in --- app/proguard-rules.pro | 5 +- .../foxyvpn/data/FastlyChallengeSolver.kt | 19 -- .../vauth/foxyvpn/data/FxaAuthRepository.kt | 229 ++--------------- .../java/com/vauth/foxyvpn/data/TokenStore.kt | 11 - .../foxyvpn/ui/screens/BrowserLoginScreen.kt | 123 +++++----- .../vauth/foxyvpn/ui/screens/LoginScreen.kt | 230 ++---------------- .../com/vauth/foxyvpn/vpn/FoxyVpnService.kt | 22 +- 7 files changed, 123 insertions(+), 516 deletions(-) mode change 100755 => 100644 app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt mode change 100755 => 100644 app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a1e51fd3..4e698bf0 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -21,6 +21,7 @@ -dontwarn reactor.blockhound.** -# The FxA WebChannel bridge is reached from JS by its registered interface name; -# keep its members so release builds never strip the @JavascriptInterface methods. -keep class com.vauth.foxyvpn.ui.screens.WebChannelBridge { *; } +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt b/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt index 4fe2e120..4ca4f865 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt @@ -31,17 +31,6 @@ private val INIT_CALL_REGEX = Regex("init\\((\\[[^\\]]*\\]),\\s*\"([^\"]+)\"\\s* class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { - /** - * Solve the Fastly bot-protection challenge and install the clearance cookies into - * the shared cookie jar. - * - * Fastly serves the challenge page with HTTP 406 (sometimes 403/200/503), so the - * HTTP status alone is meaningless — only body markers decide whether a response is - * a challenge. When the caller already received a challenged response it can hand - * that body over via [challengedBody] together with the origin that served it - * ([challengedOrigin]); the solver then replays that exact challenge instead of - * probing the hosts again. - */ suspend fun solveAndInstall( challengedBody: String? = null, challengedOrigin: String? = null, @@ -76,8 +65,6 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { } private fun baseOriginFromPrefix(html: String): String? { - // Relative asset paths ("../..") are rare; Fastly normally embeds same-origin - // "/_fs-ch-..." references, so without an absolute hint there is nothing to do. val absolute = Regex("https://[A-Za-z0-9.-]+/_fs-ch-[A-Za-z0-9]+").find(html)?.value return absolute?.let { baseOrigin(it) } } @@ -100,7 +87,6 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { return null } - /** GET that never inspects the status code: challenge pages arrive with 406/403/200/503. */ private fun fetchPage(client: OkHttpClient, url: String, userAgent: String): String { val request = Request.Builder().url(url) .header("User-Agent", userAgent) @@ -111,7 +97,6 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { } } - /** GET for challenge assets that must succeed (script.js, post-back, ...). */ private fun fetchText( client: OkHttpClient, url: String, @@ -238,8 +223,6 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { userAgent: String = MOZILLA_VPN_USER_AGENT, ) { val solverJar = SimpleCookieJar() - // Seed the solver jar with the shared control-plane cookies: the challenged - // request already stored any Fastly session cookies there via OkHttp. for (cookie in cookieJar.snapshotAll()) { solverJar.set(cookie.name, cookie.value, cookie.domain) } @@ -273,8 +256,6 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { if (!response.isSuccessful) throw FastlyChallengeError("challenge post-back returned HTTP ${response.code}") val body = JSONObject(response.body?.string().orEmpty()) if (body.optString("status") == "success") { - // Re-check with the solver's own client so the freshly issued - // clearance cookies are actually sent. val recheck = fetchPage(solver, "$origin/", userAgent) if (looksLikeChallenge(recheck)) { throw FastlyChallengeError("challenge cookie not accepted on this exit IP") diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt old mode 100755 new mode 100644 index d87e8651..8518222f --- a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt @@ -12,7 +12,6 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import org.json.JSONObject -import java.nio.ByteBuffer import java.security.MessageDigest import java.security.SecureRandom import javax.crypto.Mac @@ -34,103 +33,27 @@ class FxaRefreshFailure( cause: Throwable? = null, ) : Exception(message, cause) +class FastlyBlockedException(message: String, cause: Throwable? = null) : Exception(message, cause) + private const val FXA_AUTH_SERVER = "https://api.accounts.firefox.com/v1" -// The OAuth token server is a separate deployment from the auth server: the -// refresh_token grant lives here (no HAWK signature needed), while the -// fxa-credentials grant lives on the auth server above. private const val FXA_OAUTH_SERVER = "https://oauth.accounts.firefox.com/v1" private const val FIREFOX_CLIENT_ID = "5882386c6d801776" private const val OAUTH_SCOPE = "profile https://identity.mozilla.com/apps/vpn" private const val PROTOCOL_VERSION = "identity.mozilla.com/picl/v1/" -private const val PBKDF2_ROUNDS = 1000 -private const val STRETCHED_PW_LEN = 32 -private const val HKDF_LEN = 32 -private const val VERIFICATION_METHOD_EMAIL_2FA = "email-2fa" -private const val FXA_ERRNO_INVALID_PARAMETER = 107 private const val FXA_MAX_CHALLENGE_ATTEMPTS = 3 private const val DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 24 * 60 * 60 -/** - * The sync-style sign-in entry point (the same flow Firefox desktop uses to - * connect an account). The accounts.firefox.com page hands the session token to - * the host browser over the WebChannel bridge (command "fxaccounts:login"); our - * in-app WebView intercepts that message, and the app then mints an FxA OAuth - * token locally with the fxa-credentials grant — the only token type Guardian's - * /api/v1/fpn proxy endpoints accept. - */ private const val WEBCHANNEL_LOGIN_URL = "https://accounts.firefox.com/?context=fx_desktop_v3&service=sync&action=email" private val jsonMediaType = "application/json".toMediaType() -/** Raised when Mozilla's bot protection (Fastly) rejects the request and the challenge cannot be solved. */ -class FastlyBlockedException(message: String, cause: Throwable? = null) : Exception(message, cause) - -/** Start URL for the in-app browser sign-in flow. */ -data class BrowserLoginFlow( - val startUrl: String, -) - class FxaAuthRepository( private val tokenStore: TokenStore, private val challengeSolver: FastlyChallengeSolver = FastlyChallengeSolver(ControlPlaneHttp.cookieJar), ) { - var pendingSessionToken: String? - get() = tokenStore.pendingSessionToken - set(value) { tokenStore.pendingSessionToken = value } - - fun clearPendingSession() { - tokenStore.pendingSessionToken = null - } - - /** - * Start the browser sign-in flow: a plain accounts.firefox.com sync sign-in - * whose session token the in-app WebView receives over the WebChannel bridge - * (see [BrowserLoginScreen]). No network round trip is needed to start it. - */ - suspend fun startBrowserLoginFlow(): Result = runCatching { - BrowserLoginFlow(startUrl = WEBCHANNEL_LOGIN_URL) - } - - companion object { - fun normalizeEmail(email: String): String = - email.replace(Regex("[\\p{Z}\\u200B-\\u200D\\uFEFF\\u00A0\\s]+"), "").trim().lowercase() - - fun sanitizeCode(code: String): String = - code.replace(Regex("[\\p{Z}\\u200B-\\u200D\\uFEFF\\u00A0\\s]+"), "").trim() - } + fun getBrowserLoginUrl(): String = WEBCHANNEL_LOGIN_URL - suspend fun startLogin(email: String, password: String): Result = runCatching { - val cleanEmail = normalizeEmail(email) - val data = loginAttempt(cleanEmail, password) - val sessionToken = data.getString("sessionToken") - pendingSessionToken = sessionToken - val verified = data.optBoolean("verified", false) - if (!verified) { - true - } else { - completeLogin(sessionToken) - clearPendingSession() - false - } - } - - suspend fun submitTwoFactorCode(code: String): Result = runCatching { - val cleanCode = sanitizeCode(code) - val sessionToken = pendingSessionToken ?: error("No pending FxA session. Start sign-in again.") - fxaDo("POST", "/session/verify_code", sessionToken = sessionToken, jsonBody = JSONObject().put("code", cleanCode)) - completeLogin(sessionToken) - clearPendingSession() - false - } - - /** - * Finish the browser sign-in: the in-app WebView captured the account's session - * token from the WebChannel "fxaccounts:login" message. Mint an FxA OAuth token - * with the fxa-credentials grant (the same grant and client the reference - * clients use) so Guardian's FPN proxy endpoints accept it, and store both the - * access and the refresh token. - */ suspend fun completeBrowserLogin(sessionToken: String): Result = runCatching { val clean = sessionToken.trim().replace(Regex("[\\s\"]"), "").lowercase() if (!Regex("^[0-9a-f]{64}$").matches(clean)) { @@ -142,15 +65,6 @@ class FxaAuthRepository( suspend fun restoreSession(): SessionStatus { val stored = runCatching { tokenStore.loadAuth() }.getOrNull() ?: return SessionStatus.NEEDS_LOGIN - if (stored.accessToken.count { it == '.' } == 2) { - // A JWT-shaped access token can only come from the retired Guardian - // browser-login flow (/api/v2/vpn/login/verify). Guardian's FPN proxy - // endpoints reject it with 401, so keep the UI honest and ask for a - // fresh sign-in instead of pretending the session is usable. - AppLogger.w(TAG, "stored session is a retired Guardian login token; signing out") - runCatching { tokenStore.clear() } - return SessionStatus.NEEDS_LOGIN - } if (stored.refreshToken == null && !tokenStore.hasValidAccessToken()) { runCatching { tokenStore.clear() } return SessionStatus.NEEDS_LOGIN @@ -195,10 +109,12 @@ class FxaAuthRepository( put("scope", OAUTH_SCOPE) } - // The refresh grant is served by the dedicated OAuth token server, not by - // the HAWK-authenticated auth server (which answers 401 errno 110 for it). val tokenData = try { - oauthServerDo("POST", "/oauth/token", jsonBody = body) + executeFxaRequest( + baseUrl = FXA_OAUTH_SERVER, + path = "/oauth/token", + jsonBody = body, + ) } catch (rejected: FxaApiError) { throw FxaRefreshFailure( rejected.message ?: "FxA rejected the refresh token", @@ -267,24 +183,6 @@ class FxaAuthRepository( expiresAtEpochSeconds = expiryFrom(tokenData), ), ) - clearPendingSession() - } - - private suspend fun loginAttempt(email: String, password: String, withVerificationMethod: Boolean = true): JSONObject { - val body = JSONObject().apply { - put("email", email) - put("authPW", deriveAuthPw(email, password)) - if (withVerificationMethod) put("verificationMethod", VERIFICATION_METHOD_EMAIL_2FA) - } - return try { - fxaDo("POST", "/account/login", jsonBody = body) - } catch (e: FxaApiError) { - if (e.errno == FXA_ERRNO_INVALID_PARAMETER && withVerificationMethod) { - loginAttempt(email, password, withVerificationMethod = false) - } else { - throw e - } - } } private suspend fun oauthToken(sessionToken: String): JSONObject { @@ -294,72 +192,22 @@ class FxaAuthRepository( put("scope", OAUTH_SCOPE) put("access_type", "offline") } - return fxaDo("POST", "/oauth/token", sessionToken = sessionToken, jsonBody = body) + return executeFxaRequest( + baseUrl = FXA_AUTH_SERVER, + path = "/oauth/token", + jsonBody = body, + sessionToken = sessionToken, + ) } - /** - * JSON call to the OAuth token server (oauth.accounts.firefox.com). Unlike the - * auth server, no HAWK signature is involved; the Fastly anti-bot challenge - * handling is identical. - */ - private suspend fun oauthServerDo(method: String, path: String, jsonBody: JSONObject): JSONObject = - withContext(Dispatchers.IO) { - val url = "$FXA_OAUTH_SERVER$path".toHttpUrl() - val origin = "${url.scheme}://${url.host}" - val bodyBytes = jsonBody.toString().toByteArray(Charsets.UTF_8) - - fun buildRequest(): Request = - Request.Builder().url(url).applyMozillaVpnHeaders() - .header("Content-Type", "application/json") - .method(method, bodyBytes.toRequestBody(jsonMediaType)) - .build() - - var response: Response = ControlPlaneHttp.client.newCall(buildRequest()).execute() - var attempts = 1 - while (response.code == 406 && attempts < FXA_MAX_CHALLENGE_ATTEMPTS) { - val challengedBody = response.use { it.body?.string().orEmpty() } - try { - challengeSolver.solveAndInstall(challengedBody, origin) - } catch (cancellation: CancellationException) { - throw cancellation - } catch (solveFailure: Exception) { - AppLogger.w(TAG, "could not solve the Fastly challenge", solveFailure) - throw FastlyBlockedException(FRIENDLY_406_MESSAGE, solveFailure) - } - response = ControlPlaneHttp.client.newCall(buildRequest()).execute() - attempts++ - } - - response.use { resp -> - val text = resp.body?.string().orEmpty() - if (resp.code >= 400) { - if (resp.code == 406) { - throw FastlyBlockedException(FRIENDLY_406_MESSAGE) - } - val errorBody = try { - JSONObject(text) - } catch (e: Exception) { - JSONObject() - } - val errno = if (errorBody.has("errno")) errorBody.optInt("errno") else null - val message = when { - errorBody.length() > 0 -> errorBody.optString("message", "HTTP ${resp.code}") - text.isEmpty() -> "HTTP ${resp.code}" - else -> text.take(200) - } - throw FxaApiError(message, errno, resp.code) - } - if (text.isEmpty()) JSONObject() else JSONObject(text) - } - } - - private suspend fun fxaDo( - method: String, + private suspend fun executeFxaRequest( + baseUrl: String, path: String, - sessionToken: String? = null, + method: String = "POST", jsonBody: JSONObject, + sessionToken: String? = null, ): JSONObject = withContext(Dispatchers.IO) { - val url = "$FXA_AUTH_SERVER$path".toHttpUrl() + val url = "$baseUrl$path".toHttpUrl() val origin = "${url.scheme}://${url.host}" val bodyBytes = jsonBody.toString().toByteArray(Charsets.UTF_8) var tokenId: String? = null @@ -372,7 +220,7 @@ class FxaAuthRepository( fun buildRequest(): Request { val builder = Request.Builder().url(url).applyMozillaVpnHeaders() - builder.header("Content-Type", "application/json") + .header("Content-Type", "application/json") if (sessionToken != null) { builder.header("Authorization", hawkHeader(method, url, tokenId!!, hmacKey!!, bodyBytes)) } @@ -400,8 +248,6 @@ class FxaAuthRepository( val text = resp.body?.string().orEmpty() if (resp.code >= 400) { if (resp.code == 406) { - // A still-challenged response is never a normal API error; do not - // dump its HTML (or an opaque "HTTP 406") into the UI. throw FastlyBlockedException(FRIENDLY_406_MESSAGE) } val errorBody = try { @@ -417,21 +263,10 @@ class FxaAuthRepository( } throw FxaApiError(message, errno, resp.code) } - if (text.isEmpty()) return@withContext JSONObject() - return@withContext JSONObject(text) + if (text.isEmpty()) JSONObject() else JSONObject(text) } } - private fun deriveQuickStretch(email: String, password: String): ByteArray { - val salt = "${PROTOCOL_VERSION}quickStretch:$email".toByteArray(Charsets.UTF_8) - return pbkdf2HmacSha256(password.toByteArray(Charsets.UTF_8), salt, PBKDF2_ROUNDS, STRETCHED_PW_LEN) - } - - private fun deriveAuthPw(email: String, password: String): String { - val quickStretched = deriveQuickStretch(email, password) - return hkdf(quickStretched, "${PROTOCOL_VERSION}authPW", HKDF_LEN).toHex() - } - private fun deriveHawkCredentials(sessionTokenHex: String): Pair { val sessionToken = sessionTokenHex.hexToBytes() val expanded = hkdf(sessionToken, "${PROTOCOL_VERSION}sessionToken", 64) @@ -466,8 +301,8 @@ class FxaAuthRepository( private const val FRIENDLY_406_MESSAGE = "Mozilla's bot protection rejected this request (HTTP 406) and the automatic " + - "challenge could not be completed on this network. Use “Sign in with browser” " + - "on the login screen, or switch networks (Wi-Fi <-> mobile data) and retry." + "challenge could not be completed on this network. Please switch networks " + + "(Wi-Fi <-> mobile data) and try again." private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray { val mac = Mac.getInstance("HmacSHA256") @@ -493,23 +328,5 @@ private fun hkdf(ikm: ByteArray, info: String, length: Int, salt: ByteArray = By return result } -private fun pbkdf2HmacSha256(password: ByteArray, salt: ByteArray, iterations: Int, keyLengthBytes: Int): ByteArray { - val hLen = 32 - val numBlocks = (keyLengthBytes + hLen - 1) / hLen - val result = ByteArray(numBlocks * hLen) - val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(password, "HmacSHA256")) } - for (blockIndex in 1..numBlocks) { - val blockIndexBytes = ByteBuffer.allocate(4).putInt(blockIndex).array() - var u = mac.doFinal(salt + blockIndexBytes) - val t = u.copyOf() - for (iter in 2..iterations) { - u = mac.doFinal(u) - for (i in t.indices) t[i] = (t[i].toInt() xor u[i].toInt()).toByte() - } - System.arraycopy(t, 0, result, (blockIndex - 1) * hLen, hLen) - } - return result.copyOf(keyLengthBytes) -} - private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() diff --git a/app/src/main/java/com/vauth/foxyvpn/data/TokenStore.kt b/app/src/main/java/com/vauth/foxyvpn/data/TokenStore.kt index f4ca465b..8a6e442e 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/TokenStore.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/TokenStore.kt @@ -49,16 +49,6 @@ class TokenStore(context: Context) { fun hasRefreshToken(): Boolean = loadAuth()?.refreshToken != null - var pendingSessionToken: String? - get() = prefs.getString(KEY_PENDING_SESSION_TOKEN, null)?.takeIf { it.isNotBlank() } - set(value) { - if (value != null) { - prefs.edit().putString(KEY_PENDING_SESSION_TOKEN, value).apply() - } else { - prefs.edit().remove(KEY_PENDING_SESSION_TOKEN).apply() - } - } - fun clear() { prefs.edit().clear().apply() } @@ -67,7 +57,6 @@ class TokenStore(context: Context) { private const val KEY_ACCESS_TOKEN = "access_token" private const val KEY_REFRESH_TOKEN = "refresh_token" private const val KEY_EXPIRES_AT = "expires_at" - private const val KEY_PENDING_SESSION_TOKEN = "pending_session_token" private const val CLOCK_SKEW_TOLERANCE_SECONDS = 60L } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt index 0bdce1fa..c3e889f8 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt @@ -1,9 +1,12 @@ package com.vauth.foxyvpn.ui.screens import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri import android.webkit.CookieManager import android.webkit.JavascriptInterface import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.compose.BackHandler @@ -21,6 +24,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -37,45 +41,26 @@ import org.json.JSONObject private const val TAG = "BrowserLogin" -/** - * The WebChannel id the accounts.firefox.com content server always communicates - * on, and the commands this flow must understand. Message envelopes look like - * `{id: "account_updates", message: {command, data, messageId}}`; page -> app - * traffic is a `WebChannelMessageToChrome` CustomEvent whose detail is a JSON - * string, and app -> page replies go out as `WebChannelMessageToContent`. - */ private const val FXA_CHANNEL_ID = "account_updates" private const val CMD_FXA_STATUS = "fxaccounts:fxa_status" private const val CMD_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account" private const val CMD_LOGIN = "fxaccounts:login" -private const val CMD_OAUTH_LOGIN = "fxaccounts:oauth_login" private val SESSION_TOKEN_HEX = Regex("^[0-9a-f]{64}$") -/** - * accounts.firefox.com adjusts some of its behaviour based on the browser; a - * plain WebView UA gets "please use Firefox" style treatment and can suppress - * the WebChannel handshake. Identifying as mobile Firefox keeps the flow the - * same one Firefox for Android users see. - */ private const val FIREFOX_ANDROID_UA = "Mozilla/5.0 (Android 14; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0" private fun jsString(value: String): String = JSONObject.quote(value) -/** - * Installed on every page load. Mirrors the WebChannel bridge that Firefox - * itself provides: it forwards every WebChannelMessageToChrome event to the - * app (foxyWebChannel.onMessage) and exposes a __foxyReply helper that emits - * correctly formatted WebChannelMessageToContent envelopes for the commands - * that expect an answer. - */ private val WEBCHANNEL_HOOK_JS = """ (function() { if (window.__foxyChannelHooked) return; window.__foxyChannelHooked = true; var forward = function (detail) { try { + var host = window.location.hostname; + if (host !== 'accounts.firefox.com' && !host.endsWith('.firefox.com')) return; foxyWebChannel.onMessage(typeof detail === 'string' ? detail : JSON.stringify(detail)); } catch (e) {} }; @@ -91,14 +76,6 @@ private val WEBCHANNEL_HOOK_JS = """ })(); """.trimIndent() -/** - * Receives the WebChannel traffic forwarded by the injected hook and answers - * the two commands the sign-in flow stalls without: - * - fxaccounts:fxa_status -> capabilities + no signed-in user - * - fxaccounts:can_link_account -> {ok: true} - * and captures the account's session token from fxaccounts:login, which is - * the payload Firefox itself uses to sign the account in. - */ private class WebChannelBridge( webView: WebView, private val onSessionToken: (String) -> Unit, @@ -107,13 +84,17 @@ private class WebChannelBridge( @JavascriptInterface fun onMessage(payload: String) { + val view = webViewRef.get() ?: return + val currentHost = runCatching { Uri.parse(view.url).host?.lowercase() }.getOrNull() + if (currentHost != "accounts.firefox.com" && currentHost?.endsWith(".firefox.com") != true) { + return + } val message = runCatching { JSONObject(payload).optJSONObject("message") ?: JSONObject() }.getOrNull() ?: return val command = message.optString("command") val data = message.optJSONObject("data") ?: JSONObject() val messageId = message.optString("messageId") - AppLogger.i(TAG, "webchannel: $command") when (command) { CMD_FXA_STATUS -> reply( @@ -128,22 +109,10 @@ private class WebChannelBridge( ) CMD_LOGIN -> { val token = data.optString("sessionToken", "").trim() - val email = data.optString("email", "") if (SESSION_TOKEN_HEX.matches(token)) { - AppLogger.i(TAG, "webchannel: session token received" + if (email.isBlank()) "" else " for $email") - onSessionToken(token) - } else { - AppLogger.w( - TAG, - "webchannel: login event carried no usable session token " + - "(verified=${data.optBoolean("verified", false)})", - ) + view.post { onSessionToken(token) } } } - CMD_OAUTH_LOGIN -> AppLogger.w( - TAG, - "webchannel: oauth_login received but OAuth codes cannot be redeemed by this app", - ) } } @@ -158,13 +127,6 @@ private class WebChannelBridge( } } -/** - * In-app browser sign-in. Loads the accounts.firefox.com sync sign-in page; the - * page delivers the account session token to the "browser" (this WebView) over - * the WebChannel bridge once the user has signed in, and the app mints an FxA - * OAuth token from it (fxa-credentials grant) — the only token type Guardian's - * FPN proxy endpoints accept. - */ @SuppressLint("SetJavaScriptEnabled") @Composable fun BrowserLoginScreen( @@ -174,6 +136,7 @@ fun BrowserLoginScreen( onSessionCaptured: (String) -> Unit, onCanceled: () -> Unit, ) { + var webViewInstance by remember { mutableStateOf(null) } var capturedToken by remember { mutableStateOf(null) } var pageTitle by remember { mutableStateOf("") } var pageProgress by remember { mutableIntStateOf(0) } @@ -181,8 +144,18 @@ fun BrowserLoginScreen( val currentOnSession by rememberUpdatedState(onSessionCaptured) val currentOnCanceled by rememberUpdatedState(onCanceled) + LaunchedEffect(errorMessage) { + if (errorMessage != null) { + capturedToken = null + } + } + BackHandler(enabled = true) { - if (!busy) currentOnCanceled() + if (webViewInstance?.canGoBack() == true) { + webViewInstance?.goBack() + } else if (!busy) { + currentOnCanceled() + } } fun handleSession(token: String) { @@ -199,8 +172,17 @@ fun BrowserLoginScreen( .fillMaxWidth() .padding(end = 16.dp), ) { - IconButton(onClick = { if (!busy) currentOnCanceled() }, enabled = !busy) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Cancel sign-in") + IconButton( + onClick = { + if (webViewInstance?.canGoBack() == true) { + webViewInstance?.goBack() + } else if (!busy) { + currentOnCanceled() + } + }, + enabled = !busy, + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } Column(Modifier.weight(1f)) { Text( @@ -215,9 +197,11 @@ fun BrowserLoginScreen( ) } } - if (busy || !pageLoaded || pageProgress < 100) { + if (busy) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } else if (!pageLoaded || pageProgress < 100) { LinearProgressIndicator( - progress = { if (busy) 1f else pageProgress / 100f }, + progress = { pageProgress / 100f }, modifier = Modifier.fillMaxWidth(), ) } @@ -235,18 +219,37 @@ fun BrowserLoginScreen( AndroidView( factory = { context -> WebView(context).apply { + webViewInstance = this settings.javaScriptEnabled = true settings.domStorageEnabled = true settings.loadWithOverviewMode = true settings.useWideViewPort = true settings.userAgentString = FIREFOX_ANDROID_UA - CookieManager.getInstance().setAcceptCookie(true) - CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + val cookieManager = CookieManager.getInstance() + cookieManager.setAcceptCookie(true) + cookieManager.setAcceptThirdPartyCookies(this, true) addJavascriptInterface( WebChannelBridge(this, ::handleSession), "foxyWebChannel", ) webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + val host = request.url.host?.lowercase().orEmpty() + if (host == "accounts.firefox.com" || host.endsWith(".firefox.com")) { + return false + } + runCatching { + val intent = Intent(Intent.ACTION_VIEW, request.url).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + view.context.startActivity(intent) + } + return true + } + override fun onPageStarted( view: WebView, url: String?, @@ -273,7 +276,11 @@ fun BrowserLoginScreen( } }, modifier = Modifier.fillMaxSize(), - onRelease = { it.stopLoading() }, + onRelease = { view -> + webViewInstance = null + view.stopLoading() + view.destroy() + }, ) } } diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt old mode 100755 new mode 100644 index 801e818d..bf252dcb --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt @@ -1,41 +1,28 @@ package com.vauth.foxyvpn.ui.screens -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vauth.foxyvpn.data.FxaAuthRepository @@ -46,34 +33,15 @@ fun LoginScreen( authRepository: FxaAuthRepository, onSignedIn: () -> Unit, ) { - var email by rememberSaveable { mutableStateOf("") } - var password by rememberSaveable { mutableStateOf("") } - var code by rememberSaveable { mutableStateOf("") } - var awaitingTwoFactor by rememberSaveable { mutableStateOf(authRepository.pendingSessionToken != null) } - var isLoading by remember { mutableStateOf(false) } - var errorMessage by rememberSaveable { mutableStateOf(null) } - var showLegacyLogin by rememberSaveable { mutableStateOf(false) } var browserStartUrl by rememberSaveable { mutableStateOf(null) } - var completingBrowserLogin by remember { mutableStateOf(false) } + var completingBrowserLogin by rememberSaveable { mutableStateOf(false) } + var errorMessage by rememberSaveable { mutableStateOf(null) } val scope = rememberCoroutineScope() - val focusManager = LocalFocusManager.current - - BackHandler(enabled = awaitingTwoFactor) { - authRepository.clearPendingSession() - awaitingTwoFactor = false - errorMessage = null - code = "" - } fun startBrowserLogin() { - if (completingBrowserLogin || isLoading) return + if (completingBrowserLogin) return errorMessage = null - scope.launch { - authRepository.startBrowserLoginFlow() - .onSuccess { flow -> browserStartUrl = flow.startUrl } - .onFailure { errorMessage = it.message ?: "Could not start the browser sign-in" } - isLoading = false - } + browserStartUrl = authRepository.getBrowserLoginUrl() } fun handleBrowserSession(sessionToken: String) { @@ -88,45 +56,11 @@ fun LoginScreen( errorMessage = null onSignedIn() }.onFailure { - // Keep the sign-in page open so the error stays visible and the - // user can simply sign in again for a fresh session token. - errorMessage = it.message ?: "Browser sign-in failed" + errorMessage = it.message ?: "Browser sign-in failed. Please try again." } } } - fun doSubmitLogin() { - val cleanEmail = FxaAuthRepository.normalizeEmail(email) - if (cleanEmail.isBlank() || password.isBlank() || isLoading) return - errorMessage = null - isLoading = true - scope.launch { - val result = authRepository.startLogin(cleanEmail, password) - isLoading = false - result.onSuccess { needsVerification -> - if (needsVerification) awaitingTwoFactor = true else onSignedIn() - }.onFailure { errorMessage = it.message ?: "Sign-in failed" } - } - } - - fun doSubmitTwoFactor() { - val cleanCode = FxaAuthRepository.sanitizeCode(code) - if (cleanCode.isBlank() || isLoading) return - errorMessage = null - isLoading = true - scope.launch { - val result = authRepository.submitTwoFactorCode(cleanCode) - isLoading = false - result.onSuccess { onSignedIn() } - .onFailure { - errorMessage = it.message ?: "Verification failed" - if (authRepository.pendingSessionToken == null) { - awaitingTwoFactor = false - } - } - } - } - if (browserStartUrl != null) { BrowserLoginScreen( startUrl = browserStartUrl!!, @@ -157,152 +91,30 @@ fun LoginScreen( verticalArrangement = Arrangement.Center, ) { Text("Sign in with Firefox", style = MaterialTheme.typography.headlineMedium) - Spacer(Modifier.padding(top = 8.dp)) + Spacer(Modifier.height(8.dp)) Text( "FoxyVPN runs on the free 50 GB of monthly VPN traffic Mozilla includes with a Firefox account. No subscription is required.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(Modifier.padding(top = 24.dp)) - - if (!awaitingTwoFactor) { - Button( - onClick = { startBrowserLogin() }, - enabled = !completingBrowserLogin && !isLoading, - modifier = Modifier.fillMaxWidth(), - ) { - if (completingBrowserLogin) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Text("Sign in with browser") - } - } - Text( - "Recommended: opens the official Mozilla sign-in page in an in-app browser and returns automatically once you are signed in.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 6.dp), - ) - - Spacer(Modifier.padding(top = 20.dp)) - HorizontalDivider() - Spacer(Modifier.padding(top = 8.dp)) - TextButton( - onClick = { showLegacyLogin = !showLegacyLogin }, - enabled = !completingBrowserLogin, - modifier = Modifier.fillMaxWidth(), - ) { - Text(if (showLegacyLogin) "Hide email & password sign-in" else "Use email & password instead") - } - } + Spacer(Modifier.height(24.dp)) - if (showLegacyLogin && !awaitingTwoFactor) { - OutlinedTextField( - value = email, - onValueChange = { - email = it.replace(Regex("[\\p{Z}\\u200B-\\u200D\\uFEFF\\u00A0\\s]+"), "").lowercase() - }, - label = { Text("Firefox account email") }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - autoCorrectEnabled = false, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Down) } - ), - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.padding(top = 12.dp)) - OutlinedTextField( - value = password, - onValueChange = { - password = it.replace(Regex("[\\u200B-\\u200D\\uFEFF]"), "") - }, - label = { Text("Password") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { - focusManager.clearFocus() - doSubmitLogin() - } - ), - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.padding(top = 20.dp)) - Button( - onClick = { - focusManager.clearFocus() - doSubmitLogin() - }, - enabled = !isLoading && email.isNotBlank() && password.isNotBlank(), - modifier = Modifier.fillMaxWidth(), - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Text("Continue") - } - } - } else if (awaitingTwoFactor) { - Text("Enter the confirmation code sent to your email", style = MaterialTheme.typography.bodyMedium) - Spacer(Modifier.padding(top = 12.dp)) - OutlinedTextField( - value = code, - onValueChange = { - code = it.replace(Regex("[\\p{Z}\\u200B-\\u200D\\uFEFF\\u00A0\\s]+"), "") - }, - label = { Text("Confirmation code") }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.NumberPassword, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { - focusManager.clearFocus() - doSubmitTwoFactor() - } - ), - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.padding(top = 20.dp)) - Button( - onClick = { - focusManager.clearFocus() - doSubmitTwoFactor() - }, - enabled = !isLoading && code.isNotBlank(), - modifier = Modifier.fillMaxWidth(), - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Text("Verify") - } - } + Button( + onClick = { startBrowserLogin() }, + enabled = !completingBrowserLogin, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Sign in with Firefox") } + Text( + "Opens the official Mozilla sign-in page in an in-app browser and returns automatically once you are signed in.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp), + ) errorMessage?.let { - Spacer(Modifier.padding(top = 12.dp)) + Spacer(Modifier.height(12.dp)) Text( text = it, color = MaterialTheme.colorScheme.error, diff --git a/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt b/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt index 6bbe67c5..561713d6 100644 --- a/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt +++ b/app/src/main/java/com/vauth/foxyvpn/vpn/FoxyVpnService.kt @@ -678,20 +678,20 @@ class FoxyVpnService : VpnService() { val pass = try { guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) } catch (rejected: TokenInvalidError) { - // Mirror the reference client's recovery order: renew the OAuth - // token first, then activate the entitlement, and only give up - // after both fail. The final TokenInvalidError is fatal for the - // dial loop, so a dead session surfaces as "sign in again" - // instead of burning through all dial attempts. AppLogger.w(TAG, "Guardian rejected the OAuth access token; renewing it and retrying", rejected) - renewAccessToken(tokenStore)?.let { renewed -> + val renewed = renewAccessToken(tokenStore) + if (renewed != null) { auth = renewed AppLogger.i(TAG, "the account's OAuth token was renewed after the rejection") - } - try { - guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) - } catch (stillRejected: TokenInvalidError) { - AppLogger.w(TAG, "still rejected; activating the Guardian entitlement and retrying", stillRejected) + try { + guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) + } catch (stillRejected: TokenInvalidError) { + AppLogger.w(TAG, "still rejected; activating the Guardian entitlement and retrying", stillRejected) + guardian.activateGuardian(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) + guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) + } + } else { + AppLogger.w(TAG, "token renewal failed; activating Guardian entitlement and retrying") guardian.activateGuardian(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) guardian.fetchProxyPass(GUARDIAN_ENDPOINT_DEFAULT, auth.accessToken) } From b3f554d080f7500485e730717b84752c1ca63cfa Mon Sep 17 00:00:00 2001 From: Vauth <122077080+Vauth@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:54:40 +0330 Subject: [PATCH 06/11] Set version to 1.0.8 and restrict CI workflow push triggers --- .github/workflows/android-ci.yml | 1 - app/build.gradle.kts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 6596f52c..303e238c 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -2,7 +2,6 @@ name: Android CI on: push: - branches: ["**"] tags: ["v*.*.*"] pull_request: branches: [main] diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5eaf1f48..cdc35f01 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,7 +48,7 @@ android { minSdk = 26 targetSdk = 35 versionCode = 45 - versionName = "1.0.10" + versionName = "1.0.8" externalNativeBuild { ndkBuild { From a66786347b4d2af6e62113e728140c1809d944b6 Mon Sep 17 00:00:00 2001 From: Vauth <122077080+Vauth@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:34:47 +0330 Subject: [PATCH 07/11] Fix WebChannel reply dispatch and bridge threading on Firefox Accounts login --- .../foxyvpn/ui/screens/BrowserLoginScreen.kt | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt index c3e889f8..22d7c648 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt @@ -61,17 +61,26 @@ private val WEBCHANNEL_HOOK_JS = """ try { var host = window.location.hostname; if (host !== 'accounts.firefox.com' && !host.endsWith('.firefox.com')) return; - foxyWebChannel.onMessage(typeof detail === 'string' ? detail : JSON.stringify(detail)); + var payload = typeof detail === 'string' ? detail : JSON.stringify(detail); + foxyWebChannel.onMessage(payload, window.location.href); } catch (e) {} }; window.addEventListener('WebChannelMessageToChrome', function (e) { forward(e.detail); }); document.addEventListener('WebChannelMessageToChrome', function (e) { forward(e.detail); }); window.__foxyReply = function (channelId, messageId, command, dataJson) { - var envelope = { - id: channelId, - message: { messageId: messageId, command: command, data: JSON.parse(dataJson) } - }; - window.dispatchEvent(new CustomEvent('WebChannelMessageToContent', { detail: JSON.stringify(envelope) })); + try { + var parsedData = typeof dataJson === 'string' ? JSON.parse(dataJson) : dataJson; + var envelope = { + id: channelId, + message: { messageId: messageId, command: command, data: parsedData } + }; + var eventObj = new CustomEvent('WebChannelMessageToContent', { detail: envelope }); + window.dispatchEvent(eventObj); + document.dispatchEvent(eventObj); + var eventStr = new CustomEvent('WebChannelMessageToContent', { detail: JSON.stringify(envelope) }); + window.dispatchEvent(eventStr); + document.dispatchEvent(eventStr); + } catch (e) {} }; })(); """.trimIndent() @@ -84,9 +93,13 @@ private class WebChannelBridge( @JavascriptInterface fun onMessage(payload: String) { - val view = webViewRef.get() ?: return - val currentHost = runCatching { Uri.parse(view.url).host?.lowercase() }.getOrNull() - if (currentHost != "accounts.firefox.com" && currentHost?.endsWith(".firefox.com") != true) { + onMessage(payload, "https://accounts.firefox.com") + } + + @JavascriptInterface + fun onMessage(payload: String, originUrl: String) { + val host = runCatching { Uri.parse(originUrl).host?.lowercase() }.getOrNull() + if (host != "accounts.firefox.com" && host?.endsWith(".firefox.com") != true) { return } val message = runCatching { @@ -107,10 +120,16 @@ private class WebChannelBridge( CMD_CAN_LINK_ACCOUNT, """{"ok":true}""", ) - CMD_LOGIN -> { + CMD_LOGIN, "fxaccounts:oauth_login" -> { val token = data.optString("sessionToken", "").trim() + .ifBlank { data.optString("token", "").trim() } if (SESSION_TOKEN_HEX.matches(token)) { - view.post { onSessionToken(token) } + val view = webViewRef.get() + if (view != null) { + view.post { onSessionToken(token) } + } else { + onSessionToken(token) + } } } } @@ -222,6 +241,7 @@ fun BrowserLoginScreen( webViewInstance = this settings.javaScriptEnabled = true settings.domStorageEnabled = true + settings.databaseEnabled = true settings.loadWithOverviewMode = true settings.useWideViewPort = true settings.userAgentString = FIREFOX_ANDROID_UA @@ -271,6 +291,11 @@ fun BrowserLoginScreen( override fun onProgressChanged(view: WebView?, newProgress: Int) { pageProgress = newProgress } + + override fun onConsoleMessage(message: android.webkit.ConsoleMessage?): Boolean { + message?.message()?.let { AppLogger.d(TAG, "console: $it") } + return super.onConsoleMessage(message) + } } loadUrl(startUrl) } From 507d41bd1f58859c2035f3c8de2743cdf5c907b1 Mon Sep 17 00:00:00 2001 From: Vauth <122077080+Vauth@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:43:22 +0330 Subject: [PATCH 08/11] Fix browser login OTP redirect, unconfirmed session error, and status bar padding --- .../vauth/foxyvpn/data/FxaAuthRepository.kt | 8 + .../foxyvpn/ui/screens/BrowserLoginScreen.kt | 183 ++++++++++++++++-- .../vauth/foxyvpn/ui/screens/LoginScreen.kt | 5 +- 3 files changed, 176 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt index 8518222f..19762fb2 100644 --- a/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt +++ b/app/src/main/java/com/vauth/foxyvpn/data/FxaAuthRepository.kt @@ -27,6 +27,14 @@ enum class SessionStatus { class FxaApiError(message: String, val errno: Int?, val statusCode: Int?) : Exception(message) +val FxaApiError.isUnconfirmedSession: Boolean + get() = errno == 104 || (statusCode == 400 && message?.contains("Unconfirmed session", ignoreCase = true) == true) + +fun Throwable.isUnconfirmedSession(): Boolean = + (this as? FxaApiError)?.isUnconfirmedSession == true || + message?.contains("Unconfirmed session", ignoreCase = true) == true + + class FxaRefreshFailure( message: String, val permanent: Boolean, diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt index 22d7c648..823b6fcc 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt @@ -15,7 +15,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Icon @@ -37,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.vauth.foxyvpn.data.AppLogger import java.lang.ref.WeakReference +import kotlinx.coroutines.delay import org.json.JSONObject private const val TAG = "BrowserLogin" @@ -82,6 +86,74 @@ private val WEBCHANNEL_HOOK_JS = """ document.dispatchEvent(eventStr); } catch (e) {} }; + var checkVerifyUrl = function(url) { + if (typeof url !== 'string') return false; + var u = url.toLowerCase(); + return u.indexOf('/verify') !== -1 || u.indexOf('/totp') !== -1 || u.indexOf('/session') !== -1; + }; + var checkSuccessUrl = function(url) { + if (typeof url !== 'string') return false; + var u = url.toLowerCase(); + return u.indexOf('confirmed') !== -1 || u.indexOf('connect_another_device') !== -1 || + u.indexOf('/pair') !== -1 || u.indexOf('/settings') !== -1 || u.indexOf('/complete') !== -1; + }; + if (window.fetch) { + var origFetch = window.fetch; + window.fetch = function() { + var reqUrl = (arguments[0] && arguments[0].url) || arguments[0] || ''; + return origFetch.apply(this, arguments).then(function(res) { + try { + if (res && res.ok && checkVerifyUrl(reqUrl)) { + foxyWebChannel.onSessionVerified(); + } + } catch (e) {} + return res; + }); + }; + } + if (window.XMLHttpRequest) { + var origOpen = XMLHttpRequest.prototype.open; + var origSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this.__foxyReqUrl = url; + return origOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function() { + this.addEventListener('load', function() { + try { + if (this.status >= 200 && this.status < 300 && checkVerifyUrl(this.__foxyReqUrl)) { + foxyWebChannel.onSessionVerified(); + } + } catch (e) {} + }); + return origSend.apply(this, arguments); + }; + } + var notifyNav = function() { + try { + if (checkSuccessUrl(window.location.href)) { + foxyWebChannel.onSessionVerified(); + } + } catch (e) {} + }; + var origPushState = history.pushState; + if (origPushState) { + history.pushState = function() { + var ret = origPushState.apply(this, arguments); + notifyNav(); + return ret; + }; + } + var origReplaceState = history.replaceState; + if (origReplaceState) { + history.replaceState = function() { + var ret = origReplaceState.apply(this, arguments); + notifyNav(); + return ret; + }; + } + window.addEventListener('popstate', notifyNav); + window.addEventListener('hashchange', notifyNav); })(); """.trimIndent() @@ -90,12 +162,21 @@ private class WebChannelBridge( private val onSessionToken: (String) -> Unit, ) { private val webViewRef = WeakReference(webView) + @Volatile + var pendingSessionToken: String? = null + private set @JavascriptInterface fun onMessage(payload: String) { onMessage(payload, "https://accounts.firefox.com") } + @JavascriptInterface + fun onSessionVerified() { + val token = pendingSessionToken ?: return + notifySessionToken(token) + } + @JavascriptInterface fun onMessage(payload: String, originUrl: String) { val host = runCatching { Uri.parse(originUrl).host?.lowercase() }.getOrNull() @@ -124,14 +205,43 @@ private class WebChannelBridge( val token = data.optString("sessionToken", "").trim() .ifBlank { data.optString("token", "").trim() } if (SESSION_TOKEN_HEX.matches(token)) { - val view = webViewRef.get() - if (view != null) { - view.post { onSessionToken(token) } - } else { - onSessionToken(token) + pendingSessionToken = token + val isVerified = if (data.has("verified")) data.optBoolean("verified", true) else true + if (isVerified) { + notifySessionToken(token) } } + if (messageId.isNotBlank()) { + reply(messageId, command, """{"ok":true}""") + } } + "fxaccounts:email_verified", "fxaccounts:session_verified", "fxaccounts:verified" -> { + val token = data.optString("sessionToken", "").trim() + .ifBlank { data.optString("token", "").trim() } + .ifBlank { pendingSessionToken ?: "" } + if (SESSION_TOKEN_HEX.matches(token)) { + pendingSessionToken = token + notifySessionToken(token) + } + if (messageId.isNotBlank()) { + reply(messageId, command, """{"ok":true}""") + } + } + else -> { + if (messageId.isNotBlank()) { + reply(messageId, command, """{"ok":true}""") + } + } + } + } + + fun notifySessionToken(token: String) { + if (!SESSION_TOKEN_HEX.matches(token)) return + val view = webViewRef.get() + if (view != null) { + view.post { onSessionToken(token) } + } else { + onSessionToken(token) } } @@ -146,6 +256,16 @@ private class WebChannelBridge( } } +private fun isSuccessUrl(url: String?): Boolean { + if (url.isNullOrBlank()) return false + val u = url.lowercase() + return u.contains("/signin/confirmed") || + u.contains("/connect_another_device") || + u.contains("/pair") || + u.contains("/settings") || + u.contains("/complete") +} + @SuppressLint("SetJavaScriptEnabled") @Composable fun BrowserLoginScreen( @@ -156,19 +276,13 @@ fun BrowserLoginScreen( onCanceled: () -> Unit, ) { var webViewInstance by remember { mutableStateOf(null) } - var capturedToken by remember { mutableStateOf(null) } + var bridgeInstance by remember { mutableStateOf(null) } var pageTitle by remember { mutableStateOf("") } var pageProgress by remember { mutableIntStateOf(0) } var pageLoaded by remember { mutableStateOf(false) } val currentOnSession by rememberUpdatedState(onSessionCaptured) val currentOnCanceled by rememberUpdatedState(onCanceled) - LaunchedEffect(errorMessage) { - if (errorMessage != null) { - capturedToken = null - } - } - BackHandler(enabled = true) { if (webViewInstance?.canGoBack() == true) { webViewInstance?.goBack() @@ -179,12 +293,26 @@ fun BrowserLoginScreen( fun handleSession(token: String) { if (busy) return - if (capturedToken == token) return - capturedToken = token currentOnSession(token) } - Column(Modifier.fillMaxSize()) { + LaunchedEffect(bridgeInstance?.pendingSessionToken, busy) { + val pendingToken = bridgeInstance?.pendingSessionToken + if (pendingToken != null && !busy) { + delay(4000) + if (!busy) { + handleSession(pendingToken) + } + } + } + + Column( + Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + .imePadding(), + ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -248,10 +376,9 @@ fun BrowserLoginScreen( val cookieManager = CookieManager.getInstance() cookieManager.setAcceptCookie(true) cookieManager.setAcceptThirdPartyCookies(this, true) - addJavascriptInterface( - WebChannelBridge(this, ::handleSession), - "foxyWebChannel", - ) + val bridge = WebChannelBridge(this, ::handleSession) + bridgeInstance = bridge + addJavascriptInterface(bridge, "foxyWebChannel") webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView, @@ -270,6 +397,17 @@ fun BrowserLoginScreen( return true } + override fun doUpdateVisitedHistory( + view: WebView, + url: String?, + isReload: Boolean, + ) { + super.doUpdateVisitedHistory(view, url, isReload) + if (isSuccessUrl(url)) { + bridge.onSessionVerified() + } + } + override fun onPageStarted( view: WebView, url: String?, @@ -277,6 +415,9 @@ fun BrowserLoginScreen( ) { super.onPageStarted(view, url, favicon) pageLoaded = false + if (isSuccessUrl(url)) { + bridge.onSessionVerified() + } runCatching { view.evaluateJavascript(WEBCHANNEL_HOOK_JS, null) } } @@ -284,6 +425,9 @@ fun BrowserLoginScreen( super.onPageFinished(view, url) pageLoaded = true pageTitle = view.title.orEmpty() + if (isSuccessUrl(url)) { + bridge.onSessionVerified() + } runCatching { view.evaluateJavascript(WEBCHANNEL_HOOK_JS, null) } } } @@ -303,6 +447,7 @@ fun BrowserLoginScreen( modifier = Modifier.fillMaxSize(), onRelease = { view -> webViewInstance = null + bridgeInstance = null view.stopLoading() view.destroy() }, diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt index bf252dcb..12c34f09 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vauth.foxyvpn.data.FxaAuthRepository +import com.vauth.foxyvpn.data.isUnconfirmedSession import kotlinx.coroutines.launch @Composable @@ -56,7 +57,9 @@ fun LoginScreen( errorMessage = null onSignedIn() }.onFailure { - errorMessage = it.message ?: "Browser sign-in failed. Please try again." + if (!it.isUnconfirmedSession()) { + errorMessage = it.message ?: "Browser sign-in failed. Please try again." + } } } } From 287e70612304ec78a20199ee43af95d8bb97743c Mon Sep 17 00:00:00 2001 From: Vauth <122077080+Vauth@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:53:39 +0330 Subject: [PATCH 09/11] Refine OTP verification interceptor, remove premature polling loop, and apply window insets --- .../foxyvpn/ui/screens/BrowserLoginScreen.kt | 55 ++++++++----------- .../vauth/foxyvpn/ui/screens/LoginScreen.kt | 4 ++ 2 files changed, 28 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt index 823b6fcc..231679f6 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.vauth.foxyvpn.data.AppLogger import java.lang.ref.WeakReference -import kotlinx.coroutines.delay import org.json.JSONObject private const val TAG = "BrowserLogin" @@ -86,16 +85,22 @@ private val WEBCHANNEL_HOOK_JS = """ document.dispatchEvent(eventStr); } catch (e) {} }; + var getUrlString = function(arg) { + if (!arg) return ''; + if (typeof arg === 'string') return arg; + if (typeof arg.url === 'string') return arg.url; + if (typeof arg.href === 'string') return arg.href; + try { return arg.toString(); } catch (e) { return ''; } + }; var checkVerifyUrl = function(url) { - if (typeof url !== 'string') return false; - var u = url.toLowerCase(); - return u.indexOf('/verify') !== -1 || u.indexOf('/totp') !== -1 || u.indexOf('/session') !== -1; + var u = getUrlString(url).toLowerCase(); + return u.indexOf('/verify') !== -1 || u.indexOf('/totp') !== -1 || u.indexOf('/session/verify') !== -1; }; var checkSuccessUrl = function(url) { - if (typeof url !== 'string') return false; - var u = url.toLowerCase(); + var u = getUrlString(url).toLowerCase(); return u.indexOf('confirmed') !== -1 || u.indexOf('connect_another_device') !== -1 || - u.indexOf('/pair') !== -1 || u.indexOf('/settings') !== -1 || u.indexOf('/complete') !== -1; + u.indexOf('/pair') !== -1 || u.indexOf('/settings') !== -1 || u.indexOf('/complete') !== -1 || + u.indexOf('success') !== -1; }; if (window.fetch) { var origFetch = window.fetch; @@ -215,19 +220,16 @@ private class WebChannelBridge( reply(messageId, command, """{"ok":true}""") } } - "fxaccounts:email_verified", "fxaccounts:session_verified", "fxaccounts:verified" -> { - val token = data.optString("sessionToken", "").trim() - .ifBlank { data.optString("token", "").trim() } - .ifBlank { pendingSessionToken ?: "" } - if (SESSION_TOKEN_HEX.matches(token)) { - pendingSessionToken = token - notifySessionToken(token) - } - if (messageId.isNotBlank()) { - reply(messageId, command, """{"ok":true}""") - } - } else -> { + if (command.contains("verified", ignoreCase = true)) { + val token = data.optString("sessionToken", "").trim() + .ifBlank { data.optString("token", "").trim() } + .ifBlank { pendingSessionToken ?: "" } + if (SESSION_TOKEN_HEX.matches(token)) { + pendingSessionToken = token + notifySessionToken(token) + } + } if (messageId.isNotBlank()) { reply(messageId, command, """{"ok":true}""") } @@ -263,7 +265,8 @@ private fun isSuccessUrl(url: String?): Boolean { u.contains("/connect_another_device") || u.contains("/pair") || u.contains("/settings") || - u.contains("/complete") + u.contains("/complete") || + u.contains("success") } @SuppressLint("SetJavaScriptEnabled") @@ -296,16 +299,6 @@ fun BrowserLoginScreen( currentOnSession(token) } - LaunchedEffect(bridgeInstance?.pendingSessionToken, busy) { - val pendingToken = bridgeInstance?.pendingSessionToken - if (pendingToken != null && !busy) { - delay(4000) - if (!busy) { - handleSession(pendingToken) - } - } - } - Column( Modifier .fillMaxSize() @@ -317,7 +310,7 @@ fun BrowserLoginScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(end = 16.dp), + .padding(start = 4.dp, end = 16.dp, top = 4.dp, bottom = 4.dp), ) { IconButton( onClick = { diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt index 12c34f09..76d801a2 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/LoginScreen.kt @@ -8,7 +8,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -82,6 +84,8 @@ fun LoginScreen( Box( modifier = Modifier .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() .imePadding(), contentAlignment = Alignment.Center, ) { From ff492ee44901245a1598914774815f177ceee74c Mon Sep 17 00:00:00 2001 From: Vauth <122077080+Vauth@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:08:20 +0330 Subject: [PATCH 10/11] Remove deprecated databaseEnabled setting and unused LaunchedEffect import --- .../java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt index 231679f6..3bf530ab 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -362,7 +361,6 @@ fun BrowserLoginScreen( webViewInstance = this settings.javaScriptEnabled = true settings.domStorageEnabled = true - settings.databaseEnabled = true settings.loadWithOverviewMode = true settings.useWideViewPort = true settings.userAgentString = FIREFOX_ANDROID_UA From 0846705285fdc65a5403deb3efb289f8cdc93032 Mon Sep 17 00:00:00 2001 From: Vauth <122077080+Vauth@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:38:01 +0330 Subject: [PATCH 11/11] Harden ProGuard rules and bridge visibility for release builds --- app/proguard-rules.pro | 2 ++ .../java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4e698bf0..e3012acb 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -21,6 +21,8 @@ -dontwarn reactor.blockhound.** +-keepattributes JavascriptInterface, *Annotation* + -keep class com.vauth.foxyvpn.ui.screens.WebChannelBridge { *; } -keepclassmembers class * { @android.webkit.JavascriptInterface ; diff --git a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt index 3bf530ab..e9a6381c 100644 --- a/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt @@ -161,7 +161,7 @@ private val WEBCHANNEL_HOOK_JS = """ })(); """.trimIndent() -private class WebChannelBridge( +internal class WebChannelBridge( webView: WebView, private val onSessionToken: (String) -> Unit, ) {