Skip to content
22 changes: 22 additions & 0 deletions apps/server/src/auth/Layers/SessionCredentialService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,27 @@ export const makeSessionCredentialService = Effect.gen(function* () {
);
});

const awaitRevoked: SessionCredentialServiceShape["awaitRevoked"] = (sessionId) =>
Effect.gen(function* () {
// Subscribe before re-reading the row: the subscription buffers every
// change published from this point, so the read below can only be stale
// in the safe direction (a revocation it misses is one the subscription
// already holds).
const subscription = yield* PubSub.subscribe(changesPubSub);
const row = yield* authSessions.getById({ sessionId });
if (Option.isNone(row) || row.value.revokedAt !== null) {
return;
}

yield* Stream.fromSubscription(subscription).pipe(
Stream.filter(
(change) => change.type === "clientRemoved" && change.sessionId === sessionId,
),
Stream.take(1),
Stream.runDrain,
);
}).pipe(Effect.mapError(toSessionCredentialError("Failed to watch session revocation.")));

const markConnected: SessionCredentialServiceShape["markConnected"] = (sessionId) =>
Ref.modify(connectedSessionsRef, (current) => {
const next = new Map(current);
Expand Down Expand Up @@ -519,6 +540,7 @@ export const makeSessionCredentialService = Effect.gen(function* () {
get streamChanges() {
return Stream.fromPubSub(changesPubSub);
},
awaitRevoked,
revoke,
revokeAllExcept,
markConnected,
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/auth/Services/SessionCredentialService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Context from "effect/Context";
import type * as Effect from "effect/Effect";
import type * as Scope from "effect/Scope";
import type * as Stream from "effect/Stream";

export type SessionRole = "owner" | "client";
Expand Down Expand Up @@ -77,6 +78,21 @@ export interface SessionCredentialServiceShape {
SessionCredentialError
>;
readonly streamChanges: Stream.Stream<SessionCredentialChange>;
/**
* Resolves once `sessionId` is no longer usable, and resolves immediately when
* it is already revoked or unknown.
*
* Callers that hold a live connection for a session (the websocket route) need
* to drop it the instant the owner revokes access. `streamChanges` cannot
* carry that invariant on its own: `Stream.fromPubSub` subscribes when the
* stream starts running, so a revocation published between "read the session"
* and "start consuming" is lost and the connection stays open forever. This
* subscribes first and only then re-reads the session, so neither ordering
* drops the signal.
*/
readonly awaitRevoked: (
sessionId: AuthSessionId,
) => Effect.Effect<void, SessionCredentialError, Scope.Scope>;
readonly revoke: (sessionId: AuthSessionId) => Effect.Effect<boolean, SessionCredentialError>;
readonly revokeAllExcept: (
sessionId: AuthSessionId,
Expand Down
101 changes: 101 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import * as Deferred from "effect/Deferred";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as ManagedRuntime from "effect/ManagedRuntime";
Expand Down Expand Up @@ -1107,6 +1108,19 @@ const assertBrowserApiCorsHeaders = (headers: Headers) => {
};
const crossOriginClientOrigin = "http://remote-client.test:3773";

/**
* Suites here run on the test clock, so `Effect.sleep`/`Effect.timeout` never
* advance on their own. Assertions about real sockets closing need wall-clock
* time instead.
*/
const wallClockSleep = (durationMs: number) =>
Effect.promise(
() =>
new Promise<void>((resolve) => {
setTimeout(resolve, durationMs);
}),
);

const getWsServerUrl = (
pathname = "",
options?: { authenticated?: boolean; credential?: string },
Expand Down Expand Up @@ -1902,6 +1916,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("closes the live websocket of a revoked paired client session", () =>
Effect.gen(function* () {
yield* buildAppUnderTest({
config: {
host: "0.0.0.0",
},
// The default lifecycle mock completes immediately, which would make the
// subscription below end for reasons unrelated to revocation.
layers: {
serverLifecycleEvents: {
stream: Stream.never,
},
},
});

const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
headers: {
cookie: ownerCookie,
},
});
const pairingBody = (yield* pairingResponse.json) as {
readonly credential: string;
};
const pairedSessionCookie = yield* getAuthenticatedSessionCookieHeader(
pairingBody.credential,
);
const pairedWsUrl = appendSessionCookieToWsUrl(
yield* getWsServerUrl("/ws", { authenticated: false }),
pairedSessionCookie,
);

const { exitBeforeRevoke, exitAfterRevoke } = yield* Effect.scoped(
withWsRpcClient(pairedWsUrl, (client) =>
Effect.gen(function* () {
// One request first so the socket is actually open and registered as
// connected before the owner revokes it.
yield* client[WS_METHODS.serverGetConfig]({});
// The lifecycle stream never completes on its own, so it only ends
// here because the server dropped this session's socket.
const lifecycle = yield* Effect.forkChild(
Stream.runDrain(client[WS_METHODS.subscribeServerLifecycle]({})),
);
const clientsResponse = yield* HttpClient.get("/api/auth/clients", {
headers: {
cookie: ownerCookie,
},
});
const clients = (yield* clientsResponse.json) as ReadonlyArray<{
readonly sessionId: string;
readonly current: boolean;
readonly connected: boolean;
}>;
const pairedClient = clients.find((entry) => !entry.current);
assert.isDefined(pairedClient);
assert.isTrue(pairedClient?.connected);

yield* wallClockSleep(500);
const beforeRevoke = lifecycle.pollUnsafe();

yield* HttpClient.post("/api/auth/clients/revoke", {
headers: {
cookie: ownerCookie,
"content-type": "application/json",
},
body: HttpBody.text(
JSON.stringify({ sessionId: pairedClient?.sessionId }),
"application/json",
),
});

return {
exitBeforeRevoke: beforeRevoke,
exitAfterRevoke: yield* Effect.raceFirst(
Fiber.await(lifecycle).pipe(Effect.as(true)),
wallClockSleep(10_000).pipe(Effect.as(false)),
),
};
}),
),
);

assert.isUndefined(exitBeforeRevoke, "the paired session stream ended before it was revoked");
assertTrue(exitAfterRevoke, "revoked session websocket stayed open");
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("keeps the desktop bootstrap credential available after browser sign-in", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
Expand Down
18 changes: 10 additions & 8 deletions apps/server/src/serverRuntimeStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ import { ProviderService } from "./provider/Services/ProviderService.ts";
import { SleepInhibitor } from "./power/Services/SleepInhibitor.ts";
import {
formatHeadlessServeOutput,
formatHostForUrl,
isWildcardHost,
issueHeadlessServeAccessInfo,
resolveAdvertisedServerUrl,
} from "./startupAccess.ts";

export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{
Expand Down Expand Up @@ -253,12 +252,15 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
const resolveStartupBrowserTarget = Effect.gen(function* () {
const serverConfig = yield* ServerConfig;
const serverAuth = yield* ServerAuth;
const localUrl = `http://localhost:${serverConfig.port}`;
const bindUrl =
serverConfig.host && !isWildcardHost(serverConfig.host)
? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}`
: localUrl;
const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl;
// Dev servers keep the Vite dev URL: the client is only served there, and
// that harness retargets by hand anyway.
const baseTarget =
serverConfig.devUrl?.toString() ??
resolveAdvertisedServerUrl({
host: serverConfig.host,
port: serverConfig.port,
mode: serverConfig.mode,
});
return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe(
Effect.flatMap((target) =>
target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget),
Expand Down
89 changes: 86 additions & 3 deletions apps/server/src/startupAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,104 @@ import {
buildPairingUrl,
formatHeadlessServeOutput,
renderTerminalQrCode,
resolveAdvertisedServerUrl,
resolveHeadlessConnectionHost,
resolveHeadlessConnectionString,
resolveListeningPort,
} from "./startupAccess.ts";

it("prefers localhost when no explicit host is configured", () => {
expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost");
expect(resolveHeadlessConnectionString(undefined, 3773)).toBe("http://localhost:3773");
const LAN_INTERFACES = {
en0: [
{
address: "192.168.1.42",
netmask: "255.255.255.0",
family: "IPv4" as const,
mac: "00:00:00:00:00:00",
internal: false,
cidr: "192.168.1.42/24",
},
],
lo0: [
{
address: "127.0.0.1",
netmask: "255.0.0.0",
family: "IPv4" as const,
mac: "00:00:00:00:00:00",
internal: true,
cidr: "127.0.0.1/8",
},
],
};

// An unset host binds every interface, so the advertised URL has to be one
// another device can open. A loopback URL here is a dead pairing link.
it("resolves an unset host to a reachable interface", () => {
expect(resolveHeadlessConnectionHost(undefined, LAN_INTERFACES)).toBe("192.168.1.42");
expect(resolveHeadlessConnectionString(undefined, 3773, LAN_INTERFACES)).toBe(
"http://192.168.1.42:3773",
);
});

it("falls back to localhost when no external interface exists", () => {
expect(resolveHeadlessConnectionHost(undefined, { lo0: LAN_INTERFACES.lo0 })).toBe("localhost");
});

it("keeps explicit bind hosts in the connection string", () => {
expect(resolveHeadlessConnectionString("127.0.0.1", 3773)).toBe("http://127.0.0.1:3773");
expect(resolveHeadlessConnectionString("::1", 3773)).toBe("http://[::1]:3773");
});

// A developer machine's first external interface is often a virtual adapter
// (WSL, Hyper-V, Docker) whose subnet no phone can reach; the physical NIC
// must win. Virtual-only machines still advertise their best candidate.
it("prefers a physical interface over virtual adapters", () => {
const interfaces = {
"vEthernet (WSL (Hyper-V firewall))": [
{
address: "172.22.16.1",
netmask: "255.255.240.0",
family: "IPv4" as const,
mac: "00:15:5d:00:00:01",
internal: false,
cidr: "172.22.16.1/20",
},
],
"Wi-Fi": [
{
address: "10.0.0.15",
netmask: "255.255.255.0",
family: "IPv4" as const,
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "10.0.0.15/24",
},
],
};
expect(resolveHeadlessConnectionHost(undefined, interfaces)).toBe("10.0.0.15");
expect(
resolveHeadlessConnectionHost(undefined, {
"vEthernet (WSL (Hyper-V firewall))": interfaces["vEthernet (WSL (Hyper-V firewall))"],
}),
).toBe("172.22.16.1");
});

// The boot log's pairing URL uses the same rule as headless serve: a wildcard
// bind advertises an address other devices can open, not localhost.
it("advertises a reachable interface for wildcard binds in browser mode", () => {
expect(
resolveAdvertisedServerUrl({ host: "0.0.0.0", port: 8266, mode: "web" }, LAN_INTERFACES),
).toBe("http://192.168.1.42:8266");
});

it("advertises explicit hosts verbatim and keeps desktop wildcard binds on localhost", () => {
expect(
resolveAdvertisedServerUrl({ host: "127.0.0.1", port: 8266, mode: "web" }, LAN_INTERFACES),
).toBe("http://127.0.0.1:8266");
expect(
resolveAdvertisedServerUrl({ host: "0.0.0.0", port: 8266, mode: "desktop" }, LAN_INTERFACES),
).toBe("http://localhost:8266");
});

it("resolves wildcard hosts to a concrete external interface when one is available", () => {
const connectionString = resolveHeadlessConnectionString("0.0.0.0", 3773, {
en0: [
Expand Down
Loading
Loading