101 lines
5 KiB
JavaScript
101 lines
5 KiB
JavaScript
|
|
/// <reference path="../pb_data/types.d.ts" />
|
||
|
|
|
||
|
|
// Codeless device sign-in / phone-approve (epic #6, issue #12).
|
||
|
|
//
|
||
|
|
// A new device (a TV) signs into an account with **no password typing and no
|
||
|
|
// code transcription**: it shows a QR + short human code, and an already-logged-in
|
||
|
|
// phone/PC opens the approve page, authenticates, and authorizes the device. The
|
||
|
|
// TV then receives a minted session token and pulls its data (#11) + its
|
||
|
|
// client-encrypted addon config (#20) — no pairing code, nothing more to type.
|
||
|
|
//
|
||
|
|
// This collection is the request/handshake record. It is **never** driven through
|
||
|
|
// the normal collection REST rules by the TV — the TV is unauthenticated when it
|
||
|
|
// creates the request and when it polls — so all of the create/poll/approve/
|
||
|
|
// decline logic lives in the `pb_hooks/device_auth.pb.js` routes, which run with
|
||
|
|
// superuser context and hand back only the fields a given caller may see. The
|
||
|
|
// collection rules here therefore expose **only** the owner-scoped list/view/
|
||
|
|
// delete (the device list + revoke, #12's "device list / revoke"): create/update
|
||
|
|
// are locked to superusers (i.e. the hooks), and every sensitive field is `hidden`
|
||
|
|
// so it can never leak through the record API even to the owner.
|
||
|
|
//
|
||
|
|
// Fields:
|
||
|
|
// - `code` short human-readable code (also the QR's `?code=`), unique.
|
||
|
|
// - `deviceName` what the phone shows in "Approve <deviceName>?".
|
||
|
|
// - `status` pending | approved | denied.
|
||
|
|
// - `user` rel→users, set on approval (the account that authorized it).
|
||
|
|
// - `devicePubKey` the TV's ephemeral X25519 public key (base64), used by the
|
||
|
|
// approve page to end-to-end encrypt the addon-config vault key
|
||
|
|
// to the TV. Public by nature; hidden only to keep the record
|
||
|
|
// API tidy.
|
||
|
|
// - `authToken` (hidden) the minted user session token, delivered to the TV
|
||
|
|
// exactly once via the poll route then cleared.
|
||
|
|
// - `keyCiphertext` (hidden) the addon-config vault key, encrypted to
|
||
|
|
// `devicePubKey` by the approve page — the server never sees the
|
||
|
|
// key itself (E2E). Delivered once then cleared.
|
||
|
|
// - `pollSecret` (hidden) a secret only the requesting TV holds; the poll route
|
||
|
|
// returns the sensitive fields only to a caller presenting it.
|
||
|
|
// - `expiresAt` unix seconds; a request past it is rejected (~5 min TTL). A
|
||
|
|
// plain number (not a date) so the hooks can compare it against
|
||
|
|
// the wall clock without any datetime parsing in the JS VM.
|
||
|
|
|
||
|
|
migrate((app) => {
|
||
|
|
const users = app.findCollectionByNameOrId("users")
|
||
|
|
|
||
|
|
const deviceAuth = new Collection({
|
||
|
|
type: "base",
|
||
|
|
name: "device_auth",
|
||
|
|
// Owner-scoped read + delete only: this is the device list (approved rows)
|
||
|
|
// and revoke. Create/update happen in the hooks under superuser context, so
|
||
|
|
// they are intentionally locked here (null == superuser-only).
|
||
|
|
listRule: "@request.auth.id != '' && user = @request.auth.id",
|
||
|
|
viewRule: "@request.auth.id != '' && user = @request.auth.id",
|
||
|
|
createRule: null,
|
||
|
|
updateRule: null,
|
||
|
|
deleteRule: "@request.auth.id != '' && user = @request.auth.id",
|
||
|
|
fields: [
|
||
|
|
{ type: "text", name: "code", required: true, max: 40 },
|
||
|
|
{ type: "text", name: "deviceName", max: 100 },
|
||
|
|
{
|
||
|
|
type: "select",
|
||
|
|
name: "status",
|
||
|
|
required: true,
|
||
|
|
maxSelect: 1,
|
||
|
|
values: ["pending", "approved", "denied"],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: "relation",
|
||
|
|
name: "user",
|
||
|
|
required: false,
|
||
|
|
collectionId: users.id,
|
||
|
|
cascadeDelete: true,
|
||
|
|
maxSelect: 1,
|
||
|
|
minSelect: 0,
|
||
|
|
},
|
||
|
|
// The TV's ephemeral public key (base64). Not secret.
|
||
|
|
{ type: "text", name: "devicePubKey", hidden: true, max: 500 },
|
||
|
|
// Minted session token — delivered once, then cleared. Never returned by
|
||
|
|
// the record API (hidden); only the poll hook hands it back, over the
|
||
|
|
// matching pollSecret.
|
||
|
|
{ type: "text", name: "authToken", hidden: true, max: 2000 },
|
||
|
|
// Vault key encrypted to devicePubKey (E2E; opaque to the server).
|
||
|
|
{ type: "text", name: "keyCiphertext", hidden: true, max: 4000 },
|
||
|
|
// Only the requesting device holds this; gates the poll route.
|
||
|
|
{ type: "text", name: "pollSecret", hidden: true, max: 200 },
|
||
|
|
// Unix seconds. Past it → the request is dead.
|
||
|
|
{ type: "number", name: "expiresAt", required: true },
|
||
|
|
{ type: "autodate", name: "created", onCreate: true },
|
||
|
|
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||
|
|
],
|
||
|
|
indexes: [
|
||
|
|
"CREATE UNIQUE INDEX `idx_device_auth_code` ON `device_auth` (`code`)",
|
||
|
|
"CREATE INDEX `idx_device_auth_user` ON `device_auth` (`user`)",
|
||
|
|
],
|
||
|
|
})
|
||
|
|
app.save(deviceAuth)
|
||
|
|
}, (app) => {
|
||
|
|
// ---- Down migration ----------------------------------------------------
|
||
|
|
try {
|
||
|
|
app.delete(app.findCollectionByNameOrId("device_auth"))
|
||
|
|
} catch (_) { /* already gone */ }
|
||
|
|
})
|