From a751f10ba0db5e22eb00b4ee339e490b0bad7eb0 Mon Sep 17 00:00:00 2001 From: Patrick Dewey Date: Tue, 21 Apr 2026 09:28:53 -0400 Subject: [PATCH] feat: device api tokens --- src/server/device-tokens.ts | 120 +++++++++++++++++++++++++++++ src/server/index.ts | 6 +- src/server/routes-auth.ts | 45 +++++++++-- src/server/routes-device.ts | 57 ++++++++------ src/web/App.tsx | 150 +++++++++++++++++++++++++++++++++++- src/web/api.ts | 23 ++++++ src/web/styles.css | 17 ++++ 7 files changed, 383 insertions(+), 35 deletions(-) create mode 100644 src/server/device-tokens.ts diff --git a/src/server/device-tokens.ts b/src/server/device-tokens.ts new file mode 100644 index 0000000..fb4a8fd --- /dev/null +++ b/src/server/device-tokens.ts @@ -0,0 +1,120 @@ +import { + readFileSync, + writeFileSync, + existsSync, + renameSync, + mkdirSync, +} from "node:fs"; +import { resolve, dirname } from "node:path"; +import { randomBytes, createHash, timingSafeEqual } from "node:crypto"; + +export type DeviceTokenRecord = { + id: string; + hash: string; + did: string; + label: string; + prefix: string; + createdAt: string; + lastUsedAt: string | null; + revokedAt: string | null; +}; + +export type DeviceTokenPublic = Omit; + +type File = { tokens: DeviceTokenRecord[] }; + +const TOKEN_PREFIX = "nsd_"; + +export class DeviceTokenStore { + private path: string; + private cache: File | null = null; + + constructor(dataDir: string) { + this.path = resolve(dataDir, "device-tokens.json"); + mkdirSync(dirname(this.path), { recursive: true }); + } + + private read(): File { + if (this.cache) return this.cache; + if (!existsSync(this.path)) { + this.cache = { tokens: [] }; + return this.cache; + } + try { + this.cache = JSON.parse(readFileSync(this.path, "utf8")) as File; + } catch { + this.cache = { tokens: [] }; + } + return this.cache; + } + + private write(file: File): void { + const tmp = this.path + ".tmp"; + writeFileSync(tmp, JSON.stringify(file, null, 2), { mode: 0o600 }); + renameSync(tmp, this.path); + this.cache = file; + } + + list(did: string): DeviceTokenPublic[] { + return this.read() + .tokens.filter((t) => t.did === did) + .map(({ hash: _hash, ...rest }) => rest); + } + + create(did: string, label: string): { record: DeviceTokenPublic; token: string } { + const raw = randomBytes(32).toString("base64url"); + const token = TOKEN_PREFIX + raw; + const record: DeviceTokenRecord = { + id: randomBytes(8).toString("hex"), + hash: hashToken(token), + did, + label: label.trim() || "device", + prefix: token.slice(0, 8), + createdAt: new Date().toISOString(), + lastUsedAt: null, + revokedAt: null, + }; + const file = this.read(); + file.tokens.push(record); + this.write(file); + const { hash: _hash, ...publicRec } = record; + return { record: publicRec, token }; + } + + revoke(did: string, id: string): boolean { + const file = this.read(); + const t = file.tokens.find((r) => r.id === id && r.did === did); + if (!t || t.revokedAt) return false; + t.revokedAt = new Date().toISOString(); + this.write(file); + return true; + } + + verify(token: string): DeviceTokenRecord | null { + if (!token.startsWith(TOKEN_PREFIX)) return null; + const expected = hashToken(token); + const file = this.read(); + for (const t of file.tokens) { + if (t.revokedAt) continue; + if (constantTimeEqualHex(t.hash, expected)) return t; + } + return null; + } + + touch(id: string): void { + const file = this.read(); + const t = file.tokens.find((r) => r.id === id); + if (!t) return; + t.lastUsedAt = new Date().toISOString(); + this.write(file); + } +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +function constantTimeEqualHex(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex")); +} diff --git a/src/server/index.ts b/src/server/index.ts index 3be026f..31509e9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,7 @@ import { logger } from "hono/logger"; import { config, isLoopback, minifluxAllowed, publicBase } from "./config.js"; import { MinifluxClient } from "./miniflux.js"; import { BodyCache } from "./body-cache.js"; +import { DeviceTokenStore } from "./device-tokens.js"; import { buildOAuth } from "./oauth.js"; import { AtprotoRepo } from "./atproto.js"; import { Syncer } from "./sync.js"; @@ -17,6 +18,7 @@ import { deviceRoutes } from "./routes-device.js"; const mf = new MinifluxClient(config.miniflux.url, config.miniflux.token); const bodies = new BodyCache(); +const deviceTokens = new DeviceTokenStore(config.dataDir); const oauth = await buildOAuth(); const app = new Hono(); @@ -32,9 +34,9 @@ if (!isLoopback()) { }); } -app.route("/auth", authRoutes(oauth)); +app.route("/auth", authRoutes(oauth, deviceTokens)); app.route("/api", apiRoutes(oauth, mf)); -app.route("/device", deviceRoutes(oauth, mf, bodies)); +app.route("/device", deviceRoutes(oauth, mf, bodies, deviceTokens)); const packageRoot = resolve(import.meta.dirname, "../.."); const publicDir = resolve(packageRoot, "dist/public"); diff --git a/src/server/routes-auth.ts b/src/server/routes-auth.ts index 6efca58..b3f4878 100644 --- a/src/server/routes-auth.ts +++ b/src/server/routes-auth.ts @@ -1,4 +1,4 @@ -import { Hono } from "hono"; +import { Hono, type Context } from "hono"; import { getCookie, setCookie, deleteCookie } from "hono/cookie"; import type { NightshadeOAuth } from "./oauth.js"; import { @@ -8,26 +8,33 @@ import { deleteBrowserSession, getBrowserSession, } from "./browser-sessions.js"; +import type { DeviceTokenStore } from "./device-tokens.js"; import { isLoopback } from "./config.js"; -export function authRoutes(oauth: NightshadeOAuth) { +export function authRoutes(oauth: NightshadeOAuth, tokens: DeviceTokenStore) { const app = new Hono(); - app.get("/status", async (c) => { + async function requireDid(c: Context): Promise { const id = getCookie(c, BROWSER_SESSION_COOKIE); - if (!id) return c.json({ authenticated: false }); + if (!id) return null; const bs = getBrowserSession(id); if (!bs) { deleteCookie(c, BROWSER_SESSION_COOKIE, { path: "/" }); - return c.json({ authenticated: false }); + return null; } const session = await oauth.getSessionForDid(bs.did); if (!session) { deleteBrowserSession(id); deleteCookie(c, BROWSER_SESSION_COOKIE, { path: "/" }); - return c.json({ authenticated: false }); + return null; } - return c.json({ authenticated: true, did: session.did }); + return session.did; + } + + app.get("/status", async (c) => { + const did = await requireDid(c); + if (!did) return c.json({ authenticated: false }); + return c.json({ authenticated: true, did }); }); app.post("/login", async (c) => { @@ -66,5 +73,29 @@ export function authRoutes(oauth: NightshadeOAuth) { return c.body(null, 204); }); + app.get("/device-tokens", async (c) => { + const did = await requireDid(c); + if (!did) return c.json({ error: "not authenticated" }, 401); + return c.json(tokens.list(did)); + }); + + app.post("/device-tokens", async (c) => { + const did = await requireDid(c); + if (!did) return c.json({ error: "not authenticated" }, 401); + const { label } = await c.req + .json<{ label?: string }>() + .catch(() => ({ label: "" })); + const { record, token } = tokens.create(did, label ?? ""); + return c.json({ ...record, token }); + }); + + app.delete("/device-tokens/:id", async (c) => { + const did = await requireDid(c); + if (!did) return c.json({ error: "not authenticated" }, 401); + const ok = tokens.revoke(did, c.req.param("id")); + if (!ok) return c.json({ error: "not found" }, 404); + return c.body(null, 204); + }); + return app; } diff --git a/src/server/routes-device.ts b/src/server/routes-device.ts index d954998..59d3cc6 100644 --- a/src/server/routes-device.ts +++ b/src/server/routes-device.ts @@ -1,36 +1,39 @@ import { Hono } from "hono"; import { parseHTML } from "linkedom"; -import type { AtprotoRepo } from "./atproto.js"; +import { AtprotoRepo } from "./atproto.js"; import type { BodyCache } from "./body-cache.js"; import type { MinifluxClient } from "./miniflux.js"; import type { NightshadeOAuth } from "./oauth.js"; +import type { DeviceTokenStore } from "./device-tokens.js"; import { htmlToText } from "./readability.js"; import { renderItem, renderList } from "./reader-format.js"; import { minifluxAllowed } from "./config.js"; import type { UnifiedItem } from "../shared/types.js"; +type Env = { Variables: { repo: AtprotoRepo } }; + export function deviceRoutes( oauth: NightshadeOAuth, mf: MinifluxClient, bodies: BodyCache, + tokens: DeviceTokenStore, ) { - const app = new Hono(); + const app = new Hono(); - async function getRepo(): Promise { - // Device/e-reader endpoints have no cookie. For now, only auto-resolve - // a session when exactly one DID is stored (single-user deployment). - // Multi-user device access should be added via explicit device tokens. - const dids = oauth.listDids(); - if (dids.length !== 1) return null; - const session = await oauth.getSessionForDid(dids[0]!); - if (!session) return null; - const { AtprotoRepo } = await import("./atproto.js"); - return new AtprotoRepo(session); - } + app.use("/*", async (c, next) => { + const token = extractToken(c.req.raw); + if (!token) return c.text("missing token\n", 401); + const record = tokens.verify(token); + if (!record) return c.text("invalid token\n", 401); + const session = await oauth.getSessionForDid(record.did); + if (!session) return c.text("session expired; re-auth on server\n", 401); + tokens.touch(record.id); + c.set("repo", new AtprotoRepo(session)); + return next(); + }); app.get("/list", async (c) => { - const repo = await getRepo(); - if (!repo) return c.text("not authenticated\n", 401); + const repo = c.get("repo"); const all = c.req.query("all") !== undefined; const limit = Number(c.req.query("limit") ?? 50); @@ -76,12 +79,11 @@ export function deviceRoutes( }); app.get("/item/:id", async (c) => { + const repo = c.get("repo"); const rawId = c.req.param("id"); const page = Number(c.req.query("page") ?? 1); try { if (rawId.startsWith("s")) { - const repo = await getRepo(); - if (!repo) return c.text("not authenticated\n", 401); const rkey = rawId.slice(1); const save = await repo.getSave(rkey); if (!save) return c.text("not found\n", 404); @@ -100,8 +102,7 @@ export function deviceRoutes( { "Content-Type": "text/plain; charset=utf-8" }, ); } - const repo = await getRepo(); - if (!repo || !minifluxAllowed(repo.did)) + if (!minifluxAllowed(repo.did)) return c.text("not allowed\n", 403); const entry = await mf.getEntry(Number(rawId)); const body = entry.content @@ -119,14 +120,12 @@ export function deviceRoutes( }); app.post("/item/:id/read", async (c) => { + const repo = c.get("repo"); const rawId = c.req.param("id"); if (rawId.startsWith("s")) { - const repo = await getRepo(); - if (!repo) return c.text("not authenticated\n", 401); await repo.markSaveRead(rawId.slice(1), true); } else { - const repo = await getRepo(); - if (!repo || !minifluxAllowed(repo.did)) + if (!minifluxAllowed(repo.did)) return c.text("not allowed\n", 403); await mf.markEntries([Number(rawId)], "read"); } @@ -136,6 +135,18 @@ export function deviceRoutes( return app; } +function extractToken(req: Request): string | null { + const auth = req.headers.get("authorization"); + if (auth) { + const m = auth.match(/^Bearer\s+(.+)$/i); + if (m) return m[1]!.trim(); + } + const url = new URL(req.url); + const q = url.searchParams.get("token"); + if (q) return q.trim(); + return null; +} + function extractFromStoredHtml(html: string): string { try { const { document } = parseHTML( diff --git a/src/web/App.tsx b/src/web/App.tsx index a17d96b..12eb0af 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -1,8 +1,13 @@ import { useEffect, useState } from "preact/hooks"; -import { api, type AuthStatus } from "./api.js"; +import { + api, + type AuthStatus, + type CreatedDeviceToken, + type DeviceToken, +} from "./api.js"; import type { FeedView, SaveView } from "../shared/lexicons.js"; -type Tab = "saves" | "feeds"; +type Tab = "saves" | "feeds" | "devices"; export function App() { const [tab, setTab] = useState("saves"); @@ -40,6 +45,12 @@ export function App() { > Feeds + + + )} +
+
+ {data ? `${active.length} active` : ""} +
+ +
+ {err &&
{err}
} + {active.length === 0 && data && ( +
No device tokens yet.
+ )} + {active.length > 0 && ( +
    + {active.map((t) => ( + + ))} +
+ )} + + ); +} + +function CreateDeviceToken({ + onCreated, +}: { + onCreated: (t: CreatedDeviceToken) => void; +}) { + const [label, setLabel] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + + const submit = async (e: Event) => { + e.preventDefault(); + if (!label.trim()) return; + setBusy(true); + setErr(null); + try { + const created = await api.createDeviceToken(label.trim()); + setLabel(""); + onCreated(created); + } catch (e) { + setErr((e as Error).message); + } finally { + setBusy(false); + } + }; + + return ( + <> +
+ setLabel((e.target as HTMLInputElement).value)} + required + disabled={busy} + /> + +
+ {err &&
{err}
} + + ); +} + +function DeviceTokenRow({ + view, + onChange, +}: { + view: DeviceToken; + onChange: () => void; +}) { + const revoke = async () => { + if (!confirm(`Revoke token "${view.label}"?`)) return; + await api.revokeDeviceToken(view.id); + onChange(); + }; + const lastUsed = view.lastUsedAt + ? `last used ${formatDate(view.lastUsedAt)}` + : "never used"; + return ( +
  • +
    +
    {view.label}
    +
    + {view.prefix}… · created {formatDate(view.createdAt)} · {lastUsed} +
    +
    +
    + +
    +
  • + ); +} + function formatDate(iso: string): string { const d = new Date(iso); return d.toLocaleDateString(undefined, { diff --git a/src/web/api.ts b/src/web/api.ts index 2fb31b4..e2f4bdc 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -30,6 +30,18 @@ export type AuthStatus = | { authenticated: false } | { authenticated: true; did: string }; +export type DeviceToken = { + id: string; + did: string; + label: string; + prefix: string; + createdAt: string; + lastUsedAt: string | null; + revokedAt: string | null; +}; + +export type CreatedDeviceToken = DeviceToken & { token: string }; + export const api = { authStatus: (): Promise => req("/auth/status"), @@ -70,4 +82,15 @@ export const api = { syncNow: (): Promise<{ added: number; removed: number }> => req("/api/sync", { method: "POST" }), + + listDeviceTokens: (): Promise => req("/auth/device-tokens"), + + createDeviceToken: (label: string): Promise => + req("/auth/device-tokens", { + method: "POST", + body: JSON.stringify({ label }), + }), + + revokeDeviceToken: (id: string): Promise => + req(`/auth/device-tokens/${id}`, { method: "DELETE" }), }; diff --git a/src/web/styles.css b/src/web/styles.css index 0332fa7..b6c74f8 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -206,3 +206,20 @@ ul.list li.read { color: var(--muted); padding: 1rem 0; } + +.new-token { + background: rgba(180, 140, 40, 0.1); + border-left: 3px solid #b48c28; + padding: 0.75rem 1rem; + border-radius: 3px; + margin-bottom: 1rem; +} + +.new-token pre { + background: rgba(0, 0, 0, 0.25); + padding: 0.5rem 0.75rem; + border-radius: 3px; + overflow-x: auto; + user-select: all; + font-size: 0.9rem; +} -- 2.51.2