fix(devices): keep an in-use display paired, renewing its credential instead of expiring it
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 39s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m22s
CI / image-build (pull_request) Successful in 1m1s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m34s
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 39s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m22s
CI / image-build (pull_request) Successful in 1m1s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m34s
A device credential was accepted for a fixed 180 days from pairing, so every working screen dropped to a pairing code on the anniversary of its pairing, and screens paired together failed together. Measure the 180 days from when the device was last seen instead, and have the display descriptor - which every screen requests on start, on reconnect, and periodically - re-issue the cookie that authenticated it with a fresh lifetime. The value is not rotated, so a renewal lost in transit cannot strand a screen. Revoked, expired, and unknown credentials are never renewed, a person never receives a device cookie, and an abandoned screen still stops being an identity 180 days after it was last seen. No migration: lastSeenAt already slides with use. ADR 0056 supersedes the session-lifetime section of ADR 0036. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ae976ca229
commit
650932a170
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user