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