History

Stabilize auth session cookies per server mode (#1898)

Julius Marminge committed 7 months ago

Commit 4ae9de31 parent 5fa09fa11 files · +212 −22Browse files

Unified · Split

11 files
  1. apps/desktop/src/backendPort.test.ts +54 −1
  2. apps/desktop/src/backendPort.ts +32 −2
  3. apps/desktop/src/main.ts +2 −0
  4. apps/server/src/auth/Layers/ServerAuth.ts +11 −1
  5. apps/server/src/auth/Layers/ServerAuthPolicy.test.ts +3 −0
  6. apps/server/src/auth/Layers/ServerAuthPolicy.ts +5 −2
  7. apps/server/src/auth/Layers/SessionCredentialService.ts +8 −2
  8. apps/server/src/auth/utils.ts +12 −1
  9. apps/server/src/server.test.ts +1 −1
  10. scripts/dev-runner.test.ts +63 −10
  11. scripts/dev-runner.ts +21 −2
apps/desktop/src/backendPort.test.ts+54−1
@@ -36,6 +36,57 @@ describe("resolveDesktopBackendPort", () => { · 35 unmodified lines
3636 ]);
3737 });
3838
39+ it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => {
40+ const canListenOnHost = vi.fn(async (port: number, host: string) => {
41+ if (port === 3773 && host === "127.0.0.1") return true;
42+ if (port === 3773 && host === "0.0.0.0") return false;
43+ return port === 3774;
44+ });
45+
46+ await expect(
47+ resolveDesktopBackendPort({
48+ host: "127.0.0.1",
49+ requiredHosts: ["0.0.0.0"],
50+ startPort: 3773,
51+ canListenOnHost,
52+ }),
53+ ).resolves.toBe(3774);
54+
55+ expect(canListenOnHost.mock.calls).toEqual([
56+ [3773, "127.0.0.1"],
57+ [3773, "0.0.0.0"],
58+ [3774, "127.0.0.1"],
59+ [3774, "0.0.0.0"],
60+ ]);
61+ });
62+
63+ it("checks overlapping hosts sequentially to avoid self-interference", async () => {
64+ let inFlightCount = 0;
65+ const canListenOnHost = vi.fn(async (_port: number, _host: string) => {
66+ inFlightCount += 1;
67+ const overlapped = inFlightCount > 1;
68+ await Promise.resolve();
69+ inFlightCount -= 1;
70+ return !overlapped;
71+ });
72+
73+ await expect(
74+ resolveDesktopBackendPort({
75+ host: "127.0.0.1",
76+ requiredHosts: ["0.0.0.0", "::"],
77+ startPort: 3773,
78+ maxPort: 3773,
79+ canListenOnHost,
80+ }),
81+ ).resolves.toBe(3773);
82+
83+ expect(canListenOnHost.mock.calls).toEqual([
84+ [3773, "127.0.0.1"],
85+ [3773, "0.0.0.0"],
86+ [3773, "::"],
87+ ]);
88+ });
89+
3990 it("fails when the scan range is exhausted", async () => {
4091 const canListenOnHost = vi.fn(async () => false);
4192
@@ -46,7 +97,9 @@ describe("resolveDesktopBackendPort", () => { · 4 unmodified lines
4697 maxPort: 65535,
4798 canListenOnHost,
4899 }),
49− ).rejects.toThrow("No desktop backend port is available on 127.0.0.1 between 65534 and 65535");
100+ ).rejects.toThrow(
101+ "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535",
102+ );
50103
51104 expect(canListenOnHost.mock.calls).toEqual([
52105 [65534, "127.0.0.1"],
apps/desktop/src/backendPort.ts+32−2
@@ -8,6 +8,7 @@ export interface ResolveDesktopBackendPortOptions { · 7 unmodified lines
88 readonly host: string;
99 readonly startPort?: number;
1010 readonly maxPort?: number;
11+ readonly requiredHosts?: ReadonlyArray<string>;
1112 readonly canListenOnHost?: (port: number, host: string) => Promise<boolean>;
1213 }
1314
@@ -21,10 +22,37 @@ const defaultCanListenOnHost = async (port: number, host: string): Promise<boole · 7 unmodified lines
2122 const isValidPort = (port: number): boolean =>
2223 Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT;
2324
25+const normalizeHosts = (
26+ host: string,
27+ requiredHosts: ReadonlyArray<string>,
28+): ReadonlyArray<string> =>
29+ Array.from(
30+ new Set(
31+ [host, ...requiredHosts]
32+ .map((candidate) => candidate.trim())
33+ .filter((candidate) => candidate.length > 0),
34+ ),
35+ );
36+
37+async function canListenOnAllHosts(
38+ port: number,
39+ hosts: ReadonlyArray<string>,
40+ canListenOnHost: (port: number, host: string) => Promise<boolean>,
41+): Promise<boolean> {
42+ for (const candidateHost of hosts) {
43+ if (!(await canListenOnHost(port, candidateHost))) {
44+ return false;
45+ }
46+ }
47+
48+ return true;
49+}
50+
2451 export async function resolveDesktopBackendPort({
2552 host,
2653 startPort = DEFAULT_DESKTOP_BACKEND_PORT,
2754 maxPort = MAX_TCP_PORT,
55+ requiredHosts = [],
2856 canListenOnHost = defaultCanListenOnHost,
2957 }: ResolveDesktopBackendPortOptions): Promise<number> {
3058 if (!isValidPort(startPort)) {
@@ -39,15 +67,17 @@ export async function resolveDesktopBackendPort({ · 8 unmodified lines
3967 throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`);
4068 }
4169
70+ const hostsToCheck = normalizeHosts(host, requiredHosts);
71+
4272 // Keep desktop startup predictable across app restarts by probing upward from
4373 // the same preferred port instead of picking a fresh ephemeral port.
4474 for (let port = startPort; port <= maxPort; port += 1) {
45− if (await canListenOnHost(port, host)) {
75+ if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) {
4676 return port;
4777 }
4878 }
4979
5080 throw new Error(
51− `No desktop backend port is available on ${host} between ${startPort} and ${maxPort}`,
81+ `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`,
5282 );
5383 }
apps/desktop/src/main.ts+2−0
@@ -117,6 +117,7 @@ const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; · 116 unmodified lines
117117 const DESKTOP_UPDATE_CHANNEL = "latest";
118118 const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;
119119 const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
120+const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;
120121
121122 type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
122123 type LinuxDesktopNamedApp = Electron.App & {
@@ -1781,6 +1782,7 @@ async function bootstrap(): Promise<void> { · 1658 unmodified lines
17811782 (await resolveDesktopBackendPort({
17821783 host: DESKTOP_LOOPBACK_HOST,
17831784 startPort: DEFAULT_DESKTOP_BACKEND_PORT,
1785+ requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS,
17841786 }));
17851787 writeDesktopLogHeader(
17861788 configuredBackendPort === undefined
apps/server/src/auth/Layers/ServerAuth.ts+11−1
@@ -20,7 +20,10 @@ import { · 19 unmodified lines
2020 AuthError,
2121 type ServerAuthShape,
2222 } from "../Services/ServerAuth.ts";
23−import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
23+import {
24+ SessionCredentialError,
25+ SessionCredentialService,
26+} from "../Services/SessionCredentialService.ts";
2427 import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts";
2528
2629 type BootstrapExchangeResult = {
@@ -65,6 +68,13 @@ export const makeServerAuth = Effect.gen(function* () { · 38 unmodified lines
6568
6669 const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> =>
6770 sessions.verify(token).pipe(
71+ Effect.tapError((cause: SessionCredentialError) =>
72+ Effect.logWarning("Rejected authenticated session credential.").pipe(
73+ Effect.annotateLogs({
74+ reason: cause.message,
75+ }),
76+ ),
77+ ),
6878 Effect.map((session) => ({
6979 sessionId: session.sessionId,
7080 subject: session.subject,
apps/server/src/auth/Layers/ServerAuthPolicy.test.ts+3−0
@@ -33,10 +33,12 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { · 32 unmodified lines
3333
3434 expect(descriptor.policy).toBe("desktop-managed-local");
3535 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
36+ expect(descriptor.sessionCookieName).toBe("t3_session_3773");
3637 }).pipe(
3738 Effect.provide(
3839 makeServerAuthPolicyLayer({
3940 mode: "desktop",
41+ port: 3773,
4042 }),
4143 ),
4244 ),
@@ -66,6 +68,7 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { · 23 unmodified lines
6668
6769 expect(descriptor.policy).toBe("loopback-browser");
6870 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
71+ expect(descriptor.sessionCookieName).toBe("t3_session");
6972 }).pipe(
7073 Effect.provide(
7174 makeServerAuthPolicyLayer({
apps/server/src/auth/Layers/ServerAuthPolicy.ts+5−2
@@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"; · 2 unmodified lines
33
44 import { ServerConfig } from "../../config.ts";
55 import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";
6−import { SESSION_COOKIE_NAME } from "../utils.ts";
6+import { resolveSessionCookieName } from "../utils.ts";
77 import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts";
88
99 export const makeServerAuthPolicy = Effect.gen(function* () {
@@ -30,7 +30,10 @@ export const makeServerAuthPolicy = Effect.gen(function* () { · 20 unmodified lines
3030 policy,
3131 bootstrapMethods,
3232 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
33− sessionCookieName: SESSION_COOKIE_NAME,
33+ sessionCookieName: resolveSessionCookieName({
34+ mode: config.mode,
35+ port: config.port,
36+ }),
3437 };
3538
3639 return {
apps/server/src/auth/Layers/SessionCredentialService.ts+8−2
@@ -2,10 +2,10 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from " · 1 unmodified line
22 import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect";
33 import { Option } from "effect";
44
5+import { ServerConfig } from "../../config.ts";
56 import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts";
67 import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts";
78 import { ServerSecretStore } from "../Services/ServerSecretStore.ts";
8−import { SESSION_COOKIE_NAME } from "../utils.ts";
99 import {
1010 SessionCredentialError,
1111 SessionCredentialService,
@@ -17,6 +17,7 @@ import { · 5 unmodified lines
1717 import {
1818 base64UrlDecodeUtf8,
1919 base64UrlEncode,
20+ resolveSessionCookieName,
2021 signPayload,
2122 timingSafeEqualBase64Url,
2223 } from "../utils.ts";
@@ -81,11 +82,16 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli · 58 unmodified lines
8182 }
8283
8384 export const makeSessionCredentialService = Effect.gen(function* () {
85+ const serverConfig = yield* ServerConfig;
8486 const secretStore = yield* ServerSecretStore;
8587 const authSessions = yield* AuthSessionRepository;
8688 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
8789 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
8890 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
91+ const cookieName = resolveSessionCookieName({
92+ mode: serverConfig.mode,
93+ port: serverConfig.port,
94+ });
8995
9096 const toSessionCredentialError = (message: string) => (cause: unknown) =>
9197 new SessionCredentialError({
@@ -472,7 +478,7 @@ export const makeSessionCredentialService = Effect.gen(function* () { · 380 unmodified lines
472478 }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions.")));
473479
474480 return {
475− cookieName: SESSION_COOKIE_NAME,
481+ cookieName,
476482 issue,
477483 verify,
478484 issueWebSocketToken,
apps/server/src/auth/utils.ts+12−1
@@ -2,7 +2,18 @@ import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/ · 1 unmodified line
22 import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
33 import * as Crypto from "node:crypto";
44
5−export const SESSION_COOKIE_NAME = "t3_session";
5+const SESSION_COOKIE_NAME = "t3_session";
6+
7+export function resolveSessionCookieName(input: {
8+ readonly mode: "web" | "desktop";
9+ readonly port: number;
10+}): string {
11+ if (input.mode !== "desktop") {
12+ return SESSION_COOKIE_NAME;
13+ }
14+
15+ return `${SESSION_COOKIE_NAME}_${input.port}`;
16+}
617
718 export function base64UrlEncode(input: string | Uint8Array): string {
819 const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input);
apps/server/src/server.test.ts+1−1
@@ -789,7 +789,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 788 unmodified lines
789789 "browser-session-cookie",
790790 "bearer-session-token",
791791 ]);
792− assert.equal(body.auth.sessionCookieName, "t3_session");
792+ assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_"));
793793 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
794794 );
795795
scripts/dev-runner.test.ts+63−10
@@ -5,6 +5,7 @@ import { assert, describe, it } from "@effect/vitest"; · 4 unmodified lines
55 import { Effect } from "effect";
66
77 import {
8+ checkPortAvailabilityOnHosts,
89 createDevRunnerEnv,
910 findFirstAvailableOffset,
1011 resolveModePortOffsets,
@@ -164,11 +165,11 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 153 unmodified lines
164165 const env = yield* createDevRunnerEnv({
165166 mode: "dev:desktop",
166167 baseEnv: {
167− T3CODE_PORT: "3773",
168+ T3CODE_PORT: "13773",
168169 T3CODE_MODE: "web",
169170 T3CODE_NO_BROWSER: "0",
170171 T3CODE_HOST: "0.0.0.0",
171− VITE_WS_URL: "ws://localhost:3773",
172+ VITE_WS_URL: "ws://localhost:13773",
172173 },
173174 serverOffset: 0,
174175 webOffset: 0,
@@ -193,6 +194,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 18 unmodified lines
193194 assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222");
194195 }),
195196 );
197+
198+ it.effect("defaults dev server mode to the higher backend port range", () =>
199+ Effect.gen(function* () {
200+ const env = yield* createDevRunnerEnv({
201+ mode: "dev",
202+ baseEnv: {},
203+ serverOffset: 0,
204+ webOffset: 0,
205+ t3Home: undefined,
206+ noBrowser: undefined,
207+ autoBootstrapProjectFromCwd: undefined,
208+ logWebSocketEvents: undefined,
209+ host: undefined,
210+ port: undefined,
211+ devUrl: undefined,
212+ });
213+
214+ assert.equal(env.T3CODE_PORT, "13773");
215+ assert.equal(env.VITE_HTTP_URL, "http://localhost:13773");
216+ assert.equal(env.VITE_WS_URL, "ws://localhost:13773");
217+ }),
218+ );
196219 });
197220
198221 describe("findFirstAvailableOffset", () => {
@@ -211,7 +234,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 12 unmodified lines
211234
212235 it.effect("advances until all required ports are available", () =>
213236 Effect.gen(function* () {
214− const taken = new Set([3773, 5733, 3774, 5734]);
237+ const taken = new Set([13773, 5733, 13774, 5734]);
215238 const offset = yield* findFirstAvailableOffset({
216239 startOffset: 0,
217240 requireServerPort: true,
@@ -223,16 +246,46 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 5 unmodified lines
223246 }),
224247 );
225248
226− it.effect("allows offsets where only non-required ports exceed max", () =>
249+ it.effect("allows offsets where the non-required server port exceeds max", () =>
227250 Effect.gen(function* () {
228251 const offset = yield* findFirstAvailableOffset({
229− startOffset: 59_803,
230− requireServerPort: true,
231− requireWebPort: false,
252+ startOffset: 59_802,
253+ requireServerPort: false,
254+ requireWebPort: true,
232255 checkPortAvailability: () => Effect.succeed(true),
233256 });
234257
235− assert.equal(offset, 59_803);
258+ assert.equal(offset, 59_802);
259+ }),
260+ );
261+ });
262+
263+ describe("checkPortAvailabilityOnHosts", () => {
264+ it.effect("checks overlapping hosts sequentially to avoid self-interference", () =>
265+ Effect.gen(function* () {
266+ let inFlightCount = 0;
267+ const calls: Array<[number, string]> = [];
268+
269+ const available = yield* checkPortAvailabilityOnHosts(
270+ 13_773,
271+ ["127.0.0.1", "0.0.0.0", "::"],
272+ (port, host) =>
273+ Effect.promise(async () => {
274+ calls.push([port, host]);
275+ inFlightCount += 1;
276+ const overlapped = inFlightCount > 1;
277+ await Promise.resolve();
278+ inFlightCount -= 1;
279+ return !overlapped;
280+ }),
281+ );
282+
283+ assert.equal(available, true);
284+ assert.deepStrictEqual(calls, [
285+ [13_773, "127.0.0.1"],
286+ [13_773, "0.0.0.0"],
287+ [13_773, "::"],
288+ ]);
236289 }),
237290 );
238291 });
@@ -240,7 +293,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 1 unmodified line
240293 describe("resolveModePortOffsets", () => {
241294 it.effect("uses a shared fallback offset for dev mode", () =>
242295 Effect.gen(function* () {
243− const taken = new Set([3773, 5733]);
296+ const taken = new Set([13773, 5733]);
244297 const offsets = yield* resolveModePortOffsets({
245298 mode: "dev",
246299 startOffset: 0,
@@ -270,7 +323,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 23 unmodified lines
270323
271324 it.effect("shifts only server offset for dev:server", () =>
272325 Effect.gen(function* () {
273− const taken = new Set([3773]);
326+ const taken = new Set([13773]);
274327 const offsets = yield* resolveModePortOffsets({
275328 mode: "dev:server",
276329 startOffset: 0,
scripts/dev-runner.ts+21−2
@@ -9,11 +9,12 @@ import { Config, Data, Effect, Hash, Layer, Logger, Option, Path, Schema } from · 8 unmodified lines
99 import { Argument, Command, Flag } from "effect/unstable/cli";
1010 import { ChildProcess } from "effect/unstable/process";
1111
12−const BASE_SERVER_PORT = 3773;
12+const BASE_SERVER_PORT = 13773;
1313 const BASE_WEB_PORT = 5733;
1414 const MAX_HASH_OFFSET = 3000;
1515 const MAX_PORT = 65535;
1616 const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1";
17+const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const;
1718
1819 export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) =>
1920 path.join(homedir(), ".t3"),
@@ -221,10 +222,28 @@ function portPairForOffset(offset: number): { · 201 unmodified lines
221222 };
222223 }
223224
225+export function checkPortAvailabilityOnHosts<R>(
226+ port: number,
227+ hosts: ReadonlyArray<string>,
228+ canListenOnHost: (port: number, host: string) => Effect.Effect<boolean, never, R>,
229+): Effect.Effect<boolean, never, R> {
230+ return Effect.gen(function* () {
231+ for (const host of hosts) {
232+ if (!(yield* canListenOnHost(port, host))) {
233+ return false;
234+ }
235+ }
236+
237+ return true;
238+ });
239+}
240+
224241 const defaultCheckPortAvailability: PortAvailabilityCheck<NetService> = (port) =>
225242 Effect.gen(function* () {
226243 const net = yield* NetService;
227− return yield* net.isPortAvailableOnLoopback(port);
244+ return yield* checkPortAvailabilityOnHosts(port, DEV_PORT_PROBE_HOSTS, (candidatePort, host) =>
245+ net.canListenOnHost(candidatePort, host),
246+ );
228247 });
229248
230249 interface FindFirstAvailableOffsetInput<R = NetService> {