diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 79ee22b7..cdc35f01 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.8" externalNativeBuild { ndkBuild { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index c2d03afa..e3012acb 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -20,3 +20,10 @@ -dontwarn com.google.errorprone.annotations.** -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/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/data/FastlyChallengeSolver.kt b/app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt index a9bb4ad5..4ca4f865 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,33 @@ 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() + 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 +64,11 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { return if (idx > 0) prefixUrl.substring(0, idx) else prefixUrl } + private fun baseOriginFromPrefix(html: String): String? { + 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 +87,24 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) { return null } + 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() + } + } + 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 +113,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 +134,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 +179,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 +202,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 +217,28 @@ 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() + 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 +248,18 @@ 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") + val recheck = fetchPage(solver, "$origin/", userAgent) + if (looksLikeChallenge(recheck)) { + throw FastlyChallengeError("challenge cookie not accepted on this exit IP") + } installChallengeCookies(solverJar) return } @@ -238,4 +274,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..19762fb2 100644 --- 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 @@ -28,68 +27,47 @@ 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, 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" +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 = 5 +private const val FXA_MAX_CHALLENGE_ATTEMPTS = 3 private const val DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 24 * 60 * 60 +private const val WEBCHANNEL_LOGIN_URL = + "https://accounts.firefox.com/?context=fx_desktop_v3&service=sync&action=email" + private val jsonMediaType = "application/json".toMediaType() 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 - } - - 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 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") } - } - - 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 + completeLogin(clean) } suspend fun restoreSession(): SessionStatus { @@ -140,7 +118,11 @@ class FxaAuthRepository( } val tokenData = try { - fxaDo("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", @@ -209,24 +191,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 { @@ -236,16 +200,23 @@ 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, + ) } - 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 var hmacKey: ByteArray? = null @@ -257,7 +228,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)) } @@ -268,8 +239,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,30 +255,26 @@ class FxaAuthRepository( 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 = 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() - 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) @@ -333,6 +307,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. Please switch networks " + + "(Wi-Fi <-> mobile data) and try again." + private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray { val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(key, "HmacSHA256")) @@ -357,23 +336,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/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/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 new file mode 100644 index 00000000..e9a6381c --- /dev/null +++ b/app/src/main/java/com/vauth/foxyvpn/ui/screens/BrowserLoginScreen.kt @@ -0,0 +1,448 @@ +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 +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.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 +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 +import com.vauth.foxyvpn.data.AppLogger +import java.lang.ref.WeakReference +import org.json.JSONObject + +private const val TAG = "BrowserLogin" + +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 val SESSION_TOKEN_HEX = Regex("^[0-9a-f]{64}$") + +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) + +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; + 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) { + 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) {} + }; + 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) { + var u = getUrlString(url).toLowerCase(); + return u.indexOf('/verify') !== -1 || u.indexOf('/totp') !== -1 || u.indexOf('/session/verify') !== -1; + }; + var checkSuccessUrl = function(url) { + 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('success') !== -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() + +internal class WebChannelBridge( + webView: WebView, + 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() + if (host != "accounts.firefox.com" && host?.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") + + 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, "fxaccounts:oauth_login" -> { + val token = data.optString("sessionToken", "").trim() + .ifBlank { data.optString("token", "").trim() } + if (SESSION_TOKEN_HEX.matches(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}""") + } + } + 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}""") + } + } + } + } + + 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) + } + } + + 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) } + } + } +} + +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") || + u.contains("success") +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun BrowserLoginScreen( + startUrl: String, + busy: Boolean, + errorMessage: String? = null, + onSessionCaptured: (String) -> Unit, + onCanceled: () -> Unit, +) { + var webViewInstance 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) + + BackHandler(enabled = true) { + if (webViewInstance?.canGoBack() == true) { + webViewInstance?.goBack() + } else if (!busy) { + currentOnCanceled() + } + } + + fun handleSession(token: String) { + if (busy) return + currentOnSession(token) + } + + Column( + Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + .imePadding(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, end = 16.dp, top = 4.dp, bottom = 4.dp), + ) { + 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( + 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) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } else if (!pageLoaded || pageProgress < 100) { + LinearProgressIndicator( + progress = { pageProgress / 100f }, + 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 -> + WebView(context).apply { + webViewInstance = this + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + settings.userAgentString = FIREFOX_ANDROID_UA + val cookieManager = CookieManager.getInstance() + cookieManager.setAcceptCookie(true) + cookieManager.setAcceptThirdPartyCookies(this, true) + val bridge = WebChannelBridge(this, ::handleSession) + bridgeInstance = bridge + addJavascriptInterface(bridge, "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 doUpdateVisitedHistory( + view: WebView, + url: String?, + isReload: Boolean, + ) { + super.doUpdateVisitedHistory(view, url, isReload) + if (isSuccessUrl(url)) { + bridge.onSessionVerified() + } + } + + override fun onPageStarted( + view: WebView, + url: String?, + favicon: android.graphics.Bitmap?, + ) { + super.onPageStarted(view, url, favicon) + pageLoaded = false + if (isSuccessUrl(url)) { + bridge.onSessionVerified() + } + runCatching { view.evaluateJavascript(WEBCHANNEL_HOOK_JS, null) } + } + + override fun onPageFinished(view: WebView, url: String?) { + super.onPageFinished(view, url) + pageLoaded = true + pageTitle = view.title.orEmpty() + if (isSuccessUrl(url)) { + bridge.onSessionVerified() + } + runCatching { view.evaluateJavascript(WEBCHANNEL_HOOK_JS, null) } + } + } + webChromeClient = object : WebChromeClient() { + 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) + } + }, + 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 991d2bff..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 @@ -1,42 +1,34 @@ 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.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding 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.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text 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 +import com.vauth.foxyvpn.data.isUnconfirmedSession import kotlinx.coroutines.launch @Composable @@ -44,57 +36,56 @@ 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 browserStartUrl by rememberSaveable { mutableStateOf(null) } + 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 + fun startBrowserLogin() { + if (completingBrowserLogin) return errorMessage = null - code = "" + browserStartUrl = authRepository.getBrowserLoginUrl() } - fun doSubmitLogin() { - val cleanEmail = FxaAuthRepository.normalizeEmail(email) - if (cleanEmail.isBlank() || password.isBlank() || isLoading) return + fun handleBrowserSession(sessionToken: String) { + if (completingBrowserLogin) return + completingBrowserLogin = true 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" } + val result = authRepository.completeBrowserLogin(sessionToken) + completingBrowserLogin = false + result.onSuccess { + browserStartUrl = null + errorMessage = null + onSignedIn() + }.onFailure { + if (!it.isUnconfirmedSession()) { + errorMessage = it.message ?: "Browser sign-in failed. Please try again." + } + } } } - 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!!, + busy = completingBrowserLogin, + errorMessage = errorMessage, + onSessionCaptured = ::handleBrowserSession, + onCanceled = { + browserStartUrl = null + completingBrowserLogin = false + errorMessage = null + }, + ) + return } Box( modifier = Modifier .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() .imePadding(), contentAlignment = Alignment.Center, ) { @@ -107,117 +98,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)) + Spacer(Modifier.height(24.dp)) - if (!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 { - 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 e7f54629..561713d6 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) { + AppLogger.w(TAG, "Guardian rejected the OAuth access token; renewing it and retrying", rejected) + 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) + 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) + } } if (pass.quotaRemaining != null) { settingsStore.cachedQuotaRemaining = pass.quotaRemaining