260 lines
10 KiB
JavaScript
260 lines
10 KiB
JavaScript
|
|
/// <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`.
|
||
|
|
|
||
|
|
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 <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 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 */ }
|
||
|
|
})
|