diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 780aaabde25..c23298ba14d 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -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"; @@ -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 = { @@ -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( @@ -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) => diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index edc58f71131..3ad8fd7d9cb 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import { + base64UrlEncode, + decodeDevelopmentSessionCookieName, deriveAuthClientMetadata, isRemoteReachableHost, + planStaleDevelopmentSessionCookieSweep, resolveSessionCookieName, } from "./utils.ts"; @@ -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); }); @@ -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", () => { @@ -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]); + }); +}); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 32a6799b01f..fd6da64f6b8 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -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. */ @@ -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; + readonly stateDirExists: (stateDir: string) => boolean; +}): ReadonlyArray { + const namesToExpire: Array = []; + 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 { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index f339ff84fae..b1a351d092a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -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, @@ -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"; @@ -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(); diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 5f4f5b6e960..430cdd14392 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -37,6 +37,11 @@ browser session cookie. The cookie is an HTTP transport adapter for the same scoped session model; the response never exposes the session secret to browser JavaScript. +Loopback development servers use a per-instance cookie name containing the +server port and a base64url-encoded state directory, so parallel worktrees do +not overwrite each other's sessions. Pairing also expires cookies whose encoded +state directories no longer exist, while preserving live sibling worktrees. + ### Bearer Access Token Non-browser clients use `POST /oauth/token` with an