Sync 2: device-token bearer auth + linked-devices UI (M8)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 33s

Native clients (Tauri/Android) authenticate sync with a long-lived device
bearer token, alongside the existing web session cookie.

Backend:
- security.py: generate_token() (secrets.token_urlsafe) + hash_token()
  (SHA-256 — device tokens are already high-entropy, so no slow KDF; keeps
  per-request bearer auth cheap). Only the hash is stored.
- device_tokens table (migration 0016): id, user_id, token_hash (unique),
  name, created_at, last_used_at.
- login_required now accepts `Authorization: Bearer <token>` OR the session
  cookie. Session path stays DB-free (fast); bearer path looks up the token
  hash, sets g.user_id, and stamps last_used_at.
- Endpoints: POST /api/auth/device-login (public; email+password → token,
  the native first-link flow), POST /api/auth/devices (session/bearer →
  token, web "link a device"), GET /api/auth/devices (list), DELETE
  /api/auth/devices/<id> (revoke). All owner-scoped; token shown once.

Frontend:
- Per-user (not admin) /account view "Linked devices": create a token
  (one-time reveal + copy), list devices (name, linked/last-synced), revoke
  with confirm. Top-bar device icon for all users; devices Pinia store.

Tests (DB-free): token hash determinism + uniqueness; device endpoints
auth-guard (401 without auth, before DB); device-login input validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-22 22:59:52 -04:00
co-authored by Claude Opus 4.8
parent 58b88d2622
commit 3c76b50a9c
12 changed files with 475 additions and 3 deletions
+36
View File
@@ -0,0 +1,36 @@
"""device_tokens (M8 sync hub, step 2)
Revision ID: 0016
Revises: 0015
Create Date: 2026-07-23
Long-lived bearer tokens for native clients (Tauri/Android) to authenticate sync.
Only the SHA-256 hash of each token is stored; the plaintext is shown once at
creation. Owner-scoped + individually revocable.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "0016"
down_revision = "0015"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"device_tokens",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("token_hash", sa.Text(), nullable=False, unique=True),
sa.Column("name", sa.Text(), nullable=False, server_default=""),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_device_tokens_user", "device_tokens", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_device_tokens_user", table_name="device_tokens")
op.drop_table("device_tokens")
+3
View File
@@ -224,6 +224,9 @@ async function signOut() {
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">{{ <span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">{{
session.user?.display_name session.user?.display_name
}}</span> }}</span>
<RouterLink to="/account" class="icon-btn" title="Linked devices" aria-label="Linked devices">
<Icon name="device" />
</RouterLink>
<RouterLink <RouterLink
v-if="session.user?.is_admin" v-if="session.user?.is_admin"
to="/settings" to="/settings"
+2
View File
@@ -25,6 +25,8 @@ const paths: Record<string, string> = {
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>', history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>', download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>', upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
device: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
}; };
</script> </script>
+7
View File
@@ -27,6 +27,13 @@ const router = createRouter({
component: () => import("../views/SettingsView.vue"), component: () => import("../views/SettingsView.vue"),
meta: { requiresAuth: true, requiresAdmin: true }, meta: { requiresAuth: true, requiresAdmin: true },
}, },
{
// Per-user account: linked devices (native-client sync tokens). Any user.
path: "/account",
name: "account",
component: () => import("../views/AccountView.vue"),
meta: { requiresAuth: true },
},
{ {
path: "/login", path: "/login",
name: "login", name: "login",
+40
View File
@@ -0,0 +1,40 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
// A linked native client (Tauri/Android) that holds a device bearer token.
export interface Device {
id: string;
name: string;
created_at: string | null;
last_used_at: string | null;
}
export const useDevicesStore = defineStore("devices", () => {
const items = ref<Device[]>([]);
const loading = ref(false);
async function load(): Promise<void> {
loading.value = true;
try {
items.value = (await api.get<{ devices: Device[] }>("/api/auth/devices")).devices;
} finally {
loading.value = false;
}
}
// Issues a token for the current user; the plaintext token is returned ONCE
// (never retrievable again) for the caller to display + copy.
async function create(name: string): Promise<string> {
const res = await api.post<{ token: string; device: Device }>("/api/auth/devices", { name });
items.value.unshift(res.device);
return res.token;
}
async function revoke(id: string): Promise<void> {
await api.del(`/api/auth/devices/${id}`);
items.value = items.value.filter((d) => d.id !== id);
}
return { items, loading, load, create, revoke };
});
+177
View File
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useDevicesStore } from "../stores/devices";
import { useUiStore } from "../stores/ui";
import BaseButton from "../components/BaseButton.vue";
import Icon from "../components/Icon.vue";
// Per-user (not admin) management of linked native clients — the Tauri desktop and
// Android apps authenticate sync with a device bearer token issued here.
const devices = useDevicesStore();
const ui = useUiStore();
const error = ref("");
const newName = ref("");
const creating = ref(false);
// The freshly-issued plaintext token — shown ONCE (never retrievable again).
const freshToken = ref("");
async function load() {
error.value = "";
try {
await devices.load();
} catch {
error.value = "Couldn't load your linked devices.";
}
}
async function link() {
creating.value = true;
error.value = "";
freshToken.value = "";
try {
freshToken.value = await devices.create(newName.value.trim() || "Device");
newName.value = "";
} catch (e) {
error.value = (e as { error?: string }).error ?? "Couldn't create a device token.";
} finally {
creating.value = false;
}
}
async function copyToken() {
try {
await navigator.clipboard.writeText(freshToken.value);
ui.showToast("Token copied to clipboard.");
} catch {
ui.showToast("Couldn't copy — select and copy it manually.");
}
}
async function revoke(id: string, name: string) {
if (!window.confirm(`Revoke "${name}"? That device will need to link again to sync.`)) return;
try {
await devices.revoke(id);
} catch {
ui.showToast("Couldn't revoke that device.");
}
}
function fmt(iso: string | null): string {
if (!iso) return "never";
return new Date(iso).toLocaleString();
}
onMounted(load);
</script>
<template>
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
<header class="mb-8 flex items-center gap-3">
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
<svg
class="h-[18px] w-[18px]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6" />
</svg>
</RouterLink>
<h1 class="text-xl font-bold tracking-tight">Linked devices</h1>
</header>
<p class="mb-6 max-w-xl text-sm text-neutral-500 dark:text-neutral-400">
Link the ThoughtSync desktop or mobile app to sync your notes. Create a device token here, then
paste it into the app when it asks to connect. You can revoke a device at any time.
</p>
<!-- One-time token reveal -->
<div
v-if="freshToken"
class="mb-6 rounded-xl border border-brand/40 bg-brand/5 p-4 dark:border-brand/30 dark:bg-brand/10"
>
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Copy this token now it won't be shown again.
</p>
<div class="mt-2 flex items-center gap-2">
<code
class="min-w-0 flex-1 overflow-x-auto rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-xs text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
>{{ freshToken }}</code
>
<button
type="button"
class="icon-btn shrink-0"
title="Copy token"
aria-label="Copy token"
@click="copyToken"
>
<Icon name="copy" />
</button>
</div>
<button
type="button"
class="mt-3 text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300"
@click="freshToken = ''"
>
Done
</button>
</div>
<!-- Create a device token -->
<form class="mb-8 flex items-end gap-3" @submit.prevent="link">
<div class="flex flex-1 flex-col gap-1">
<label for="device-name" class="text-sm font-medium text-neutral-800 dark:text-neutral-200"
>Link a new device</label
>
<input
id="device-name"
v-model="newName"
type="text"
placeholder="e.g. My laptop, Pixel phone"
class="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
/>
</div>
<BaseButton type="submit" :loading="creating">Create token</BaseButton>
</form>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<!-- Device list -->
<div v-if="devices.loading" class="py-10 text-center text-sm text-neutral-400">Loading…</div>
<div
v-else-if="!devices.items.length"
class="rounded-xl border border-dashed border-neutral-300 py-10 text-center dark:border-neutral-700"
>
<p class="text-sm text-neutral-500 dark:text-neutral-400">No devices linked yet.</p>
</div>
<ul v-else class="flex flex-col gap-2">
<li
v-for="d in devices.items"
:key="d.id"
class="flex items-center justify-between gap-4 rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<div class="flex min-w-0 items-center gap-3">
<span class="text-neutral-400"><Icon name="device" /></span>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ d.name }}</p>
<p class="text-xs text-neutral-400">
Linked {{ fmt(d.created_at) }} · last synced {{ fmt(d.last_used_at) }}
</p>
</div>
</div>
<button
type="button"
class="shrink-0 rounded-md border border-neutral-300 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:text-red-400 dark:hover:bg-red-950/40"
@click="revoke(d.id, d.name)"
>
Revoke
</button>
</li>
</ul>
</div>
</template>
+117 -2
View File
@@ -2,19 +2,22 @@ from __future__ import annotations
import functools import functools
import uuid import uuid
from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request, session from quart import Blueprint, g, jsonify, request, session
from sqlalchemy import func, select from sqlalchemy import func, select
from .db import session_scope from .db import session_scope
from .models.device_token import DeviceToken
from .models.user import User from .models.user import User
from .security import hash_password, verify_password from .security import generate_token, hash_password, hash_token, verify_password
from .settings import get_setting from .settings import get_setting
bp = Blueprint("auth", __name__, url_prefix="/api/auth") bp = Blueprint("auth", __name__, url_prefix="/api/auth")
SESSION_KEY = "user_id" SESSION_KEY = "user_id"
MIN_PASSWORD_LEN = 8 MIN_PASSWORD_LEN = 8
DEVICE_NAME_CAP = 100
def _serialize_user(user: User) -> dict: def _serialize_user(user: User) -> dict:
@@ -38,12 +41,40 @@ def _session_user_id() -> uuid.UUID | None:
return None return None
def _bearer_token() -> str | None:
"""Extract a `Authorization: Bearer <token>` device token, if present."""
header = request.headers.get("Authorization", "")
if header.startswith("Bearer "):
return header[7:].strip() or None
return None
async def _user_id_from_bearer() -> uuid.UUID | None:
"""Resolve a device bearer token to its owner, refreshing last_used_at. Native
clients (Tauri/Android) authenticate sync this way instead of a session cookie."""
token = _bearer_token()
if not token:
return None
async with session_scope() as db:
row = await db.scalar(select(DeviceToken).where(DeviceToken.token_hash == hash_token(token)))
if row is None:
return None
# Cheap liveness stamp; sync calls are user-initiated/periodic, not per-keystroke.
row.last_used_at = datetime.now(timezone.utc)
await db.commit()
return row.user_id
def login_required(fn): def login_required(fn):
"""Guard: 401 unless a valid session is present. Sets g.user_id for the view.""" """Guard: 401 unless authenticated. Accepts a web session cookie OR a device
bearer token (native clients). Sets g.user_id for the view. The session path
stays DB-free (fast); only bearer auth does a token lookup."""
@functools.wraps(fn) @functools.wraps(fn)
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
uid = _session_user_id() uid = _session_user_id()
if uid is None:
uid = await _user_id_from_bearer()
if uid is None: if uid is None:
return jsonify({"error": "authentication required"}), 401 return jsonify({"error": "authentication required"}), 401
g.user_id = uid g.user_id = uid
@@ -142,3 +173,87 @@ async def me():
session.pop(SESSION_KEY, None) session.pop(SESSION_KEY, None)
return jsonify({"error": "authentication required"}), 401 return jsonify({"error": "authentication required"}), 401
return jsonify(_serialize_user(user)) return jsonify(_serialize_user(user))
# --- Device (bearer) tokens for native clients — M8 sync hub ---
def _serialize_device(d: DeviceToken) -> dict:
return {
"id": str(d.id),
"name": d.name,
"created_at": d.created_at.isoformat() if d.created_at else None,
"last_used_at": d.last_used_at.isoformat() if d.last_used_at else None,
}
async def _issue_device_token(db, user_id: uuid.UUID, name: str) -> tuple[DeviceToken, str]:
"""Create a device token; return the row plus the ONE-TIME plaintext token."""
token = generate_token()
row = DeviceToken(
user_id=user_id,
token_hash=hash_token(token),
name=(name or "").strip()[:DEVICE_NAME_CAP] or "Device",
)
db.add(row)
await db.flush()
return row, token
@bp.post("/device-login")
async def device_login():
"""Native first-link: exchange email+password for a device bearer token. Public
(no existing session) — this is how a fresh native install authenticates."""
data = await request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
if not email or not password:
return jsonify({"error": "email and password are required"}), 400
async with session_scope() as db:
user = await db.scalar(select(User).where(User.email == email))
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
return jsonify({"error": "invalid email or password"}), 401
row, token = await _issue_device_token(db, user.id, data.get("name") or "")
await db.commit()
return jsonify({"token": token, "device": _serialize_device(row), "user": _serialize_user(user)}), 201
@bp.post("/devices")
@login_required
async def create_device():
"""Issue a device token for the already-authenticated user (web 'Link a device')."""
data = await request.get_json(silent=True) or {}
async with session_scope() as db:
row, token = await _issue_device_token(db, g.user_id, data.get("name") or "")
await db.commit()
return jsonify({"token": token, "device": _serialize_device(row)}), 201
@bp.get("/devices")
@login_required
async def list_devices():
async with session_scope() as db:
rows = (
await db.scalars(
select(DeviceToken).where(DeviceToken.user_id == g.user_id).order_by(DeviceToken.created_at.desc())
)
).all()
return jsonify({"devices": [_serialize_device(d) for d in rows]})
@bp.delete("/devices/<device_id>")
@login_required
async def revoke_device(device_id: str):
try:
did = uuid.UUID(device_id)
except (ValueError, TypeError):
return jsonify({"error": "not found"}), 404
async with session_scope() as db:
row = await db.scalar(
select(DeviceToken).where(DeviceToken.id == did, 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})
+1
View File
@@ -4,6 +4,7 @@ Imported for side effects only (model registration on Base.metadata).
""" """
from . import ( # noqa: F401 from . import ( # noqa: F401
device_token,
group, group,
label, label,
note, note,
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class DeviceToken(Base):
"""A long-lived bearer token a native client (Tauri/Android) uses to authenticate
sync. Only the token's SHA-256 hash is stored; the plaintext is shown once at
creation. Owner-scoped and individually revocable."""
__tablename__ = "device_tokens"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
token_hash: Mapped[str] = mapped_column(Text(), nullable=False, unique=True)
name: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+15
View File
@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import secrets
import bcrypt import bcrypt
# bcrypt hashes at most 72 bytes and bcrypt>=4 raises on longer input, so we # bcrypt hashes at most 72 bytes and bcrypt>=4 raises on longer input, so we
@@ -16,3 +19,15 @@ def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8")[:_MAX_BCRYPT_BYTES], password_hash.encode("utf-8")) return bcrypt.checkpw(password.encode("utf-8")[:_MAX_BCRYPT_BYTES], password_hash.encode("utf-8"))
except (ValueError, TypeError): except (ValueError, TypeError):
return False return False
def generate_token() -> str:
"""A high-entropy opaque device (bearer) token, URL-safe so it pastes cleanly."""
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
"""One-way hash for device-token LOOKUP. A device token is already high-entropy
random, so a plain SHA-256 is enough (no slow KDF like passwords need) — which
keeps per-request bearer auth cheap. Only this hash is stored server-side."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
+35
View File
@@ -0,0 +1,35 @@
import pytest
from thoughtsync.app import create_app
@pytest.fixture
def app():
return create_app()
async def test_create_device_requires_auth(app):
# No session cookie and no bearer header → 401 before any DB access.
client = app.test_client()
resp = await client.post("/api/auth/devices", json={"name": "phone"})
assert resp.status_code == 401
async def test_list_devices_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/auth/devices")
assert resp.status_code == 401
async def test_revoke_device_requires_auth(app):
client = app.test_client()
resp = await client.delete("/api/auth/devices/00000000-0000-0000-0000-000000000000")
assert resp.status_code == 401
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).
client = app.test_client()
resp = await client.post("/api/auth/device-login", json={})
assert resp.status_code == 400
+15 -1
View File
@@ -1,4 +1,4 @@
from thoughtsync.security import hash_password, verify_password from thoughtsync.security import generate_token, hash_password, hash_token, verify_password
def test_password_roundtrip(): def test_password_roundtrip():
@@ -7,6 +7,20 @@ def test_password_roundtrip():
assert not verify_password("wrong password", h) assert not verify_password("wrong password", h)
def test_hash_token_deterministic():
t = generate_token()
# Lookup hash is deterministic (same token → same hash) and SHA-256 hex (64 chars).
assert hash_token(t) == hash_token(t)
assert len(hash_token(t)) == 64
# Different tokens hash differently.
assert hash_token(t) != hash_token(generate_token())
def test_generate_token_unique():
assert generate_token() != generate_token()
assert len(generate_token()) >= 32
def test_password_hash_is_salted(): def test_password_hash_is_salted():
# Same input hashes differently each time (random salt). # Same input hashes differently each time (random salt).
assert hash_password("same-input") != hash_password("same-input") assert hash_password("same-input") != hash_password("same-input")