diff --git a/desktop/src-tauri/src/sync/client.rs b/desktop/src-tauri/src/sync/client.rs index eb8bc1d..846df3d 100644 --- a/desktop/src-tauri/src/sync/client.rs +++ b/desktop/src-tauri/src/sync/client.rs @@ -58,6 +58,64 @@ struct DeviceLoginResponse { user: Identity, } +/// What became of this device's token on the SERVER when unlinking. +/// +/// Not a bool, and not an error: unlinking must never be blocked by the network — +/// wanting to stop syncing is a local decision — so the remote half reports back +/// instead of failing the call, and each outcome needs different advice. +/// +/// Serialized tagged, like `Compatibility`, so the frontend can `switch` on `status`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum RevokeOutcome { + /// The server confirmed it: this token authenticates nothing now. + Revoked, + /// This server has no self-revoke route — it predates one. The token is still + /// live, and only the web app can retire it. + Unsupported, + /// We couldn't reach the server, or it refused. The token is still live. + Failed { reason: String }, + /// Nothing to revoke; the app wasn't linked. + Skipped, +} + +/// Retire the device token we authenticate with, server-side. +/// +/// Identified by the token itself rather than a device id, because a token pasted +/// from the web app never carried one — a route keyed on the id would work for +/// exactly one of the two ways this app can be linked. +pub async fn revoke_self(base_url: &str, token: &str) -> RevokeOutcome { + let client = match http() { + Ok(client) => client, + Err(reason) => return RevokeOutcome::Failed { reason }, + }; + let request = prepare(client.delete(revoke_self_url(base_url)), Some(token)); + let response = match request.send().await { + Ok(response) => response, + Err(e) => { + return RevokeOutcome::Failed { + reason: describe_transport_error(base_url, &e), + } + } + }; + + let status = response.status(); + // 401 counts as revoked: the token already authenticates nothing — retired by + // another device, or purged server-side — which is the state we were asking for. + if status.is_success() || status == StatusCode::UNAUTHORIZED { + return RevokeOutcome::Revoked; + } + match status { + // No such route: a server older than self-revoke. Any other shape of 404 + // (a proxy, a stale base URL) leaves the token live too, so the advice the + // user needs is the same either way. + StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => RevokeOutcome::Unsupported, + other => RevokeOutcome::Failed { + reason: unexpected_status(base_url, other), + }, + } +} + fn http_with(timeout: Duration) -> Result { reqwest::Client::builder() .timeout(timeout) @@ -300,6 +358,11 @@ fn me_url(base_url: &str) -> String { format!("{base_url}/api/auth/me") } +/// `self` rather than a device id: see `revoke_self`. +fn revoke_self_url(base_url: &str) -> String { + format!("{base_url}/api/auth/devices/self") +} + /// Turn a transport failure into something a person can act on. reqwest's own /// Display is accurate but reads like a stack trace. fn describe_transport_error(base_url: &str, err: &reqwest::Error) -> String { @@ -341,6 +404,23 @@ mod tests { me_url("https://notes.example.com"), "https://notes.example.com/api/auth/me" ); + assert_eq!( + revoke_self_url("https://notes.example.com"), + "https://notes.example.com/api/auth/devices/self" + ); + } + + #[test] + fn revoke_outcome_serializes_tagged_for_the_frontend() { + // The UI decides between "signed out on the server" and "still valid, go + // revoke it" by reading this tag, so its shape is part of the contract. + let json = serde_json::to_string(&RevokeOutcome::Failed { + reason: "offline".into(), + }) + .expect("outcome serializes"); + assert!(json.contains("\"status\":\"failed\""), "got {json}"); + let json = serde_json::to_string(&RevokeOutcome::Revoked).expect("outcome serializes"); + assert!(json.contains("\"status\":\"revoked\""), "got {json}"); } #[test] diff --git a/desktop/src-tauri/src/sync/commands.rs b/desktop/src-tauri/src/sync/commands.rs index e11a60f..9999103 100644 --- a/desktop/src-tauri/src/sync/commands.rs +++ b/desktop/src-tauri/src/sync/commands.rs @@ -112,18 +112,42 @@ pub async fn sync_link(input: LinkInput, db: State<'_, Db>) -> Result) -> Result { +pub async fn sync_unlink(db: State<'_, Db>) -> Result { + // Read and release before the network call: a std MutexGuard isn't Send, and + // holding the store across a round-trip would freeze every note operation in + // the UI. + let link = { + let conn = db.0.lock().map_err(|e| e.to_string())?; + let current = state::read(&conn).map_err(|e| e.to_string())?; + current.server_url.zip(current.device_token) + }; + let revoked = match &link { + Some((base_url, token)) => client::revoke_self(base_url, token).await, + None => client::RevokeOutcome::Skipped, + }; + let conn = db.0.lock().map_err(|e| e.to_string())?; state::clear_link(&conn).map_err(|e| e.to_string())?; - log::info!("unlinked from server"); - state::status(&conn).map_err(|e| e.to_string()) + log::info!("unlinked from server (server-side token: {revoked:?})"); + Ok(UnlinkResult { + status: state::status(&conn).map_err(|e| e.to_string())?, + revoked, + }) } #[tauri::command] diff --git a/docs/sync.md b/docs/sync.md index 3138a1e..9787f07 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -89,6 +89,16 @@ shown once at creation. device}`. They paste it into the native app. - **Manage:** `GET /api/auth/devices` (list), `DELETE /api/auth/devices/` (revoke). A revoked token stops authenticating immediately. +- **Self-revoke:** `DELETE /api/auth/devices/self` retires the token presented in + the `Authorization` header. This is what a native client calls when the user + unlinks. It exists because a client can't use the id-keyed route: a token pasted + from the web app arrives without a device id, and `/api/auth/me` describes the + user, not the device row. A caller authenticated by session cookie gets `400` — + it holds no device token, so there is nothing for it to mean. + + Unlinking is never blocked on this call. If the server is unreachable or too old + to have the route, the client still unlinks locally and tells the user the token + is still live and where to revoke it. Every authenticated request (sync or otherwise) accepts the bearer token in place of the session cookie. diff --git a/frontend/src/desktop/bridge.ts b/frontend/src/desktop/bridge.ts index 9164fad..00e22af 100644 --- a/frontend/src/desktop/bridge.ts +++ b/frontend/src/desktop/bridge.ts @@ -125,6 +125,22 @@ export interface SyncOutcome { status: SyncStatus; } +/** + * What happened to this device's token on the SERVER when unlinking. Unlinking + * always succeeds locally, so this is the only part that can disappoint — and the + * user who unlinked to retire a machine is exactly who needs to be told. + */ +export type RevokeOutcome = + | { status: "revoked" } + | { status: "unsupported" } + | { status: "failed"; reason: string } + | { status: "skipped" }; + +export interface UnlinkResult { + status: SyncStatus; + revoked: RevokeOutcome; +} + /** Either a password login or a token pasted from the web app's Linked devices. */ export interface LinkInput { url: string; @@ -138,7 +154,8 @@ export const sync = { /** Ask who's at an address without committing to anything. */ probe: (url: string) => invoke("sync_probe", { url }), link: (input: LinkInput) => invoke("sync_link", { input }), - unlink: () => invoke("sync_unlink"), + /** Stops syncing AND revokes this device's token server-side; see UnlinkResult. */ + unlink: () => invoke("sync_unlink"), status: () => invoke("sync_status"), /** * One full cycle: push, then pull. There is deliberately no bare "pull" — pulling diff --git a/frontend/src/views/SyncView.vue b/frontend/src/views/SyncView.vue index 6ba885e..0a2a34a 100644 --- a/frontend/src/views/SyncView.vue +++ b/frontend/src/views/SyncView.vue @@ -9,6 +9,7 @@ import { updates as updateBridge, type Compatibility, type ProbeResult, + type RevokeOutcome, type SyncStatus, type UpdateChannel, type UpdateStatus, @@ -45,6 +46,9 @@ const syncing = ref(false); const syncError = ref(""); const lastResult = ref(""); +/** Set only when unlinking left the token valid server-side (see revokeWarning). */ +const unlinkWarning = ref(""); + const linked = computed(() => status.value?.linked === true); /** Only offer to connect once a probe has said the server is usable. */ @@ -205,20 +209,41 @@ async function syncNow() { } } +/** + * Advice for an unlink whose server-side revoke didn't land — empty when it did. + * Never a toast: a toast disappears, and "your token is still live" is exactly the + * kind of thing someone comes back to this screen to check. + */ +function revokeWarning(outcome: RevokeOutcome): string { + if (outcome.status === "unsupported") { + return "This server is older than in-app sign-out, so this device's token had to be left in place. Revoke it in the web app under Account → Linked devices."; + } + if (outcome.status === "failed") { + return `${outcome.reason} Until it's revoked, this device's token still works — you can revoke it in the web app under Account → Linked devices.`; + } + return ""; +} + async function disconnect() { if ( !window.confirm( - "Stop syncing with this server?\n\nYour notes stay on this device, and the copy on the server is left alone. The device token remains valid until you revoke it on the server under Account → Linked devices.", + "Stop syncing with this server?\n\nYour notes stay on this device, and the copy on the server is left alone. This device's access token is revoked, so it can't be used to reach the server again.", ) ) { return; } try { - status.value = await syncBridge.unlink(); + const result = await syncBridge.unlink(); + status.value = result.status; linkedAs.value = ""; degraded.value = []; lastResult.value = ""; - ui.showToast("Disconnected. This device now works offline only."); + unlinkWarning.value = revokeWarning(result.revoked); + ui.showToast( + result.revoked.status === "revoked" + ? "Disconnected, and this device is signed out on the server." + : "Disconnected. This device now works offline only.", + ); } catch (e) { ui.showToast(String((e as Error)?.message ?? e)); } @@ -292,6 +317,20 @@ onMounted(refresh);