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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4e6f2da3fb
commit
b5c8dda9a2
@@ -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
|
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*
|
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.
|
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.**
|
||||||
|
|||||||
@@ -39,6 +39,17 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="echolot" android:host="enroll" />
|
<data android:scheme="echolot" android:host="enroll" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!--
|
||||||
|
Sign-in redirect. The browser hands the authorization code back through this, which
|
||||||
|
is exactly why the flow uses PKCE: any app may register this scheme, so the code
|
||||||
|
alone must not be enough to complete a sign-in.
|
||||||
|
-->
|
||||||
|
<intent-filter android:autoVerify="false">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="echolot" android:host="auth" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import app.echo_lot.protocol.AuthInfo
|
||||||
|
import app.echo_lot.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.OidcLogin
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signing in to the configured server's identity provider.
|
||||||
|
*
|
||||||
|
* The awkward part of a browser-based sign-in on Android is that the app is not running while it
|
||||||
|
* happens. Handing control to a browser puts this process in the background, where it may be
|
||||||
|
* killed at any moment; the callback then arrives at a fresh process with none of the state the
|
||||||
|
* exchange needs. So the PKCE verifier and state are written to storage before the browser opens,
|
||||||
|
* not held in memory — an in-memory value works on a developer's device and fails on a phone under
|
||||||
|
* memory pressure, which is the worst way for this to break.
|
||||||
|
*
|
||||||
|
* Nothing from the identity provider is kept afterwards. The ID token proves who is signing in,
|
||||||
|
* once; the device credential authenticates everything from then on.
|
||||||
|
*/
|
||||||
|
class Account(private val settings: Settings) {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
sealed interface SignInStart {
|
||||||
|
/** Open this in a browser. */
|
||||||
|
data class Browser(val url: String) : SignInStart
|
||||||
|
data class Unavailable(val reason: String) : SignInStart
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches the server's auth configuration and builds the authorization URL. */
|
||||||
|
fun begin(): SignInStart {
|
||||||
|
if (!settings.serverConfigured) {
|
||||||
|
return SignInStart.Unavailable(
|
||||||
|
"Enrol with a server first — sign-in belongs to the server's identity provider."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val auth = runCatching { client().profile(settings.serverCredential).auth }.getOrNull()
|
||||||
|
?: return SignInStart.Unavailable("Could not reach the server to ask how to sign in.")
|
||||||
|
|
||||||
|
auth.discoveryError?.let {
|
||||||
|
// The distinction matters: "the operator configured an IdP that is not answering" is
|
||||||
|
// their problem to fix, and is not the same as "this server has no accounts".
|
||||||
|
return SignInStart.Unavailable("The server's identity provider is not responding: $it")
|
||||||
|
}
|
||||||
|
if (!auth.enabled) {
|
||||||
|
return SignInStart.Unavailable("This server does not offer accounts.")
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val pending = OidcLogin.begin(auth)
|
||||||
|
// Written before the browser opens, because after that this process may not survive.
|
||||||
|
settings.pendingVerifier = pending.verifier
|
||||||
|
settings.pendingState = pending.state
|
||||||
|
SignInStart.Browser(pending.authorizationUrl)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
SignInStart.Unavailable(t.message ?: "Could not start sign-in.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes sign-in from the `echolot://auth` redirect.
|
||||||
|
*
|
||||||
|
* Blocking; callers run it off the main thread.
|
||||||
|
*/
|
||||||
|
fun complete(callbackUri: String): String {
|
||||||
|
val verifier = settings.pendingVerifier
|
||||||
|
val state = settings.pendingState
|
||||||
|
// Cleared first, whatever happens next: these are single-use, and leaving them behind
|
||||||
|
// would let a later callback be completed against a flow nobody started.
|
||||||
|
settings.clearPendingAuth()
|
||||||
|
|
||||||
|
if (verifier.isBlank() || state.isBlank()) {
|
||||||
|
return "That sign-in did not start on this device."
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val auth = client().profile(settings.serverCredential).auth
|
||||||
|
val idToken = OidcLogin.complete(
|
||||||
|
auth, OidcLogin.Pending("", verifier, state), callbackUri,
|
||||||
|
)
|
||||||
|
val reply = client().linkAccount(settings.serverCredential, idToken)
|
||||||
|
val o = json.parseToJsonElement(reply).jsonObject
|
||||||
|
val name = o["display_name"]?.jsonPrimitive?.content ?: "signed in"
|
||||||
|
settings.accountName = name
|
||||||
|
settings.accountId = o["account_id"]?.jsonPrimitive?.content ?: ""
|
||||||
|
val admin = o["admin"]?.jsonPrimitive?.content == "true"
|
||||||
|
"Signed in as $name" + if (admin) " (administrator)" else ""
|
||||||
|
} catch (e: OidcLogin.LoginFailed) {
|
||||||
|
e.message ?: "Sign-in failed."
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
"Sign-in failed: ${t.message ?: t.javaClass.simpleName}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs out. The device stays enrolled — signing out should not cost an enrolment. */
|
||||||
|
fun signOut(): String = try {
|
||||||
|
client().unlinkAccount(settings.serverCredential)
|
||||||
|
settings.accountName = ""
|
||||||
|
settings.accountId = ""
|
||||||
|
"Signed out. This device is still enrolled."
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
"Could not sign out: ${t.message ?: t.javaClass.simpleName}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks the server who it thinks is signed in, so the UI is not trusting stale local state. */
|
||||||
|
fun refresh(): String? = runCatching {
|
||||||
|
val o = json.parseToJsonElement(client().accountStatus(settings.serverCredential)).jsonObject
|
||||||
|
val signedIn = o["signed_in"]?.jsonPrimitive?.content == "true"
|
||||||
|
settings.accountName = if (signedIn) {
|
||||||
|
o["display_name"]?.jsonPrimitive?.content ?: ""
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
settings.accountName.takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun client() =
|
||||||
|
ControlClient(settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER)
|
||||||
|
}
|
||||||
@@ -63,13 +63,24 @@ class MainActivity : ComponentActivity() {
|
|||||||
// An echolot://enroll link (QR scan, or a link the operator sent) opens the
|
// An echolot://enroll link (QR scan, or a link the operator sent) opens the
|
||||||
// app straight into settings with the enrollment already done, so the user
|
// app straight into settings with the enrollment already done, so the user
|
||||||
// sees the result rather than a form they still have to fill in.
|
// sees the result rather than a form they still have to fill in.
|
||||||
val enrollUri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.dataString
|
// Both deep links land here. They are told apart by host, so a sign-in
|
||||||
|
// redirect is never mistaken for an enrolment link — one spends a token, the
|
||||||
|
// other completes an authorization, and confusing them would fail obscurely.
|
||||||
|
val incoming = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.dataString
|
||||||
|
val authUri = incoming?.takeIf { it.startsWith("echolot://auth") }
|
||||||
|
val enrollUri = incoming?.takeIf { it.startsWith("echolot://enroll") }
|
||||||
androidx.compose.runtime.LaunchedEffect(enrollUri) {
|
androidx.compose.runtime.LaunchedEffect(enrollUri) {
|
||||||
if (enrollUri != null) {
|
if (enrollUri != null) {
|
||||||
vm.enroll(enrollUri)
|
vm.enroll(enrollUri)
|
||||||
screen = Screen.SETTINGS
|
screen = Screen.SETTINGS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
androidx.compose.runtime.LaunchedEffect(authUri) {
|
||||||
|
if (authUri != null) {
|
||||||
|
vm.completeSignIn(authUri)
|
||||||
|
screen = Screen.SETTINGS
|
||||||
|
}
|
||||||
|
}
|
||||||
androidx.compose.runtime.LaunchedEffect(autorun) {
|
androidx.compose.runtime.LaunchedEffect(autorun) {
|
||||||
if (autorun) vm.run(devUpload = true)
|
if (autorun) vm.run(devUpload = true)
|
||||||
}
|
}
|
||||||
@@ -107,6 +118,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
lifecycleScope.launch { preview = vm.previewNewestRun() }
|
lifecycleScope.launch { preview = vm.previewNewestRun() }
|
||||||
},
|
},
|
||||||
onCheckServer = vm::checkServer,
|
onCheckServer = vm::checkServer,
|
||||||
|
accountName = vm.accountName,
|
||||||
|
onSignIn = {
|
||||||
|
vm.beginSignIn { url ->
|
||||||
|
// 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,
|
onEnroll = vm::enroll,
|
||||||
serverStatus = vm.state.archiveStatus,
|
serverStatus = vm.state.archiveStatus,
|
||||||
onBack = { screen = Screen.RUN },
|
onBack = { screen = Screen.RUN },
|
||||||
|
|||||||
@@ -215,6 +215,44 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
* history screen has been opened - the two disagreeing read as data loss. */
|
* history screen has been opened - the two disagreeing read as data loss. */
|
||||||
fun archivedRunCount(): Int = store.list().size
|
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. */
|
/** Redeems an enrollment link, from a paste or from an echolot:// deep link. */
|
||||||
fun enroll(link: String, deviceName: String? = android.os.Build.MODEL) {
|
fun enroll(link: String, deviceName: String? = android.os.Build.MODEL) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -104,6 +104,36 @@ class Settings(context: Context) {
|
|||||||
val serverConfigured: Boolean
|
val serverConfigured: Boolean
|
||||||
get() = serverUrl.isNotBlank() && serverPin.isNotBlank() && serverCredential.isNotBlank()
|
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) {
|
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()
|
((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_URL = "server_url"
|
||||||
const val SERVER_PIN = "server_pin"
|
const val SERVER_PIN = "server_pin"
|
||||||
const val SERVER_CRED = "server_credential"
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ fun SettingsScreen(
|
|||||||
onDeleteAll: () -> Unit,
|
onDeleteAll: () -> Unit,
|
||||||
onPreviewUpload: () -> Unit,
|
onPreviewUpload: () -> Unit,
|
||||||
onCheckServer: () -> Unit,
|
onCheckServer: () -> Unit,
|
||||||
|
accountName: String,
|
||||||
|
onSignIn: () -> Unit,
|
||||||
|
onSignOut: () -> Unit,
|
||||||
onEnroll: (String) -> Unit,
|
onEnroll: (String) -> Unit,
|
||||||
serverStatus: String?,
|
serverStatus: String?,
|
||||||
onBack: () -> Unit,
|
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 ----
|
// ---- upload ----
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user