sync: unlinking a device now revokes its token on the server (issue 2110)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s

Unlink was local-only. It cleared the server URL, token and cursor from the
device, and left the bearer token valid on the server indefinitely — so someone
who unlinked because the laptop was being sold or handed on believed they had
revoked access when they hadn't.

The blocker was identification, not intent: a token pasted from the web app
never carried a device id, and /api/auth/me describes the user, not the device
row, so DELETE /devices/<id> could only ever have worked for one of the two ways
this app can be linked. DELETE /api/auth/devices/self keys off the token in the
Authorization header instead, which the caller always holds — one route that
works for both paths, owner-scoped like the rest, and no local schema change.

Unlinking is never blocked on the network. Wanting to stop syncing is a local
decision, so the revoke is attempted first, its outcome carried back, and the
link cleared either way. When the token survives — server unreachable, or older
than the route — the Sync screen says so in place, with where to revoke it. A
toast would have been the wrong shape for that: it disappears, and this is
exactly what someone returns to the screen to check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 10:31:06 -04:00
co-authored by Claude Opus 5
parent edf52da97f
commit 2cfe049f9c
7 changed files with 237 additions and 12 deletions
+80
View File
@@ -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, String> {
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]
+32 -8
View File
@@ -112,18 +112,42 @@ pub async fn sync_link(input: LinkInput, db: State<'_, Db>) -> Result<LinkResult
})
}
/// Stop syncing and forget the server.
#[derive(Serialize)]
pub struct UnlinkResult {
pub status: state::Status,
/// What happened to the token on the SERVER — kept separate from `status`
/// because the local half always succeeds and the remote half may not.
pub revoked: client::RevokeOutcome,
}
/// Stop syncing, and retire this device's token on the server.
///
/// Local only: the device token remains valid on the SERVER until revoked there
/// (Account → Linked devices). We can't reliably revoke it from here — a pasted
/// token arrives without its device id — so the UI must say so rather than imply a
/// remote revoke that didn't happen. Tracked for follow-up.
/// The local half is unconditional. Someone unlinking because the machine is being
/// sold or handed on must not be held to it by a server that's offline or gone — so
/// the revoke is attempted first, its outcome carried back for the UI to report
/// honestly, and the link cleared either way.
#[tauri::command]
pub fn sync_unlink(db: State<'_, Db>) -> Result<state::Status, String> {
pub async fn sync_unlink(db: State<'_, Db>) -> Result<UnlinkResult, String> {
// 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]
+10
View File
@@ -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/<id>`
(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.
+18 -1
View File
@@ -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<ProbeResult>("sync_probe", { url }),
link: (input: LinkInput) => invoke<LinkResult>("sync_link", { input }),
unlink: () => invoke<SyncStatus>("sync_unlink"),
/** Stops syncing AND revokes this device's token server-side; see UnlinkResult. */
unlink: () => invoke<UnlinkResult>("sync_unlink"),
status: () => invoke<SyncStatus>("sync_status"),
/**
* One full cycle: push, then pull. There is deliberately no bare "pull" — pulling
+42 -3
View File
@@ -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);
<!-- Not linked: the normal resting state, deliberately not framed as a problem -->
<template v-else>
<!-- The one exception to that framing: an unlink whose server-side revoke
didn't land leaves a live credential behind, and the person who unlinked
to retire a machine has to be told plainly rather than by a toast. -->
<section
v-if="unlinkWarning"
class="mb-6 rounded-xl border border-amber-300 bg-amber-50 p-4 dark:border-amber-500/40 dark:bg-amber-500/10"
role="alert"
>
<p class="text-sm font-medium text-amber-800 dark:text-amber-300">
This device's token is still valid on the server
</p>
<p class="mt-1 text-sm text-amber-700 dark:text-amber-300/80">{{ unlinkWarning }}</p>
</section>
<section
class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
aria-live="polite"
+37
View File
@@ -242,6 +242,43 @@ async def list_devices():
return jsonify({"devices": [_serialize_device(d) for d in rows]})
@bp.delete("/devices/self")
@login_required
async def revoke_own_device():
"""Revoke the device token presented on THIS request.
What makes "unlink" on a native client actually stop its access. The client
can't use the id-keyed route below, because it doesn't reliably know its own
device id: a token pasted from the web app arrives without one, and `/me`
describes the user, not the device row. Identifying the row by the presented
token needs nothing the caller doesn't already hold, so it works for both ways
a client can be linked.
Declared above the `<device_id>` rule for reading order only — Werkzeug ranks a
static rule ahead of a converter regardless of registration order.
"""
token = _bearer_token()
if token is None:
# A session-cookie caller holds no device token, so "revoke the one I'm
# using" is meaningless rather than merely unauthorized. The web app
# revokes by id.
return jsonify({"error": "no device token was presented"}), 400
async with session_scope() as db:
row = await db.scalar(
select(DeviceToken).where(
DeviceToken.token_hash == hash_token(token),
# Owner-scoped like every other device route. The hash already pins
# a single row; the guarantee shouldn't rest on one column.
DeviceToken.user_id == g.user_id,
)
)
if row is None:
return jsonify({"error": "not found"}), 404
await db.delete(row)
await db.commit()
return jsonify({"ok": True})
@bp.delete("/devices/<device_id>")
@login_required
async def revoke_device(device_id: str):
+18
View File
@@ -27,6 +27,24 @@ async def test_revoke_device_requires_auth(app):
assert resp.status_code == 401
async def test_revoke_self_requires_auth(app):
client = app.test_client()
resp = await client.delete("/api/auth/devices/self")
assert resp.status_code == 401
async def test_revoke_self_without_a_bearer_token_is_a_bad_request(app):
# Doubles as the routing check: a session-authenticated caller presents no
# device token, so the self-revoke view answers 400 BEFORE any DB access. A 404
# here would mean "self" fell through to the id-keyed route as a malformed UUID
# — i.e. that the static rule stopped winning.
client = app.test_client()
async with client.session_transaction() as sess:
sess["user_id"] = "00000000-0000-0000-0000-000000000001"
resp = await client.delete("/api/auth/devices/self")
assert resp.status_code == 400
async def test_device_login_validates_input(app):
# Missing credentials → 400 BEFORE any DB access, so it's checkable in the
# DB-free unit lane (invalid-cred and success paths are operator-verified).