/// // Codeless device sign-in — server routes (epic #6, issue #12). // // The TV is unauthenticated when it creates a request and when it polls, so the // whole handshake lives here in hooks (superuser context) rather than in the // `device_auth` collection's REST rules — those expose only the owner-scoped // device list + revoke. Every route hands back exactly the fields its caller may // see; the minted session token and the E2E-encrypted vault key are delivered to // the TV once, over a per-request `pollSecret`, then scrubbed. // // Crypto note: `keyCiphertext` is the addon-config vault key (issue #20) // encrypted by the approve page to the TV's ephemeral public key. The server // stores and forwards it as an opaque blob and never holds the key — see // `pb_hooks/device_page.pb.js` and the app's `device_key_transfer.dart`. const DEVICE_TTL_SECONDS = 300 // 5 min to approve a fresh request const PICKUP_TTL_SECONDS = 120 // after approval, the TV has this long to fetch // Human code: 8 chars, unambiguous alphabet (Crockford minus I L O U), shown as // XXXX-XXXX. Stored without the dash; the app + page normalize before lookup. const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" function nowSeconds() { return Math.floor(Date.now() / 1000) } function genCode() { return $security.randomStringWithAlphabet(8, CODE_ALPHABET) } // ── POST /api/device-auth/request ───────────────────────────────────────────── // Public. The TV creates a sign-in request and gets back the code to show, the // pollSecret only it will hold, and the id + expiry. Body: { deviceName, // devicePubKey }. routerAdd("POST", "/api/device-auth/request", (e) => { const data = new DynamicModel({ deviceName: "", devicePubKey: "" }) e.bindBody(data) const collection = $app.findCollectionByNameOrId("device_auth") const pollSecret = $security.randomString(40) // Retry on the (astronomically unlikely) unique-code collision. let record = null let lastErr = null for (let i = 0; i < 5; i++) { const rec = new Record(collection) rec.set("code", genCode()) rec.set("deviceName", (data.deviceName || "").substring(0, 100)) rec.set("status", "pending") rec.set("devicePubKey", (data.devicePubKey || "").substring(0, 500)) rec.set("pollSecret", pollSecret) rec.set("expiresAt", nowSeconds() + DEVICE_TTL_SECONDS) try { $app.save(rec) record = rec break } catch (err) { lastErr = err } } if (!record) { throw new BadRequestError("could not create device request", lastErr) } return e.json(200, { id: record.id, code: record.getString("code"), pollSecret: pollSecret, expiresAt: record.getInt("expiresAt"), ttl: DEVICE_TTL_SECONDS, }) }) // ── GET /api/device-auth/info?code=CODE ─────────────────────────────────────── // Public, non-secret. The approve page uses it to show "Approve ?" // and to read the TV's ephemeral public key it encrypts the vault key to. Returns // nothing sensitive (no pollSecret, no token). routerAdd("GET", "/api/device-auth/info", (e) => { const code = (e.request.url.query().get("code") || "").toUpperCase().trim() if (!code) throw new BadRequestError("missing code") let record try { record = $app.findFirstRecordByFilter( "device_auth", "code = {:code}", { code: code }) } catch (_) { throw new NotFoundError("unknown code") } if (record.getInt("expiresAt") < nowSeconds() && record.getString("status") === "pending") { throw new NotFoundError("request expired") } return e.json(200, { deviceName: record.getString("deviceName"), devicePubKey: record.getString("devicePubKey"), status: record.getString("status"), }) }) // ── GET /api/device-auth/poll?id=..&secret=.. ───────────────────────────────── // Public but pollSecret-gated. The TV polls this until approved/denied/expired. // The minted token + the E2E vault-key ciphertext are only ever returned here, to // a caller holding the matching pollSecret. routerAdd("GET", "/api/device-auth/poll", (e) => { const id = e.request.url.query().get("id") || "" const secret = e.request.url.query().get("secret") || "" if (!id || !secret) throw new BadRequestError("missing id/secret") let record try { record = $app.findRecordById("device_auth", id) } catch (_) { throw new NotFoundError("unknown request") } // Constant-ish comparison; secret is a 40-char random string. if (record.getString("pollSecret") !== secret) { throw new ForbiddenError("bad secret") } const status = record.getString("status") const expiresAt = record.getInt("expiresAt") if (status === "pending") { if (expiresAt < nowSeconds()) return e.json(200, { status: "expired" }) return e.json(200, { status: "pending", expiresAt: expiresAt }) } if (status === "denied") { return e.json(200, { status: "denied" }) } // approved. const token = record.getString("authToken") if (!token) { // Already picked up (token scrubbed) — the TV has its session. Nothing more. return e.json(200, { status: "approved" }) } if (expiresAt < nowSeconds()) { // Pickup window elapsed before the TV fetched — treat as expired so it // restarts cleanly rather than adopting a token about to be scrubbed. return e.json(200, { status: "expired" }) } const keyCiphertext = record.getString("keyCiphertext") const uid = record.getString("user") let userRecord = null if (uid) { try { const u = $app.findRecordById("users", uid) userRecord = { id: u.id, email: u.getString("email"), username: u.getString("username"), verified: u.getBool("verified"), } } catch (_) { /* user gone; deliver token anyway */ } } return e.json(200, { status: "approved", token: token, keyCiphertext: keyCiphertext, record: userRecord, }) }) // ── POST /api/device-auth/approve ───────────────────────────────────────────── // Auth required (a logged-in user). Authorizes a pending request: mints a session // token for the caller and stores the E2E vault-key ciphertext the page produced. // Body: { code, keyCiphertext }. routerAdd("POST", "/api/device-auth/approve", (e) => { const data = new DynamicModel({ code: "", keyCiphertext: "" }) e.bindBody(data) const code = (data.code || "").toUpperCase().trim() if (!code) throw new BadRequestError("missing code") let record try { record = $app.findFirstRecordByFilter( "device_auth", "code = {:code}", { code: code }) } catch (_) { throw new NotFoundError("unknown code") } if (record.getString("status") !== "pending") { throw new BadRequestError("request is not pending") } if (record.getInt("expiresAt") < nowSeconds()) { throw new BadRequestError("request expired") } // e.auth is the authenticated users record — mint a real session token for it. const token = e.auth.newAuthToken() record.set("status", "approved") record.set("user", e.auth.id) record.set("authToken", token) record.set("keyCiphertext", (data.keyCiphertext || "").substring(0, 4000)) // Reset the clock to the shorter pickup window for the TV to fetch the token. record.set("expiresAt", nowSeconds() + PICKUP_TTL_SECONDS) $app.save(record) return e.json(200, { ok: true, deviceName: record.getString("deviceName") }) }, $apis.requireAuth("users")) // ── POST /api/device-auth/decline ───────────────────────────────────────────── // Auth required. The user declines a pending request. routerAdd("POST", "/api/device-auth/decline", (e) => { const data = new DynamicModel({ code: "" }) e.bindBody(data) const code = (data.code || "").toUpperCase().trim() if (!code) throw new BadRequestError("missing code") let record try { record = $app.findFirstRecordByFilter( "device_auth", "code = {:code}", { code: code }) } catch (_) { throw new NotFoundError("unknown code") } if (record.getString("status") === "pending") { record.set("status", "denied") // Scrub the pending secrets immediately; a declined request keeps nothing. record.set("pollSecret", "") record.set("devicePubKey", "") $app.save(record) } return e.json(200, { ok: true }) }, $apis.requireAuth("users")) // ── Cleanup cron ────────────────────────────────────────────────────────────── // Scrub the minted token + vault ciphertext from approved rows whose pickup // window elapsed (they've been delivered, or the TV gave up), and delete stale // pending/denied rows. The device list keeps the approved rows (status + user + // created), just without any live secret at rest. cronAdd("device_auth_cleanup", "*/5 * * * *", () => { const now = nowSeconds() try { const stale = $app.findRecordsByFilter( "device_auth", "status = 'approved' && authToken != '' && expiresAt < {:now}", "created", 200, 0, { now: now }) for (const r of stale) { r.set("authToken", "") r.set("keyCiphertext", "") r.set("pollSecret", "") r.set("devicePubKey", "") $app.save(r) } } catch (_) { /* best effort */ } try { // Pending that never got approved, and declined rows: drop them a day after // expiry so they don't accumulate. (Approved rows stay — they're the device // list.) const dead = $app.findRecordsByFilter( "device_auth", "(status = 'pending' || status = 'denied') && expiresAt < {:cutoff}", "created", 200, 0, { cutoff: now - 86400 }) for (const r of dead) $app.delete(r) } catch (_) { /* best effort */ } })