Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion apps/desktop/src/backendPort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,57 @@ describe("resolveDesktopBackendPort", () => {
]);
});

it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => {
const canListenOnHost = vi.fn(async (port: number, host: string) => {
if (port === 3773 && host === "127.0.0.1") return true;
if (port === 3773 && host === "0.0.0.0") return false;
return port === 3774;
});

await expect(
resolveDesktopBackendPort({
host: "127.0.0.1",
requiredHosts: ["0.0.0.0"],
startPort: 3773,
canListenOnHost,
}),
).resolves.toBe(3774);

expect(canListenOnHost.mock.calls).toEqual([
[3773, "127.0.0.1"],
[3773, "0.0.0.0"],
[3774, "127.0.0.1"],
[3774, "0.0.0.0"],
]);
});

it("checks overlapping hosts sequentially to avoid self-interference", async () => {
let inFlightCount = 0;
const canListenOnHost = vi.fn(async (_port: number, _host: string) => {
inFlightCount += 1;
const overlapped = inFlightCount > 1;
await Promise.resolve();
inFlightCount -= 1;
return !overlapped;
});

await expect(
resolveDesktopBackendPort({
host: "127.0.0.1",
requiredHosts: ["0.0.0.0", "::"],
startPort: 3773,
maxPort: 3773,
canListenOnHost,
}),
).resolves.toBe(3773);

expect(canListenOnHost.mock.calls).toEqual([
[3773, "127.0.0.1"],
[3773, "0.0.0.0"],
[3773, "::"],
]);
});

it("fails when the scan range is exhausted", async () => {
const canListenOnHost = vi.fn(async () => false);

Expand All @@ -46,7 +97,9 @@ describe("resolveDesktopBackendPort", () => {
maxPort: 65535,
canListenOnHost,
}),
).rejects.toThrow("No desktop backend port is available on 127.0.0.1 between 65534 and 65535");
).rejects.toThrow(
"No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535",
);

