From cd4c0dbf5de967ac1f381496e108919720b03fec Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Tue, 29 Sep 2026 12:49:43 +0200 Subject: [PATCH 1/7] experimental(auth): add device sessions and device-code login flow --- apps/backend/src/index.ts | 36 ++++- apps/backend/src/lib/data/links.ts | 9 +- .../lib/data/pocketbase-permissions.test.ts | 33 +++++ apps/backend/src/lib/data/sessions.ts | 12 ++ apps/backend/src/lib/device-code.ts | 126 ++++++++++++++++++ apps/backend/src/routes/auth.route.ts | 25 ++++ apps/backend/src/routes/links.route.ts | 3 +- apps/backend/src/routes/sessions.route.ts | 10 +- .../(authenticated)/settings/account/page.tsx | 37 ++++- apps/web/src/components/auth/LoginForm.tsx | 21 ++- apps/web/src/lib/apiClient.ts | 28 ++++ apps/web/src/lib/session.ts | 4 + ...1785000006_secure_link_collection_rules.js | 79 +++++++++++ ...07_secure_notification_collection_rules.js | 45 +++++++ 14 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 apps/backend/src/lib/data/pocketbase-permissions.test.ts create mode 100644 apps/backend/src/lib/device-code.ts create mode 100644 pocketbase/migrations/1785000006_secure_link_collection_rules.js create mode 100644 pocketbase/migrations/1785000007_secure_notification_collection_rules.js diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 8d0b095f3..96f1ca801 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -12,6 +12,7 @@ import { subscribeActivity, } from "./lib/activity"; import { ensureSession } from "./lib/data/sessions"; +import { authenticateDeviceSocket, closeDeviceRequest, createDeviceRequest } from "./lib/device-code"; import { jobsApi, registerJobsCron } from "./jobs/index"; import { startPocketbase } from "./pocketbase"; import { createLogger } from "./lib/logger"; @@ -93,7 +94,40 @@ app.route("/", dataRoute); app.get("/health", (c) => c.json({ status: "ok" })); -app.get("/api/v1/activity", upgradeWebSocket((c) => { +// Device login never accepts credentials in the URL. The first WebSocket frame +// is only a protocol acknowledgement; the server owns the hidden secret. +app.get("/api/v1/auth/device-code", upgradeWebSocket((c) => { + let requestId = ""; + return { + onOpen(_event, ws) { + try { + if (config.ENVIRONMENT === "production" && !["https", "wss"].includes((c.req.header("x-forwarded-proto") ?? new URL(c.req.url).protocol.replace(":", "")))) { + ws.close(1008, "HTTPS is required"); + return; + } + const request = createDeviceRequest(ws, c.req.header("x-forwarded-for")?.split(",")[0] ?? "unknown", c.req.header("user-agent") ?? "unknown"); + requestId = request.id; + ws.send(JSON.stringify({ type: "device-code", code: request.code, secret: request.secret, requestId: request.id, expiresAt: request.expiresAt })); + } catch (error) { + ws.send(JSON.stringify({ type: "error", message: error instanceof Error ? error.message : "Device login unavailable" })); + ws.close(1013, "Device login unavailable"); + } + }, + onMessage(event, ws) { + try { + const message = JSON.parse(String(event.data)); + if (message.type === "authenticate" && requestId && authenticateDeviceSocket(requestId, String(message.secret ?? ""))) { + ws.send(JSON.stringify({ type: "authenticated" })); + } else { + ws.close(1008, "Invalid device login"); + } + } catch { ws.close(1008, "Invalid device login"); } + }, + onClose() { if (requestId) closeDeviceRequest(requestId); }, + }; +})); + +app.get("/api/v1/activity", upgradeWebSocket((c) => { let refreshTimer: ReturnType | undefined; let unsubscribeActivity: (() => void) | undefined; let unregisterSessionConnection: (() => void) | undefined; diff --git a/apps/backend/src/lib/data/links.ts b/apps/backend/src/lib/data/links.ts index 9a6ccf337..1ae93d0e4 100644 --- a/apps/backend/src/lib/data/links.ts +++ b/apps/backend/src/lib/data/links.ts @@ -207,9 +207,10 @@ export async function getLinksItems(userId: string, listId: string, folderId?: s })); } -export async function getLinksTags() { +export async function getLinksTags(userId: string) { const pb = getServerPB(); const records = await pb.collection("linksTags").getFullList({ + filter: `user = "${userId}"`, sort: "name", }); @@ -231,11 +232,10 @@ export async function createLinkTag( throw new Error("Tag name is required"); } - void userId; - const record = await pb.collection("linksTags").create({ name: normalizedName, color: data.color ?? "", + user: userId, }); return { @@ -274,7 +274,8 @@ export async function updateLinkTag( data: { name?: string; color?: string }, ): Promise<{ id: string; name: string; color: string }> { const pb = getServerPB(); - void userId; + const tag = await pb.collection("linksTags").getOne(tagId); + if (tag.user !== userId) throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); const record = await pb.collection("linksTags").update(tagId, { ...(data.name !== undefined && { name: String(data.name).trim() }), diff --git a/apps/backend/src/lib/data/pocketbase-permissions.test.ts b/apps/backend/src/lib/data/pocketbase-permissions.test.ts new file mode 100644 index 000000000..32affa59f --- /dev/null +++ b/apps/backend/src/lib/data/pocketbase-permissions.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; + +const migrationsDirectory = `${import.meta.dir}/../../../../../pocketbase/migrations`; + +async function readMigration(name: string) { + return Bun.file(`${migrationsDirectory}/${name}`).text(); +} + +describe("PocketBase collection permission migrations", () => { + test("link collections require an authenticated owner", async () => { + const migration = await readMigration("1785000006_secure_link_collection_rules.js"); + + expect(migration).not.toContain('"createRule": ""'); + expect(migration).not.toContain('"listRule": ""'); + expect(migration).toContain("@request.body.user = @request.auth.id"); + expect(migration).toContain("@request.body.list.user = @request.auth.id"); + expect(migration).toContain("@request.body.collection.user = @request.auth.id"); + expect(migration).toContain("list.user = @request.auth.id"); + expect(migration).toContain("collection.user = @request.auth.id"); + expect(migration).toContain("@request.body.user:changed = false"); + }); + + test("notifications are owner-readable and backend-writable only", async () => { + const migration = await readMigration("1785000007_secure_notification_collection_rules.js"); + + expect(migration).not.toContain('"listRule": ""'); + expect(migration).toContain("topicId.userId = @request.auth.id"); + expect(migration).toContain("userId = @request.auth.id"); + expect(migration.match(/"createRule": null/g)).toHaveLength(4); + expect(migration.match(/"updateRule": null/g)).toHaveLength(4); + expect(migration.match(/"deleteRule": null/g)).toHaveLength(4); + }); +}); diff --git a/apps/backend/src/lib/data/sessions.ts b/apps/backend/src/lib/data/sessions.ts index 074925499..d888fd8a3 100644 --- a/apps/backend/src/lib/data/sessions.ts +++ b/apps/backend/src/lib/data/sessions.ts @@ -124,6 +124,18 @@ export async function getCurrentSession( return session; } +export async function listSessions(pb: { collection: (name: "sessions") => any }, userId: string) { + return (await pb.collection("sessions").getFullList({ sort: "-lastSeenAt" })) + .filter((session: RecordModel) => session.user === userId).map(toSessionRecord); +} + +export async function revokeSession(pb: { collection: (name: "sessions") => any }, userId: string, rawSessionId: unknown) { + const session = await getSessionById(pb, userId, rawSessionId); + if (!session) throw new ApiActionError("Session not found", 404, { error: "Session not found" }); + await pb.collection("sessions").delete(session.id); + return { success: true }; +} + export async function renameCurrentSession( pb: { collection: (name: "sessions") => any }, userId: string, diff --git a/apps/backend/src/lib/device-code.ts b/apps/backend/src/lib/device-code.ts new file mode 100644 index 000000000..92112ea63 --- /dev/null +++ b/apps/backend/src/lib/device-code.ts @@ -0,0 +1,126 @@ +import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; + +import { ApiActionError } from "./data/auth"; +import { ensureSession } from "./data/sessions"; +import { config } from "./config"; + +type DeviceSocket = { send: (data: string) => void; close?: (code?: number, reason?: string) => void }; + +type PendingDeviceCode = { + id: string; + userId: string | null; + code: string; + secret: string; + createdAt: string; + expiresAt: number; + ip: string; + userAgent: string; + socket: DeviceSocket; + consumed: boolean; + socketAuthenticated: boolean; +}; + +const pending = new Map(); +const rate = new Map(); +const auditEvents: Array> = []; +const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +function purge() { + const now = Date.now(); + for (const [id, request] of pending) { + if (request.expiresAt <= now || request.consumed) pending.delete(id); + } + for (const [key, timestamps] of rate) { + const current = timestamps.filter((time) => time > now - 60_000); + if (current.length) rate.set(key, current); else rate.delete(key); + } +} + +function limited(key: string, maximum: number) { + purge(); + const timestamps = rate.get(key) ?? []; + if (timestamps.length >= maximum) return true; + timestamps.push(Date.now()); + rate.set(key, timestamps); + return false; +} + +function randomCode() { + let code = ""; + for (let i = 0; i < 6; i++) code += codeAlphabet[randomBytes(1)[0] % codeAlphabet.length]; + return `${code.slice(0, 3)}-${code.slice(3)}`; +} + +function sameSecret(a: string, b: string) { + const left = Buffer.from(a); + const right = Buffer.from(b); + return left.length === right.length && timingSafeEqual(left, right); +} + +export function createDeviceRequest(socket: DeviceSocket, ip: string, userAgent: string) { + purge(); + const activeSockets = [...pending.values()].filter((request) => !request.consumed).length; + if (pending.size >= config.AUTH_DEVICE_CODE_MAX_PENDING || + activeSockets >= config.AUTH_DEVICE_CODE_MAX_SOCKETS) { + throw new ApiActionError("Device login is temporarily unavailable", 429, { error: "Device login is temporarily unavailable" }); + } + if (limited(`create:${ip}`, 10)) { + throw new ApiActionError("Too many device login requests", 429, { error: "Too many device login requests" }); + } + let code = randomCode(); + while (pending.has(code)) code = randomCode(); + const request: PendingDeviceCode = { + id: randomUUID(), userId: null, code, secret: randomBytes(32).toString("base64url"), + createdAt: new Date().toISOString(), expiresAt: Date.now() + config.AUTH_DEVICE_CODE_TTL_MS, + ip: ip || "unknown", userAgent: userAgent.slice(0, 300), socket, consumed: false, socketAuthenticated: false, + }; + pending.set(request.id, request); + return { id: request.id, code: request.code, secret: request.secret, expiresAt: new Date(request.expiresAt).toISOString() }; +} + +function invalidCode() { + return new ApiActionError("Invalid or expired device code", 400, { error: "Invalid or expired device code" }); +} + +export function findDeviceRequest(rawCode: unknown, ip: string) { + const code = typeof rawCode === "string" ? rawCode.trim().toUpperCase() : ""; + if (!/^[A-Z0-9]{3}-[A-Z0-9]{3}$/.test(code) || limited(`entry:${ip}`, 20)) throw invalidCode(); + purge(); + const request = [...pending.values()].find((item) => item.code === code && !item.consumed); + if (!request || request.expiresAt <= Date.now()) throw invalidCode(); + return { id: request.id, code: request.code, createdAt: request.createdAt, expiresAt: new Date(request.expiresAt).toISOString(), ip: request.ip, userAgent: request.userAgent }; +} + +export async function approveDeviceRequest(rawCode: unknown, approver: { pb: any; userId: string; sessionId: string | null }, ip: string) { + const viewed = findDeviceRequest(rawCode, ip); + const request = pending.get(viewed.id); + if (!request || request.consumed || !request.socketAuthenticated) throw invalidCode(); + if (request.userId && request.userId !== approver.userId) throw invalidCode(); + request.userId = approver.userId; + request.consumed = true; + const authModel = await approver.pb.collection("users").authRefresh(); + const token = authModel.token; + const sessionId = randomUUID().replace(/-/g, ""); + await ensureSession(approver.pb, approver.userId, sessionId, { clientType: "device-code", platform: request.userAgent }); + request.socket.send(JSON.stringify({ type: "approved", token, user: authModel.record, sessionId })); + auditEvents.push({ type: "device_login_approved", userId: approver.userId, approvingSessionId: approver.sessionId, createdSessionId: sessionId, createdAt: new Date().toISOString() }); + request.socket.close?.(1000, "Approved"); + return { success: true }; +} + +export function cancelDeviceRequest(rawCode: unknown, ip: string) { + const viewed = findDeviceRequest(rawCode, ip); + const request = pending.get(viewed.id); + if (request) { request.consumed = true; request.socket.send(JSON.stringify({ type: "cancelled" })); request.socket.close?.(1000, "Cancelled"); } + return { success: true }; +} + +export function authenticateDeviceSocket(id: string, secret: string) { + const request = pending.get(id); + if (!request || request.consumed || request.expiresAt <= Date.now() || !sameSecret(request.secret, secret)) return false; + request.socketAuthenticated = true; + return true; +} + +export function getDeviceAuditEvents() { return auditEvents.slice(-100); } +export function closeDeviceRequest(id: string) { const request = pending.get(id); if (request) { request.consumed = true; pending.delete(id); } } diff --git a/apps/backend/src/routes/auth.route.ts b/apps/backend/src/routes/auth.route.ts index 7dd61134d..4b292c367 100644 --- a/apps/backend/src/routes/auth.route.ts +++ b/apps/backend/src/routes/auth.route.ts @@ -11,10 +11,13 @@ import { import type { UserPropertyValue } from "../lib/data/auth"; import { ensureBuiltinIntegrations } from "../lib/data/integrations"; import { getSuperuserPB } from "../lib/pb/pocketbase"; +import { approveDeviceRequest, cancelDeviceRequest, findDeviceRequest } from "../lib/device-code"; import { loadSignupDefaults, readAuthToken, + readAuth, + requireAuth, readJsonBody, routeRedirectTarget, withJson, @@ -53,6 +56,28 @@ authRoute.get( }); }), ) + .post( + "/api/v1/auth/device-code/lookup", + withJson(async (c) => { + const auth = await requireAuth(readAuth(c)); + return findDeviceRequest((await readJsonBody<{ code?: string }>(c)).code, c.req.header("x-forwarded-for")?.split(",")[0] ?? "unknown"); + }), + ) + .post( + "/api/v1/auth/device-code/approve", + withJson(async (c) => { + const requestAuth = readAuth(c); + const auth = await requireAuth(requestAuth); + return approveDeviceRequest((await readJsonBody<{ code?: string }>(c)).code, { ...auth, sessionId: requestAuth.sessionId }, c.req.header("x-forwarded-for")?.split(",")[0] ?? "unknown"); + }), + ) + .post( + "/api/v1/auth/device-code/cancel", + withJson(async (c) => { + await requireAuth(readAuth(c)); + return cancelDeviceRequest((await readJsonBody<{ code?: string }>(c)).code, c.req.header("x-forwarded-for")?.split(",")[0] ?? "unknown"); + }), + ) .post( "/api/v1/auth/signup", withJson(async (c) => { diff --git a/apps/backend/src/routes/links.route.ts b/apps/backend/src/routes/links.route.ts index 797336ffe..60f7024e8 100644 --- a/apps/backend/src/routes/links.route.ts +++ b/apps/backend/src/routes/links.route.ts @@ -101,8 +101,7 @@ linksRoute })) .get("/api/v1/links/tags", withJson(async (c) => { const { userId } = await requireAuth({ token: readAuthToken(c) }); - void userId; - return getLinksTags(); + return getLinksTags(userId); })) .post("/api/v1/links/tags", withJson(async (c) => { const body = await readJsonBody(c); diff --git a/apps/backend/src/routes/sessions.route.ts b/apps/backend/src/routes/sessions.route.ts index 948c179d6..9bf38adf3 100644 --- a/apps/backend/src/routes/sessions.route.ts +++ b/apps/backend/src/routes/sessions.route.ts @@ -1,11 +1,19 @@ import { Hono } from "hono"; -import { getCurrentSession, renameCurrentSession } from "../lib/data/sessions"; +import { getCurrentSession, listSessions, renameCurrentSession, revokeSession } from "../lib/data/sessions"; import { readAuth, readJsonBody, readSessionMetadata, requireAuth, withJson } from "./shared"; const sessionsRoute = new Hono(); sessionsRoute + .get("/api/v1/sessions", withJson(async (c) => { + const auth = await requireAuth(readAuth(c)); + return listSessions(auth.pb, auth.userId); + })) + .delete("/api/v1/sessions/:sessionId", withJson(async (c) => { + const auth = await requireAuth(readAuth(c)); + return revokeSession(auth.pb, auth.userId, c.req.param("sessionId")); + })) .get("/api/v1/sessions/current", withJson(async (c) => { const requestAuth = readAuth(c); const auth = await requireAuth(requestAuth); diff --git a/apps/web/src/app/(authenticated)/settings/account/page.tsx b/apps/web/src/app/(authenticated)/settings/account/page.tsx index 610f827f2..296bd9d76 100644 --- a/apps/web/src/app/(authenticated)/settings/account/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/account/page.tsx @@ -17,7 +17,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { ChangePasswordRequest } from '@/lib/apiClient'; import { useNavigate } from "react-router-dom"; import { changePasswordAction, deleteAccountAction } from '@/lib/apiClient'; -import { getCurrentSessionAction, renameCurrentSessionAction } from '@/lib/apiClient'; +import { approveDeviceCodeAction, cancelDeviceCodeAction, getCurrentSessionAction, listSessionsAction, lookupDeviceCodeAction, renameCurrentSessionAction, revokeSessionAction } from '@/lib/apiClient'; import { DialogDescription } from "@radix-ui/react-dialog"; import useAuth from "@/context/useAuth"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -41,7 +41,13 @@ export default function AccountSettingsPage() { const [sessionName, setSessionName] = useState(""); const [sessionError, setSessionError] = useState(null); const [isDeviceNameDialogOpen, setIsDeviceNameDialogOpen] = useState(false); + const [isDeviceCodeDialogOpen, setIsDeviceCodeDialogOpen] = useState(false); + const [deviceCode, setDeviceCode] = useState(""); + const [deviceRequest, setDeviceRequest] = useState> | null>(null); + const [deviceCodeError, setDeviceCodeError] = useState(null); + const [deviceCodeLoading, setDeviceCodeLoading] = useState(false); const queryClient = useQueryClient(); + const sessionsQuery = useQuery({ queryKey: ["auth", "sessions", token], enabled: Boolean(token), queryFn: () => withAuth(listSessionsAction) }); const sessionQuery = useQuery({ queryKey: queryKeys.auth.session(token), enabled: Boolean(token), @@ -79,6 +85,22 @@ export default function AccountSettingsPage() { const needsDeviceName = !normalizedSessionDisplayName || DEFAULT_SESSION_NAMES.has(normalizedSessionDisplayName); + const handleDeviceCodeSubmit = async (event: React.FormEvent) => { + event.preventDefault(); setDeviceCodeError(null); setDeviceCodeLoading(true); + try { setDeviceRequest(await lookupDeviceCodeAction({ token }, deviceCode)); } + catch (cause: any) { setDeviceCodeError(cause?.message ?? "Invalid or expired device code"); } + finally { setDeviceCodeLoading(false); } + }; + const approveDeviceCode = async () => { + setDeviceCodeLoading(true); setDeviceCodeError(null); + try { await approveDeviceCodeAction({ token }, deviceCode); setIsDeviceCodeDialogOpen(false); setDeviceCode(""); setDeviceRequest(null); } + catch (cause: any) { setDeviceCodeError(cause?.message ?? "Unable to approve device"); } + finally { setDeviceCodeLoading(false); } + }; + const cancelDeviceCode = async () => { + try { await cancelDeviceCodeAction({ token }, deviceCode); setIsDeviceCodeDialogOpen(false); setDeviceCode(""); setDeviceRequest(null); } catch (cause: any) { setDeviceCodeError(cause?.message ?? "Unable to cancel device"); } + }; + const handleChangePasswordSubmit = async ( e: React.FormEvent, ) => { @@ -153,7 +175,7 @@ export default function AccountSettingsPage() { } const payload: { email: string; password: string; totp?: string } = { - email: user.email ?? user.username ?? "", + email: String(user.email ?? user.username ?? ""), password: deletePassword, }; @@ -188,6 +210,7 @@ export default function AccountSettingsPage() {

Authentication

+

Active sessions

{sessionsQuery.data?.map((session) =>
{session.displayName} · last used {new Date(session.lastSeenAt).toLocaleString()}
)}{sessionsQuery.data?.length === 0 &&

No active sessions found.

}
+ { setIsDeviceCodeDialogOpen(open); if (!open) { setDeviceRequest(null); setDeviceCodeError(null); } }}> + +

Authenticate another session using Device Code

+
+ Authenticate another session using Device CodeEnter the code shown on the other browser. Verify its details before approving. +
setDeviceCode(e.target.value.toUpperCase())} placeholder="ABC-DEF" maxLength={7} disabled={deviceCodeLoading || !!deviceRequest} /> + {deviceRequest &&

Requested: {new Date(deviceRequest.createdAt).toLocaleString()}

Browser: {deviceRequest.userAgent}

IP: {deviceRequest.ip}

}{deviceCodeError &&

{deviceCodeError}

}
{deviceRequest ? <> : <>}
+
+
+ diff --git a/apps/web/src/components/auth/LoginForm.tsx b/apps/web/src/components/auth/LoginForm.tsx index 0cc600b74..0ba40f61b 100644 --- a/apps/web/src/components/auth/LoginForm.tsx +++ b/apps/web/src/components/auth/LoginForm.tsx @@ -1,12 +1,13 @@ "use client" import { Link, useNavigate } from "react-router-dom" -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import { useMutation, useQuery } from "@tanstack/react-query" import { getAppConfigAction } from '@/lib/apiClient'; -import { loginUserAction, validateAuthTokenAction } from '@/lib/apiClient'; +import { deviceCodeSocketUrl, loginUserAction, validateAuthTokenAction } from '@/lib/apiClient'; import useAuth from "@/context/useAuth" import { queryKeys } from "@/lib/queryClient"; +import { setClientSessionId } from "@/lib/session"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" @@ -30,10 +31,25 @@ export default function LoginCard() { const [password, setPassword] = useState("") const [error, setError] = useState(null) const [success, setSuccess] = useState(null) + const [deviceCode, setDeviceCode] = useState(null) + const deviceSocket = useRef(null) const appConfigQuery = useQuery({ queryKey: queryKeys.appConfig, queryFn: getAppConfigAction }); const enableSSO = appConfigQuery.data?.enableSSO ?? false; const loginMutation = useMutation({ mutationFn: loginUserAction }); + useEffect(() => { + const socket = new WebSocket(deviceCodeSocketUrl()); + deviceSocket.current = socket; + socket.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.type === "device-code") { setDeviceCode(message.code); socket.send(JSON.stringify({ type: "authenticate", requestId: message.requestId, secret: message.secret })); } + if (message.type === "approved") { setClientSessionId(message.sessionId); setAuth(message.user, message.token); setSuccess("Device approved! Redirecting..."); setTimeout(() => navigate("/home"), 500); } + if (message.type === "error") setError(message.message); + }; + socket.onerror = () => setError("Unable to start device login"); + return () => { socket.close(); deviceSocket.current = null; }; + }, [navigate, setAuth]); + //on load: check for existing auth, validate using /api/v1/auth/validate-auth endpoint if returned success to /home useEffect(() => { @@ -153,6 +169,7 @@ export default function LoginCard() { className="frosted" required /> +

Or login using Device Code {deviceCode ?? "connecting..."}