diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 32dd36d..674d773 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -31,7 +31,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js import type { DashboardHostAssets } from "./dashboard-host-assets.js"; import { createDashboardHostRoutes } from "./dashboard-host-routes.js"; import { createDashboardRoutes } from "./dashboard-routes.js"; -import { createDeviceRoutes } from "./device-routes.js"; +import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js"; import type { DisplayHostAssets } from "./display-host-assets.js"; import { createGrantsRoutes } from "./grants-routes.js"; import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js"; @@ -407,6 +407,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono { registry: options.authorization.registry, runtime, ...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}), + ...(options.devices + ? { + renewDeviceCredential: (c: Context) => + renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true), + } + : {}), }); if (options.devices) { diff --git a/apps/api/src/device-routes.ts b/apps/api/src/device-routes.ts index ba9a6c6..de0fbbe 100644 --- a/apps/api/src/device-routes.ts +++ b/apps/api/src/device-routes.ts @@ -92,7 +92,7 @@ function readString(body: Record, key: string, max: number): st */ function writeDeviceCookie( c: Context, - options: DeviceRoutesOptions, + options: Pick, name: string, value: string, maxAgeSeconds: number, @@ -106,6 +106,27 @@ function writeDeviceCookie( }); } +/** + * Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056). + * + * The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still + * holds a working credential. Only the browser's own expiry is pushed forward, matching the + * server's sliding window. Called only after the request resolved to a live, unrevoked device. + */ +export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void { + const credential = getCookie(c, DEVICE_SESSION_COOKIE); + if (credential === undefined || credential.length === 0) { + return; + } + writeDeviceCookie( + c, + { secureCookies }, + DEVICE_SESSION_COOKIE, + credential, + Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000), + ); +} + export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { const routes = new HonoApp(); const now = options.now ?? ((): Date => new Date()); diff --git a/apps/api/src/module-display-routes.ts b/apps/api/src/module-display-routes.ts index 81cb5ea..4429e58 100644 --- a/apps/api/src/module-display-routes.ts +++ b/apps/api/src/module-display-routes.ts @@ -83,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio /** Omitted only when the registry contains no display entrypoints. Production reads the exact * surface-owned files before binding and supplies their frozen contents here. */ readonly assets?: DisplayHostAssets; + /** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the + * deployment has device support; called only for a request that resolved to a device. */ + readonly renewDeviceCredential?: (c: Context) => void; } /** A composition error, detected before any display route is registered. Carries no module id, @@ -199,6 +202,11 @@ function mountDisplay( if (!viewer) { return displayFailure(c); } + // Every screen asks for this on start, on reconnect, and periodically while running, so it + // is where an in-use credential's browser expiry is pushed forward. + if (principal.kind === "device") { + options.renewDeviceCredential?.(c); + } return c.json(await describeDisplay(options, principal, display, viewer), 200); } catch { return displayFailure(c); diff --git a/apps/api/tests/device-pairing.integration.ts b/apps/api/tests/device-pairing.integration.ts index 99f6d4b..a2bc7d1 100644 --- a/apps/api/tests/device-pairing.integration.ts +++ b/apps/api/tests/device-pairing.integration.ts @@ -782,6 +782,127 @@ describe.sequential("display device pairing, end to end", () => { } }); + /** + * ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last + * seen. Ages are set directly in the database, exactly as months of real use would leave them. + */ + describe("credential lifetime", () => { + const ttlSeconds = 180 * 24 * 60 * 60; + const descriptor = "/api/v1/module-displays/announcements"; + + function renewal(response: Response): string | undefined { + return setCookieFor(response, "ekklesia_device_session"); + } + + it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => { + const running = await platform("renewal"); + const device = new DeviceBrowser(running.origin); + try { + await pairDevice(running, device); + const credential = device.header().replace(/^.*ekklesia_device_session=/u, ""); + // Paired 200 days ago, and seen yesterday: a screen that has simply been working. + await query( + running.url, + `update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`, + ); + + const response = await device.request(descriptor); + expect(response.status).toBe(200); + // The same credential, re-issued with a full lifetime and the same protections. + const header = renewal(response); + expect(header).toBeDefined(); + expect(header).toContain(`ekklesia_device_session=${credential};`); + expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u")); + expect(header).toMatch(/HttpOnly/u); + expect(header).toMatch(/SameSite=Strict/u); + expect(header).toMatch(/Path=\//u); + expect(header).not.toMatch(/Domain=/iu); + + // Using it moved the server's window forward too. + const [row] = await query<{ recent: boolean }>( + running.url, + `select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`, + ); + expect(row?.recent).toBe(true); + } finally { + await running.close(); + } + }); + + it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => { + const running = await platform("legacy"); + const device = new DeviceBrowser(running.origin); + try { + await pairDevice(running, device); + // A production screen paired 179 days ago that checked in this morning: under the old + // rule it had one day left. It must carry straight on. + await query( + running.url, + `update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`, + ); + expect((await device.request(descriptor)).status).toBe(200); + await query( + running.url, + `update ekklesia_core_device set created_at = now() - interval '190 days'`, + ); + expect((await device.request(descriptor)).status).toBe(200); + } finally { + await running.close(); + } + }); + + it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => { + const running = await platform("abandoned"); + const device = new DeviceBrowser(running.origin); + try { + await pairDevice(running, device); + await query( + running.url, + `update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`, + ); + const response = await device.request(descriptor); + // Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code. + expect(response.status).toBe(403); + expect(renewal(response)).toBeUndefined(); + } finally { + await running.close(); + } + }); + + it("never renews a revoked screen", async () => { + const running = await platform("revoked-renewal"); + const device = new DeviceBrowser(running.origin); + try { + await pairDevice(running, device); + const listed = (await ( + await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices") + ).json()) as { devices: Array<{ id: string }> }; + await adminRequest( + running.origin, + running.ownerCookie, + `/api/v1/devices/${listed.devices[0]?.id}/revoke`, + {}, + ); + const response = await device.request(descriptor); + expect(response.status).toBe(403); + expect(renewal(response)).toBeUndefined(); + } finally { + await running.close(); + } + }); + + it("does not hand a device cookie to a person viewing the display", async () => { + const running = await platform("human-renewal"); + try { + const response = await adminRequest(running.origin, running.ownerCookie, descriptor); + expect(response.status).toBe(200); + expect(renewal(response)).toBeUndefined(); + } finally { + await running.close(); + } + }); + }); + it("keeps a human administrator's own session unaffected by a device cookie", async () => { const running = await platform("mixed"); const device = new DeviceBrowser(running.origin); diff --git a/core/src/devices/store.ts b/core/src/devices/store.ts index 5d3ffa2..1bdf4d4 100644 --- a/core/src/devices/store.ts +++ b/core/src/devices/store.ts @@ -44,17 +44,26 @@ export class DeviceStoreError extends Error { export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000; /** - * How long a device credential is accepted before the device must pair again. + * How long a device credential may go **unused** before the device must pair again (ADR 0056). * - * A display runs unattended for months; forcing a signage player to be re-paired every fortnight - * because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen - * on a schedule. What makes a long horizon safe is that it is not the security boundary: the server - * decides on every single request whether the device is still revoked, so an administrator's revoke - * takes effect immediately regardless of how long the credential would otherwise have lived. The - * expiry exists so an abandoned, forgotten screen eventually stops being an identity at all. + * The window slides: every request from the device moves `lastSeenAt` forward, and the host + * re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an + * administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it + * on every single request. The window exists only so an abandoned, forgotten screen eventually stops + * being an identity at all: 180 days after it was last seen, its credential is dead. */ export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000; +/** When a device credential expires: the idle window, measured from the last time the device was + * seen, or from pairing if it never has been. */ +export function deviceCredentialExpiresAt(input: { + readonly createdAt: Date; + readonly lastSeenAt?: Date | null | undefined; +}): Date { + const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0); + return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS); +} + /** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling * realtime and assets would otherwise turn an operational nicety into a continuous write load. */ export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000; @@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore { } // The credential's own horizon, measured from when the device was paired. Revocation is // checked above and is what makes a long horizon acceptable. - if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) { + // Expiry is measured from last use, not from pairing, so a screen in use never ages out. + if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) { return undefined; } return Object.freeze({ diff --git a/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md b/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md index e73bf26..a5fcc6a 100644 --- a/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md +++ b/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md @@ -2,6 +2,7 @@ - Status: Accepted - Date: 2026-08-24 +- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md) - Extends: [0013](0013-core-authorization-and-owner-bootstrap.md), [0032](0032-native-module-realtime-transport.md), [0033](0033-display-kiosk-host-contract.md), diff --git a/docs/architecture/decisions/0056-device-credentials-last-while-in-use.md b/docs/architecture/decisions/0056-device-credentials-last-while-in-use.md new file mode 100644 index 0000000..0e61461 --- /dev/null +++ b/docs/architecture/decisions/0056-device-credentials-last-while-in-use.md @@ -0,0 +1,76 @@ +# 0056: A device credential lasts while the device is in use + +- Status: Accepted +- Date: 2026-10-12 +- Supersedes: the "Session lifetime" section of [0036](0036-managed-device-identity-and-pairing.md). + Everything else in ADR 0036 — the device as its own principal, pairing, revocation on every + request, re-pairing as a new identity — is unchanged. + +## Context + +ADR 0036 gave a device credential a fixed 180 days from pairing. The intent was that a screen on a +ceiling should not need re-pairing on a short cycle, and that an abandoned screen should eventually +stop being an identity. + +A fixed horizon does the second and fails the first. Every screen in the building expires on the +anniversary of its pairing whether or not it was in use the day before, and drops to a pairing code +in front of the congregation until someone with Dashboard access walks to it. For screens paired +together, that is every screen at once. Nothing about a working, unrevoked screen is less trustworthy +on day 181 than on day 179; only the calendar changed. + +## Decision + +**The 180 days are an idle window, not a lifetime.** A device credential is accepted while the +device was last seen less than 180 days ago (or, if it has never been seen, was paired less than 180 +days ago). `lastSeenAt` already moves forward on authenticated device requests (throttled to one +write per 15 minutes), so the server-side window slides with use and needs no new column or +migration. + +**The browser's copy is renewed by the host, not rotated.** The display descriptor — +`GET /api/v1/module-displays/`, which every display requests when it starts, when it +reconnects, and periodically while it runs — re-issues the device cookie that authenticated the +request with a fresh 180-day `Max-Age` and the same attributes as at pairing (`HttpOnly`, +`SameSite=Strict`, host-only, `Path=/`, `Secure` on https). The value is unchanged. + +Renewal happens only for a request that already resolved to a live, unrevoked device. A revoked, +expired, or unknown credential is anonymous, as before, and is never renewed; a person viewing a +display never receives a device cookie. + +**Revocation remains the boundary.** It is still checked on every request, and an administrator's +revoke still takes effect immediately. + +## Why not rotate + +Rotation (issue a new value, invalidate the old) would bound how long any one stolen value works and +could reveal a cloned credential. For an unattended screen it trades that for a new way to strand +the display: a renewal response lost to a network drop, a power cut, or a browser that never +persisted the new cookie leaves the screen holding a dead credential and showing a pairing code, +which is the failure this decision exists to remove. A refresh-token scheme with overlap windows +could avoid that, at the cost of real complexity in the one place mistakes are invisible until a +wall goes dark. That is not justified while revocation is immediate and a device principal can only +read the displays it was paired for. + +## Consequences + +- A screen that is used at least once every 180 days stays paired until an administrator revokes + it. An abandoned screen still stops being an identity 180 days after it was last seen. +- Existing credentials need no migration. A screen paired under the fixed rule is renewed the next + time it requests its descriptor, provided its browser still holds the cookie. +- A stolen device credential stays usable for as long as anyone keeps using it, until it is revoked. + Under ADR 0036 it was usable for at most 180 days from pairing. The exposure is bounded by what a + device may do — the display permissions of the module it was paired for, never a person's access + — and by revocation. Operators can see each device's last-seen time and revoke anything + unexpected. +- A credential is not bound to a browser or network. Like a human session cookie, whoever presents + it is the device. + +## Alternatives + +- **Keep the fixed 180 days.** Rejected: every in-use screen fails on a schedule, and screens paired + together fail together. +- **Never expire.** Rejected: an abandoned or forgotten screen would remain an identity forever, and + its credential would never stop working without someone remembering to revoke it. +- **Rotate on renewal.** Rejected for now; see above. +- **Renew on every authenticated response.** Rejected: principal resolution happens deep in shared + middleware that never writes responses. One endpoint every display already requests periodically + is enough, and keeps the behaviour in one visible place. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 18c70de..ca39271 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -44,7 +44,7 @@ ADRs capture durable choices that affect more than one package or phase. - [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted - [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted - [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted -- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted +- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted (session lifetime superseded by 0056) - [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted - [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted - [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted @@ -64,3 +64,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted - [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted - [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted +- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted