From 03a9e682f1330c2781ad8d487bbbb427b67c37bd Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 13 Jul 2026 18:10:45 -0700 Subject: [PATCH 1/2] feat: silent pairing for local web-dev servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local web dev previously required scraping a one-time, 5-minute /pair#token URL from dev-runner stdout for every fresh browser context, which made parallel-instance work and agent browser verification painful. The web app now authenticates automatically against eligible dev servers via a guarded dev-only endpoint. Server: POST /api/auth/dev-pairing-token mints a one-time administrative credential (subject dev-auto-bootstrap, hidden from pairing-link listings) only when the server is web-mode, loopback-bound (loopback-browser policy), and configured with a loopback dev URL. Requests must carry an Origin exactly matching the dev origin (survives the Vite proxy, unlike Host) and a loopback Host header (DNS-rebinding defense). Ineligible configs answer an explicit 404 — the handler is always mounted so the SPA/redirect wildcard can't shadow it. Startup: eligible dev servers now open the plain dev URL instead of minting a startup pairing credential the silent path would leave unconsumed in the URL and scrollback. Web: the auth-gate bootstrap silently tries the endpoint when unauthenticated in dev (never in hosted/desktop contexts, never when an explicit #token is present) and falls back to the pairing screen on any failure. import.meta.env.DEV keeps the request out of prod bundles. Extracted host parsing into netHost.ts (auth/http.ts importing the old helper from http.ts would have been a module cycle; the old helper also mishandled Host headers with ports). Docs: AGENTS.md browser-verification section, dev-instance auth notes in scripts.md (plus stale T3CODE_STATE_DIR fix), and a security spec in environment-auth.md. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 17 ++ apps/server/src/auth/EnvironmentAuth.ts | 18 ++ apps/server/src/auth/devPairing.test.ts | 139 +++++++++++++ apps/server/src/auth/devPairing.ts | 47 +++++ apps/server/src/auth/http.ts | 45 ++++- apps/server/src/netHost.test.ts | 59 ++++++ apps/server/src/netHost.ts | 65 +++++++ apps/server/src/server.test.ts | 195 +++++++++++++++++++ apps/server/src/serverRuntimeStartup.test.ts | 110 +++++++++++ apps/server/src/serverRuntimeStartup.ts | 38 ++-- apps/server/src/startupAccess.ts | 26 +-- apps/web/src/authBootstrap.test.ts | 134 ++++++++++++- apps/web/src/environments/primary/auth.ts | 49 +++++ apps/web/test/environmentHttpTest.ts | 17 ++ docs/cloud/environment-auth.md | 33 ++++ docs/reference/scripts.md | 13 +- packages/contracts/src/environmentHttp.ts | 25 ++- 17 files changed, 992 insertions(+), 38 deletions(-) create mode 100644 apps/server/src/auth/devPairing.test.ts create mode 100644 apps/server/src/auth/devPairing.ts create mode 100644 apps/server/src/netHost.test.ts create mode 100644 apps/server/src/netHost.ts diff --git a/AGENTS.md b/AGENTS.md index 380a9202683..a7ea3924cd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,23 @@ If a tradeoff is required, choose correctness and robustness over short-term con Long term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem. +## Verifying Changes in the Browser + +Starting a dev server to verify changes is expected and allowed: + +- `T3CODE_DEV_INSTANCE= bun run dev` runs an isolated instance + with deterministic port offsets (server base `13773`, web base `5733`; the + runner probes for free ports and prints the chosen pair). +- On localhost dev servers the web UI authenticates automatically — no pairing + code needed. Just load the printed web port (e.g. `http://localhost:5733`). + This applies to fresh/headless browser profiles too. +- Dev instances sharing the default `T3CODE_HOME` (`~/.t3`) share state under + `~/.t3/dev`; a browser session carries across instances on the same hostname. + Use `localhost` consistently — it does not share cookies with `127.0.0.1`. + +See `docs/reference/scripts.md` for parallel-instance details and +`docs/cloud/environment-auth.md` for how dev auto-auth is secured. + ## Package Roles - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index dd53a83ca95..5cbc2f595a5 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -37,6 +37,7 @@ import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sql export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; +export const INTERNAL_DEV_AUTO_BOOTSTRAP_SUBJECT = "dev-auto-bootstrap"; export interface IssuedPairingLink { readonly id: string; @@ -446,6 +447,10 @@ export class EnvironmentAuth extends Context.Service< AuthPairingCredentialResult, ServerAuthInternalError >; + readonly issueDevAutoPairingCredential: () => Effect.Effect< + AuthPairingCredentialResult, + ServerAuthInternalError + >; readonly listPairingLinks: (input?: { readonly excludeSubjects?: ReadonlyArray; }) => Effect.Effect, ServerAuthInternalError>; @@ -793,6 +798,7 @@ export const make = Effect.gen(function* () { Effect.map((pairingLinks) => { const excludedSubjects = input?.excludeSubjects ?? [ INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, + INTERNAL_DEV_AUTO_BOOTSTRAP_SUBJECT, ]; return pairingLinks .filter((pairingLink) => !excludedSubjects.includes(pairingLink.subject)) @@ -874,6 +880,17 @@ export const make = Effect.gen(function* () { subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); + // Same administrative grant as startup pairing, under its own subject so + // dev-auto sessions are identifiable and excluded from pairing-link + // listings. Only reachable through the guarded dev-only HTTP endpoint. + const issueDevAutoPairingCredential: EnvironmentAuth["Service"]["issueDevAutoPairingCredential"] = + () => + issuePairingCredentialForSubject({ + scopes: AuthAdministrativeScopes, + subject: INTERNAL_DEV_AUTO_BOOTSTRAP_SUBJECT, + label: "dev-auto", + }).pipe(Effect.withSpan("EnvironmentAuth.issueDevAutoPairingCredential")); + const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => listSessions().pipe( Effect.map((clientSessions) => @@ -964,6 +981,7 @@ export const make = Effect.gen(function* () { createPairingLink, issuePairingCredential, issueStartupPairingCredential, + issueDevAutoPairingCredential, listPairingLinks, revokePairingLink, issueSession, diff --git a/apps/server/src/auth/devPairing.test.ts b/apps/server/src/auth/devPairing.test.ts new file mode 100644 index 00000000000..05f783a9442 --- /dev/null +++ b/apps/server/src/auth/devPairing.test.ts @@ -0,0 +1,139 @@ +import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; + +import { isDevPairingEligible, validateDevPairingRequestHeaders } from "./devPairing.ts"; + +const descriptorWithPolicy = (policy: ServerAuthDescriptor["policy"]): ServerAuthDescriptor => ({ + policy, + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"], + sessionCookieName: "t3_session", +}); + +const devUrl = new URL("http://localhost:5733"); + +describe("isDevPairingEligible", () => { + it("is eligible only for loopback web-dev servers", () => { + assert.isTrue( + isDevPairingEligible({ mode: "web", devUrl }, descriptorWithPolicy("loopback-browser")), + ); + }); + + it("rejects production configurations (no devUrl)", () => { + assert.isFalse( + isDevPairingEligible( + { mode: "web", devUrl: undefined }, + descriptorWithPolicy("loopback-browser"), + ), + ); + }); + + it("rejects desktop mode even with a loopback devUrl", () => { + assert.isFalse( + isDevPairingEligible( + { mode: "desktop", devUrl }, + descriptorWithPolicy("desktop-managed-local"), + ), + ); + assert.isFalse( + isDevPairingEligible({ mode: "desktop", devUrl }, descriptorWithPolicy("loopback-browser")), + ); + }); + + it("rejects remote-reachable policies", () => { + assert.isFalse( + isDevPairingEligible({ mode: "web", devUrl }, descriptorWithPolicy("remote-reachable")), + ); + }); + + it("rejects non-loopback dev URLs so --dev-url cannot become an auth root", () => { + assert.isFalse( + isDevPairingEligible( + { mode: "web", devUrl: new URL("https://some-site.example") }, + descriptorWithPolicy("loopback-browser"), + ), + ); + assert.isFalse( + isDevPairingEligible( + { mode: "web", devUrl: new URL("http://192.168.1.20:5733") }, + descriptorWithPolicy("loopback-browser"), + ), + ); + }); + + it("accepts loopback dev URL variants", () => { + for (const url of ["http://127.0.0.1:5733", "http://[::1]:5733"]) { + assert.isTrue( + isDevPairingEligible( + { mode: "web", devUrl: new URL(url) }, + descriptorWithPolicy("loopback-browser"), + ), + url, + ); + } + }); +}); + +describe("validateDevPairingRequestHeaders", () => { + it("accepts an exact dev-origin Origin with a loopback Host", () => { + assert.equal( + validateDevPairingRequestHeaders({ + originHeader: "http://localhost:5733", + hostHeader: "localhost:13773", + devUrl, + }), + null, + ); + }); + + it("rejects missing, null, or foreign Origins", () => { + for (const originHeader of [undefined, "", "null", "https://evil.example"]) { + assert.equal( + validateDevPairingRequestHeaders({ + originHeader, + hostHeader: "localhost:13773", + devUrl, + }), + "origin_mismatch", + String(originHeader), + ); + } + }); + + it("rejects Origins that differ from the dev origin only by port or scheme", () => { + for (const originHeader of [ + "http://localhost:5734", + "https://localhost:5733", + "http://127.0.0.1:5733", + ]) { + assert.equal( + validateDevPairingRequestHeaders({ + originHeader, + hostHeader: "localhost:13773", + devUrl, + }), + "origin_mismatch", + originHeader, + ); + } + }); + + it("rejects non-loopback Hosts (DNS rebinding) even with a matching Origin", () => { + assert.equal( + validateDevPairingRequestHeaders({ + originHeader: "http://localhost:5733", + hostHeader: "evil.example:13773", + devUrl, + }), + "host_not_loopback", + ); + assert.equal( + validateDevPairingRequestHeaders({ + originHeader: "http://localhost:5733", + hostHeader: undefined, + devUrl, + }), + "host_not_loopback", + ); + }); +}); diff --git a/apps/server/src/auth/devPairing.ts b/apps/server/src/auth/devPairing.ts new file mode 100644 index 00000000000..cf901040e04 --- /dev/null +++ b/apps/server/src/auth/devPairing.ts @@ -0,0 +1,47 @@ +/** + * devPairing - Eligibility and request validation for dev-mode silent pairing. + * + * The dev pairing endpoint mints an administrative one-time credential with no + * prior authentication, so eligibility is deliberately narrow: a web-mode dev + * server, bound to loopback (policy "loopback-browser"), whose configured dev + * URL is itself loopback. Request-level checks then require a browser-attached + * Origin matching the dev origin exactly — the Origin header survives the Vite + * dev proxy unmodified, which Host-based checks do not — plus a loopback Host + * as anti-DNS-rebinding defense for direct requests. + * + * @module devPairing + */ +import type { ServerAuthDescriptor } from "@t3tools/contracts"; + +import type { ServerConfig } from "../config.ts"; +import { isLoopbackHost, isLoopbackHostHeader, normalizeHost } from "../netHost.ts"; + +export interface DevPairingConfigInput { + readonly mode: ServerConfig["Service"]["mode"]; + readonly devUrl: URL | undefined; +} + +export const isDevPairingEligible = ( + config: DevPairingConfigInput, + descriptor: ServerAuthDescriptor, +): boolean => + config.mode === "web" && + config.devUrl !== undefined && + isLoopbackHost(normalizeHost(config.devUrl.hostname)) && + descriptor.policy === "loopback-browser"; + +export type DevPairingRequestRejection = "origin_mismatch" | "host_not_loopback"; + +export const validateDevPairingRequestHeaders = (input: { + readonly originHeader: string | undefined; + readonly hostHeader: string | undefined; + readonly devUrl: URL; +}): DevPairingRequestRejection | null => { + if (!input.originHeader || input.originHeader.trim() !== input.devUrl.origin) { + return "origin_mismatch"; + } + if (!isLoopbackHostHeader(input.hostHeader)) { + return "host_not_loopback"; + } + return null; +}; diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 780aaabde25..0ee5b09bcd4 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -39,6 +39,8 @@ import * as SessionStore from "./SessionStore.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; +import { isDevPairingEligible, validateDevPairingRequestHeaders } from "./devPairing.ts"; +import * as ServerConfig from "../config.ts"; const CREDENTIAL_RESPONSE_HEADERS = { "cache-control": "no-store", @@ -125,7 +127,9 @@ export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope ); } -function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_allowed") { +function failEnvironmentOperationForbidden( + reason: "current_session_revoke_not_allowed" | "dev_pairing_request_rejected", +) { return currentEnvironmentTraceId.pipe( Effect.flatMap((traceId) => Effect.fail( @@ -331,6 +335,45 @@ export const authHttpApiLayer = HttpApiBuilder.group( ), ), ) + .handle( + "devPairingCredential", + Effect.fn("environment.auth.devPairingCredential")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const config = yield* ServerConfig.ServerConfig; + const descriptor = yield* serverAuth.getDescriptor(); + + // Ineligible configurations answer not_found so the endpoint is + // indistinguishable from absent outside local web dev. The + // handler must exist unconditionally: unregistered paths fall + // into the SPA/redirect wildcard route, not a 404. + if (!isDevPairingEligible(config, descriptor) || config.devUrl === undefined) { + return yield* failEnvironmentNotFound("dev_pairing_not_available"); + } + + const request = yield* HttpServerRequest.HttpServerRequest; + const rejection = validateDevPairingRequestHeaders({ + originHeader: request.headers["origin"], + hostHeader: request.headers["host"], + devUrl: config.devUrl, + }); + if (rejection !== null) { + yield* Effect.logWarning("rejected dev pairing request", { + rejection, + origin: request.headers["origin"] ?? null, + host: request.headers["host"] ?? null, + }); + return yield* failEnvironmentOperationForbidden("dev_pairing_request_rejected"); + } + + yield* appendCredentialResponseHeaders; + return yield* serverAuth.issueDevAutoPairingCredential(); + }, + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("dev_pairing_credential_issuance_failed", error), + ), + ), + ) .handle( "pairingCredential", Effect.fn("environment.auth.pairingCredential")( diff --git a/apps/server/src/netHost.test.ts b/apps/server/src/netHost.test.ts new file mode 100644 index 00000000000..b5aee1dd62c --- /dev/null +++ b/apps/server/src/netHost.test.ts @@ -0,0 +1,59 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { isLoopbackHost, isLoopbackHostHeader, parseHostHeaderHostname } from "./netHost.ts"; + +describe("parseHostHeaderHostname", () => { + it("extracts hostnames from host headers with and without ports", () => { + assert.equal(parseHostHeaderHostname("localhost"), "localhost"); + assert.equal(parseHostHeaderHostname("localhost:13773"), "localhost"); + assert.equal(parseHostHeaderHostname("127.0.0.1:13773"), "127.0.0.1"); + assert.equal(parseHostHeaderHostname("[::1]:13773"), "[::1]"); + assert.equal(parseHostHeaderHostname("example.com:8080"), "example.com"); + assert.equal(parseHostHeaderHostname(" LOCALHOST:13773 "), "localhost"); + }); + + it("returns null for missing or malformed host headers", () => { + assert.equal(parseHostHeaderHostname(undefined), null); + assert.equal(parseHostHeaderHostname(""), null); + assert.equal(parseHostHeaderHostname(" "), null); + assert.equal(parseHostHeaderHostname("exa mple.com"), null); + assert.equal(parseHostHeaderHostname("http://example.com"), null); + }); +}); + +describe("isLoopbackHostHeader", () => { + it("accepts loopback host headers, with ports and brackets", () => { + assert.isTrue(isLoopbackHostHeader("localhost")); + assert.isTrue(isLoopbackHostHeader("localhost:13773")); + assert.isTrue(isLoopbackHostHeader("127.0.0.1:13773")); + assert.isTrue(isLoopbackHostHeader("127.5.5.5")); + assert.isTrue(isLoopbackHostHeader("127.5.5.5:8080")); + assert.isTrue(isLoopbackHostHeader("[::1]:13773")); + assert.isTrue(isLoopbackHostHeader("[::1]")); + }); + + it("rejects non-loopback and malformed host headers (fails closed)", () => { + assert.isFalse(isLoopbackHostHeader("evil.example")); + assert.isFalse(isLoopbackHostHeader("evil.example:13773")); + assert.isFalse(isLoopbackHostHeader("localhost.evil.example")); + assert.isFalse(isLoopbackHostHeader("10.0.0.5:13773")); + assert.isFalse(isLoopbackHostHeader("[::2]:13773")); + assert.isFalse(isLoopbackHostHeader(undefined)); + assert.isFalse(isLoopbackHostHeader("")); + assert.isFalse(isLoopbackHostHeader("exa mple.com")); + }); +}); + +describe("isLoopbackHost", () => { + it("keeps the config-host semantics: undefined means loopback", () => { + assert.isTrue(isLoopbackHost(undefined)); + assert.isTrue(isLoopbackHost("")); + assert.isTrue(isLoopbackHost("localhost")); + assert.isTrue(isLoopbackHost("127.0.0.1")); + assert.isTrue(isLoopbackHost("127.99.0.1")); + assert.isTrue(isLoopbackHost("::1")); + assert.isTrue(isLoopbackHost("[::1]")); + assert.isFalse(isLoopbackHost("0.0.0.0")); + assert.isFalse(isLoopbackHost("192.168.1.10")); + }); +}); diff --git a/apps/server/src/netHost.ts b/apps/server/src/netHost.ts new file mode 100644 index 00000000000..a570e42e08d --- /dev/null +++ b/apps/server/src/netHost.ts @@ -0,0 +1,65 @@ +/** + * netHost - Pure host/hostname predicates and parsing. + * + * Dependency-free so it can be imported from auth modules, startup code, and + * HTTP layers without creating module cycles. + * + * @module netHost + */ + +export const isLoopbackHost = (host: string | undefined): boolean => { + if (!host || host.length === 0) { + return true; + } + + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "[::1]" || + host.startsWith("127.") + ); +}; + +export const isWildcardHost = (host: string | undefined): boolean => + host === "0.0.0.0" || host === "::" || host === "[::]"; + +export const formatHostForUrl = (host: string): string => + host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + +export const normalizeHost = (host: string): string => + host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + +/** + * Extracts the hostname from a raw `Host` request header, handling ports, + * bracketed IPv6 literals, and surrounding whitespace. Returns null for + * missing or malformed values. + */ +export const parseHostHeaderHostname = (hostHeader: string | undefined): string | null => { + const trimmed = hostHeader?.trim().toLowerCase(); + if (!trimmed || trimmed.includes("/")) { + return null; + } + + // URL handles `host[:port]` and `[v6]:port` uniformly and rejects garbage. + try { + const parsed = new URL(`http://${trimmed}`); + return parsed.hostname.length > 0 ? parsed.hostname : null; + } catch { + return null; + } +}; + +/** + * True when a raw `Host` request header names a loopback host. Malformed or + * missing headers are NOT loopback: this is used as an auth guard, so parse + * failures must fail closed (unlike `isLoopbackHost`, whose undefined input + * means "no bind host configured" and defaults open). + */ +export const isLoopbackHostHeader = (hostHeader: string | undefined): boolean => { + const hostname = parseHostHeaderHostname(hostHeader); + if (hostname === null) { + return false; + } + return isLoopbackHost(normalizeHost(hostname)); +}; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 347c2920792..f0918a8ec92 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3418,6 +3418,201 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + const DEV_PAIRING_PATH = "/api/auth/dev-pairing-token"; + const devPairingEligibleConfig = { + mode: "web", + host: "127.0.0.1", + devUrl: new URL("http://localhost:5733"), + desktopBootstrapToken: undefined, + } as const; + const devOriginHeaders = { + "content-type": "application/json", + origin: "http://localhost:5733", + } as const; + + const postDevPairing = (headers: Record) => + Effect.gen(function* () { + const url = yield* getHttpServerUrl(DEV_PAIRING_PATH); + return yield* fetchEffect(url, { + method: "POST", + headers, + body: jsonRequestBody({}), + }); + }); + + it.effect("mints an admin-scope dev pairing credential for the dev origin", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: devPairingEligibleConfig }); + + const response = yield* postDevPairing({ ...devOriginHeaders }); + const body = (yield* response.json) as { + readonly credential: string; + readonly expiresAt: string; + }; + + assert.equal(response.status, 200); + assert.equal(response.headers["cache-control"], "no-store"); + assert.isTrue(body.credential.length > 0); + + // The credential exchanges into a session with administrative scopes. + const sessionCookie = yield* getAuthenticatedSessionCookieHeader(body.credential); + const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: sessionCookie }, + body: yield* HttpBody.json({}), + }); + assert.equal(pairingResponse.status, 200); + + // One-time: a second exchange of the same credential fails. + const reused = yield* bootstrapBrowserSession(body.credential); + assert.equal(reused.response.status, 401); + + // The dev-auto subject is excluded from the pairing-link listing. + const listResponse = yield* HttpClient.get("/api/auth/pairing-links", { + headers: { cookie: sessionCookie }, + }); + const listedLinks = (yield* listResponse.json) as ReadonlyArray<{ + readonly subject: string; + }>; + assert.equal(listResponse.status, 200); + assert.isFalse(listedLinks.some((entry) => entry.subject === "dev-auto-bootstrap")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects dev pairing requests with a missing or foreign Origin", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: devPairingEligibleConfig }); + + for (const origin of [undefined, "https://evil.example", "null"]) { + const response = yield* postDevPairing({ + "content-type": "application/json", + ...(origin === undefined ? {} : { origin }), + }); + const body = (yield* response.json) as { + readonly _tag: string; + readonly reason: string; + }; + assert.equal(response.status, 403, String(origin)); + assert.equal(body._tag, "EnvironmentOperationForbiddenError"); + assert.equal(body.reason, "dev_pairing_request_rejected"); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects dev pairing requests whose Host header is not loopback", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: devPairingEligibleConfig }); + const url = new URL(yield* getHttpServerUrl(DEV_PAIRING_PATH)); + + // fetch() silently drops forbidden Host overrides, so drive node:http + // directly to simulate the DNS-rebinding request shape. + const rebound = yield* Effect.promise(async () => { + const NodeHttp = await import("node:http"); + return await new Promise<{ status: number; body: string }>((resolve, reject) => { + const request = NodeHttp.request( + { + host: url.hostname, + port: url.port, + path: url.pathname, + method: "POST", + headers: { + "content-type": "application/json", + origin: devOriginHeaders.origin, + host: "evil.example", + }, + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk) => + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)), + ); + response.on("end", () => + resolve({ + status: response.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf8"), + }), + ); + }, + ); + request.on("error", reject); + request.end(JSON.stringify({})); + }); + }); + + assert.equal(rebound.status, 403); + assert.include(rebound.body, "dev_pairing_request_rejected"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("answers not_found for dev pairing outside eligible dev configurations", () => + Effect.gen(function* () { + const assertNotAvailable = Effect.fn(function* () { + const response = yield* postDevPairing({ ...devOriginHeaders }); + const body = (yield* response.json) as { + readonly _tag: string; + readonly reason: string; + }; + assert.equal(response.status, 404); + assert.equal(body._tag, "EnvironmentResourceNotFoundError"); + assert.equal(body.reason, "dev_pairing_not_available"); + }); + + // Production shape: no devUrl. + yield* buildAppUnderTest({ + config: { ...devPairingEligibleConfig, devUrl: undefined }, + }); + yield* assertNotAvailable(); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("answers not_found for dev pairing on remote-reachable and desktop servers", () => + Effect.gen(function* () { + const assertNotAvailable = Effect.fn(function* () { + const response = yield* postDevPairing({ ...devOriginHeaders }); + assert.equal(response.status, 404); + }); + + yield* buildAppUnderTest({ + config: { ...devPairingEligibleConfig, host: "0.0.0.0" }, + }); + yield* assertNotAvailable(); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("answers not_found for dev pairing with a non-loopback dev URL", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + ...devPairingEligibleConfig, + devUrl: new URL("https://some-site.example"), + }, + }); + + const response = yield* postDevPairing({ + "content-type": "application/json", + origin: "https://some-site.example", + }); + assert.equal(response.status, 404); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("answers not_found for dev pairing in desktop mode", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + mode: "desktop", + host: "127.0.0.1", + devUrl: new URL("http://127.0.0.1:5733"), + }, + }); + + const response = yield* postDevPairing({ + "content-type": "application/json", + origin: "http://127.0.0.1:5733", + }); + assert.equal(response.status, 404); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects pairing credential requests without access management scope", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 1a331bc717f..4ffee89543a 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -11,6 +11,7 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; +import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; @@ -107,6 +108,115 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa ), ); +const startupTargetAuthMock = (input: { + readonly policy: "loopback-browser" | "remote-reachable" | "desktop-managed-local"; + readonly onIssueStartupPairingUrl?: (baseUrl: string) => void; +}) => + ({ + getDescriptor: () => + Effect.succeed({ + policy: input.policy, + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"], + sessionCookieName: "t3_session", + }), + issueStartupPairingUrl: (baseUrl: string) => { + input.onIssueStartupPairingUrl?.(baseUrl); + return Effect.succeed(`${baseUrl}pair#token=STARTUP`); + }, + }) as never; + +it.effect( + "opens the plain dev URL without minting a credential when silent pairing is eligible", + () => + Effect.gen(function* () { + const result = yield* ServerRuntimeStartup.resolveStartupBrowserTarget.pipe( + Effect.provideService(ServerConfig.ServerConfig, { + mode: "web", + host: "127.0.0.1", + port: 13773, + devUrl: new URL("http://localhost:5733/"), + } as never), + Effect.provideService( + EnvironmentAuth.EnvironmentAuth, + startupTargetAuthMock({ + policy: "loopback-browser", + onIssueStartupPairingUrl: () => { + throw new Error("must not mint a startup credential in eligible dev"); + }, + }), + ), + ); + + assert.deepStrictEqual(result, { + target: "http://localhost:5733/", + requiresPairing: false, + }); + }), +); + +it.effect("still mints a startup pairing URL for non-dev web servers", () => + Effect.gen(function* () { + const result = yield* ServerRuntimeStartup.resolveStartupBrowserTarget.pipe( + Effect.provideService(ServerConfig.ServerConfig, { + mode: "web", + host: "127.0.0.1", + port: 3773, + devUrl: undefined, + } as never), + Effect.provideService( + EnvironmentAuth.EnvironmentAuth, + startupTargetAuthMock({ policy: "loopback-browser" }), + ), + ); + + assert.isTrue(result.requiresPairing); + assert.include(result.target, "pair#token=STARTUP"); + }), +); + +it.effect("still mints a startup pairing URL for remote-reachable dev servers", () => + Effect.gen(function* () { + const result = yield* ServerRuntimeStartup.resolveStartupBrowserTarget.pipe( + Effect.provideService(ServerConfig.ServerConfig, { + mode: "web", + host: "0.0.0.0", + port: 13773, + devUrl: new URL("http://localhost:5733/"), + } as never), + Effect.provideService( + EnvironmentAuth.EnvironmentAuth, + startupTargetAuthMock({ policy: "remote-reachable" }), + ), + ); + + assert.isTrue(result.requiresPairing); + assert.include(result.target, "pair#token=STARTUP"); + }), +); + +it.effect("desktop startup keeps the plain target without pairing", () => + Effect.gen(function* () { + const result = yield* ServerRuntimeStartup.resolveStartupBrowserTarget.pipe( + Effect.provideService(ServerConfig.ServerConfig, { + mode: "desktop", + host: "127.0.0.1", + port: 13773, + devUrl: new URL("http://127.0.0.1:5733/"), + } as never), + Effect.provideService( + EnvironmentAuth.EnvironmentAuth, + startupTargetAuthMock({ policy: "desktop-managed-local" }), + ), + ); + + assert.deepStrictEqual(result, { + target: "http://127.0.0.1:5733/", + requiresPairing: false, + }); + }), +); + it.effect("resolveWelcomeBase derives cwd and project name from server config", () => Effect.gen(function* () { const welcome = yield* ServerRuntimeStartup.resolveWelcomeBase.pipe( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index b52b577c5b5..ad20ad1ba72 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -40,6 +40,7 @@ import { isWildcardHost, issueHeadlessServeAccessInfo, } from "./startupAccess.ts"; +import { isDevPairingEligible } from "./auth/devPairing.ts"; export class ServerRuntimeStartupError extends Schema.TaggedErrorClass()( "ServerRuntimeStartupError", @@ -249,7 +250,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { } as const; }); -const resolveStartupBrowserTarget = Effect.gen(function* () { +export const resolveStartupBrowserTarget = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const localUrl = `http://localhost:${serverConfig.port}`; @@ -258,11 +259,22 @@ const resolveStartupBrowserTarget = Effect.gen(function* () { ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` : localUrl; const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl; - return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe( - Effect.flatMap((target) => - target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget), - ), - ); + if (serverConfig.mode === "desktop") { + return { target: baseTarget, requiresPairing: false }; + } + // Silent-pairing-eligible dev servers self-authenticate on page load, so + // open the plain URL. Minting a /pair#token URL here would leave an + // unconsumed administrative credential in the URL and terminal scrollback: + // the silent path authenticates before /pair could consume the token, and + // /pair redirects away from authenticated sessions without exchanging it. + const descriptor = yield* serverAuth.getDescriptor(); + if (isDevPairingEligible(serverConfig, descriptor)) { + return { target: baseTarget, requiresPairing: false }; + } + return { + target: yield* serverAuth.issueStartupPairingUrl(baseTarget), + requiresPairing: true, + }; }); const maybeOpenBrowser = (target: string) => @@ -456,13 +468,17 @@ export const make = Effect.gen(function* () { ); } else { yield* Effect.logDebug("startup phase: browser open check"); - const startupBrowserTarget = yield* resolveStartupBrowserTarget; + const startupBrowser = yield* resolveStartupBrowserTarget; if (serverConfig.mode !== "desktop") { - yield* Effect.logInfo( - "Authentication required. Open T3 Code using the pairing URL.", - ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget })); + yield* startupBrowser.requiresPairing + ? Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.").pipe( + Effect.annotateLogs({ pairingUrl: startupBrowser.target }), + ) + : Effect.logInfo("T3 Code dev server ready.").pipe( + Effect.annotateLogs({ url: startupBrowser.target }), + ); } - yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); + yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowser.target)); } yield* Effect.logDebug("startup phase: complete"); }), diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 7df131669ba..67f6555b6d1 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -6,6 +6,9 @@ import { HttpServer } from "effect/unstable/http"; import { ServerConfig } from "./config.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import { formatHostForUrl, isWildcardHost, normalizeHost } from "./netHost.ts"; + +export { isLoopbackHost, isWildcardHost, formatHostForUrl } from "./netHost.ts"; export interface HeadlessServeAccessInfo { readonly connectionString: string; @@ -15,29 +18,6 @@ export interface HeadlessServeAccessInfo { type NetworkInterfacesMap = ReturnType; -export const isLoopbackHost = (host: string | undefined): boolean => { - if (!host || host.length === 0) { - return true; - } - - return ( - host === "localhost" || - host === "127.0.0.1" || - host === "::1" || - host === "[::1]" || - host.startsWith("127.") - ); -}; - -export const isWildcardHost = (host: string | undefined): boolean => - host === "0.0.0.0" || host === "::" || host === "[::]"; - -export const formatHostForUrl = (host: string): string => - host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; - -const normalizeHost = (host: string): string => - host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; - const isIpv4Family = (family: string | number): boolean => family === "IPv4" || family === 4; const isIpv6Family = (family: string | number): boolean => family === "IPv6" || family === 6; diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 1a79f729bb0..c52b69747d0 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -1,5 +1,6 @@ import { EnvironmentAuthInvalidError, + EnvironmentResourceNotFoundError, type AuthBrowserSessionResult, type AuthCreatePairingCredentialInput, type AuthSessionState, @@ -104,6 +105,15 @@ async function installAuthApi(input: { readonly label?: string; readonly expiresAt: DateTime.Utc; }>; + readonly devPairingCredential?: () => Effect.Effect< + { + readonly id: string; + readonly credential: string; + readonly label?: string; + readonly expiresAt: DateTime.Utc; + }, + EnvironmentResourceNotFoundError + >; }) { const testApi = await installEnvironmentHttpTest({ ...(input.session ? { session: () => Effect.succeed(input.session!()) } : {}), @@ -113,11 +123,24 @@ async function installAuthApi(input: { ...(input.pairingCredential ? { pairingCredential: (payload) => input.pairingCredential!(payload) } : {}), + // Vitest runs with import.meta.env.DEV, so unauthenticated web scenarios + // attempt silent dev pairing; default to "not available" (the production + // shape) unless a test opts in. + devPairingCredential: + input.devPairingCredential ?? (() => Effect.fail(devPairingNotAvailableError())), }); disposeHttpTest = testApi.dispose; return testApi; } +function devPairingNotAvailableError() { + return new EnvironmentResourceNotFoundError({ + code: "not_found", + reason: "dev_pairing_not_available", + traceId: "test-trace", + }); +} + describe("resolveInitialServerAuthGateState", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -238,6 +261,114 @@ describe("resolveInitialServerAuthGateState", () => { }); }); + it("silently pairs through the dev pairing endpoint when the server offers it", async () => { + const nextSession = sequence( + unauthenticatedSession(LOOPBACK_AUTH), + authenticatedSession(LOOPBACK_AUTH), + ); + const testApi = await installAuthApi({ + session: nextSession, + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + devPairingCredential: () => + Effect.succeed({ + id: "dev-auto-1", + credential: "DEV-AUTO-CREDENTIAL", + expiresAt: SESSION_EXPIRES_AT, + }), + }); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + expect(testApi.calls.devPairingCredential).toBe(1); + expect(testApi.calls.browserSession).toEqual([{ credential: "DEV-AUTO-CREDENTIAL" }]); + }); + + it("falls back to the pairing screen silently when dev pairing is not available", async () => { + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(LOOPBACK_AUTH), + }); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + }); + expect(testApi.calls.devPairingCredential).toBe(1); + expect(testApi.calls.browserSession).toEqual([]); + }); + + it("skips dev pairing when an explicit pairing token is present in the URL", async () => { + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(LOOPBACK_AUTH), + }); + installTestBrowser("http://localhost:5733/pair#token=EXPLICIT"); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + }); + expect(testApi.calls.devPairingCredential).toBe(0); + }); + + it("skips dev pairing in desktop-bridge contexts", async () => { + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(DESKTOP_AUTH), + }); + const testWindow = installTestBrowser("http://127.0.0.1:5733/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstraps: () => [], + } as unknown as DesktopBridge; + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: DESKTOP_AUTH, + }); + expect(testApi.calls.devPairingCredential).toBe(0); + }); + + it("retries dev pairing after a reauthentication reset", async () => { + const nextSession = sequence( + unauthenticatedSession(LOOPBACK_AUTH), + unauthenticatedSession(LOOPBACK_AUTH), + authenticatedSession(LOOPBACK_AUTH), + ); + let available = false; + const testApi = await installAuthApi({ + session: nextSession, + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + devPairingCredential: () => + available + ? Effect.succeed({ + id: "dev-auto-2", + credential: "DEV-AUTO-RETRY", + expiresAt: SESSION_EXPIRES_AT, + }) + : Effect.fail(devPairingNotAvailableError()), + }); + + const { resolveInitialServerAuthGateState, reauthenticatePrimaryEnvironment } = + await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + }); + + available = true; + await expect(reauthenticatePrimaryEnvironment()).resolves.toEqual({ + status: "authenticated", + }); + expect(testApi.calls.devPairingCredential).toBe(2); + }); + it("retries transient auth session bootstrap failures after restart", async () => { vi.useFakeTimers(); let attempts = 0; @@ -266,7 +397,8 @@ describe("resolveInitialServerAuthGateState", () => { status: "requires-auth", auth: LOOPBACK_AUTH, }); - expect(attempts).toBe(4); + // 3 transient failures + 1 session success + 1 silent dev-pairing attempt. + expect(attempts).toBe(5); }); it("takes a pairing token from the location hash and strips it immediately", async () => { diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index f9381bcad71..26471d9257c 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -21,6 +21,7 @@ import { import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; +import { isHostedStaticApp } from "../../hostedPairing"; const PrimaryEnvironmentRequestOperation = Schema.Literals([ "fetch-session-state", @@ -317,6 +318,51 @@ function isTransientBootstrapError(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } +// Dev-only silent pairing: local web-dev servers expose a guarded endpoint +// that mints a one-time pairing credential for the dev origin, so fresh +// browser contexts authenticate without a pairing code. Any failure means +// "not available" (prod, remote, guard rejection) and the caller falls back +// to the normal pairing screen — never surface an error for this path. +// import.meta.env.DEV keeps the request out of production bundles entirely. +function shouldAttemptDevPairing(auth: AuthSessionState["auth"]): boolean { + return ( + import.meta.env.DEV && + auth.policy === "loopback-browser" && + !isHostedStaticApp() && + window.desktopBridge === undefined && + peekPairingTokenFromUrl() === null + ); +} + +async function fetchDevPairingCredential(): Promise { + try { + const result = await runPrimaryHttp( + PrimaryEnvironmentHttpClient.pipe( + Effect.flatMap((client) => client.auth.devPairingCredential({ payload: {} })), + ), + ); + return typeof result.credential === "string" && result.credential.length > 0 + ? result.credential + : null; + } catch { + return null; + } +} + +async function attemptSilentDevPairing(): Promise { + const credential = await fetchDevPairingCredential(); + if (credential === null) { + return false; + } + try { + await exchangeBootstrapCredential(credential); + await waitForAuthenticatedSessionAfterBootstrap(); + return true; + } catch { + return false; + } +} + async function bootstrapServerAuth(): Promise { const bootstrapCredential = getDesktopBootstrapCredential(); const currentSession = await fetchSessionState(); @@ -325,6 +371,9 @@ async function bootstrapServerAuth(): Promise { } if (!bootstrapCredential) { + if (shouldAttemptDevPairing(currentSession.auth) && (await attemptSilentDevPairing())) { + return { status: "authenticated" }; + } return { status: "requires-auth", auth: currentSession.auth, diff --git a/apps/web/test/environmentHttpTest.ts b/apps/web/test/environmentHttpTest.ts index ce43faacc40..abdded59a51 100644 --- a/apps/web/test/environmentHttpTest.ts +++ b/apps/web/test/environmentHttpTest.ts @@ -12,6 +12,7 @@ import { type AuthSessionState, type ExecutionEnvironmentDescriptor, type EnvironmentAuthInvalidError, + type EnvironmentResourceNotFoundError, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import type * as Context from "effect/Context"; @@ -35,6 +36,10 @@ interface EnvironmentHttpTestScenario { readonly pairingCredential?: ( payload: AuthCreatePairingCredentialInput, ) => Effect.Effect; + readonly devPairingCredential?: () => Effect.Effect< + AuthPairingCredentialResult, + EnvironmentResourceNotFoundError + >; } export interface EnvironmentHttpTestCalls { @@ -42,6 +47,7 @@ export interface EnvironmentHttpTestCalls { session: number; browserSession: Array; pairingCredential: Array; + devPairingCredential: number; } const unexpectedEndpoint = (endpoint: string) => @@ -66,6 +72,7 @@ export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestSc session: 0, browserSession: [], pairingCredential: [], + devPairingCredential: 0, }; const client = await Effect.runPromise( @@ -101,6 +108,16 @@ export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestSc ) .handle("token", () => unexpectedEndpoint("auth.token")) .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket")) + .handle( + "devPairingCredential", + Effect.fn("test.environment.auth.devPairingCredential")(function* () { + calls.devPairingCredential += 1; + return yield* ( + scenario.devPairingCredential?.() ?? + unexpectedEndpoint("auth.devPairingCredential") + ); + }), + ) .handle( "pairingCredential", Effect.fn("test.environment.auth.pairingCredential")(function* ({ payload }) { diff --git a/docs/cloud/environment-auth.md b/docs/cloud/environment-auth.md index af92bcb474d..3bae55afd4c 100644 --- a/docs/cloud/environment-auth.md +++ b/docs/cloud/environment-auth.md @@ -82,6 +82,39 @@ currently dispatches an orchestration operation, so clients performing it also need `orchestration:operate`. Creating a ticket is not authorization to call every RPC method. +### Dev-Mode Silent Pairing + +`POST /api/auth/dev-pairing-token` lets the web app authenticate automatically +against a **local development** server, so contributors and coding agents never +handle pairing codes in dev. It mints an ordinary one-time, five-minute +bootstrap credential (administrative scopes, subject `dev-auto-bootstrap`, +hidden from the pairing-links listing) that the client immediately exchanges +through the normal browser-session flow. + +The endpoint answers `404 dev_pairing_not_available` unless all of the +following hold: + +- the server runs in web mode with a configured dev URL (`bun run dev`), +- the dev URL's hostname is loopback (`--dev-url` never becomes an auth root), +- the auth policy is exactly `loopback-browser` (excludes desktop dev and any + non-loopback bind host). + +Eligible requests are then authenticated per-request: the browser-attached +`Origin` header must exactly equal the dev origin (this survives the Vite dev +proxy, which rewrites `Host` but not `Origin`), and the parsed `Host` header +must be loopback (DNS-rebinding defense for direct requests). Failures answer +`403 dev_pairing_request_rejected`. Because the payload is JSON, the request is +preflighted, so unauthorized web origins cannot even trigger credential +issuance. Local non-browser processes can forge these headers, but they can +already read the dev state directory directly, so no privilege is gained. + +When silent pairing is eligible, server startup opens the plain dev URL instead +of minting a `/pair#token=...` startup credential (the silent path would leave +that administrative token unconsumed in the URL and terminal scrollback). All +other startup shapes are unchanged, and the `/pair` screen remains the flow for +production, remote access, and explicit pairing links; an explicit `#token` in +the URL always takes precedence over silent pairing. + ## Standards Alignment - Bearer access tokens are used through the `Authorization: Bearer` scheme from diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index d4d2b96869e..ab559018e81 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -3,7 +3,7 @@ - `bun run dev` — Starts contracts, server, and web in `turbo watch` mode. - `bun run dev:server` — Starts just the WebSocket server (uses Bun TypeScript execution). - `bun run dev:web` — Starts just the Vite dev server for the web app. -- Dev commands default `T3CODE_STATE_DIR` to `~/.t3/dev` to keep dev state isolated from desktop/prod state. +- Dev servers store state under `/dev` (default `~/.t3/dev`) to keep dev state isolated from desktop/prod state. - Override server CLI-equivalent flags from root dev commands with `--`, for example: `bun run dev -- --base-dir ~/.t3-2` - `bun run start` — Runs the production server (serves built web app as static files). @@ -43,3 +43,14 @@ Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports - Example: `T3CODE_DEV_INSTANCE=branch-a bun run dev:desktop` If you want full control instead of hashing, set `T3CODE_PORT_OFFSET` to a numeric offset. + +### Authentication across dev instances + +Local web-dev servers support silent pairing: the web app authenticates +automatically on load, with no pairing code (see the dev-mode section in +`docs/cloud/environment-auth.md` for the guards). Additionally, all dev +instances sharing a `T3CODE_HOME` share the session store and signing secret +under `/dev`, so a browser session created against one instance +verifies against every other. Cookies are host-scoped: sessions carry across +ports on the same hostname, but `localhost` and `127.0.0.1` do not share +cookies — use one hostname consistently. diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 2d40dad60cc..80011ae1a19 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -67,6 +67,7 @@ export type EnvironmentAuthInvalidReason = typeof EnvironmentAuthInvalidReason.T export const EnvironmentOperationForbiddenReason = Schema.Literals([ "current_session_revoke_not_allowed", + "dev_pairing_request_rejected", ]); export type EnvironmentOperationForbiddenReason = typeof EnvironmentOperationForbiddenReason.Type; @@ -77,6 +78,7 @@ export const EnvironmentInternalErrorReason = Schema.Literals([ "access_token_issuance_failed", "websocket_ticket_issuance_failed", "pairing_credential_issuance_failed", + "dev_pairing_credential_issuance_failed", "pairing_links_load_failed", "pairing_link_revoke_failed", "client_sessions_load_failed", @@ -158,7 +160,10 @@ export class EnvironmentInternalError extends Schema.TaggedErrorClass()( @@ -356,6 +361,13 @@ export const EnvironmentCloudPreferencesRequest = Schema.Struct({ }); export type EnvironmentCloudPreferencesRequest = typeof EnvironmentCloudPreferencesRequest.Type; +// Dev-only silent pairing (see docs/cloud/environment-auth.md). The payload is +// deliberately a JSON object so the request is non-simple: browsers preflight +// it and always attach an Origin header, which the server verifies against the +// configured dev origin before minting. +export const AuthDevPairingRequest = Schema.Struct({}); +export type AuthDevPairingRequest = typeof AuthDevPairingRequest.Type; + export const AuthPairingLinkRevokeResult = Schema.Struct({ revoked: Schema.Boolean, }); @@ -407,6 +419,17 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") error: [EnvironmentInternalError], }).middleware(EnvironmentAuthenticatedAuth), ) + .add( + HttpApiEndpoint.post("devPairingCredential", "/api/auth/dev-pairing-token", { + payload: AuthDevPairingRequest, + success: AuthPairingCredentialResult, + error: [ + EnvironmentResourceNotFoundError, + EnvironmentOperationForbiddenError, + EnvironmentInternalError, + ], + }), + ) .add( HttpApiEndpoint.post("pairingCredential", "/api/auth/pairing-token", { headers: OptionalBearerHeaders, From 9faf5533eb22adb8f4dcb04604396949627f00e1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 13 Jul 2026 18:19:48 -0700 Subject: [PATCH 2/2] fix: restrict 127.* loopback matching to real IPv4 addresses A prefix test also accepted DNS names like 127.attacker.example, which would pass both the devUrl eligibility check and the Host header guard (review finding from macroscope). Co-Authored-By: Claude Fable 5 --- apps/server/src/netHost.test.ts | 12 ++++++++++++ apps/server/src/netHost.ts | 20 +++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/server/src/netHost.test.ts b/apps/server/src/netHost.test.ts index b5aee1dd62c..32410961673 100644 --- a/apps/server/src/netHost.test.ts +++ b/apps/server/src/netHost.test.ts @@ -36,6 +36,8 @@ describe("isLoopbackHostHeader", () => { assert.isFalse(isLoopbackHostHeader("evil.example")); assert.isFalse(isLoopbackHostHeader("evil.example:13773")); assert.isFalse(isLoopbackHostHeader("localhost.evil.example")); + assert.isFalse(isLoopbackHostHeader("127.attacker.example")); + assert.isFalse(isLoopbackHostHeader("127.attacker.example:13773")); assert.isFalse(isLoopbackHostHeader("10.0.0.5:13773")); assert.isFalse(isLoopbackHostHeader("[::2]:13773")); assert.isFalse(isLoopbackHostHeader(undefined)); @@ -56,4 +58,14 @@ describe("isLoopbackHost", () => { assert.isFalse(isLoopbackHost("0.0.0.0")); assert.isFalse(isLoopbackHost("192.168.1.10")); }); + + it("only accepts real 127.0.0.0/8 addresses, not 127.* DNS names", () => { + assert.isFalse(isLoopbackHost("127.attacker.example")); + assert.isFalse(isLoopbackHost("127.0.0.999")); + assert.isFalse(isLoopbackHost("127.0.0")); + assert.isFalse(isLoopbackHost("127.0.0.0.1")); + assert.isFalse(isLoopbackHost("127.0.0.-1")); + assert.isTrue(isLoopbackHost("127.0.0.0")); + assert.isTrue(isLoopbackHost("127.255.255.255")); + }); }); diff --git a/apps/server/src/netHost.ts b/apps/server/src/netHost.ts index a570e42e08d..a4fe4b9afa1 100644 --- a/apps/server/src/netHost.ts +++ b/apps/server/src/netHost.ts @@ -7,18 +7,24 @@ * @module netHost */ +// Strict 127.0.0.0/8 check: a prefix test would also accept DNS names like +// "127.attacker.example", which must not count as loopback anywhere this is +// used as a security boundary. +const isLoopbackIpv4 = (host: string): boolean => { + const octets = host.split("."); + if (octets.length !== 4) { + return false; + } + const values = octets.map((octet) => (/^\d{1,3}$/.test(octet) ? Number(octet) : -1)); + return values[0] === 127 && values.every((value) => value >= 0 && value <= 255); +}; + export const isLoopbackHost = (host: string | undefined): boolean => { if (!host || host.length === 0) { return true; } - return ( - host === "localhost" || - host === "127.0.0.1" || - host === "::1" || - host === "[::1]" || - host.startsWith("127.") - ); + return host === "localhost" || host === "::1" || host === "[::1]" || isLoopbackIpv4(host); }; export const isWildcardHost = (host: string | undefined): boolean =>