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]>
932 lines
35 KiB
TypeScript
932 lines
35 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
createAuthorizationDrizzleDatabase,
|
|
createInitialPlatformOwner,
|
|
} from "@ekklesia/core/authorization";
|
|
import {
|
|
createBootstrapIdentityCreator,
|
|
createIdentityDrizzleDatabase,
|
|
} from "@ekklesia/core/identity";
|
|
import {
|
|
applyModuleMigrations,
|
|
createPostgresMigrationDatabase,
|
|
discoverAllMigrations,
|
|
} from "@ekklesia/core/migrations";
|
|
import { Client, type QueryResultRow } from "pg";
|
|
import { afterAll, describe, expect, it } from "vitest";
|
|
import { createApiRuntimeConfig } from "../src/config.js";
|
|
import { type RunningApi, startApi } from "../src/server.js";
|
|
|
|
/**
|
|
* Device pairing end to end, against real PostgreSQL and the real HTTP transport (ADR 0036).
|
|
*
|
|
* The whole point is that none of this is mocked. A screen with no identity asks to be paired, a
|
|
* real administrator signs in through the real login transport and approves it, the host issues a
|
|
* credential in a real `Set-Cookie`, and the resulting device opens a real display descriptor and a
|
|
* real realtime stream — and is then revoked and loses all of it. Anything short of that would
|
|
* prove that rows were written, not that a television in a foyer works.
|
|
*/
|
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
|
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
|
|
const trackedDatabases = new Set<string>();
|
|
const trackedDirectories: string[] = [];
|
|
let databaseCounter = 0;
|
|
|
|
const identityBaseUrl = "https://identity.example.test";
|
|
const dashboardOrigin = "https://dashboard.example.test";
|
|
const untrustedOrigin = "https://evil.example.test";
|
|
const ownerPassword = "correct horse battery staple";
|
|
const displayPermission = "announcements.slideshow.view";
|
|
|
|
class IntegrationHarnessError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "IntegrationHarnessError";
|
|
this.stack = `${this.name}: ${this.message}`;
|
|
}
|
|
}
|
|
|
|
function baseDatabaseUrl(): string {
|
|
const url = process.env[integrationDatabaseUrlEnvironment];
|
|
if (typeof url !== "string" || url.trim() === "") {
|
|
throw new IntegrationHarnessError("The integration database URL is not configured.");
|
|
}
|
|
return url;
|
|
}
|
|
|
|
function databaseUrl(name: string): string {
|
|
const url = new URL(baseDatabaseUrl());
|
|
url.pathname = `/${name}`;
|
|
return url.toString();
|
|
}
|
|
|
|
async function openAdministrativeClient(): Promise<Client> {
|
|
const client = new Client({ connectionString: baseDatabaseUrl() });
|
|
await client.connect();
|
|
return client;
|
|
}
|
|
|
|
async function safeQuery<T extends QueryResultRow>(
|
|
client: Client,
|
|
text: string,
|
|
values: readonly unknown[] = [],
|
|
): Promise<T[]> {
|
|
try {
|
|
return (await client.query<T>(text, [...values])).rows;
|
|
} catch {
|
|
throw new IntegrationHarnessError("An integration database statement failed.");
|
|
}
|
|
}
|
|
|
|
async function query<T extends QueryResultRow>(
|
|
url: string,
|
|
text: string,
|
|
values: readonly unknown[] = [],
|
|
): Promise<T[]> {
|
|
const client = new Client({ connectionString: url });
|
|
await client.connect();
|
|
try {
|
|
return await safeQuery<T>(client, text, values);
|
|
} finally {
|
|
await client.end().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
/** A display-capable module: one display entrypoint, one restricted permission protecting it, and
|
|
* one realtime channel gated by that same permission. */
|
|
async function createDisplayModuleDirectory(): Promise<string> {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-"));
|
|
trackedDirectories.push(directory);
|
|
const moduleDirectory = path.join(directory, "announcements");
|
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
|
await Promise.all([
|
|
writeFile(
|
|
path.join(moduleDirectory, "package.json"),
|
|
JSON.stringify({
|
|
ekklesia: { layer: "module" },
|
|
name: "@test/module-announcements",
|
|
type: "module",
|
|
version: "1.0.0",
|
|
}),
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
|
JSON.stringify({
|
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
|
entrypoints: {
|
|
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
|
server: "./dist/server/index.js",
|
|
},
|
|
id: "announcements",
|
|
manifestVersion: 8,
|
|
name: "Announcements fixture",
|
|
permissions: [
|
|
{
|
|
baseline: "restricted",
|
|
description: "View the announcement slideshow.",
|
|
id: displayPermission,
|
|
},
|
|
],
|
|
version: "1.0.0",
|
|
}),
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "dist/server/index.js"),
|
|
`export default {
|
|
id: "announcements",
|
|
version: "1.0.0",
|
|
register({ realtime }) {
|
|
realtime.channel({ channel: "state", permission: ${JSON.stringify(displayPermission)} });
|
|
},
|
|
start({ realtime }) {
|
|
realtime.publish("state", { slides: [] });
|
|
},
|
|
stop() {},
|
|
};\n`,
|
|
),
|
|
writeFile(path.join(moduleDirectory, "dist/ui/display.js"), "export function mount() {}\n"),
|
|
]);
|
|
return directory;
|
|
}
|
|
|
|
async function createMigratedDatabase(label: string): Promise<string> {
|
|
databaseCounter += 1;
|
|
const name =
|
|
`ekk_device_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
|
|
const admin = await openAdministrativeClient();
|
|
try {
|
|
await safeQuery(admin, `create database "${name}"`);
|
|
} finally {
|
|
await admin.end().catch(() => undefined);
|
|
}
|
|
trackedDatabases.add(name);
|
|
const url = databaseUrl(name);
|
|
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-device-no-modules-"));
|
|
try {
|
|
const aggregate = await discoverAllMigrations({
|
|
coreMigrationsDirectory,
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: emptyModules,
|
|
platformVersion: "0.1.0",
|
|
},
|
|
});
|
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
|
} finally {
|
|
await rm(emptyModules, { force: true, recursive: true });
|
|
}
|
|
return url;
|
|
}
|
|
|
|
async function startPlatform(url: string, modulesDirectory: string): Promise<RunningApi> {
|
|
const config = createApiRuntimeConfig(
|
|
{
|
|
databaseUrl: url,
|
|
host: "127.0.0.1",
|
|
identity: {
|
|
baseUrl: identityBaseUrl,
|
|
// The production posture: no public signup anywhere in this suite.
|
|
publicSignupEnabled: false,
|
|
secret: "integration-test-secret-0123456789abcdefgh",
|
|
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
|
},
|
|
port: 0,
|
|
},
|
|
{ environment: "development" },
|
|
);
|
|
return startApi(config, {
|
|
modulesDirectoryOverride: modulesDirectory,
|
|
rateLimitOverride: { max: 1000, window: 1 },
|
|
});
|
|
}
|
|
|
|
function apiOrigin(running: RunningApi): string {
|
|
const address = running.server.address();
|
|
if (!address || typeof address !== "object") {
|
|
throw new IntegrationHarnessError("The API integration server has no bound address.");
|
|
}
|
|
return `http://127.0.0.1:${address.port}`;
|
|
}
|
|
|
|
/** Matches a cookie by suffix, because Better Auth prefixes its session cookie with `__Secure-`
|
|
* whenever the configured base URL is https — which this harness's is. */
|
|
function cookiePair(response: Response, name: string): string | undefined {
|
|
for (const header of response.headers.getSetCookie()) {
|
|
const pair = header.split(";")[0]?.trim() ?? "";
|
|
const cookieName = pair.slice(0, Math.max(0, pair.indexOf("=")));
|
|
if (cookieName === name || cookieName.endsWith(name)) {
|
|
return pair;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function setCookieFor(response: Response, name: string): string | undefined {
|
|
return response.headers.getSetCookie().find((header) => header.startsWith(`${name}=`));
|
|
}
|
|
|
|
async function createOwner(url: string, origin: string): Promise<string> {
|
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
|
const authorization = createAuthorizationDrizzleDatabase(url);
|
|
const identity = createIdentityDrizzleDatabase(url, {});
|
|
try {
|
|
await createInitialPlatformOwner(
|
|
{
|
|
authorizationDb: authorization.db,
|
|
createIdentity: createBootstrapIdentityCreator(identity.db),
|
|
identityDb: identity.db,
|
|
},
|
|
{ email, name: "Platform Owner", password: ownerPassword },
|
|
);
|
|
} finally {
|
|
await Promise.all([authorization.close(), identity.close()]);
|
|
}
|
|
const signIn = await fetch(`${origin}/api/auth/sign-in/email`, {
|
|
body: JSON.stringify({ email, password: ownerPassword }),
|
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
|
method: "POST",
|
|
});
|
|
const cookie = cookiePair(signIn, "better-auth.session_token");
|
|
if (signIn.status !== 200 || cookie === undefined) {
|
|
throw new IntegrationHarnessError("The platform owner could not sign in.");
|
|
}
|
|
return cookie;
|
|
}
|
|
|
|
interface Platform {
|
|
readonly api: RunningApi;
|
|
readonly origin: string;
|
|
readonly url: string;
|
|
readonly ownerCookie: string;
|
|
readonly close: () => Promise<void>;
|
|
}
|
|
|
|
async function platform(label: string): Promise<Platform> {
|
|
const url = await createMigratedDatabase(label);
|
|
const modulesDirectory = await createDisplayModuleDirectory();
|
|
const api = await startPlatform(url, modulesDirectory);
|
|
const origin = apiOrigin(api);
|
|
const ownerCookie = await createOwner(url, origin);
|
|
return {
|
|
api,
|
|
close: async () => {
|
|
await api.shutdown();
|
|
},
|
|
origin,
|
|
ownerCookie,
|
|
url,
|
|
};
|
|
}
|
|
|
|
/** The device's own browser: no human session, only whatever cookies the host has set on it. */
|
|
class DeviceBrowser {
|
|
#cookies = new Map<string, string>();
|
|
|
|
constructor(private readonly origin: string) {}
|
|
|
|
absorb(response: Response): void {
|
|
for (const header of response.headers.getSetCookie()) {
|
|
const pair = header.split(";")[0] ?? "";
|
|
const separator = pair.indexOf("=");
|
|
if (separator === -1) {
|
|
continue;
|
|
}
|
|
const name = pair.slice(0, separator).trim();
|
|
const value = pair.slice(separator + 1).trim();
|
|
if (value.length === 0 || /max-age=0/iu.test(header)) {
|
|
this.#cookies.delete(name);
|
|
} else {
|
|
this.#cookies.set(name, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
header(): string {
|
|
return [...this.#cookies].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
}
|
|
|
|
has(name: string): boolean {
|
|
return this.#cookies.has(name);
|
|
}
|
|
|
|
overwrite(name: string, value: string): void {
|
|
this.#cookies.set(name, value);
|
|
}
|
|
|
|
async request(path: string, init: RequestInit = {}): Promise<Response> {
|
|
const cookie = this.header();
|
|
const response = await fetch(`${this.origin}${path}`, {
|
|
...init,
|
|
headers: {
|
|
origin: dashboardOrigin,
|
|
...(cookie.length > 0 ? { cookie } : {}),
|
|
...(init.headers as Record<string, string> | undefined),
|
|
},
|
|
});
|
|
this.absorb(response);
|
|
return response;
|
|
}
|
|
|
|
post(path: string, body: unknown): Promise<Response> {
|
|
return this.request(path, {
|
|
body: JSON.stringify(body),
|
|
headers: { "content-type": "application/json" },
|
|
method: "POST",
|
|
});
|
|
}
|
|
}
|
|
|
|
function adminRequest(
|
|
origin: string,
|
|
cookie: string,
|
|
path: string,
|
|
body?: unknown,
|
|
overrides: RequestInit = {},
|
|
): Promise<Response> {
|
|
return fetch(`${origin}${path}`, {
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
headers: {
|
|
cookie,
|
|
origin: dashboardOrigin,
|
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
...(overrides.headers as Record<string, string> | undefined),
|
|
},
|
|
method: body === undefined ? "GET" : "POST",
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
/** Drives a screen from "nobody has paired me" to "I hold a device session". */
|
|
async function pairDevice(
|
|
platform: Platform,
|
|
device: DeviceBrowser,
|
|
deviceName = "Foyer TV",
|
|
): Promise<{ readonly code: string }> {
|
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
|
moduleId: "announcements",
|
|
});
|
|
if (requested.status !== 201) {
|
|
throw new IntegrationHarnessError("The pairing request was refused.");
|
|
}
|
|
const { code } = (await requested.json()) as { code: string };
|
|
const approved = await adminRequest(
|
|
platform.origin,
|
|
platform.ownerCookie,
|
|
"/api/v1/device-pairing/approvals",
|
|
{ code, deviceName },
|
|
);
|
|
if (approved.status !== 200) {
|
|
throw new IntegrationHarnessError("The pairing approval was refused.");
|
|
}
|
|
const claimed = await device.post("/api/v1/device-pairing/claims", {});
|
|
if (((await claimed.json()) as { status: string }).status !== "paired") {
|
|
throw new IntegrationHarnessError("The device was not issued a session.");
|
|
}
|
|
return { code };
|
|
}
|
|
|
|
afterAll(async () => {
|
|
const admin = await openAdministrativeClient().catch(() => undefined);
|
|
if (admin) {
|
|
try {
|
|
for (const name of trackedDatabases) {
|
|
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
|
|
() => undefined,
|
|
);
|
|
}
|
|
} finally {
|
|
await admin.end().catch(() => undefined);
|
|
}
|
|
}
|
|
for (const directory of trackedDirectories.splice(0)) {
|
|
await rm(directory, { force: true, recursive: true }).catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
describe.sequential("display device pairing, end to end", () => {
|
|
it("takes an unpaired screen all the way to a working display, and back off again", async () => {
|
|
const running = await platform("e2e");
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
// 1. The screen loads. It has no identity, so the descriptor refuses it — but the page it
|
|
// needs in order to show a code is served.
|
|
expect((await device.request("/display/announcements")).status).toBe(200);
|
|
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
|
|
|
|
// 2. It asks to be paired and receives a code plus an HttpOnly claim cookie.
|
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
|
moduleId: "announcements",
|
|
});
|
|
expect(requested.status).toBe(201);
|
|
const body = (await requested.json()) as Record<string, unknown>;
|
|
expect(body.code).toMatch(/^[0-9]{6}$/u);
|
|
// The claim secret is never in the body — only in a cookie script cannot read.
|
|
expect(JSON.stringify(body)).not.toContain("claim");
|
|
const claimCookie = setCookieFor(requested, "ekklesia_device_pairing");
|
|
expect(claimCookie).toMatch(/HttpOnly/iu);
|
|
|
|
// 3. Nothing has happened yet: polling says pending, and the display is still refused.
|
|
expect(
|
|
(
|
|
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
|
|
status: string;
|
|
}
|
|
).status,
|
|
).toBe("pending");
|
|
|
|
// 4. The administrator looks the code up, sees what it wants, and approves it by name.
|
|
const lookup = await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/device-pairing/lookups",
|
|
{ code: body.code },
|
|
);
|
|
expect(lookup.status).toBe(200);
|
|
expect(await lookup.json()).toMatchObject({
|
|
moduleId: "announcements",
|
|
permissionId: displayPermission,
|
|
});
|
|
expect(
|
|
(
|
|
await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/device-pairing/approvals",
|
|
{ code: body.code, deviceName: "Foyer TV" },
|
|
)
|
|
).status,
|
|
).toBe(200);
|
|
|
|
// 5. The screen collects its credential — through Set-Cookie, never through JSON.
|
|
const claimed = await device.post("/api/v1/device-pairing/claims", {});
|
|
expect(await claimed.json()).toEqual({ status: "paired" });
|
|
const sessionCookie = setCookieFor(claimed, "ekklesia_device_session");
|
|
expect(sessionCookie).toMatch(/HttpOnly/iu);
|
|
expect(sessionCookie).toMatch(/SameSite=Strict/iu);
|
|
expect(sessionCookie).toMatch(/Path=\//u);
|
|
expect(sessionCookie).not.toMatch(/Domain=/iu);
|
|
// The spent pairing cookie is cleared rather than left on the device.
|
|
expect(device.has("ekklesia_device_pairing")).toBe(false);
|
|
expect(device.has("ekklesia_device_session")).toBe(true);
|
|
|
|
// 6. The display now works, and reports the device as its viewer.
|
|
const descriptor = await device.request("/api/v1/module-displays/announcements");
|
|
expect(descriptor.status).toBe(200);
|
|
const descriptorBody = (await descriptor.json()) as {
|
|
viewer: { kind: string; deviceId?: string };
|
|
module: { permissions: string[] };
|
|
};
|
|
expect(descriptorBody.viewer.kind).toBe("device");
|
|
expect(descriptorBody.viewer.deviceId).toMatch(/^[0-9a-f-]{36}$/u);
|
|
// Exactly the one permission it was paired for.
|
|
expect(descriptorBody.module.permissions).toEqual([displayPermission]);
|
|
|
|
// 7. Realtime, through the ordinary permission-gated channel.
|
|
const stream = await device.request("/api/v1/module-realtime/announcements/state", {
|
|
headers: { accept: "text/event-stream" },
|
|
});
|
|
expect(stream.status).toBe(200);
|
|
expect(stream.headers.get("content-type")).toContain("text/event-stream");
|
|
await stream.body?.cancel();
|
|
|
|
// 8. The device is still nobody as far as the Dashboard is concerned.
|
|
expect(await (await device.request("/api/v1/principal")).json()).toEqual({
|
|
kind: "anonymous",
|
|
});
|
|
expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403);
|
|
expect((await device.request("/api/v1/devices")).status).toBe(403);
|
|
|
|
// 9. The administrator revokes it.
|
|
const devices = (await (
|
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
|
).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> };
|
|
expect(devices.devices).toHaveLength(1);
|
|
expect(devices.devices[0]?.name).toBe("Foyer TV");
|
|
expect(devices.devices[0]?.permissions).toEqual([displayPermission]);
|
|
const revoked = await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
`/api/v1/devices/${devices.devices[0]?.id}/revoke`,
|
|
{},
|
|
);
|
|
expect(revoked.status).toBe(204);
|
|
|
|
// 10. Everything it could do, it can no longer do — while still holding its cookie.
|
|
expect(device.has("ekklesia_device_session")).toBe(true);
|
|
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
|
|
expect(
|
|
(
|
|
await device.request("/api/v1/module-realtime/announcements/state", {
|
|
headers: { accept: "text/event-stream" },
|
|
})
|
|
).status,
|
|
).toBe(403);
|
|
// And the screen can start over: the page it needs for a new code is still served.
|
|
expect((await device.request("/display/announcements")).status).toBe(200);
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
|
|
it("stores no usable credential, and puts none in any response body", async () => {
|
|
const running = await platform("secrets");
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
await pairDevice(running, device);
|
|
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
|
expect(credential.length).toBeGreaterThan(20);
|
|
|
|
const stored = await query<{ credential_hash: string; name: string }>(
|
|
running.url,
|
|
`select credential_hash, name from ekklesia_core_device`,
|
|
);
|
|
expect(stored).toHaveLength(1);
|
|
// The raw credential is nowhere in the database — only an irreversible digest of it.
|
|
expect(stored[0]?.credential_hash).not.toContain(credential);
|
|
expect(stored[0]?.credential_hash).toMatch(/^[0-9a-f]{64}$/u);
|
|
|
|
const everything = JSON.stringify(
|
|
await query(running.url, `select * from ekklesia_core_device_pairing_request`),
|
|
);
|
|
expect(everything).not.toContain(credential);
|
|
|
|
// The descriptor a module's UI can read carries no credential either.
|
|
const descriptor = await device.request("/api/v1/module-displays/announcements");
|
|
expect(await descriptor.text()).not.toContain(credential);
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
|
|
describe("pairing cannot be talked into anything", () => {
|
|
it("derives the permission from the display, never from the request", async () => {
|
|
const running = await platform("derive");
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
// The browser tries to name its own permission and its own module.
|
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
|
moduleId: "announcements",
|
|
permission: "core.devices.manage",
|
|
permissionId: "core.devices.manage",
|
|
});
|
|
expect(requested.status).toBe(201);
|
|
const { code } = (await requested.json()) as { code: string };
|
|
|
|
const lookup = await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/device-pairing/lookups",
|
|
{ code },
|
|
);
|
|
// The host recorded the display's own declared permission and ignored the rest.
|
|
expect(await lookup.json()).toMatchObject({ permissionId: displayPermission });
|
|
|
|
const stored = await query<{ permission_id: string }>(
|
|
running.url,
|
|
`select permission_id from ekklesia_core_device_pairing_request`,
|
|
);
|
|
expect(stored.map((row) => row.permission_id)).toEqual([displayPermission]);
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
|
|
it("refuses to start pairing for a module that has no display", async () => {
|
|
const running = await platform("nodisplay");
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
for (const moduleId of ["calendar", "core", "../announcements", ""]) {
|
|
const response = await device.post("/api/v1/device-pairing/requests", { moduleId });
|
|
expect(response.status, moduleId).toBeGreaterThanOrEqual(400);
|
|
}
|
|
expect(
|
|
await query(running.url, `select id from ekklesia_core_device_pairing_request`),
|
|
).toHaveLength(0);
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
|
|
it("issues nothing to someone who knows only the code", async () => {
|
|
const running = await platform("codeonly");
|
|
const device = new DeviceBrowser(running.origin);
|
|
const onlooker = new DeviceBrowser(running.origin);
|
|
try {
|
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
|
moduleId: "announcements",
|
|
});
|
|
const { code } = (await requested.json()) as { code: string };
|
|
await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/device-pairing/approvals",
|
|
{
|
|
code,
|
|
deviceName: "Foyer TV",
|
|
},
|
|
);
|
|
|
|
// Someone who read the code off the television — and even guessed the request exists —
|
|
// holds no claim cookie, so there is nothing for them to collect.
|
|
const stolen = await onlooker.post("/api/v1/device-pairing/claims", {});
|
|
expect(stolen.status).toBe(400);
|
|
expect(onlooker.has("ekklesia_device_session")).toBe(false);
|
|
|
|
// The real screen still gets its credential.
|
|
const claimed = await device.post("/api/v1/device-pairing/claims", {});
|
|
expect(await claimed.json()).toEqual({ status: "paired" });
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("approval is an administrator's act", () => {
|
|
it("refuses an anonymous or unauthorized caller, and enforces a trusted origin", async () => {
|
|
const running = await platform("approve");
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
|
moduleId: "announcements",
|
|
});
|
|
const { code } = (await requested.json()) as { code: string };
|
|
|
|
// Anonymous.
|
|
const anonymous = await fetch(`${running.origin}/api/v1/device-pairing/approvals`, {
|
|
body: JSON.stringify({ code, deviceName: "Intruder TV" }),
|
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
|
method: "POST",
|
|
});
|
|
expect(anonymous.status).toBe(403);
|
|
|
|
// The paired-device cookie is not an administrator either — a device can never approve
|
|
// another device, or itself.
|
|
const selfApproval = await device.post("/api/v1/device-pairing/approvals", {
|
|
code,
|
|
deviceName: "Self TV",
|
|
});
|
|
expect(selfApproval.status).toBe(403);
|
|
|
|
// A real administrator from an untrusted origin: CSRF policy applies before anything else.
|
|
const crossOrigin = await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/device-pairing/approvals",
|
|
{ code, deviceName: "Foyer TV" },
|
|
{ headers: { origin: untrustedOrigin } },
|
|
);
|
|
expect(crossOrigin.status).toBe(403);
|
|
|
|
// Nothing was approved by any of that.
|
|
expect(
|
|
(
|
|
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
|
|
status: string;
|
|
}
|
|
).status,
|
|
).toBe("pending");
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
|
|
it("cannot approve the same code twice, and cannot mint two credentials from one", async () => {
|
|
const running = await platform("single");
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
|
moduleId: "announcements",
|
|
});
|
|
const { code } = (await requested.json()) as { code: string };
|
|
expect(
|
|
(
|
|
await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/device-pairing/approvals",
|
|
{ code, deviceName: "Foyer TV" },
|
|
)
|
|
).status,
|
|
).toBe(200);
|
|
// A second approval of the same code finds nothing pending.
|
|
expect(
|
|
(
|
|
await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/device-pairing/approvals",
|
|
{ code, deviceName: "Second TV" },
|
|
)
|
|
).status,
|
|
).toBe(404);
|
|
|
|
// Two claims race for one approved request; exactly one credential may exist.
|
|
const [first, second] = await Promise.all([
|
|
device.post("/api/v1/device-pairing/claims", {}),
|
|
device.post("/api/v1/device-pairing/claims", {}),
|
|
]);
|
|
const statuses = [
|
|
((await first.json()) as { status: string }).status,
|
|
((await second.json()) as { status: string }).status,
|
|
];
|
|
expect(statuses.filter((status) => status === "paired")).toHaveLength(1);
|
|
expect(await query(running.url, `select id from ekklesia_core_device`)).toHaveLength(1);
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("re-pairs a revoked screen into a fresh identity, leaving the old credential dead", async () => {
|
|
const running = await platform("repair");
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
await pairDevice(running, device, "Foyer TV");
|
|
const firstCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
|
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`,
|
|
{},
|
|
);
|
|
|
|
await pairDevice(running, device, "Foyer TV (replaced)");
|
|
const secondCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
|
expect(secondCredential).not.toBe(firstCredential);
|
|
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200);
|
|
|
|
// Re-pairing creates a new identity rather than reviving the revoked one, so the old
|
|
// credential can never come back — presenting it again is simply anonymous.
|
|
const revokedBrowser = new DeviceBrowser(running.origin);
|
|
revokedBrowser.overwrite("ekklesia_device_session", firstCredential);
|
|
expect((await revokedBrowser.request("/api/v1/module-displays/announcements")).status).toBe(
|
|
403,
|
|
);
|
|
|
|
const devices = (await (
|
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
|
).json()) as { devices: Array<{ revoked: boolean }> };
|
|
expect(devices.devices).toHaveLength(2);
|
|
expect(devices.devices.filter((entry) => entry.revoked)).toHaveLength(1);
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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);
|
|
try {
|
|
await pairDevice(running, device);
|
|
const deviceCredential = device.header();
|
|
|
|
// A browser presenting both: the person still wins, and stays a person.
|
|
const both = await fetch(`${running.origin}/api/v1/principal`, {
|
|
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
|
|
});
|
|
expect(((await both.json()) as { kind: string }).kind).toBe("authenticated");
|
|
|
|
// And that administrator can still open the display for testing, as a human.
|
|
const descriptor = await fetch(`${running.origin}/api/v1/module-displays/announcements`, {
|
|
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
|
|
});
|
|
expect(descriptor.status).toBe(200);
|
|
expect(((await descriptor.json()) as { viewer: { kind: string } }).viewer.kind).toBe(
|
|
"authenticated",
|
|
);
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
});
|