From b5c8dda9a20e1ab8ff9bdf3d0c61786caf04bcfc Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sat, 1 Aug 2026 19:53:38 +0200 Subject: [PATCH] app: sign in to the server's identity provider Authorization code with PKCE, a Sign in card in settings, and the echolot://auth redirect handled next to the enrolment one - told apart by host, because one spends a token and the other completes an authorization, and confusing them would fail obscurely. The detail that decides whether this survives a real phone: the PKCE verifier is written to storage before the browser opens rather than held in memory. Handing control to a browser backgrounds the process and Android may kill it, so the callback arrives at a fresh one. An in-memory verifier works on a developer's device and fails under memory pressure. Pending state is cleared before the exchange is attempted, whatever the outcome: it is single-use, and leaving it behind would let a later callback complete a flow nobody started. Also records an open issue the question about server requirements surfaced: two probes hardcode the reference deployment, so a user with no server still sends DNS and STUN traffic to fmr without being told. For a tool this careful about what leaves the device, that is the wrong default. Co-Authored-By: Claude Fable 5 --- docs/build-status.md | 28 ++++ echolot-app/app/src/main/AndroidManifest.xml | 11 ++ .../main/kotlin/app/echo_lot/app/Account.kt | 123 ++++++++++++++++++ .../kotlin/app/echo_lot/app/MainActivity.kt | 25 +++- .../kotlin/app/echo_lot/app/RunViewModel.kt | 38 ++++++ .../main/kotlin/app/echo_lot/app/Settings.kt | 34 +++++ .../kotlin/app/echo_lot/app/SettingsScreen.kt | 35 +++++ 7 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 echolot-app/app/src/main/kotlin/app/echo_lot/app/Account.kt diff --git a/docs/build-status.md b/docs/build-status.md index 529241a..671c248 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -1054,3 +1054,31 @@ Four consequences that decide whether it is worth it: What keeps this possible: uploads are already stored byte-for-byte as received, and every index field is derived in one function (`runs.Put`). The thing to avoid is admin features that *require* reading content — those would have to be unbuilt later. + +### App sign-in, and an undisclosed dependency it surfaced (2026-08-01) +The app can now sign in to the server's identity provider: authorization code with PKCE, a +`Sign in` card in settings, and the `echolot://auth` redirect handled alongside the enrolment one +(told apart by host, since one spends a token and the other completes an authorization). + +The detail that decides whether this works on a real phone: **the PKCE verifier is written to +storage before the browser opens**, not held in memory. Handing control to a browser backgrounds +the process and Android may kill it; the callback then arrives at a fresh process. An in-memory +verifier works on a developer's device and fails under memory pressure, which is the worst way for +a sign-in to break. + +Nothing from the IdP is retained. The ID token proves who is signing in, once, and the device +credential authenticates everything after — no access tokens stored, no refresh tokens rotated. + +**A server remains entirely optional.** All eight probes are device-tier; `serverConfigured` gates +only upload and the account. But answering that question exposed something worth fixing: two probes +hardcode the reference deployment — + +```kotlin +DnsCanaryProbe(canaryZone = "c.echo-lot.app", ...) // "Hardcoded to the reference deployment" +StunProbe(serverHost = "fmr-1.echo-lot.app") +``` + +so a user with no server of their own still sends DNS and STUN traffic to fmr without being told. +For a tool that goes to this much trouble over what leaves the device, an undisclosed dependency on +a third party's infrastructure is the wrong default. It should prefer the configured server, and be +explicit when there is none. **Open.** diff --git a/echolot-app/app/src/main/AndroidManifest.xml b/echolot-app/app/src/main/AndroidManifest.xml index e29224f..130cfdb 100644 --- a/echolot-app/app/src/main/AndroidManifest.xml +++ b/echolot-app/app/src/main/AndroidManifest.xml @@ -39,6 +39,17 @@ + + + + + + + + // A plain VIEW intent rather than a Custom Tab: the browser is + // where the user's existing IdP session already lives, and + // androidx.browser would be a dependency for a rounded corner. + runCatching { + startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url))) + } + } + }, + onSignOut = vm::signOut, onEnroll = vm::enroll, serverStatus = vm.state.archiveStatus, onBack = { screen = Screen.RUN }, diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt index 8d54cab..b60040f 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt @@ -215,6 +215,44 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { * history screen has been opened - the two disagreeing read as data loss. */ fun archivedRunCount(): Int = store.list().size + private val account = Account(settings) + + /** Name of whoever is signed in on this device, for the settings screen. */ + var accountName by mutableStateOf(settings.accountName) + private set + + /** Starts sign-in; the caller opens the returned URL in a browser. */ + fun beginSignIn(open: (String) -> Unit) { + viewModelScope.launch { + state = state.copy(archiveStatus = "contacting the server …") + when (val r = withContext(Dispatchers.IO) { account.begin() }) { + is Account.SignInStart.Browser -> { + state = state.copy(archiveStatus = "continue in your browser …") + open(r.url) + } + is Account.SignInStart.Unavailable -> + state = state.copy(archiveStatus = r.reason) + } + } + } + + /** Completes sign-in from the echolot://auth redirect. */ + fun completeSignIn(callbackUri: String) { + viewModelScope.launch { + val msg = withContext(Dispatchers.IO) { account.complete(callbackUri) } + accountName = settings.accountName + state = state.copy(archiveStatus = msg) + } + } + + fun signOut() { + viewModelScope.launch { + val msg = withContext(Dispatchers.IO) { account.signOut() } + accountName = settings.accountName + state = state.copy(archiveStatus = msg) + } + } + /** Redeems an enrollment link, from a paste or from an echolot:// deep link. */ fun enroll(link: String, deviceName: String? = android.os.Build.MODEL) { viewModelScope.launch { diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt index 56f9473..1703ef6 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt @@ -104,6 +104,36 @@ class Settings(context: Context) { val serverConfigured: Boolean get() = serverUrl.isNotBlank() && serverPin.isNotBlank() && serverCredential.isNotBlank() + // ---- account --------------------------------------------------------------------- + + /** + * The PKCE verifier and state for a sign-in that is out at the browser. + * + * Persisted rather than held in memory because handing control to a browser backgrounds this + * process, and Android may kill it before the callback returns. An in-memory value works on a + * developer's device and fails on a phone under memory pressure. + */ + var pendingVerifier: String + get() = prefs.getString(PENDING_VERIFIER, "") ?: "" + set(v) = prefs.edit().putString(PENDING_VERIFIER, v).apply() + + var pendingState: String + get() = prefs.getString(PENDING_STATE, "") ?: "" + set(v) = prefs.edit().putString(PENDING_STATE, v).apply() + + fun clearPendingAuth() = prefs.edit().remove(PENDING_VERIFIER).remove(PENDING_STATE).apply() + + /** Display name of whoever is signed in on this device; empty when nobody is. */ + var accountName: String + get() = prefs.getString(ACCOUNT_NAME, "") ?: "" + set(v) = prefs.edit().putString(ACCOUNT_NAME, v).apply() + + var accountId: String + get() = prefs.getString(ACCOUNT_ID, "") ?: "" + set(v) = prefs.edit().putString(ACCOUNT_ID, v).apply() + + val signedIn: Boolean get() = accountName.isNotBlank() + private fun hex(s: String) = ByteArray(s.length / 2) { ((Character.digit(s[it * 2], 16) shl 4) or Character.digit(s[it * 2 + 1], 16)).toByte() } @@ -120,5 +150,9 @@ class Settings(context: Context) { const val SERVER_URL = "server_url" const val SERVER_PIN = "server_pin" const val SERVER_CRED = "server_credential" + const val PENDING_VERIFIER = "pending_auth_verifier" + const val PENDING_STATE = "pending_auth_state" + const val ACCOUNT_NAME = "account_name" + const val ACCOUNT_ID = "account_id" } } diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt index f82bb00..6ff9a45 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt @@ -49,6 +49,9 @@ fun SettingsScreen( onDeleteAll: () -> Unit, onPreviewUpload: () -> Unit, onCheckServer: () -> Unit, + accountName: String, + onSignIn: () -> Unit, + onSignOut: () -> Unit, onEnroll: (String) -> Unit, serverStatus: String?, onBack: () -> Unit, @@ -152,6 +155,38 @@ fun SettingsScreen( } } + // ---- account ---- + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Account", style = MaterialTheme.typography.titleMedium) + if (accountName.isNotBlank()) { + Text("Signed in as $accountName", style = MaterialTheme.typography.bodyMedium) + Text( + "Runs from every device signed in to this account share one history.", + style = MaterialTheme.typography.bodySmall, + ) + TextButton(onClick = onSignOut) { Text("Sign out") } + } else { + Text( + "Signing in is optional. It links this device to an account on your " + + "server, so several devices share one history — and some servers only " + + "accept uploads from a signed-in device.", + style = MaterialTheme.typography.bodySmall, + ) + Button(onClick = onSignIn, enabled = settings.serverConfigured) { + Text("Sign in") + } + if (!settings.serverConfigured) { + Text( + "Enrol with a server first — the account belongs to the server, not " + + "to the app.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + // ---- upload ---- Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {