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:
mrambossek
2026-08-01 19:53:38 +02:00
co-authored by Claude Fable 5
parent 4e6f2da3fb
commit b5c8dda9a2
7 changed files with 293 additions and 1 deletions
@@ -39,6 +39,17 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="echolot" android:host="enroll" />
</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>
<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
// 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.
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) {
if (enrollUri != null) {
vm.enroll(enrollUri)
screen = Screen.SETTINGS
}
}
androidx.compose.runtime.LaunchedEffect(authUri) {
if (authUri != null) {
vm.completeSignIn(authUri)
screen = Screen.SETTINGS
}
}
androidx.compose.runtime.LaunchedEffect(autorun) {
if (autorun) vm.run(devUpload = true)
}
@@ -107,6 +118,18 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch { preview = vm.previewNewestRun() }
},
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,
serverStatus = vm.state.archiveStatus,
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. */
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 {
@@ -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"
}
}
@@ -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)) {