diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaAuth.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaAuth.kt index 61c46948..ee92cbbc 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaAuth.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaAuth.kt @@ -3,6 +3,7 @@ package radio.ks3ckc.ft8us.pota import android.content.Context import android.content.SharedPreferences import android.os.Build +import android.util.Base64 import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey @@ -23,7 +24,10 @@ import java.io.File import java.io.FileWriter import java.net.HttpURLConnection import java.net.URL +import java.net.URLEncoder import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -34,12 +38,19 @@ import kotlin.coroutines.resume * activation logs directly (see [PotaClient.uploadAdif]). * * POTA's pool/client IDs are public — they ship in pota.app's own JS bundle and - * are reproduced in the open-source `pota-adif-upload` Rust client. The user logs - * in with their normal pota.app account email + password. + * are reproduced in the open-source `pota-adif-upload` Rust client. * - * Flow: - * - [login] runs Cognito's USER_SRP_AUTH (password never leaves the SRP proof) - * via the AWS SDK and stashes the long-lived **refresh token**. + * Two ways in, both ending in a stored refresh token the rest of the class treats + * identically: + * - [login] (email + password) runs Cognito's USER_SRP_AUTH (password never + * leaves the SRP proof) via the AWS SDK. Only works for accounts that have a + * Cognito password. + * - [authorizeUrl] + [exchangeCode] drive the hosted-UI OAuth2 code+PKCE flow + * (see [radio.ks3ckc.ft8us.ui.pota.PotaOAuthDialog]). This is the only path + * that works for **federated** accounts (Google / Facebook / Login-with-Amazon), + * which have no Cognito password for SRP to verify. + * + * Either way: * - [idToken] returns a short-lived JWT ID token, refreshing it from the stored * refresh token via REFRESH_TOKEN_AUTH (a plain JSON POST — no SRP, no SDK) * when the cached one is missing or near expiry. @@ -58,6 +69,19 @@ object PotaAuth { private val REGION = Regions.US_EAST_2 private const val COGNITO_IDP = "https://cognito-idp.us-east-2.amazonaws.com/" + // Hosted-UI (managed login) origin for the OAuth2 code flow used by federated + // sign-in. From the pool's /.well-known/openid-configuration + pota.app's JS. + private const val HOSTED_UI = "https://parksontheair.auth.us-east-2.amazoncognito.com" + private const val OAUTH_SCOPE = "openid email phone profile" + + /** + * The single redirect URI POTA registered on its Cognito app client. We can't + * register our own (no custom scheme / localhost is accepted — every other + * value returns redirect_mismatch), so the WebView flow watches for navigation + * to this URL and lifts the `?code=` out before pota.app actually loads. + */ + const val OAUTH_REDIRECT = "https://pota.app/" + private const val PREFS = "pota_auth" private const val KEY_REFRESH = "refresh_token" private const val KEY_EMAIL = "email" @@ -245,6 +269,112 @@ object PotaAuth { } } + // --- Hosted-UI OAuth2 (authorization code + PKCE) for federated sign-in --- + + /** A PKCE verifier/challenge pair for one hosted-UI login attempt. */ + data class Pkce(val verifier: String, val challenge: String) + + /** Mint a fresh PKCE pair (S256). Hold onto it for the matching [exchangeCode]. */ + fun newPkce(): Pkce { + val raw = ByteArray(32).also { SecureRandom().nextBytes(it) } + val verifier = b64url(raw) + val challenge = b64url( + MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(StandardCharsets.US_ASCII)) + ) + return Pkce(verifier, challenge) + } + + /** The hosted-UI authorize URL to load in the login WebView for [pkce]. */ + fun authorizeUrl(pkce: Pkce): String { + val q = buildString { + append("client_id=").append(CLIENT_ID) + append("&response_type=code") + append("&scope=").append(URLEncoder.encode(OAUTH_SCOPE, "UTF-8")) + append("&redirect_uri=").append(URLEncoder.encode(OAUTH_REDIRECT, "UTF-8")) + append("&code_challenge=").append(pkce.challenge) + append("&code_challenge_method=S256") + } + return "$HOSTED_UI/oauth2/authorize?$q" + } + + /** + * Exchange a hosted-UI authorization [code] for tokens, store the refresh token + * (so later uploads mint ID tokens silently via [idToken]), and return the ID + * token. [verifier] must be the one from the [Pkce] used to build [authorizeUrl]. + */ + suspend fun exchangeCode(code: String, verifier: String): Result = withContext(Dispatchers.IO) { + val ctx = GeneralVariables.getMainContext() + ?: return@withContext Result.failure(IllegalStateException("no app context")) + try { + val form = buildString { + append("grant_type=authorization_code") + append("&client_id=").append(CLIENT_ID) + append("&code=").append(URLEncoder.encode(code, "UTF-8")) + append("&redirect_uri=").append(URLEncoder.encode(OAUTH_REDIRECT, "UTF-8")) + append("&code_verifier=").append(verifier) + } + val obj = JSONObject(postForm("$HOSTED_UI/oauth2/token", form)) + val refresh = obj.optString("refresh_token", "") + val id = obj.optString("id_token", "") + if (refresh.isBlank() || id.isBlank()) { + return@withContext Result.failure(IllegalStateException("token response missing tokens")) + } + val email = emailFromIdToken(id).orEmpty() + prefs(ctx).edit() + .putString(KEY_REFRESH, refresh) + .putString(KEY_EMAIL, email) + .apply() + cachedIdToken = id + cachedIdTokenExpiryMs = System.currentTimeMillis() + ID_TOKEN_TTL_MS + log("oauth login ok email=$email (refresh stored)") + Result.success(id) + } catch (e: Exception) { + log("oauth exchange FAILED: ${e.javaClass.simpleName}: ${e.message ?: "?"}") + Result.failure(e) + } + } + + private fun postForm(urlStr: String, form: String): String { + var conn: HttpURLConnection? = null + try { + conn = (URL(urlStr).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 10_000 + readTimeout = 10_000 + doOutput = true + setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + setRequestProperty("Accept", "application/json") + } + conn.outputStream.use { it.write(form.toByteArray(StandardCharsets.UTF_8)) } + val code = conn.responseCode + if (code !in 200..299) { + val err = conn.errorStream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() } ?: "" + throw IllegalStateException("token http $code: ${err.take(200)}") + } + return conn.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } + } finally { + conn?.disconnect() + } + } + + /** Pull the `email` claim out of a JWT ID token (for display only). */ + private fun emailFromIdToken(idToken: String): String? = try { + val parts = idToken.split(".") + if (parts.size < 2) null + else { + val payload = String( + Base64.decode(parts[1], Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP), + StandardCharsets.UTF_8, + ) + JSONObject(payload).optString("email").ifBlank { null } + } + } catch (_: Exception) { + null + } + + private fun b64url(bytes: ByteArray): String = + Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + private fun log(msg: String) { Log.d(TAG, msg) try { diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaOAuthLogin.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaOAuthLogin.kt new file mode 100644 index 00000000..fef7a9b1 --- /dev/null +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaOAuthLogin.kt @@ -0,0 +1,173 @@ +package radio.ks3ckc.ft8us.ui.pota + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.bg7yoz.ft8cn.R +import kotlinx.coroutines.launch +import radio.ks3ckc.ft8us.pota.PotaAuth +import radio.ks3ckc.ft8us.theme.Accent +import radio.ks3ckc.ft8us.theme.BgApp +import radio.ks3ckc.ft8us.theme.TextMuted +import radio.ks3ckc.ft8us.theme.TextPrimary + +/** + * Full-screen WebView that drives POTA's Cognito hosted-UI OAuth2 flow, so users + * who sign in with Google / Facebook / Login-with-Amazon (federated identities + * with no Cognito password) can authenticate. The SRP email+password path in + * [radio.ks3ckc.ft8us.ui.pota.PotaLoginDialog] can't serve those accounts. + * + * Why a WebView and not Chrome Custom Tabs (Google's preferred container): POTA's + * Cognito app client registers exactly one redirect URI — `https://pota.app/` — + * and rejects anything we could claim from the app (custom scheme, localhost, …). + * A Custom Tab would hand that redirect to the system browser and we'd never see + * the code. A WebView lets us watch navigation and lift the `?code=` out the + * instant Cognito redirects, before pota.app's page loads. + * + * The default Android WebView UA contains "; wv", which Google's consent screen + * rejects (`disallowed_useragent`). We override it with a plain Chrome UA so + * Google sign-in works; Facebook / Amazon / email work either way. + * + * [onClose] fires exactly once: `true` if a refresh token was obtained and stored + * (caller can proceed to upload), `false` on cancel / error. + */ +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun PotaOAuthDialog(onClose: (success: Boolean) -> Unit) { + val scope = rememberCoroutineScope() + val pkce = remember { PotaAuth.newPkce() } + val currentOnClose by rememberUpdatedState(onClose) + var exchanging by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = { if (!exchanging) currentOnClose(false) }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(BgApp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + ) { + Text( + stringResource(R.string.pota_oauth_title), + color = TextPrimary, + fontSize = 14.sp, + modifier = Modifier.align(Alignment.CenterStart), + ) + TextButton( + onClick = { if (!exchanging) currentOnClose(false) }, + modifier = Modifier.align(Alignment.CenterEnd), + ) { + Text(stringResource(R.string.pota_login_cancel), color = TextMuted) + } + } + Box(Modifier.fillMaxSize()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + // Google's consent screen rejects the "; wv" token the default + // Android WebView UA carries (disallowed_useragent). Strip just + // that token so the UA stays current with the device's real + // Chrome/WebView version instead of pinning a version that ages out. + settings.userAgentString = stripWebViewToken(settings.userAgentString) + + var captured = false + + fun handleRedirect(url: String): Boolean { + if (captured || !url.startsWith(PotaAuth.OAUTH_REDIRECT)) return false + captured = true + val uri = Uri.parse(url) + val code = uri.getQueryParameter("code") + if (code.isNullOrBlank()) { + // error=… or user bailed at the provider — treat as cancel. + currentOnClose(false) + return true + } + exchanging = true + scope.launch { + val r = PotaAuth.exchangeCode(code, pkce.verifier) + exchanging = false + currentOnClose(r.isSuccess) + } + return true + } + + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean = handleRedirect(request.url.toString()) + + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading( + view: WebView, + url: String, + ): Boolean = handleRedirect(url) + } + // removeAllCookies is async; load the authorize URL from its + // callback (fires on the main thread) so navigation only begins + // once cookies are cleared — otherwise the page could reuse a + // stale session, contradicting the clean-start intent. + val authUrl = PotaAuth.authorizeUrl(pkce) + CookieManager.getInstance().removeAllCookies { loadUrl(authUrl) } + } + }, + ) + if (exchanging) { + Box( + modifier = Modifier + .fillMaxSize() + .background(BgApp.copy(alpha = 0.7f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = Accent) + } + } + } + } + } +} + +/** + * Remove the "; wv" token an Android WebView appends to its user-agent. Google's + * OAuth consent screen rejects any UA carrying that token with + * `disallowed_useragent`; stripping it yields a plain Chrome UA that loads, while + * keeping the device's real (and self-updating) Chrome/WebView version. + */ +internal fun stripWebViewToken(ua: String): String = ua.replace("; wv", "") diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaScreen.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaScreen.kt index 9588c479..79e36850 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaScreen.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaScreen.kt @@ -573,6 +573,9 @@ private fun HistoryTab(mainViewModel: MainViewModel) { var uploadingId by remember { mutableStateOf(null) } var loginFor by remember { mutableStateOf(null) } var loginSubmitting by remember { mutableStateOf(false) } + // Set when the user picks federated sign-in (Google/Facebook/Amazon); shows the + // hosted-UI WebView. The pending activation is uploaded once login succeeds. + var oauthFor by remember { mutableStateOf(null) } // Reload whenever activation state changes (start/end will appear here). LaunchedEffect(refreshKey, activation?.id, activation?.endedAtMs) { @@ -660,8 +663,23 @@ private fun HistoryTab(mainViewModel: MainViewModel) { } } }, + onUseSocial = { + // Federated sign-in (Google/Facebook/Amazon) can't go through SRP — + // hand off to the hosted-UI WebView, keeping the same pending upload. + if (!loginSubmitting) { + loginFor = null + oauthFor = pending + } + }, ) } + + oauthFor?.let { pending -> + PotaOAuthDialog(onClose = { success -> + oauthFor = null + if (success) startUpload(pending) + }) + } } /** No usable ID token (never logged in, or the stored refresh token was revoked). */ @@ -702,6 +720,7 @@ private fun PotaLoginDialog( submitting: Boolean, onDismiss: () -> Unit, onSubmit: (email: String, password: String) -> Unit, + onUseSocial: () -> Unit, ) { var email by rememberSaveable { mutableStateOf(PotaAuth.loggedInEmail().orEmpty()) } // Plain remember (not rememberSaveable) — the password must never be written to @@ -742,6 +761,32 @@ private fun PotaLoginDialog( colors = textFieldColors(), modifier = Modifier.fillMaxWidth(), ) + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f).height(1.dp).background(Border)) + Text( + stringResource(R.string.pota_login_or), + color = TextMuted, + fontSize = 11.sp, + modifier = Modifier.padding(horizontal = 8.dp), + ) + Box(Modifier.weight(1f).height(1.dp).background(Border)) + } + Spacer(Modifier.height(12.dp)) + // Federated accounts (Google/Facebook/Amazon) have no Cognito password, + // so the email/password fields above can't authenticate them — this + // opens the hosted-UI WebView flow instead. + OutlinedButton( + onClick = onUseSocial, + enabled = !submitting, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource(R.string.pota_login_social), + color = TextPrimary, + fontSize = 13.sp, + ) + } } }, confirmButton = { diff --git a/ft8cn/app/src/main/res/values/strings_compose.xml b/ft8cn/app/src/main/res/values/strings_compose.xml index 300973c1..21e13f9d 100644 --- a/ft8cn/app/src/main/res/values/strings_compose.xml +++ b/ft8cn/app/src/main/res/values/strings_compose.xml @@ -267,6 +267,9 @@ Sign in Cancel Sign-in failed: %1$s + or + Sign in with Google / Facebook / Amazon + Sign in to POTA Add park Remove Maximum of 10 parks reached diff --git a/ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/pota/StripWebViewTokenTest.kt b/ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/pota/StripWebViewTokenTest.kt new file mode 100644 index 00000000..b0636fc4 --- /dev/null +++ b/ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/pota/StripWebViewTokenTest.kt @@ -0,0 +1,35 @@ +package radio.ks3ckc.ft8us.ui.pota + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Coverage for [stripWebViewToken], which sanitizes the WebView user-agent for + * POTA's hosted-UI OAuth flow: Google's consent screen rejects any UA carrying + * the "; wv" token (disallowed_useragent). + */ +class StripWebViewTokenTest { + + @Test + fun `removes the wv token from a default WebView user agent`() { + val webViewUa = + "Mozilla/5.0 (Linux; Android 14; Pixel 8 Build/AP1A.240505.004; wv) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " + + "Chrome/125.0.0.0 Mobile Safari/537.36" + + val cleaned = stripWebViewToken(webViewUa) + + assertThat(cleaned).doesNotContain("; wv") + assertThat(cleaned).contains("Chrome/125.0.0.0") + assertThat(cleaned).startsWith("Mozilla/5.0 (Linux; Android 14; Pixel 8 Build/AP1A.240505.004)") + } + + @Test + fun `leaves a plain Chrome user agent unchanged`() { + val chromeUa = + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36" + + assertThat(stripWebViewToken(chromeUa)).isEqualTo(chromeUa) + } +}