diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b1c85da..d83514461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable user-facing changes are listed here. Detailed notes for older releases: [docs/releases/](docs/releases/). ## Unreleased +- Added an experimental Cloudflare OAuth + API Worker deployment PoC with PKCE, multiple independent Cloudflare logins/accounts, encrypted access-token storage, deploy/update/delete, workers.dev smoke testing, and automatic Worker Pool registration. ## 1.11.0 - 2026-09-19 - Promoted the userspace `awg_warp` backend to the stable channel. Telegram traffic can use AmneziaWG/WARP inside the app without Android `VpnService`, root, or a system TUN interface. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 82f026b6e..35ae480b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,21 @@ plugins { val releaseVersionCode = 54 val releaseVersionName = "1.11.0" +fun asBuildConfigString(value: String): String { + val escaped = value.replace("\\", "\\\\").replace("\"", "\\\"") + return "\"$escaped\"" +} + +val cloudflareOAuthClientId = providers.gradleProperty("TGWSPROXY_CF_OAUTH_CLIENT_ID") + .orElse(providers.environmentVariable("TGWSPROXY_CF_OAUTH_CLIENT_ID")) + .getOrElse("") +val cloudflareOAuthScopes = providers.gradleProperty("TGWSPROXY_CF_OAUTH_SCOPES") + .orElse(providers.environmentVariable("TGWSPROXY_CF_OAUTH_SCOPES")) + .getOrElse("memberships.read workers-scripts.read workers-scripts.write") +val cloudflareOAuthRedirectUri = "tgwsproxy://oauth/cloudflare" +val generatedCloudflareWorkerAssets = layout.buildDirectory.dir("generated/cloudflare-worker-assets") + + val buildNativeAndroid by tasks.registering(org.gradle.api.tasks.Exec::class) { val script = rootProject.file("scripts/build-native-android.ps1") val nativeDir = rootProject.file("native/tgwsproxy") @@ -36,8 +51,16 @@ val generateAppIcons by tasks.registering(org.gradle.api.tasks.Exec::class) { } } +val syncCloudflareWorkerAssets by tasks.registering(org.gradle.api.tasks.Copy::class) { + from(rootProject.file("scripts/cloudflare-worker")) { + include("worker.js", "chunk-relay-worker.js", "chunk-relay-status-worker.js") + into("cloudflare-worker") + } + into(generatedCloudflareWorkerAssets) +} + tasks.named("preBuild") { - dependsOn(buildNativeAndroid, generateAppIcons) + dependsOn(buildNativeAndroid, generateAppIcons, syncCloudflareWorkerAssets) } android { @@ -51,6 +74,10 @@ android { versionCode = releaseVersionCode versionName = releaseVersionName + buildConfigField("String", "CLOUDFLARE_OAUTH_CLIENT_ID", asBuildConfigString(cloudflareOAuthClientId)) + buildConfigField("String", "CLOUDFLARE_OAUTH_SCOPES", asBuildConfigString(cloudflareOAuthScopes)) + buildConfigField("String", "CLOUDFLARE_OAUTH_REDIRECT_URI", asBuildConfigString(cloudflareOAuthRedirectUri)) + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true @@ -103,6 +130,7 @@ android { } buildFeatures { compose = true + buildConfig = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.8" @@ -115,6 +143,7 @@ android { sourceSets { getByName("main") { jniLibs.srcDir("src/main/jniLibs") + assets.srcDir(generatedCloudflareWorkerAssets) } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0b4cfea72..1d42e6630 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,6 +42,22 @@ android:exported="false" android:theme="@android:style/Theme.NoTitleBar" /> + + + + + + + + + + val payload = response.body?.string().orEmpty() + if (!response.isSuccessful) { + throw CloudflareApiException( + oauthError(payload, "OAuth token exchange failed"), + response.code, + ) + } + val json = JSONObject(payload) + val token = json.optString("access_token").takeIf { it.isNotBlank() } + ?: throw CloudflareApiException("OAuth response does not contain access_token") + val expiresInSeconds = json.optLong("expires_in", -1L).takeIf { it > 0 } + CloudflareOAuthToken( + accessToken = token, + expiresAtEpochMs = expiresInSeconds?.let { + System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(it) + }, + ) + } + } + + suspend fun listAccounts(accessToken: String): List = + withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(API_BASE + "/accounts?per_page=50") + .get() + .bearer(accessToken) + .build() + httpClient.newCall(request).execute().use { response -> + val payload = response.body?.string().orEmpty() + val result = requireCloudflareResult(response.code, response.isSuccessful, payload) + val accounts = result as? JSONArray + ?: throw CloudflareApiException("Cloudflare accounts response has unexpected shape") + buildList { + for (index in 0 until accounts.length()) { + val account = accounts.getJSONObject(index) + val id = account.optString("id") + val name = account.optString("name") + if (id.isNotBlank() && name.isNotBlank()) { + add(CloudflareAuthorizedAccount(id = id, name = name)) + } + } + } + } + } + + suspend fun revoke(accessToken: String, clientId: String) = withContext(Dispatchers.IO) { + val body = FormBody.Builder() + .add("token", accessToken) + .add("client_id", clientId) + .build() + val request = Request.Builder() + .url(REVOKE_URL) + .post(body) + .header("Accept", "application/json") + .build() + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw CloudflareApiException("OAuth token revocation failed", response.code) + } + } + } + + private fun oauthError(payload: String, fallback: String): String { + return runCatching { + val json = JSONObject(payload) + json.optString("error_description") + .ifBlank { json.optString("error") } + .ifBlank { fallback } + }.getOrDefault(fallback) + } + + private companion object { + const val AUTHORIZATION_URL = "https://dash.cloudflare.com/oauth2/auth" + const val TOKEN_URL = "https://dash.cloudflare.com/oauth2/token" + const val REVOKE_URL = "https://dash.cloudflare.com/oauth2/revoke" + } +} + +data class CloudflareWorkerDeploymentResult( + val workerName: String, + val workersDevUrl: String, + val smokeStatusCode: Int, +) + +class CloudflareWorkerDeploymentClient( + context: Context, + private val httpClient: OkHttpClient = defaultCloudflareHttpClient(), +) { + private val appContext = context.applicationContext + + suspend fun deploy( + accessToken: String, + accountId: String, + workerName: String, + ): CloudflareWorkerDeploymentResult = withContext(Dispatchers.IO) { + val normalizedName = CloudflareWorkerName.normalize(workerName) + if (!CloudflareWorkerName.isValid(normalizedName)) { + throw CloudflareApiException("Invalid Worker name") + } + + uploadWorker(accessToken, accountId, normalizedName) + enableWorkersDev(accessToken, accountId, normalizedName) + val subdomain = getAccountWorkersSubdomain(accessToken, accountId) + val url = "https://$normalizedName.$subdomain.workers.dev" + val smokeStatus = smokeTest(url) + + CloudflareWorkerDeploymentResult( + workerName = normalizedName, + workersDevUrl = url, + smokeStatusCode = smokeStatus, + ) + } + + suspend fun delete( + accessToken: String, + accountId: String, + workerName: String, + ) = withContext(Dispatchers.IO) { + val normalizedName = CloudflareWorkerName.normalize(workerName) + if (!CloudflareWorkerName.isValid(normalizedName)) { + throw CloudflareApiException("Invalid Worker name") + } + val request = Request.Builder() + .url(API_BASE + "/accounts/" + accountId + "/workers/scripts/" + normalizedName) + .delete() + .bearer(accessToken) + .build() + httpClient.newCall(request).execute().use { response -> + val payload = response.body?.string().orEmpty() + if (response.code == 404 || (response.isSuccessful && payload.isBlank())) { + return@use + } + requireCloudflareResult(response.code, response.isSuccessful, payload) + } + } + + private fun uploadWorker( + accessToken: String, + accountId: String, + workerName: String, + ) { + val metadata = JSONObject() + .put("main_module", MAIN_MODULE) + .put("compatibility_date", COMPATIBILITY_DATE) + .put( + "bindings", + JSONArray().put( + JSONObject() + .put("type", "durable_object_namespace") + .put("name", "CHUNK_RELAY") + .put("class_name", "ChunkRelaySession"), + ), + ) + .put( + "exports", + JSONObject().put( + "ChunkRelaySession", + JSONObject() + .put("type", "durable-object") + .put("storage", "sqlite"), + ), + ) + + val multipart = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "metadata", + "metadata.json", + metadata.toString().toRequestBody(JSON_MEDIA_TYPE), + ) + MODULES.forEach { moduleName -> + multipart.addFormDataPart( + moduleName, + moduleName, + readAsset(moduleName).toRequestBody(JAVASCRIPT_MODULE_MEDIA_TYPE), + ) + } + + val request = Request.Builder() + .url(API_BASE + "/accounts/" + accountId + "/workers/scripts/" + workerName) + .put(multipart.build()) + .bearer(accessToken) + .build() + httpClient.newCall(request).execute().use { response -> + val payload = response.body?.string().orEmpty() + requireCloudflareResult(response.code, response.isSuccessful, payload) + } + } + + private fun enableWorkersDev( + accessToken: String, + accountId: String, + workerName: String, + ) { + val body = JSONObject() + .put("enabled", true) + .put("previews_enabled", false) + .toString() + .toRequestBody(JSON_MEDIA_TYPE) + val request = Request.Builder() + .url(API_BASE + "/accounts/" + accountId + "/workers/scripts/" + workerName + "/subdomain") + .post(body) + .bearer(accessToken) + .build() + httpClient.newCall(request).execute().use { response -> + val payload = response.body?.string().orEmpty() + requireCloudflareResult(response.code, response.isSuccessful, payload) + } + } + + private fun getAccountWorkersSubdomain( + accessToken: String, + accountId: String, + ): String { + val request = Request.Builder() + .url(API_BASE + "/accounts/" + accountId + "/workers/subdomain") + .get() + .bearer(accessToken) + .build() + httpClient.newCall(request).execute().use { response -> + val payload = response.body?.string().orEmpty() + val result = requireCloudflareResult(response.code, response.isSuccessful, payload) + val subdomain = (result as? JSONObject)?.optString("subdomain").orEmpty() + if (subdomain.isBlank()) { + throw CloudflareApiException("Cloudflare account does not have a workers.dev subdomain") + } + return subdomain + } + } + + private fun smokeTest(url: String): Int { + val request = Request.Builder() + .url(url) + .get() + .header("Cache-Control", "no-cache") + .build() + httpClient.newCall(request).execute().use { response -> + if (response.code >= 500) { + throw CloudflareApiException( + "Worker smoke test returned HTTP " + response.code, + response.code, + ) + } + return response.code + } + } + + private fun readAsset(name: String): String { + return appContext.assets.open("cloudflare-worker/$name") + .bufferedReader() + .use { it.readText() } + } + + private companion object { + const val MAIN_MODULE = "chunk-relay-status-worker.js" + const val COMPATIBILITY_DATE = "2026-09-12" + val MODULES = listOf( + "chunk-relay-status-worker.js", + "chunk-relay-worker.js", + "worker.js", + ) + val JSON_MEDIA_TYPE = "application/json".toMediaType() + val JAVASCRIPT_MODULE_MEDIA_TYPE = "application/javascript+module".toMediaType() + } +} + +private fun Request.Builder.bearer(accessToken: String): Request.Builder { + return header("Authorization", "Bearer $accessToken") + .header("Accept", "application/json") +} + +private fun requireCloudflareResult( + statusCode: Int, + transportSuccess: Boolean, + payload: String, +): Any? { + val json = runCatching { JSONObject(payload) }.getOrNull() + val success = json?.optBoolean("success", false) == true + if (!transportSuccess || !success) { + val errorMessage = json + ?.optJSONArray("errors") + ?.optJSONObject(0) + ?.optString("message") + ?.takeIf { it.isNotBlank() } + ?: "Cloudflare API request failed" + throw CloudflareApiException(errorMessage, statusCode) + } + return json?.opt("result") +} + +private const val API_BASE = "https://api.cloudflare.com/client/v4" + +private fun defaultCloudflareHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .callTimeout(45, TimeUnit.SECONDS) + .build() +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthActivity.kt b/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthActivity.kt new file mode 100644 index 000000000..677cf267c --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthActivity.kt @@ -0,0 +1,589 @@ +package com.amurcanov.tgwsproxy.cloudflare + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import com.amurcanov.tgwsproxy.R +import com.amurcanov.tgwsproxy.WorkerDomain +import com.amurcanov.tgwsproxy.worker.SharedPreferencesWorkerPoolPersistence +import com.amurcanov.tgwsproxy.worker.WorkerEndpoint +import com.amurcanov.tgwsproxy.worker.WorkerPoolRepository +import kotlinx.coroutines.launch +import java.util.UUID + +class CloudflareOAuthActivity : ComponentActivity() { + private lateinit var config: CloudflareOAuthConfig + private lateinit var sessionStore: CloudflareOAuthSessionStore + private lateinit var oauthClient: CloudflareOAuthClient + private lateinit var deploymentClient: CloudflareWorkerDeploymentClient + + private var uiState by mutableStateOf(CloudflareProvisioningUiState()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + config = CloudflareOAuthConfig.fromBuildConfig() + sessionStore = CloudflareOAuthSessionStore(this) + oauthClient = CloudflareOAuthClient() + deploymentClient = CloudflareWorkerDeploymentClient(this) + refreshState() + + setContent { + val prefs = remember { getSharedPreferences("ProxyPrefs", Context.MODE_PRIVATE) } + val context = LocalContext.current + val systemDarkTheme = isSystemInDarkTheme() + val isDarkTheme = when (prefs.getString("theme_mode", "System")) { + "Light" -> false + "Dark" -> true + else -> systemDarkTheme + } + val colorScheme = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + isDarkTheme -> darkColorScheme() + else -> lightColorScheme() + } + + MaterialTheme(colorScheme = colorScheme) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + CloudflareOAuthScreen( + configured = config.isConfigured, + state = uiState, + onBack = ::finish, + onAddLogin = ::startLogin, + onSelectAccount = ::selectAccount, + onRemoveLogin = ::removeLogin, + onDeploy = ::deployWorker, + onDelete = ::deleteWorker, + ) + } + } + } + + handleOAuthCallback(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleOAuthCallback(intent) + } + + private fun startLogin() { + if (!config.isConfigured || uiState.busy) { + uiState = uiState.copy(message = getString(R.string.cf_oauth_not_configured)) + return + } + val pkce = CloudflarePkce.create() + val state = UUID.randomUUID().toString() + pendingPrefs().edit() + .putString(KEY_PENDING_STATE, state) + .putString(KEY_PENDING_VERIFIER, pkce.verifier) + .apply() + + val authorizationUrl = oauthClient.buildAuthorizationUrl(config, pkce, state) + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(authorizationUrl))) + } + + private fun handleOAuthCallback(intent: Intent?) { + val callback = intent?.data ?: return + val expected = Uri.parse(config.redirectUri) + if (callback.scheme != expected.scheme || + callback.host != expected.host || + callback.path != expected.path + ) { + return + } + + val pendingState = pendingPrefs().getString(KEY_PENDING_STATE, null) + val verifier = pendingPrefs().getString(KEY_PENDING_VERIFIER, null) + val returnedState = callback.getQueryParameter("state") + val error = callback.getQueryParameter("error") + val code = callback.getQueryParameter("code") + + if (pendingState.isNullOrBlank() || verifier.isNullOrBlank() || returnedState != pendingState) { + clearPendingLogin() + uiState = uiState.copy(message = getString(R.string.cf_oauth_state_mismatch)) + return + } + clearPendingLogin() + + if (!error.isNullOrBlank()) { + uiState = uiState.copy( + message = getString( + R.string.cf_oauth_authorization_failed, + callback.getQueryParameter("error_description") ?: error, + ), + ) + return + } + if (code.isNullOrBlank()) { + uiState = uiState.copy(message = getString(R.string.cf_oauth_missing_code)) + return + } + + lifecycleScope.launch { + runOperation { + val token = oauthClient.exchangeCode(config, code, verifier) + val accounts = oauthClient.listAccounts(token.accessToken) + if (accounts.isEmpty()) { + throw CloudflareApiException(getString(R.string.cf_oauth_no_accounts)) + } + val session = CloudflareOAuthSession.create( + accessToken = token.accessToken, + expiresAtEpochMs = token.expiresAtEpochMs, + accounts = accounts, + ) + sessionStore.upsertSession(session) + sessionStore.saveActiveAccount( + ActiveCloudflareAccountRef(session.id, accounts.first().id), + ) + refreshState(getString(R.string.cf_oauth_login_added, accounts.size)) + } + } + } + + private fun selectAccount(ref: ActiveCloudflareAccountRef) { + sessionStore.saveActiveAccount(ref) + refreshState() + } + + private fun removeLogin(sessionId: String) { + val session = sessionStore.loadSessions().firstOrNull { it.id == sessionId } ?: return + lifecycleScope.launch { + uiState = uiState.copy(busy = true, message = null) + runCatching { oauthClient.revoke(session.accessToken, config.clientId) } + sessionStore.removeSession(sessionId) + refreshState(getString(R.string.cf_oauth_login_removed)) + } + } + + private fun deployWorker(rawWorkerName: String) { + val selection = selectedSessionAndAccount() ?: return + val workerName = CloudflareWorkerName.normalize(rawWorkerName) + if (!CloudflareWorkerName.isValid(workerName)) { + uiState = uiState.copy(message = getString(R.string.cf_oauth_worker_name_invalid)) + return + } + val (session, account) = selection + if (session.isExpired()) { + uiState = uiState.copy(message = getString(R.string.cf_oauth_session_expired)) + return + } + + lifecycleScope.launch { + runOperation { + val result = deploymentClient.deploy( + accessToken = session.accessToken, + accountId = account.id, + workerName = workerName, + ) + addWorkerToPool(result) + refreshState( + getString( + R.string.cf_oauth_deploy_success, + result.workersDevUrl, + result.smokeStatusCode, + ), + ) + } + } + } + + private fun deleteWorker(rawWorkerName: String) { + val selection = selectedSessionAndAccount() ?: return + val workerName = CloudflareWorkerName.normalize(rawWorkerName) + if (!CloudflareWorkerName.isValid(workerName)) { + uiState = uiState.copy(message = getString(R.string.cf_oauth_worker_name_invalid)) + return + } + val (session, account) = selection + if (session.isExpired()) { + uiState = uiState.copy(message = getString(R.string.cf_oauth_session_expired)) + return + } + + lifecycleScope.launch { + runOperation { + deploymentClient.delete( + accessToken = session.accessToken, + accountId = account.id, + workerName = workerName, + ) + refreshState(getString(R.string.cf_oauth_delete_success, workerName)) + } + } + } + + private fun selectedSessionAndAccount(): Pair? { + val active = sessionStore.loadActiveAccount() + val sessions = sessionStore.loadSessions() + val session = sessions.firstOrNull { it.id == active?.sessionId } + val account = session?.accounts?.firstOrNull { it.id == active?.accountId } + if (session == null || account == null) { + uiState = uiState.copy(message = getString(R.string.cf_oauth_select_account_first)) + return null + } + return session to account + } + + private suspend fun runOperation(block: suspend () -> Unit) { + uiState = uiState.copy(busy = true, message = null) + try { + block() + } catch (error: Exception) { + uiState = uiState.copy( + busy = false, + message = getString( + R.string.cf_oauth_operation_failed, + error.message ?: error.javaClass.simpleName, + ), + ) + } + } + + private fun refreshState(message: String? = uiState.message) { + val sessions = sessionStore.loadSessions() + val savedActive = sessionStore.loadActiveAccount() + val active = savedActive?.takeIf { ref -> + sessions.any { session -> + session.id == ref.sessionId && session.accounts.any { it.id == ref.accountId } + } + } ?: sessions.firstNotNullOfOrNull { session -> + session.accounts.firstOrNull()?.let { account -> + ActiveCloudflareAccountRef(session.id, account.id) + } + } + if (active != savedActive) { + sessionStore.saveActiveAccount(active) + } + uiState = CloudflareProvisioningUiState( + sessions = sessions, + activeAccount = active, + busy = false, + message = message, + ) + } + + private fun addWorkerToPool(result: CloudflareWorkerDeploymentResult) { + val prefs = getSharedPreferences("ProxyPrefs", Context.MODE_PRIVATE) + val repository = WorkerPoolRepository(SharedPreferencesWorkerPoolPersistence(prefs)) + val normalizedDomain = WorkerDomain.normalize(result.workersDevUrl) + val existing = repository.getWorkers().firstOrNull { + WorkerDomain.normalize(it.url).equals(normalizedDomain, ignoreCase = true) + } + val worker = if (existing == null) { + repository.addWorker( + WorkerEndpoint.create( + name = getString(R.string.cf_oauth_worker_pool_name, result.workerName), + url = result.workersDevUrl, + enabled = true, + ), + ).getOrThrow() + } else if (!existing.enabled) { + repository.setWorkerEnabled(existing.id, true).getOrThrow() + } else { + existing + } + repository.setPoolEnabled(true) + repository.selectWorker(worker.id).getOrThrow() + } + + private fun pendingPrefs() = getSharedPreferences(PENDING_PREFS_NAME, Context.MODE_PRIVATE) + + private fun clearPendingLogin() { + pendingPrefs().edit() + .remove(KEY_PENDING_STATE) + .remove(KEY_PENDING_VERIFIER) + .apply() + } + + private companion object { + const val PENDING_PREFS_NAME = "CloudflareOAuthPending" + const val KEY_PENDING_STATE = "state" + const val KEY_PENDING_VERIFIER = "verifier" + } +} + +private data class CloudflareProvisioningUiState( + val sessions: List = emptyList(), + val activeAccount: ActiveCloudflareAccountRef? = null, + val busy: Boolean = false, + val message: String? = null, +) + +@Composable +private fun CloudflareOAuthScreen( + configured: Boolean, + state: CloudflareProvisioningUiState, + onBack: () -> Unit, + onAddLogin: () -> Unit, + onSelectAccount: (ActiveCloudflareAccountRef) -> Unit, + onRemoveLogin: (String) -> Unit, + onDeploy: (String) -> Unit, + onDelete: (String) -> Unit, +) { + var workerName by rememberSaveable { mutableStateOf("tgwsproxy") } + val normalizedWorkerName = remember(workerName) { CloudflareWorkerName.normalize(workerName) } + val hasActiveAccount = state.activeAccount != null + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onBack) { + Text(stringResource(R.string.action_back)) + } + Text( + text = stringResource(R.string.cf_oauth_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } + + if (!configured) { + item { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Text( + text = stringResource(R.string.cf_oauth_not_configured), + modifier = Modifier.padding(12.dp), + ) + } + } + } + + item { + Text( + text = stringResource(R.string.cf_oauth_accounts_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.cf_oauth_accounts_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onAddLogin, + enabled = configured && !state.busy, + ) { + Text(stringResource(R.string.cf_oauth_add_login)) + } + } + + if (state.sessions.isEmpty()) { + item { + Text( + text = stringResource(R.string.cf_oauth_no_logins), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + items(state.sessions, key = { it.id }) { session -> + CloudflareSessionCard( + session = session, + activeAccount = state.activeAccount, + busy = state.busy, + onSelectAccount = onSelectAccount, + onRemoveLogin = { onRemoveLogin(session.id) }, + ) + } + + item { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(14.dp)) { + Text( + text = stringResource(R.string.cf_oauth_deploy_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.cf_oauth_deploy_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = workerName, + onValueChange = { workerName = it }, + label = { Text(stringResource(R.string.cf_oauth_worker_name)) }, + supportingText = { + Text( + stringResource( + R.string.cf_oauth_worker_name_normalized, + normalizedWorkerName.ifBlank { "-" }, + ), + ) + }, + enabled = !state.busy, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { onDeploy(normalizedWorkerName) }, + enabled = configured && + hasActiveAccount && + normalizedWorkerName.isNotBlank() && + !state.busy, + ) { + Text(stringResource(R.string.cf_oauth_deploy_or_update)) + } + OutlinedButton( + onClick = { onDelete(normalizedWorkerName) }, + enabled = configured && + hasActiveAccount && + normalizedWorkerName.isNotBlank() && + !state.busy, + ) { + Text(stringResource(R.string.cf_oauth_delete_worker)) + } + } + } + } + } + + if (state.busy) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CircularProgressIndicator() + Text(stringResource(R.string.cf_oauth_operation_running)) + } + } + } + + state.message?.let { message -> + item { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + item { + Text( + text = stringResource(R.string.cf_oauth_security_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun CloudflareSessionCard( + session: CloudflareOAuthSession, + activeAccount: ActiveCloudflareAccountRef?, + busy: Boolean, + onSelectAccount: (ActiveCloudflareAccountRef) -> Unit, + onRemoveLogin: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(14.dp)) { + Text( + text = session.label, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + if (session.isExpired()) { + Text( + text = stringResource(R.string.cf_oauth_session_expired), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + session.accounts.forEach { account -> + val ref = ActiveCloudflareAccountRef(session.id, account.id) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = activeAccount == ref, + enabled = !busy && !session.isExpired(), + onClick = { onSelectAccount(ref) }, + ) + Column(modifier = Modifier.weight(1f)) { + Text(account.name) + Text( + account.id, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + TextButton( + onClick = onRemoveLogin, + enabled = !busy, + ) { + Text(stringResource(R.string.cf_oauth_remove_login)) + } + } + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthModels.kt b/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthModels.kt new file mode 100644 index 000000000..4d2d4d605 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthModels.kt @@ -0,0 +1,132 @@ +package com.amurcanov.tgwsproxy.cloudflare + +import com.amurcanov.tgwsproxy.BuildConfig +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import java.util.UUID + +data class CloudflareOAuthConfig( + val clientId: String, + val redirectUri: String, + val scopes: List, +) { + val isConfigured: Boolean + get() = clientId.isNotBlank() && redirectUri.isNotBlank() && scopes.isNotEmpty() + + companion object { + fun fromBuildConfig(): CloudflareOAuthConfig { + return CloudflareOAuthConfig( + clientId = BuildConfig.CLOUDFLARE_OAUTH_CLIENT_ID.trim(), + redirectUri = BuildConfig.CLOUDFLARE_OAUTH_REDIRECT_URI.trim(), + scopes = BuildConfig.CLOUDFLARE_OAUTH_SCOPES + .split(Regex("\\s+")) + .map(String::trim) + .filter(String::isNotBlank), + ) + } + } +} + +data class CloudflarePkcePair( + val verifier: String, + val challenge: String, +) + +object CloudflarePkce { + private const val VERIFIER_BYTES = 32 + + fun create(random: SecureRandom = SecureRandom()): CloudflarePkcePair { + val bytes = ByteArray(VERIFIER_BYTES) + random.nextBytes(bytes) + val verifier = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + return CloudflarePkcePair( + verifier = verifier, + challenge = challenge(verifier), + ) + } + + internal fun challenge(verifier: String): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(verifier.toByteArray(Charsets.US_ASCII)) + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) + } +} + +data class CloudflareAuthorizedAccount( + val id: String, + val name: String, +) + +data class CloudflareOAuthToken( + val accessToken: String, + val expiresAtEpochMs: Long?, +) + +data class CloudflareOAuthSession( + val id: String, + val label: String, + val accessToken: String, + val expiresAtEpochMs: Long?, + val accounts: List, + val createdAtEpochMs: Long, +) { + fun isExpired(nowEpochMs: Long = System.currentTimeMillis()): Boolean { + val expiresAt = expiresAtEpochMs ?: return false + return nowEpochMs >= expiresAt - EXPIRY_SKEW_MS + } + + companion object { + private const val EXPIRY_SKEW_MS = 30_000L + + fun create( + accessToken: String, + expiresAtEpochMs: Long?, + accounts: List, + nowEpochMs: Long = System.currentTimeMillis(), + ): CloudflareOAuthSession { + val label = when { + accounts.isEmpty() -> "Cloudflare" + accounts.size == 1 -> accounts.first().name + else -> accounts.take(2).joinToString(" / ") { it.name } + } + return CloudflareOAuthSession( + id = UUID.randomUUID().toString(), + label = label, + accessToken = accessToken, + expiresAtEpochMs = expiresAtEpochMs, + accounts = accounts, + createdAtEpochMs = nowEpochMs, + ) + } + } +} + +data class ActiveCloudflareAccountRef( + val sessionId: String, + val accountId: String, +) + +object CloudflareWorkerName { + private val INVALID_CHARS = Regex("[^a-z0-9-]+") + private val REPEATED_DASH = Regex("-+") + + fun normalize(raw: String): String { + return raw.trim() + .lowercase() + .replace(INVALID_CHARS, "-") + .replace(REPEATED_DASH, "-") + .trim('-') + .take(MAX_LENGTH) + .trimEnd('-') + } + + fun isValid(raw: String): Boolean { + val normalized = normalize(raw) + return normalized.isNotBlank() && + normalized.length <= MAX_LENGTH && + normalized == raw.trim().lowercase() + } + + private const val MAX_LENGTH = 63 +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthSessionStore.kt b/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthSessionStore.kt new file mode 100644 index 000000000..3fd6c02e8 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthSessionStore.kt @@ -0,0 +1,185 @@ +package com.amurcanov.tgwsproxy.cloudflare + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import org.json.JSONArray +import org.json.JSONObject +import java.security.KeyStore +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class CloudflareOAuthSessionStore( + context: Context, +) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val cipher = CloudflareTokenCipher() + + @Synchronized + fun loadSessions(): List { + val raw = prefs.getString(KEY_SESSIONS, null) ?: return emptyList() + return runCatching { + val array = JSONArray(raw) + buildList { + for (index in 0 until array.length()) { + decodeSession(array.getJSONObject(index))?.let(::add) + } + } + }.getOrDefault(emptyList()) + } + + @Synchronized + fun upsertSession(session: CloudflareOAuthSession) { + val sessions = loadSessions().toMutableList() + val existingIndex = sessions.indexOfFirst { it.id == session.id } + if (existingIndex >= 0) { + sessions[existingIndex] = session + } else { + sessions += session + } + saveSessions(sessions) + } + + @Synchronized + fun removeSession(sessionId: String) { + val sessions = loadSessions().filterNot { it.id == sessionId } + saveSessions(sessions) + if (loadActiveAccount()?.sessionId == sessionId) { + val fallback = sessions.firstNotNullOfOrNull { session -> + session.accounts.firstOrNull()?.let { account -> + ActiveCloudflareAccountRef(session.id, account.id) + } + } + saveActiveAccount(fallback) + } + } + + fun loadActiveAccount(): ActiveCloudflareAccountRef? { + val sessionId = prefs.getString(KEY_ACTIVE_SESSION_ID, null)?.takeIf { it.isNotBlank() } + ?: return null + val accountId = prefs.getString(KEY_ACTIVE_ACCOUNT_ID, null)?.takeIf { it.isNotBlank() } + ?: return null + return ActiveCloudflareAccountRef(sessionId, accountId) + } + + fun saveActiveAccount(ref: ActiveCloudflareAccountRef?) { + prefs.edit() + .putString(KEY_ACTIVE_SESSION_ID, ref?.sessionId) + .putString(KEY_ACTIVE_ACCOUNT_ID, ref?.accountId) + .apply() + } + + private fun saveSessions(sessions: List) { + val array = JSONArray() + sessions.forEach { session -> array.put(encodeSession(session)) } + prefs.edit().putString(KEY_SESSIONS, array.toString()).apply() + } + + private fun encodeSession(session: CloudflareOAuthSession): JSONObject { + val accounts = JSONArray() + session.accounts.forEach { account -> + accounts.put( + JSONObject() + .put("id", account.id) + .put("name", account.name), + ) + } + return JSONObject() + .put("id", session.id) + .put("label", session.label) + .put("accessToken", cipher.encrypt(session.accessToken)) + .put("expiresAtEpochMs", session.expiresAtEpochMs ?: JSONObject.NULL) + .put("createdAtEpochMs", session.createdAtEpochMs) + .put("accounts", accounts) + } + + private fun decodeSession(json: JSONObject): CloudflareOAuthSession? { + return runCatching { + val accountsJson = json.getJSONArray("accounts") + val accounts = buildList { + for (index in 0 until accountsJson.length()) { + val account = accountsJson.getJSONObject(index) + add( + CloudflareAuthorizedAccount( + id = account.getString("id"), + name = account.getString("name"), + ), + ) + } + } + CloudflareOAuthSession( + id = json.getString("id"), + label = json.optString("label").ifBlank { "Cloudflare" }, + accessToken = cipher.decrypt(json.getString("accessToken")), + expiresAtEpochMs = if (json.isNull("expiresAtEpochMs")) { + null + } else { + json.getLong("expiresAtEpochMs") + }, + accounts = accounts, + createdAtEpochMs = json.optLong("createdAtEpochMs", 0L), + ) + }.getOrNull() + } + + private fun encode(value: ByteArray): String { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value) + } + + private fun decode(value: String): ByteArray { + return Base64.getUrlDecoder().decode(value) + } + + private inner class CloudflareTokenCipher { + fun encrypt(plainText: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + val encrypted = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) + return encode(cipher.iv) + "." + encode(encrypted) + } + + fun decrypt(value: String): String { + val separator = value.indexOf('.') + require(separator > 0 && separator < value.lastIndex) + val iv = decode(value.substring(0, separator)) + val encrypted = decode(value.substring(separator + 1)) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv)) + return cipher.doFinal(encrypted).toString(Charsets.UTF_8) + } + + private fun getOrCreateKey(): SecretKey { + val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) } + val existing = keyStore.getKey(KEY_ALIAS, null) as? SecretKey + if (existing != null) { + return existing + } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE).run { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + generateKey() + } + } + } + + private companion object { + const val PREFS_NAME = "CloudflareOAuthSessions" + const val KEY_SESSIONS = "sessions_v1" + const val KEY_ACTIVE_SESSION_ID = "active_session_id_v1" + const val KEY_ACTIVE_ACCOUNT_ID = "active_account_id_v1" + const val ANDROID_KEY_STORE = "AndroidKeyStore" + const val KEY_ALIAS = "tgwsproxy_cloudflare_oauth_access_token_v1" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val GCM_TAG_BITS = 128 + } +} diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c94f8aec1..e29e36b26 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -945,4 +945,35 @@ Ошибка MTProto runtime Локальный listener доступен, но outbound-маршрут не настроен. Сохраните SOCKS5/WS как резервный вариант. Укажите публичный домен. + Аккаунты Cloudflare + Войдите в Cloudflare и разверните встроенный chunk-relay Worker без Wrangler. + Управлять аккаунтами и Workers + Cloudflare OAuth / Worker PoC + Входы Cloudflare + Каждый вход хранится отдельно. Выберите аккаунт, в который будет развёрнут Worker. + Добавить вход Cloudflare + Входов Cloudflare пока нет. + Удалить вход + В этой сборке не задан OAuth client ID. + OAuth callback отклонён: state не совпадает с ожидаемым значением входа. + Ошибка авторизации Cloudflare: %1$s + Cloudflare не вернул authorization code. + После авторизации не найдено доступных аккаунтов Cloudflare. + Вход Cloudflare добавлен. Доступно аккаунтов: %1$d. + Вход Cloudflare удалён. + OAuth-сессия истекла. Удалите её и войдите снова. + Сначала выберите аккаунт Cloudflare. + Имя Worker должно содержать только строчные латинские буквы, цифры и дефисы. + Развернуть тестовый Worker + Если Worker с таким именем уже существует, выполняется обновление. Успешный workers.dev endpoint автоматически добавляется в Worker Pool. + Имя Worker + Имя deployment: %1$s + Развернуть / обновить + Удалить Worker + Выполняется операция Cloudflare… + Ошибка операции Cloudflare: %1$s + Worker развёрнут: %1$s (smoke HTTP %2$d). Добавлен в Worker Pool. + Worker %1$s удалён. + PoC хранит только OAuth access tokens, зашифрованные Android Keystore. Refresh tokens и client secrets не сохраняются. + Cloudflare / %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2368b5bc6..26477f330 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -945,4 +945,35 @@ MTProto port must be between 1 and 65535. MTProto secret must be exactly 32 hex characters (16 bytes). Use a valid public domain. + Cloudflare accounts + Sign in with Cloudflare and deploy the built-in chunk-relay Worker without Wrangler. + Manage accounts and Workers + Cloudflare OAuth / Worker PoC + Cloudflare logins + Each login is stored separately. Select the account that will receive the Worker deployment. + Add Cloudflare login + No Cloudflare logins yet. + Remove login + OAuth client ID is not configured for this build. + OAuth callback was rejected because its state does not match the pending login. + Cloudflare authorization failed: %1$s + Cloudflare did not return an authorization code. + The authorization returned no accessible Cloudflare accounts. + Cloudflare login added. Accessible accounts: %1$d. + Cloudflare login removed. + This OAuth session has expired. Remove it and sign in again. + Select a Cloudflare account first. + Worker name must contain only lowercase letters, digits and hyphens. + Deploy test Worker + Deploy updates the same Worker when the name already exists. A successful workers.dev endpoint is added to Worker Pool automatically. + Worker name + Deployment name: %1$s + Deploy / update + Delete Worker + Cloudflare operation in progress… + Cloudflare operation failed: %1$s + Worker deployed: %1$s (smoke HTTP %2$d). Added to Worker Pool. + Worker %1$s deleted. + PoC stores only OAuth access tokens, encrypted with Android Keystore. Refresh tokens and client secrets are not stored. + Cloudflare / %1$s diff --git a/app/src/test/java/com/amurcanov/tgwsproxy/cloudflare/CloudflarePkceTest.kt b/app/src/test/java/com/amurcanov/tgwsproxy/cloudflare/CloudflarePkceTest.kt new file mode 100644 index 000000000..de1722af7 --- /dev/null +++ b/app/src/test/java/com/amurcanov/tgwsproxy/cloudflare/CloudflarePkceTest.kt @@ -0,0 +1,36 @@ +package com.amurcanov.tgwsproxy.cloudflare + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CloudflarePkceTest { + @Test + fun rfc7636ChallengeMatchesKnownVector() { + val verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + + assertEquals( + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CloudflarePkce.challenge(verifier), + ) + } + + @Test + fun generatedVerifierIsUrlSafeAndUnpadded() { + val pair = CloudflarePkce.create() + + assertTrue(pair.verifier.length in 43..128) + assertTrue(pair.verifier.matches(Regex("[A-Za-z0-9_-]+"))) + assertTrue(pair.challenge.matches(Regex("[A-Za-z0-9_-]+"))) + assertFalse(pair.verifier.contains('=')) + assertFalse(pair.challenge.contains('=')) + } + + @Test + fun workerNameNormalizationIsDeterministic() { + assertEquals("my-worker-1", CloudflareWorkerName.normalize(" My Worker #1 ")) + assertTrue(CloudflareWorkerName.isValid("my-worker-1")) + assertFalse(CloudflareWorkerName.isValid("My Worker #1")) + } +} diff --git a/docs/cloudflare-oauth-poc.md b/docs/cloudflare-oauth-poc.md new file mode 100644 index 000000000..81e3c7e38 --- /dev/null +++ b/docs/cloudflare-oauth-poc.md @@ -0,0 +1,74 @@ +# Cloudflare OAuth + Worker deployment PoC + +Актуальность проверки: 2026-09-19. Документ относится к Issue #76 и не заменяет production security review. + +## Feasibility + +Cloudflare поддерживает OAuth 2.0 Authorization Code для third-party clients. Для browser/mobile/desktop/CLI public client требуется PKCE S256 и token_endpoint_auth_method=none; client secret в Android APK не нужен. + +Новые OAuth clients создаются private. Для public visibility Cloudflare требует client name, logo, client URL, scopes и подтверждение владения доменом client URL через DNS. Перевод client в public необратим. + +OAuth endpoints: authorization https://dash.cloudflare.com/oauth2/auth, token https://dash.cloudflare.com/oauth2/token, revoke https://dash.cloudflare.com/oauth2/revoke. + +Cloudflare указывает, что OAuth scope names соответствуют API token permission names. Реальная проверка GET /client/v4/oauth/scopes 2026-09-19 показала рабочие IDs memberships.read, workers-scripts.read и workers-scripts.write; workers-platform.read/write в фактическом списке scopes отсутствуют. + +Для Worker deployment endpoint PUT /accounts/{account_id}/workers/scripts/{script_name} требует Workers Scripts Write. PoC отправляет multipart modules chunk-relay-status-worker.js, chunk-relay-worker.js и worker.js, binding CHUNK_RELAY -> ChunkRelaySession и declarative export ChunkRelaySession с storage=sqlite. + +Declarative exports используется вместо legacy migrations: текущий Cloudflare API автоматически provision-ит новый SQLite Durable Object namespace при первом deploy и сопоставляет его при последующих deploy того же class. + +После upload PoC включает workers.dev route, читает account subdomain через GET /accounts/{account_id}/workers/subdomain, строит https://..workers.dev и выполняет HTTP smoke-test. + +## Multi-account model + +Один OAuth consent может дать доступ к нескольким Cloudflare accounts. Повторная команда Add Cloudflare login создаёт независимую OAuth session, поэтому несколько Cloudflare identities могут сосуществовать. + +Storage model: OAuth session -> encrypted access token -> list of Cloudflare accounts. Активный account выбирается явно; deploy/update/delete используют token именно его session. + +PoC не запрашивает и не хранит refresh token. Access token сохраняется только в app-private SharedPreferences в AES/GCM ciphertext, а ключ создаётся в Android Keystore. После expiry требуется повторный вход. + +## OAuth client setup + +1. Grant type: authorization_code. +2. Response type: code. +3. Token authentication method: none. +4. PKCE: S256. +5. Redirect URI: tgwsproxy://oauth/cloudflare. +6. Проверенный набор scopes для PoC: memberships.read workers-scripts.read workers-scripts.write. +7. Для публичного приложения выполнить publisher domain verification и затем перевести client visibility в public. + +Client ID передаётся при сборке, secret отсутствует: + + .\gradlew.bat :app:assembleDebug ` + -PTGWSPROXY_CF_OAUTH_CLIENT_ID="" ` + -PTGWSPROXY_CF_OAUTH_SCOPES="memberships.read workers-scripts.read workers-scripts.write" + +Те же два значения можно передать environment variables TGWSPROXY_CF_OAUTH_CLIENT_ID и TGWSPROXY_CF_OAUTH_SCOPES. + +Если client ID не задан, экран доступен для inspection, но OAuth login отключён. + +## Worker source + +APK не содержит вручную поддерживаемую вторую копию Worker source. Gradle перед сборкой копирует worker.js, chunk-relay-worker.js и chunk-relay-status-worker.js из scripts/cloudflare-worker/ в generated assets. Android deploy поэтому использует текущие production source-файлы репозитория. + +## Deploy behavior + +- новый worker name: create/deploy; +- существующий worker name: PUT того же script и update; +- delete: DELETE /accounts/{account_id}/workers/scripts/{script_name}; +- успешный workers.dev endpoint автоматически добавляется в TgWsProxy Worker Pool, pool включается, endpoint выбирается активным; +- повторный deploy того же URL не создаёт дубликат Worker Pool entry. + +## Manual acceptance gate + +Без зарегистрированного Cloudflare OAuth client и реального Android device нельзя считать выполненными следующие пункты: + +- Cloudflare API registration подтверждает поддержку redirect URI tgwsproxy://oauth/cloudflare для Android PKCE client; +- consent с Workers scopes реально возвращает ожидаемые account resources; +- выбранные scopes достаточны и могут быть сужены; +- upload создаёт ChunkRelaySession / SQLite namespace и CHUNK_RELAY binding; +- workers.dev smoke проходит; +- второй Cloudflare login сосуществует с первым; +- redeploy сохраняет Durable Object mapping; +- delete удаляет test Worker. + +До этой проверки Issue #76 остаётся открытым.