Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 135 additions & 5 deletions ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaAuth.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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"
Expand Down Expand Up @@ -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<String> = 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 {
Expand Down
173 changes: 173 additions & 0 deletions ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaOAuthLogin.kt
Original file line number Diff line number Diff line change
@@ -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", "")
Loading
Loading