Skip to content
Open
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
6 changes: 6 additions & 0 deletions ft8cn/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@ dependencies {
// Image loading for QRZ profile avatars
implementation 'io.coil-kt:coil-compose:2.6.0'

// AWS Cognito user-pool SRP login — used only to mint a pota.app ID token for
// in-app POTA log upload (POST api.pota.app/adif). POTA's user pool/client IDs
// are public (shipped in pota.app's JS). cognitoidentityprovider pulls in
// aws-android-sdk-core transitively; no other AWS modules are needed.
implementation 'com.amazonaws:aws-android-sdk-cognitoidentityprovider:2.76.0'

// --- JVM unit + Robolectric tests ---
testImplementation 'junit:junit:4.13.2'
// 4.14.1 ships android-all for SDK 35; older versions fail with
Expand Down
215 changes: 215 additions & 0 deletions ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaAuth.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package radio.ks3ckc.ft8us.pota

import android.content.Context
import android.util.Log
import com.amazonaws.mobileconnectors.cognitoidentityprovider.CognitoUserPool
import com.amazonaws.mobileconnectors.cognitoidentityprovider.CognitoUserSession
import com.amazonaws.mobileconnectors.cognitoidentityprovider.continuations.AuthenticationContinuation
import com.amazonaws.mobileconnectors.cognitoidentityprovider.continuations.AuthenticationDetails
import com.amazonaws.mobileconnectors.cognitoidentityprovider.continuations.ChallengeContinuation
import com.amazonaws.mobileconnectors.cognitoidentityprovider.continuations.MultiFactorAuthenticationContinuation
import com.amazonaws.mobileconnectors.cognitoidentityprovider.handlers.AuthenticationHandler
import com.amazonaws.regions.Regions
import com.bg7yoz.ft8cn.GeneralVariables
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.FileWriter
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.coroutines.resume

/**
* Authenticates against pota.app's AWS Cognito user pool so the app can upload
* 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.
*
* 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**.
* - [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.
*
* Storage note (spike): the refresh token lives in a private SharedPreferences
* file, matching how the rest of the app persists the QRZ password (plaintext in
* app-private storage). Swapping this for EncryptedSharedPreferences is a drop-in
* follow-up and should cover the QRZ password too.
*/
object PotaAuth {
private const val TAG = "PotaAuth"

// Public POTA Cognito identifiers (from pota.app's JS / the pota-adif-upload crate).
private const val POOL_ID = "us-east-2_nA5jZ0klh"
private const val CLIENT_ID = "7hluqct0n2nckib7i7sd5753oa"
private val REGION = Regions.US_EAST_2
private const val COGNITO_IDP = "https://cognito-idp.us-east-2.amazonaws.com/"

private const val PREFS = "pota_auth"
private const val KEY_REFRESH = "refresh_token"
private const val KEY_EMAIL = "email"

// Refresh ~5 min before the ID token's nominal 60-min lifetime expires.
private const val ID_TOKEN_TTL_MS = 55 * 60 * 1000L

Comment on lines +57 to +63
@Volatile private var cachedIdToken: String? = null
@Volatile private var cachedIdTokenExpiryMs: Long = 0L

private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)

/** Whether a refresh token is stored (i.e. the user has logged in at least once). */
fun isLoggedIn(): Boolean {
val ctx = GeneralVariables.getMainContext() ?: return false
return !prefs(ctx).getString(KEY_REFRESH, null).isNullOrBlank()
}

/** Email the user last logged in with, for display. */
fun loggedInEmail(): String? =
GeneralVariables.getMainContext()?.let { prefs(it).getString(KEY_EMAIL, null) }

fun logout() {
cachedIdToken = null
cachedIdTokenExpiryMs = 0L
GeneralVariables.getMainContext()?.let { prefs(it).edit().clear().apply() }
log("logout — cleared stored token")
}

/**
* Log in with a pota.app account. On success the refresh token is persisted and
* a usable ID token is cached. Returns the ID token on success.
*/
suspend fun login(email: String, password: String): Result<String> = withContext(Dispatchers.IO) {
val ctx = GeneralVariables.getMainContext()
?: return@withContext Result.failure(IllegalStateException("no app context"))
try {
val session = srpLogin(ctx, email.trim(), password)
val refresh = session.refreshToken?.token
if (refresh.isNullOrBlank()) {
return@withContext Result.failure(IllegalStateException("no refresh token in session"))
}
prefs(ctx).edit()
.putString(KEY_REFRESH, refresh)
.putString(KEY_EMAIL, email.trim())
.apply()
val id = session.idToken?.jwtToken
?: return@withContext Result.failure(IllegalStateException("no id token in session"))
cachedIdToken = id
cachedIdTokenExpiryMs = System.currentTimeMillis() + ID_TOKEN_TTL_MS
log("login ok email=${email.trim()} (refresh stored)")
Result.success(id)
} catch (e: Exception) {
log("login FAILED: ${e.javaClass.simpleName}: ${e.message ?: "?"}")
Result.failure(e)
}
}

/**
* A valid ID token, refreshed from the stored refresh token when needed.
* Returns null if the user has never logged in or the refresh failed (e.g.
* the refresh token was revoked) — callers should then prompt for login.
*/
suspend fun idToken(): String? = withContext(Dispatchers.IO) {
cachedIdToken?.let {
if (System.currentTimeMillis() < cachedIdTokenExpiryMs) return@withContext it
}
val ctx = GeneralVariables.getMainContext() ?: return@withContext null
val refresh = prefs(ctx).getString(KEY_REFRESH, null)
if (refresh.isNullOrBlank()) return@withContext null
try {
val id = refreshIdToken(refresh)
cachedIdToken = id
cachedIdTokenExpiryMs = System.currentTimeMillis() + ID_TOKEN_TTL_MS
log("idToken refreshed")
id
} catch (e: Exception) {
log("idToken refresh FAILED: ${e.javaClass.simpleName}: ${e.message ?: "?"}")
null
}
}

// --- Cognito SRP login via the AWS SDK (callback API wrapped as a coroutine) ---

private suspend fun srpLogin(ctx: Context, email: String, password: String): CognitoUserSession =
suspendCancellableCoroutine { cont ->
val pool = CognitoUserPool(ctx, POOL_ID, CLIENT_ID, null, REGION)
val handler = object : AuthenticationHandler {
override fun onSuccess(userSession: CognitoUserSession, newDevice: com.amazonaws.mobileconnectors.cognitoidentityprovider.CognitoDevice?) {
if (cont.isActive) cont.resume(userSession)
}

override fun getAuthenticationDetails(continuation: AuthenticationContinuation, userId: String?) {
continuation.setAuthenticationDetails(AuthenticationDetails(email, password, null))
continuation.continueTask()
}

override fun getMFACode(continuation: MultiFactorAuthenticationContinuation) {
// POTA accounts don't use MFA; surface it as an error rather than hang.
if (cont.isActive) cont.resumeWith(Result.failure(UnsupportedOperationException("MFA not supported")))
}

override fun authenticationChallenge(continuation: ChallengeContinuation) {
if (cont.isActive) cont.resumeWith(Result.failure(UnsupportedOperationException("auth challenge not supported")))
}

override fun onFailure(exception: Exception) {
if (cont.isActive) cont.resumeWith(Result.failure(exception))
}
}
// getSession runs the SRP handshake on the calling thread (already Dispatchers.IO).
pool.getUser(email).getSession(handler)
}

// --- REFRESH_TOKEN_AUTH: trade the refresh token for a fresh ID token (no SRP) ---

private fun refreshIdToken(refreshToken: String): String {
val body = JSONObject().apply {
put("AuthFlow", "REFRESH_TOKEN_AUTH")
put("ClientId", CLIENT_ID)
put("AuthParameters", JSONObject().put("REFRESH_TOKEN", refreshToken))
}.toString()

var conn: HttpURLConnection? = null
try {
conn = (URL(COGNITO_IDP).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 10_000
readTimeout = 10_000
doOutput = true
setRequestProperty("Content-Type", "application/x-amz-json-1.1")
setRequestProperty("X-Amz-Target", "AWSCognitoIdentityProviderService.InitiateAuth")
}
conn.outputStream.use { it.write(body.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("InitiateAuth http $code: ${err.take(200)}")
}
val resp = conn.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }
return JSONObject(resp)
.getJSONObject("AuthenticationResult")
.getString("IdToken")
} finally {
conn?.disconnect()
}
}

private fun log(msg: String) {
Log.d(TAG, msg)
try {
val ctx = GeneralVariables.getMainContext() ?: return
val dir = ctx.getExternalFilesDir(null) ?: return
val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date())
FileWriter(File(dir, "debug.log"), true).use { it.append("$ts PotaAuth: $msg\n") }
} catch (_: Exception) {
}
}
}
79 changes: 79 additions & 0 deletions ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,85 @@ object PotaClient {
}
}

