From 3c76b50a9cc29918d9d3b8d8f98c356418c7947f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Jul 2026 22:59:52 -0400 Subject: [PATCH] Sync 2: device-token bearer auth + linked-devices UI (M8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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/ (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) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- alembic/versions/0016_device_tokens.py | 36 +++++ frontend/src/components/AppShell.vue | 3 + frontend/src/components/Icon.vue | 2 + frontend/src/router/index.ts | 7 + frontend/src/stores/devices.ts | 40 ++++++ frontend/src/views/AccountView.vue | 177 +++++++++++++++++++++++++ src/thoughtsync/auth.py | 119 ++++++++++++++++- src/thoughtsync/models/all.py | 1 + src/thoughtsync/models/device_token.py | 27 ++++ src/thoughtsync/security.py | 15 +++ tests/test_devices.py | 35 +++++ tests/test_security.py | 16 ++- 12 files changed, 475 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/0016_device_tokens.py create mode 100644 frontend/src/stores/devices.ts create mode 100644 frontend/src/views/AccountView.vue create mode 100644 src/thoughtsync/models/device_token.py create mode 100644 tests/test_devices.py diff --git a/alembic/versions/0016_device_tokens.py b/alembic/versions/0016_device_tokens.py new file mode 100644 index 0000000..b6ff96d --- /dev/null +++ b/alembic/versions/0016_device_tokens.py @@ -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") diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 975ef6a..a38b5aa 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -224,6 +224,9 @@ async function signOut() { + + + = { history: '', download: '', upload: '', + device: '', + copy: '', }; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 5f43f15..6cb8f1a 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -27,6 +27,13 @@ const router = createRouter({ component: () => import("../views/SettingsView.vue"), 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", name: "login", diff --git a/frontend/src/stores/devices.ts b/frontend/src/stores/devices.ts new file mode 100644 index 0000000..eb98321 --- /dev/null +++ b/frontend/src/stores/devices.ts @@ -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([]); + const loading = ref(false); + + async function load(): Promise { + 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 { + 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 { + await api.del(`/api/auth/devices/${id}`); + items.value = items.value.filter((d) => d.id !== id); + } + + return { items, loading, load, create, revoke }; +}); diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue new file mode 100644 index 0000000..d421dc7 --- /dev/null +++ b/frontend/src/views/AccountView.vue @@ -0,0 +1,177 @@ + + + diff --git a/src/thoughtsync/auth.py b/src/thoughtsync/auth.py index 9114a71..520b083 100644 --- a/src/thoughtsync/auth.py +++ b/src/thoughtsync/auth.py @@ -2,19 +2,22 @@ from __future__ import annotations import functools import uuid +from datetime import datetime, timezone from quart import Blueprint, g, jsonify, request, session from sqlalchemy import func, select from .db import session_scope +from .models.device_token import DeviceToken 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 bp = Blueprint("auth", __name__, url_prefix="/api/auth") SESSION_KEY = "user_id" MIN_PASSWORD_LEN = 8 +DEVICE_NAME_CAP = 100 def _serialize_user(user: User) -> dict: @@ -38,12 +41,40 @@ def _session_user_id() -> uuid.UUID | None: return None +def _bearer_token() -> str | None: + """Extract a `Authorization: Bearer ` 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): - """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) async def wrapper(*args, **kwargs): uid = _session_user_id() + if uid is None: + uid = await _user_id_from_bearer() if uid is None: return jsonify({"error": "authentication required"}), 401 g.user_id = uid @@ -142,3 +173,87 @@ async def me(): session.pop(SESSION_KEY, None) return jsonify({"error": "authentication required"}), 401 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/") +@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}) diff --git a/src/thoughtsync/models/all.py b/src/thoughtsync/models/all.py index 36a7359..058e180 100644 --- a/src/thoughtsync/models/all.py +++ b/src/thoughtsync/models/all.py @@ -4,6 +4,7 @@ Imported for side effects only (model registration on Base.metadata). """ from . import ( # noqa: F401 + device_token, group, label, note, diff --git a/src/thoughtsync/models/device_token.py b/src/thoughtsync/models/device_token.py new file mode 100644 index 0000000..eb95bbf --- /dev/null +++ b/src/thoughtsync/models/device_token.py @@ -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) diff --git a/src/thoughtsync/security.py b/src/thoughtsync/security.py index 4ba68f5..c57ca70 100644 --- a/src/thoughtsync/security.py +++ b/src/thoughtsync/security.py @@ -1,5 +1,8 @@ from __future__ import annotations +import hashlib +import secrets + import bcrypt # 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")) except (ValueError, TypeError): 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() diff --git a/tests/test_devices.py b/tests/test_devices.py new file mode 100644 index 0000000..c44ae7f --- /dev/null +++ b/tests/test_devices.py @@ -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 diff --git a/tests/test_security.py b/tests/test_security.py index 3e45742..dafae41 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -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(): @@ -7,6 +7,20 @@ def test_password_roundtrip(): 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(): # Same input hashes differently each time (random salt). assert hash_password("same-input") != hash_password("same-input")