Skip to content

Commit 4ae9de3

Browse files
Stabilize auth session cookies per server mode (#1898)
1 parent 5fa09fa commit 4ae9de3

File tree

11 files changed

+212
-22
lines changed

11 files changed

+212
-22
lines changed

apps/desktop/src/backendPort.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,57 @@ describe("resolveDesktopBackendPort", () => {
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", () => {
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

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface ResolveDesktopBackendPortOptions {
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
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({
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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
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> {
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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
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* () {
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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => {
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) => {
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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Effect, Layer } from "effect";
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* () {
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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "
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 {
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
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* () {
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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,18 @@ import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/
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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -789,7 +789,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
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

0 commit comments

Comments
 (0)