/**
* Upload one ADIF document to the authenticated endpoint. [idToken] is a
* Cognito ID token from [PotaAuth.idToken]; it goes in the Authorization
* header verbatim (POTA's API Gateway expects the raw JWT, not "Bearer …").
* The body is multipart/form-data with a single `adif` part, matching the
* pota.app website uploader. Returns the (possibly empty) response body on
* success, or a failure carrying the HTTP status / error text.
*/
suspend fun uploadAdif(idToken: String, filename: String, adif: String): Result<String> =
withContext(Dispatchers.IO) {
val boundary = "----ft8af${System.nanoTime()}"
val preamble = buildString {
append("--").append(boundary).append("\r\n")
append("Content-Disposition: form-data; name=\"adif\"; filename=\"").append(filename).append("\"\r\n")
append("Content-Type: application/octet-stream\r\n\r\n")
}.toByteArray(StandardCharsets.UTF_8)
val epilogue = "\r\n--$boundary--\r\n".toByteArray(StandardCharsets.UTF_8)
val payload = adif.toByteArray(StandardCharsets.UTF_8)

var conn: HttpURLConnection? = null
try {
conn = (URL("$BASE_URL/adif").openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = IO_TIMEOUT_MS
readTimeout = 30_000
doOutput = true
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Authorization", idToken)
setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
setRequestProperty("Accept", "application/json")
}
conn.outputStream.use { out ->
out.write(preamble)
out.write(payload)
out.write(epilogue)
}
val code = conn.responseCode
if (code !in 200..299) {
val err = conn.errorStream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() } ?: ""
log("uploadAdif $filename -> http $code ${err.take(200)}")
return@withContext Result.failure(IllegalStateException("HTTP $code${if (err.isNotBlank()) ": ${err.take(160)}" else ""}"))
}
val resp = conn.inputStream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() } ?: ""
log("uploadAdif ok $filename (${payload.size}B) -> ${resp.take(120)}")
Result.success(resp)
} catch (e: Exception) {
log("uploadAdif $filename failed: ${e.javaClass.simpleName}: ${e.message ?: "?"}")
Result.failure(e)
} finally {
conn?.disconnect()
}
}

/** Fetch the user's recent upload/processing jobs (authenticated). Raw JSON. */
suspend fun getJobs(idToken: String): String? = withContext(Dispatchers.IO) {
var conn: HttpURLConnection? = null
try {
conn = (URL("$BASE_URL/user/jobs").openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = IO_TIMEOUT_MS
readTimeout = IO_TIMEOUT_MS
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Authorization", idToken)
setRequestProperty("Accept", "application/json")
}
val code = conn.responseCode
if (code != HttpURLConnection.HTTP_OK) {
log("getJobs -> http $code")
return@withContext null
}
conn.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }
} catch (e: Exception) {
log("getJobs failed: ${e.javaClass.simpleName}: ${e.message ?: "?"}")
null
} finally {
conn?.disconnect()
}
}

private fun httpGet(url: String): String? {
var conn: HttpURLConnection? = null
return try {
Expand Down
Loading
Loading