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
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user