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
+