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
62 changes: 55 additions & 7 deletions apps/server/src/auth/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
import { causeErrorTag } from "@t3tools/shared/observability";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import { identity } from "effect/Function";
import * as Layer from "effect/Layer";
import * as Result from "effect/Result";
import * as Cookies from "effect/unstable/http/Cookies";
import * as HttpEffect from "effect/unstable/http/HttpEffect";
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
Expand All @@ -37,7 +39,12 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
import * as EnvironmentAuth from "./EnvironmentAuth.ts";
import * as SessionStore from "./SessionStore.ts";
import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts";
import { deriveAuthClientMetadata } from "./utils.ts";
import * as ServerConfig from "../config.ts";
import {
decodeDevelopmentSessionCookieName,
deriveAuthClientMetadata,
planStaleDevelopmentSessionCookieSweep,
} from "./utils.ts";
import { verifyRequestDpopProof } from "./dpop.ts";

const CREDENTIAL_RESPONSE_HEADERS = {
Expand Down Expand Up @@ -203,6 +210,8 @@ export const authHttpApiLayer = HttpApiBuilder.group(
Effect.fnUntraced(function* (handlers) {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const sessions = yield* SessionStore.SessionStore;
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;

return handlers
.handle(
Expand All @@ -228,13 +237,52 @@ export const authHttpApiLayer = HttpApiBuilder.group(
args.payload.credential,
deriveAuthClientMetadata({ request }),
);
const requestCookieNames = Object.keys(request.cookies);
const cookieNamesToExpire =
serverConfig.devUrl === undefined
? []
: yield* Effect.gen(function* () {
const stateDirs = new Set(
requestCookieNames.flatMap((name) => {
const decoded = decodeDevelopmentSessionCookieName(name);
return decoded !== null && "stateDir" in decoded ? [decoded.stateDir] : [];
}),
);
const stateDirExistence = new Map(
yield* Effect.all(
Array.from(stateDirs, (stateDir) =>
fileSystem.exists(stateDir).pipe(
Effect.orElseSucceed(() => true),
Effect.map((exists) => [stateDir, exists] as const),
),
),
{ concurrency: "unbounded" },
),
);
return planStaleDevelopmentSessionCookieSweep({
ownCookieName: sessions.cookieName,
ownPort: serverConfig.port,
requestCookieNames,
stateDirExists: (stateDir) => stateDirExistence.get(stateDir) ?? true,
});
});
const sessionCookies = yield* Effect.fromResult(
Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, {
expires: DateTime.toDate(result.response.expiresAt),
httpOnly: true,
path: "/",
sameSite: "lax",
}),
cookieNamesToExpire.reduce(
(cookies, name) =>
Result.flatMap(cookies, (current) =>
Cookies.expireCookie(current, name, {
httpOnly: true,
path: "/",
sameSite: "lax",
}),
),
Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, {
expires: DateTime.toDate(result.response.expiresAt),
httpOnly: true,
path: "/",
sameSite: "lax",
}),
),
).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")));

yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Expand Down
75 changes: 68 additions & 7 deletions apps/server/src/auth/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vite-plus/test";

import {
base64UrlEncode,
decodeDevelopmentSessionCookieName,
deriveAuthClientMetadata,
isRemoteReachableHost,
planStaleDevelopmentSessionCookieSweep,
resolveSessionCookieName,
} from "./utils.ts";

Expand Down Expand Up @@ -58,24 +61,34 @@ describe("deriveAuthClientMetadata", () => {
});

describe("session cookie isolation", () => {
it("isolates loopback web servers by port and server state", () => {
it("isolates loopback web servers by port and encoded server state", () => {
const firstStateDir = "/tmp/t3-agent-one";
const secondStateDir = "/tmp/t3-agent-two";
const first = resolveSessionCookieName({
mode: "web",
port: 5775,
host: "127.0.0.1",
instanceKey: "/tmp/t3-agent-one",
instanceKey: firstStateDir,
development: true,
});
const second = resolveSessionCookieName({
mode: "web",
port: 5775,
host: "127.0.0.1",
instanceKey: "/tmp/t3-agent-two",
instanceKey: secondStateDir,
development: true,
});

expect(first).toMatch(/^t3_session_5775_[a-f0-9]{12}$/);
expect(second).toMatch(/^t3_session_5775_[a-f0-9]{12}$/);
expect(first).toBe(`t3_session_5775_${base64UrlEncode(firstStateDir)}`);
expect(second).toBe(`t3_session_5775_${base64UrlEncode(secondStateDir)}`);
expect(decodeDevelopmentSessionCookieName(first)).toEqual({
port: 5775,
stateDir: firstStateDir,
});
expect(decodeDevelopmentSessionCookieName(second)).toEqual({
port: 5775,
stateDir: secondStateDir,
});
expect(first).not.toBe(second);
});

Expand Down Expand Up @@ -113,15 +126,16 @@ describe("session cookie isolation", () => {
});

it("isolates development servers even when they bind a wildcard host", () => {
const stateDir = "/tmp/t3-wildcard-dev";
expect(
resolveSessionCookieName({
mode: "web",
port: 5775,
host: "0.0.0.0",
instanceKey: "/tmp/t3-wildcard-dev",
instanceKey: stateDir,
development: true,
}),
).toMatch(/^t3_session_5775_[a-f0-9]{12}$/);
).toBe(`t3_session_5775_${base64UrlEncode(stateDir)}`);
});

it("classifies loopback aliases separately from remotely reachable hosts", () => {
Expand All @@ -133,3 +147,50 @@ describe("session cookie isolation", () => {
expect(isRemoteReachableHost("192.168.1.50")).toBe(true);
});
});

describe("development session cookie decoding", () => {
it("classifies encoded state directories, legacy hashes, and unrelated names", () => {
const stateDir = "/tmp/t3-agent-state";

expect(
decodeDevelopmentSessionCookieName(`t3_session_5775_${base64UrlEncode(stateDir)}`),
).toEqual({ port: 5775, stateDir });
expect(decodeDevelopmentSessionCookieName("t3_session_5775_0123456789ab")).toEqual({
port: 5775,
legacyHash: "0123456789ab",
});
expect(decodeDevelopmentSessionCookieName("t3_session")).toBeNull();
expect(decodeDevelopmentSessionCookieName("t3_session_5775")).toBeNull();
expect(
decodeDevelopmentSessionCookieName(`t3_session_5775_${base64UrlEncode("relative/state")}`),
).toBeNull();
expect(decodeDevelopmentSessionCookieName("other_5775_0123456789ab")).toBeNull();
});
});

describe("stale development session cookie sweep", () => {
it("expires only dead encoded siblings and same-port legacy cookies", () => {
const ownCookieName = `t3_session_5775_${base64UrlEncode("/tmp/own")}`;
const liveSibling = `t3_session_5776_${base64UrlEncode("/tmp/live")}`;
const staleSibling = `t3_session_5777_${base64UrlEncode("/tmp/stale")}`;
const samePortLegacy = "t3_session_5775_0123456789ab";
const otherPortLegacy = "t3_session_5778_abcdef012345";

expect(
planStaleDevelopmentSessionCookieSweep({
ownCookieName,
ownPort: 5775,
requestCookieNames: [
ownCookieName,
liveSibling,
staleSibling,
samePortLegacy,
otherPortLegacy,
"t3_session",
"t3_session_3773",
],
stateDirExists: (stateDir) => stateDir === "/tmp/live",
}),
).toEqual([staleSibling, samePortLegacy]);
});
});
73 changes: 65 additions & 8 deletions apps/server/src/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const SESSION_COOKIE_NAME = "t3_session";
* - **Desktop**, which scans upward from 3773 for a free port and binds
* 127.0.0.1, so a second instance lands on a different port and the same host.
*
* Dev names encode the state directory so a server can recognize and expire
* cookies belonging to worktrees whose state directories no longer exist.
*
* Hosted deployments keep the stable production name: their public port can
* change between releases, and scoping it would log every user out.
*/
Expand All @@ -40,14 +43,68 @@ export function resolveSessionCookieName(input: {
return SESSION_COOKIE_NAME;
}

// Cookies are scoped by host, not port. Loopback development servers need an
// instance-specific name or parallel agents overwrite each other's session,
// and a server that later reuses the port receives a token signed elsewhere.
const instanceHash = NodeCrypto.createHash("sha256")
.update(input.instanceKey)
.digest("hex")
.slice(0, 12);
return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`;
return `${SESSION_COOKIE_NAME}_${input.port}_${base64UrlEncode(input.instanceKey)}`;
}

export type DevelopmentSessionCookieName =
| { readonly port: number; readonly stateDir: string }
| { readonly port: number; readonly legacyHash: string };

export function decodeDevelopmentSessionCookieName(
name: string,
): DevelopmentSessionCookieName | null {
const match = /^t3_session_(\d+)_([A-Za-z0-9_-]+)$/.exec(name);
const port = Number(match?.[1]);
const suffix = match?.[2];
if (!Number.isSafeInteger(port) || suffix === undefined) {
return null;
}

if (/^[0-9a-f]{12}$/.test(suffix)) {
return { port, legacyHash: suffix };
}

const decoded = Encoding.decodeBase64UrlString(suffix);
if (Result.isFailure(decoded) || !isAbsolutePathLike(decoded.success)) {
return null;
}
return { port, stateDir: decoded.success };
}

// POSIX root, Windows drive, or UNC. Format only: the sweep decides existence.
function isAbsolutePathLike(value: string): boolean {
return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
}

/**
* Plans cookie expiry from already-observed directory existence. Encoding the
* path creates a localhost-only path-existence oracle for pages that craft
* cookie names and observe expiry. This is accepted for development servers,
* where such a page already runs code on the same machine.
*/
export function planStaleDevelopmentSessionCookieSweep(input: {
readonly ownCookieName: string;
readonly ownPort: number;
readonly requestCookieNames: Iterable<string>;
readonly stateDirExists: (stateDir: string) => boolean;
}): ReadonlyArray<string> {
const namesToExpire: Array<string> = [];
for (const name of input.requestCookieNames) {
if (name === input.ownCookieName) {
continue;
}
const decoded = decodeDevelopmentSessionCookieName(name);
if (decoded === null) {
continue;
}
if (
("legacyHash" in decoded && decoded.port === input.ownPort) ||
("stateDir" in decoded && !input.stateDirExists(decoded.stateDir))
) {
namesToExpire.push(name);
}
}
return namesToExpire;
}

export function isRemoteReachableHost(host: string | undefined): boolean {
Expand Down
76 changes: 76 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { ChildProcessSpawner } from "effect/unstable/process";
import * as Cookies from "effect/unstable/http/Cookies";
import {
FetchHttpClient,
HttpBody,
Expand Down Expand Up @@ -141,6 +142,7 @@ import * as ReviewService from "./review/ReviewService.ts";
import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import { resolveSessionCookieName } from "./auth/utils.ts";
import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
Expand Down Expand Up @@ -1594,6 +1596,80 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("sweeps stale development session cookies when pairing", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const liveStateDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-live-dev-cookie-",
});
const staleStateDir = yield* fileSystem.makeTempDirectory({
prefix: "t3-stale-dev-cookie-",
});
yield* fileSystem.remove(staleStateDir, { recursive: true });

const config = yield* buildAppUnderTest({
config: {
mode: "web",
port: 5775,
devUrl: new URL("http://127.0.0.1:5173"),
},
});
const ownCookieName = resolveSessionCookieName({
mode: config.mode,
port: config.port,
host: config.host,
instanceKey: config.stateDir,
development: true,
});
const staleCookieName = resolveSessionCookieName({
mode: "web",
port: 5776,
host: "127.0.0.1",
instanceKey: staleStateDir,
development: true,
});
const liveCookieName = resolveSessionCookieName({
mode: "web",
port: 5777,
host: "127.0.0.1",
instanceKey: liveStateDir,
development: true,
});
const legacyCookieName = `t3_session_${config.port}_0123456789ab`;

const { response } = yield* bootstrapBrowserSession(defaultDesktopBootstrapToken, {
headers: {
cookie: [
`${staleCookieName}=stale-token`,
`${liveCookieName}=live-token`,
`${legacyCookieName}=legacy-token`,
].join("; "),
},
});
const setCookieHeaders = Cookies.toSetCookieHeaders(response.cookies);
const ownSetCookie = setCookieHeaders.find((header) =>
header.startsWith(`${ownCookieName}=`),
);
const staleSetCookie = setCookieHeaders.find((header) =>
header.startsWith(`${staleCookieName}=`),
);
const legacySetCookie = setCookieHeaders.find((header) =>
header.startsWith(`${legacyCookieName}=`),
);

assert.equal(response.status, 200);
assert.isDefined(ownSetCookie);
assert.notInclude(ownSetCookie ?? "", "Max-Age=0");
for (const expiredCookie of [staleSetCookie, legacySetCookie]) {
assert.isDefined(expiredCookie);
assert.include(expiredCookie ?? "", "Max-Age=0");
assert.include(expiredCookie ?? "", "Path=/");
assert.include(expiredCookie ?? "", "HttpOnly");
}
assert.notInclude(setCookieHeaders.join("\n"), liveCookieName);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("exchanges a bootstrap grant for a scoped bearer access token", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
Expand Down
Loading
Loading