expect(canListenOnHost.mock.calls).toEqual([
[65534, "127.0.0.1"],
Expand Down
34 changes: 32 additions & 2 deletions apps/desktop/src/backendPort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface ResolveDesktopBackendPortOptions {
readonly host: string;
readonly startPort?: number;
readonly maxPort?: number;
readonly requiredHosts?: ReadonlyArray<string>;
readonly canListenOnHost?: (port: number, host: string) => Promise<boolean>;
}

Expand All @@ -21,10 +22,37 @@ const defaultCanListenOnHost = async (port: number, host: string): Promise<boole
const isValidPort = (port: number): boolean =>
Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT;

const normalizeHosts = (
host: string,
requiredHosts: ReadonlyArray<string>,
): ReadonlyArray<string> =>
Array.from(
new Set(
[host, ...requiredHosts]
.map((candidate) => candidate.trim())
.filter((candidate) => candidate.length > 0),
),
);

async function canListenOnAllHosts(
port: number,
hosts: ReadonlyArray<string>,
canListenOnHost: (port: number, host: string) => Promise<boolean>,
): Promise<boolean> {
for (const candidateHost of hosts) {
if (!(await canListenOnHost(port, candidateHost))) {
return false;
}
}

return true;
}

export async function resolveDesktopBackendPort({
host,
startPort = DEFAULT_DESKTOP_BACKEND_PORT,
maxPort = MAX_TCP_PORT,
requiredHosts = [],
canListenOnHost = defaultCanListenOnHost,
}: ResolveDesktopBackendPortOptions): Promise<number> {
if (!isValidPort(startPort)) {
Expand All @@ -39,15 +67,17 @@ export async function resolveDesktopBackendPort({
throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`);
}

const hostsToCheck = normalizeHosts(host, requiredHosts);

// Keep desktop startup predictable across app restarts by probing upward from
// the same preferred port instead of picking a fresh ephemeral port.
for (let port = startPort; port <= maxPort; port += 1) {
if (await canListenOnHost(port, host)) {
if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) {
return port;
}
}

throw new Error(
`No desktop backend port is available on ${host} between ${startPort} and ${maxPort}`,
`No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`,
);
}
2 changes: 2 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
const DESKTOP_UPDATE_CHANNEL = "latest";
const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;
const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;

type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
type LinuxDesktopNamedApp = Electron.App & {
Expand Down Expand Up @@ -1773,6 +1774,7 @@ async function bootstrap(): Promise<void> {
(await resolveDesktopBackendPort({
host: DESKTOP_LOOPBACK_HOST,
startPort: DEFAULT_DESKTOP_BACKEND_PORT,
requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS,
}));
writeDesktopLogHeader(
configuredBackendPort === undefined
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/auth/Layers/ServerAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import {
AuthError,
type ServerAuthShape,
} from "../Services/ServerAuth.ts";
import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
import {
SessionCredentialError,
SessionCredentialService,
} from "../Services/SessionCredentialService.ts";
import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts";

type BootstrapExchangeResult = {
Expand Down Expand Up @@ -65,6 +68,13 @@ export const makeServerAuth = Effect.gen(function* () {

const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> =>
sessions.verify(token).pipe(
Effect.tapError((cause: SessionCredentialError) =>
Effect.logWarning("Rejected authenticated session credential.").pipe(
Effect.annotateLogs({
reason: cause.message,
}),
),
),
Effect.map((session) => ({
sessionId: session.sessionId,
subject: session.subject,
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/auth/Layers/ServerAuthPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => {

expect(descriptor.policy).toBe("desktop-managed-local");
expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
expect(descriptor.sessionCookieName).toBe("t3_session_3773");
}).pipe(
Effect.provide(
makeServerAuthPolicyLayer({
mode: "desktop",
port: 3773,
}),
),
),
Expand Down Expand Up @@ -66,6 +68,7 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => {

expect(descriptor.policy).toBe("loopback-browser");
expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
expect(descriptor.sessionCookieName).toBe("t3_session");
}).pipe(
Effect.provide(
makeServerAuthPolicyLayer({
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/auth/Layers/ServerAuthPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect";

import { ServerConfig } from "../../config.ts";
import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";
import { SESSION_COOKIE_NAME } from "../utils.ts";
import { resolveSessionCookieName } from "../utils.ts";
import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts";

export const makeServerAuthPolicy = Effect.gen(function* () {
Expand All @@ -30,7 +30,10 @@ export const makeServerAuthPolicy = Effect.gen(function* () {
policy,
bootstrapMethods,
sessionMethods: ["browser-session-cookie", "bearer-session-token"],
sessionCookieName: SESSION_COOKIE_NAME,
sessionCookieName: resolveSessionCookieName({
mode: config.mode,
port: config.port,
}),
};

return {
Expand Down
10 changes: 8 additions & 2 deletions apps/server/src/auth/Layers/SessionCredentialService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "
import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect";
import { Option } from "effect";

import { ServerConfig } from "../../config.ts";
import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts";
import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts";
import { ServerSecretStore } from "../Services/ServerSecretStore.ts";
import { SESSION_COOKIE_NAME } from "../utils.ts";
import {
SessionCredentialError,
SessionCredentialService,
Expand All @@ -17,6 +17,7 @@ import {
import {
base64UrlDecodeUtf8,
base64UrlEncode,
resolveSessionCookieName,
signPayload,
timingSafeEqualBase64Url,
} from "../utils.ts";
Expand Down Expand Up @@ -81,11 +82,16 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli
}

export const makeSessionCredentialService = Effect.gen(function* () {
const serverConfig = yield* ServerConfig;
const secretStore = yield* ServerSecretStore;
const authSessions = yield* AuthSessionRepository;
const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
const cookieName = resolveSessionCookieName({
mode: serverConfig.mode,
port: serverConfig.port,
});

const toSessionCredentialError = (message: string) => (cause: unknown) =>
new SessionCredentialError({
Expand Down Expand Up @@ -472,7 +478,7 @@ export const makeSessionCredentialService = Effect.gen(function* () {
}).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions.")));

return {
cookieName: SESSION_COOKIE_NAME,
cookieName,
issue,
verify,
issueWebSocketToken,
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@ import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/
import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
import * as Crypto from "node:crypto";

export const SESSION_COOKIE_NAME = "t3_session";
const SESSION_COOKIE_NAME = "t3_session";

export function resolveSessionCookieName(input: {
readonly mode: "web" | "desktop";
readonly port: number;
}): string {
if (input.mode !== "desktop") {
return SESSION_COOKIE_NAME;
}

return `${SESSION_COOKIE_NAME}_${input.port}`;
}

export function base64UrlEncode(input: string | Uint8Array): string {
const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input);
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
"browser-session-cookie",
"bearer-session-token",
]);
assert.equal(body.auth.sessionCookieName, "t3_session");
assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_"));
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

Expand Down
Loading
Loading