amber-backend/pb_hooks/device_auth.pb.js

263 lines
11 KiB
JavaScript
Raw Normal View History

/// <reference path="../pb_data/types.d.ts" />
// 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`.
// NOTE: PocketBase runs every routerAdd/cronAdd handler in its OWN isolated JSVM,
// so file-level consts/functions are NOT visible inside the callbacks — referencing
// them throws `ReferenceError: x is not defined` at request time. So everything a
// handler needs (nowSeconds, the code alphabet, the TTLs) is defined *inside* it.
// Keep it that way; don't hoist helpers back to file scope.
// ── 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 nowSeconds = () => Math.floor(Date.now() / 1000)
const DEVICE_TTL_SECONDS = 300 // 5 min to approve a fresh request
// 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"
const genCode = () => $security.randomStringWithAlphabet(8, CODE_ALPHABET)
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 <deviceName>?"
// 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 nowSeconds = () => Math.floor(Date.now() / 1000)
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 nowSeconds = () => Math.floor(Date.now() / 1000)
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 nowSeconds = () => Math.floor(Date.now() / 1000)
const PICKUP_TTL_SECONDS = 120 // after approval, the TV has this long to fetch
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 nowSeconds = () => Math.floor(Date.now() / 1000)
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 */ }
})